diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 81ece871..6c6d7c98 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -498,17 +498,18 @@ jobs: done < <(jq -r '.[]' <<<"$FILES") exit "$failed" - # Kubernetes manifests: render the infrastructure and every language overlay, validate - # against the 1.36.5 schemas (and the Strimzi CRDs), and assert the conventions the - # minikube run relies on. No cluster is needed. + # Kubernetes and OpenShift manifests: render the infrastructure and every language overlay, validate + # against the Kubernetes schemas (and the Strimzi, OLM and OpenShift ones), and assert the conventions + # the minikube (k8s/) and OpenShift Local (openshift/) runs rely on. No cluster is needed. + # The selector picks examples//k8s and examples//openshift; a k8s/ change picks both. k8s: - name: Kubernetes manifests + name: Kubernetes and OpenShift manifests needs: changes if: needs.changes.outputs.k8s != '[]' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - # The runner's kubectl is not pinned; install the version the cluster runs. + # The runner's kubectl is not pinned; install the version the minikube cluster runs. - name: Install kubectl v1.36.5 and kubeconform v0.8.0 env: KUBECTL_SHA256: 33bc88a24c3b09cf55bfd59e6ca977881b61fc3f25ab65bc80c65b430248648e @@ -526,32 +527,70 @@ jobs: - name: Render, validate and assert env: DIRS: ${{ needs.changes.outputs.k8s }} - # datreeio/CRDs-catalog pinned to a commit; it carries the Strimzi schemas. + # OpenShift 4.22 runs Kubernetes 1.35. To confirm against `oc version` in the live run. + OCP_KUBE_VERSION: 1.35.6 + # datreeio/CRDs-catalog pinned to a commit; it carries the Strimzi and OLM schemas and, under + # openshift/v4.15-strict, the Route and ImageStream ones (a different file name layout). CRDS: https://raw.githubusercontent.com/datreeio/CRDs-catalog/63669a570e231d4f1f8396d229a1de512bcf0a34/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json + OCP_CRDS: https://raw.githubusercontent.com/datreeio/CRDs-catalog/63669a570e231d4f1f8396d229a1de512bcf0a34/openshift/v4.15-strict/{{.ResourceKind}}_{{.Group}}_{{.ResourceAPIVersion}}.json run: | failed=0 while IFS= read -r dir; do [ -d "$dir" ] || { echo "::error::$dir not found"; failed=1; continue; } kubeconform -v - kubeconform -strict -summary -kubernetes-version 1.36.5 -schema-location default -schema-location "$CRDS" "$dir/namespace.yaml" || failed=1 - targets=("$dir/infra") + flavor="$(basename "$dir")" + if [ "$flavor" = openshift ]; then + kver="$OCP_KUBE_VERSION" + targets=("$dir/operators" "$dir/infra") + # Exactly the five languages that run on OpenShift Local (no C++), none of them extra. + langs="$(for o in "$dir"/overlays/*/; do basename "$o"; done | sort | tr '\n' ' ')" + [ "$langs" = "dotnet go python quarkus spring-boot " ] || { echo "::error::$dir/overlays must be dotnet go python quarkus spring-boot, found: $langs"; failed=1; } + # Credentials and cluster access: no token or password handling the plan forbids, in code (comments excluded). + if grep -rhvE '^\s*#' "$dir"/*.sh | grep -nE 'use-context|oc adm policy|port-forward|skopeo login|--dest-creds|--dest-registry-token|--tls-verify=false|--insecure|curl[^|]* -k |crc delete'; then + echo "::error::$dir scripts use a forbidden command"; failed=1 + fi + else + kver=1.36.5 + kubeconform -strict -summary -kubernetes-version "$kver" -schema-location default -schema-location "$CRDS" "$dir/namespace.yaml" || failed=1 + targets=("$dir/infra") + fi for o in "$dir"/overlays/*/; do targets+=("${o%/}"); done for t in "${targets[@]}"; do echo "::group::$t" - # infra reads the shared schema from ../../db/init, outside its directory. + # infra reads the shared schema from ../../db/init and openshift/infra the k8s files, outside its directory. kubectl kustomize --load-restrictor LoadRestrictionsNone "$t" > "$RUNNER_TEMP/render.yaml" || { echo "::error::render failed: $t"; failed=1; echo "::endgroup::"; continue; } - kubeconform -strict -summary -kubernetes-version 1.36.5 -schema-location default -schema-location "$CRDS" "$RUNNER_TEMP/render.yaml" || { echo "::error::kubeconform failed: $t"; failed=1; } + kubeconform -strict -summary -kubernetes-version "$kver" -schema-location default -schema-location "$CRDS" -schema-location "$OCP_CRDS" "$RUNNER_TEMP/render.yaml" || { echo "::error::kubeconform failed: $t"; failed=1; } if grep -nE 'image: .*:latest|:latest$' "$RUNNER_TEMP/render.yaml"; then echo "::error::$t uses :latest"; failed=1; fi - case "$t" in - */overlays/*) - lang="$(basename "$t")" - [ "$(grep -cE "image: localhost/cndp-05/${lang}-[a-z-]+:v1$" "$RUNNER_TEMP/render.yaml")" -eq 3 ] || { echo "::error::$t must use 3 images localhost/cndp-05/${lang}-:v1"; failed=1; } - [ "$(grep -c 'imagePullPolicy: Never' "$RUNNER_TEMP/render.yaml")" -eq 3 ] || { echo "::error::$t must set imagePullPolicy: Never on 3 containers"; failed=1; } - grep -q 'nodePort: 30080' "$RUNNER_TEMP/render.yaml" || { echo "::error::$t lacks nodePort 30080"; failed=1; } ;; - */infra) - grep -q 'nodePort: 30300' "$RUNNER_TEMP/render.yaml" || { echo "::error::$t lacks nodePort 30300"; failed=1; } - grep -vE 'image: (docker.io|quay.io)/' <(grep -E '^\s+image: ' "$RUNNER_TEMP/render.yaml") && { echo "::error::$t has an image that is not fully qualified"; failed=1; } ;; - esac + if [ "$flavor" = openshift ]; then + if grep -nE 'nodePort|runAsUser|fsGroup|anyuid' "$RUNNER_TEMP/render.yaml"; then echo "::error::$t has a nodePort, a fixed UID or group, or anyuid"; failed=1; fi + case "$t" in + */operators) + grep -q 'installPlanApproval: Manual' "$RUNNER_TEMP/render.yaml" || { echo "::error::$t Subscription must use installPlanApproval: Manual"; failed=1; } + grep -qE 'startingCSV: amqstreams\.v[0-9.]+-[0-9]+' "$RUNNER_TEMP/render.yaml" || { echo "::error::$t Subscription must pin startingCSV"; failed=1; } + grep -q 'targetNamespaces' "$RUNNER_TEMP/render.yaml" && { echo "::error::$t OperatorGroup must be AllNamespaces"; failed=1; } ;; + */overlays/*) + lang="$(basename "$t")" + reg=image-registry.openshift-image-registry.svc:5000/cndp-05 + [ "$(grep -cE "image: ${reg}/${lang}-[a-z-]+:v1$" "$RUNNER_TEMP/render.yaml")" -eq 3 ] || { echo "::error::$t must use 3 images ${reg}/${lang}-:v1"; failed=1; } + [ "$(grep -c 'imagePullPolicy: Always' "$RUNNER_TEMP/render.yaml")" -eq 3 ] || { echo "::error::$t must set imagePullPolicy: Always on 3 containers"; failed=1; } + if ! { grep -q 'kind: Route' "$RUNNER_TEMP/render.yaml" && grep -q 'termination: edge' "$RUNNER_TEMP/render.yaml" && grep -q 'insecureEdgeTerminationPolicy: Redirect' "$RUNNER_TEMP/render.yaml"; }; then echo "::error::$t lacks the edge Route with Redirect"; failed=1; fi ;; + */infra) + if ! { grep -q 'kind: Route' "$RUNNER_TEMP/render.yaml" && grep -q 'insecureEdgeTerminationPolicy: Redirect' "$RUNNER_TEMP/render.yaml"; }; then echo "::error::$t lacks the Grafana Route"; failed=1; fi + grep -q 'version: 4.2.0' "$RUNNER_TEMP/render.yaml" || { echo "::error::$t Kafka must run version 4.2.0 (the AMQ Streams bundle)"; failed=1; } + grep -vE 'image: (docker.io|quay.io|registry.redhat.io)/' <(grep -E '^\s+image: ' "$RUNNER_TEMP/render.yaml") && { echo "::error::$t has an image that is not fully qualified"; failed=1; } ;; + esac + else + case "$t" in + */overlays/*) + lang="$(basename "$t")" + [ "$(grep -cE "image: localhost/cndp-05/${lang}-[a-z-]+:v1$" "$RUNNER_TEMP/render.yaml")" -eq 3 ] || { echo "::error::$t must use 3 images localhost/cndp-05/${lang}-:v1"; failed=1; } + [ "$(grep -c 'imagePullPolicy: Never' "$RUNNER_TEMP/render.yaml")" -eq 3 ] || { echo "::error::$t must set imagePullPolicy: Never on 3 containers"; failed=1; } + grep -q 'nodePort: 30080' "$RUNNER_TEMP/render.yaml" || { echo "::error::$t lacks nodePort 30080"; failed=1; } ;; + */infra) + grep -q 'nodePort: 30300' "$RUNNER_TEMP/render.yaml" || { echo "::error::$t lacks nodePort 30300"; failed=1; } + grep -vE 'image: (docker.io|quay.io)/' <(grep -E '^\s+image: ' "$RUNNER_TEMP/render.yaml") && { echo "::error::$t has an image that is not fully qualified"; failed=1; } ;; + esac + fi echo "::endgroup::" done done < <(jq -r '.[]' <<<"$DIRS") diff --git a/CLAUDE.md b/CLAUDE.md index 3513287e..943760ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,9 +65,18 @@ Example 05 also runs on minikube (`examples/05-event-driven/k8s/`, Appendix R): (docker driver, containerd, Kubernetes v1.36.5), NodePorts 30080 and 30300 published on 127.0.0.1 at creation, Strimzi 1.2.0 for Kafka, kustomize manifests, `RUNTIME=kubernetes ./verify.sh`. Always pass `--keep-context` to `minikube start` (setup-profile.sh also restores the prior current-context, because minikube's restart path overrides the flag) and `--context cndp-k8s` to kubectl; never use port-forward, -`minikube tunnel` or `minikube service`. OpenShift Local (Appendix S) is added later in -the modernization plan. Prometheus/Mimir is published on host port 19090 (Cockpit holds 9090 -on Fedora/RHEL). Each example must run standalone. +`minikube tunnel` or `minikube service`. Prometheus/Mimir is published on host port 19090 (Cockpit holds 9090 +on Fedora/RHEL). Each example must run standalone. Example 05 also runs on OpenShift Local +(`examples/05-event-driven/openshift/`, Appendix S; Spring Boot, Quarkus, .NET, Python, Go): crc 2.64.0 sized +12 vCPU / 32768 MiB, AMQ Streams from OLM (pinned `startingCSV`, `installPlanApproval: Manual`, only that +InstallPlan approved), Kafka 4.2.0, `restricted-v2` pods with no fixed UID, Red Hat `rhel10/postgresql-18`, +edge-TLS Routes verified with the ingress CA, `RUNTIME=kubernetes KUBECTL=oc ./verify.sh`. OpenShift rules: +call `oc --context crc-admin` on every command and never `oc config use-context` (`setup-crc.sh` restores the +recorded current-context, including "none"); never print or store the kubeadmin password, a token or the pull +secret; images go to the internal registry with `skopeo copy` and a `builder` token held in a shell variable and +passed through a process-substitution authfile (no `skopeo login`, no `--dest-creds`, no token in a file or an +argument list); never `curl -k` or port-forward; CRC is stopped after use, never deleted, and `teardown.sh` +removes only the CRDs our CSV owns or that carry OLM's `operators.coreos.com/amq-streams.cndp-amq-streams` label (and only with no foreign Kafka items), plus OLM's `Operator` object for that package; it reports foreign items and never removes them. ## Running-example domain diff --git a/README.md b/README.md index 2054ea62..44efd6a9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ the *Designing Cloud-Native APIs* deck series; the decks themselves live in ## What's inside -- **A setup guide, 13 chapters, 18 deep-dive appendices (A–R), and a glossary** — +- **A setup guide, 13 chapters, 19 deep-dive appendices (A–S), and a glossary** — covering REST, gRPC, GraphQL, WebSockets, event-driven architecture, schemas and versioning, observability, resilience, and the patterns that tie them together. - **Six languages, one set of tabs** — every code sample appears in Spring Boot, @@ -55,7 +55,7 @@ cd python && docker compose down -v | Path | What it holds | |---|---| | `_parts/` | the five Parts that form the homepage cards | -| `_docs/` | the setup guide, chapters, appendices A–R, and the glossary | +| `_docs/` | the setup guide, chapters, appendices A–S, and the glossary | | `_includes/codetabs.html` + `assets/js/codetabs.js` | the multi-language code tabs (no plugin) | | `assets/diagrams/` | paired `name.svg` + `name.excalidraw` figures | | `lgtm-presentation/` | the six source decks + `BUILD.md` manifest | diff --git a/_docs/05-event-driven.md b/_docs/05-event-driven.md index ce930a50..7fc6ca2b 100644 --- a/_docs/05-event-driven.md +++ b/_docs/05-event-driven.md @@ -1291,8 +1291,8 @@ redelivery is the at-least-once guarantee, and the reason your handler dedupes. The code is in [`examples/05-event-driven/`](https://github.com/patterncatalyst/cloud-native-design-patterns/tree/main/examples/05-event-driven/). The run script there builds and runs it; its `README.md` covers what it does and how to drive it. The same example, with Kafka managed by -the Strimzi operator, also runs on a Kubernetes cluster; Appendix R covers that deployment. +the Strimzi operator, also runs on a Kubernetes cluster; Appendix R covers that deployment on minikube and Appendix S covers OpenShift Local. --- -*Verification status: verified — [`examples/05-event-driven/`](https://github.com/patterncatalyst/cloud-native-design-patterns/tree/main/examples/05-event-driven/) passes 10/10 checks in each of the six languages on Docker Engine (2026-10-10) and on minikube (2026-10-11, Appendix R) +*Verification status: verified — [`examples/05-event-driven/`](https://github.com/patterncatalyst/cloud-native-design-patterns/tree/main/examples/05-event-driven/) passes 10/10 checks in each of the six languages on Docker Engine (2026-10-10) and on minikube (2026-10-11, Appendix R), and 10/10 in five languages on OpenShift Local 4.22.14 (2026-10-11, Appendix S) (event publication to order.placed, fan-out to two independent consumer groups (shipping and notification), and idempotent consumers under a redelivered duplicate event).* diff --git a/_docs/32-appendix-s-openshift-local.md b/_docs/32-appendix-s-openshift-local.md new file mode 100644 index 00000000..caa9f061 --- /dev/null +++ b/_docs/32-appendix-s-openshift-local.md @@ -0,0 +1,492 @@ +--- +title: "Running Example 05 on OpenShift Local" +marker: "S" +label: "Appendix S" +order: 32 +part: "Deep-dive appendices" +description: "Example 05 on OpenShift Local (CRC): AMQ Streams installed through OLM with a pinned, manually approved CSV, pods under the restricted-v2 SCC, a Red Hat Postgres image, edge-TLS Routes, images pushed to the internal registry without a token on disk, and the same verify.sh over HTTPS." +duration: 30 minutes +--- + +Appendix R ran Example 05 on minikube, where the platform accepts almost anything: root images, +host-built images loaded by name, a NodePort on loopback. OpenShift is stricter in four places, and +each one changes the deployment. Operators are installed through the Operator Lifecycle Manager +(OLM) from a catalog, not from a Helm chart. Pods run under a security context constraint (SCC) +that assigns an arbitrary non-root user ID. Traffic enters through a Route, not a NodePort. Images +come from a registry inside the cluster. This appendix runs the same three services and the same +`verify.sh` on **OpenShift Local** (CRC), and records what had to change and what did not. + +The scope is the same as Appendix R: one example, one node, one Kafka broker, one Postgres +instance. What carries over is the contract between an application and its platform: the Service +names, the Secret, the environment variables and the health endpoint. The five language overlays +reuse the minikube overlays unchanged; the OpenShift directory adds only what the platform needs. +C++ is not part of this appendix: Appendix R covers it, and the other five languages are enough to +exercise every OpenShift-specific mechanism. + +## The runtime + +OpenShift Local runs a single-node OpenShift cluster in a virtual machine on the host, managed by +the `crc` command. The host needs KVM, and `crc setup` makes `api.crc.testing` and +`*.apps-crc.testing` resolve locally, which is what lets a Route work without a tunnel. + +| Setting | Value | Reason | +|---------|-------|--------| +| OpenShift Local | crc 2.64.0, OpenShift 4.22.14, Kubernetes v1.35.6 | Newest release at the time of writing; the checksum is verified on install. `oc version` on the running cluster reported the OpenShift and Kubernetes versions | +| Resources | 12 vCPU, 32768 MiB, 80 GB disk | The cluster platform, Kafka, Postgres, LGTM and three services share one VM | +| Pull secret | a file path in `crc config`, never opened by the scripts | The secret stays in the file Red Hat issued | +| kubeconfig context | `crc-admin`, named on every call | No script runs `oc config use-context`, and no kubeadmin password is typed, stored or printed | +| Current-context | restored after `crc start` | `crc start` can change it; `setup-crc.sh` records it first, including the case where none is set, and restores it on exit | +| One cluster at a time | refuse if a minikube profile or a container is running | The VM and a minikube node compete for the same memory | + +`crc start` prints the kubeadmin and developer passwords. `setup-crc.sh` discards its standard +output and filters its standard error, so neither reaches a terminal, a log or a file. If you want the +web console login, run `crc console --credentials` yourself. + +## What changes from minikube + +| Concern | minikube (Appendix R) | OpenShift Local | Why | +|---------|----------------------|-----------------|-----| +| Kafka operator | Strimzi 1.2.0 from a Helm chart | AMQ Streams 3.2 from OLM, pinned CSV, Manual approval | The supported Red Hat build of Strimzi, installed the OpenShift way | +| Kafka version | 4.3.1 | 4.2.0 (`metadataVersion: 4.2-IV1`) | The AMQ Streams 3.2 bundle ships broker images for Kafka 4.1 and 4.2 only | +| Postgres image | `docker.io/library/postgres:18.6-alpine`, root-capable | `registry.redhat.io/rhel10/postgresql-18`, arbitrary UID | The Red Hat image is built for an assigned user ID | +| Schema | mounted at `/docker-entrypoint-initdb.d` | a `postgresql-start` hook that runs the same SQL as appuser | The image has its own startup contract | +| Host access | NodePorts on loopback | edge-TLS Routes on `apps-crc.testing` | Routes are the platform's ingress | +| Images | `docker build`, then `minikube image load`, `pullPolicy: Never` | `docker build`, then `skopeo copy` to the internal registry, `pullPolicy: Always` | There is no node store to load into; a registry is the interface | +| Pod security | restricted-ready, not enforced | `restricted-v2` enforced, no fixed UID | The SCC assigns the user ID from the project's range | +| Grafana | NodePort 30300 | Route `lgtm-grafana` | Same reason as order-service | + +{% include excalidraw.html + file="32-openshift-topology" + alt="The host runs curl and verify.sh, a browser and oc with the crc-admin context. Inside OpenShift Local there are two namespaces. cndp-amq-streams holds an OLM Subscription with Manual approval and a pinned CSV, and the AMQ Streams operator, which manages the Kafka cluster. cndp-05 holds order-service behind an edge-TLS Route, the shipping and notification consumers, Kafka 4.2.0, Postgres 18 from the sclorg image, the LGTM stack behind a Grafana Route and the Postgres Secret." + caption="Figure S.1 — The operator lives in its own namespace and manages Kafka in cndp-05; the host reaches order-service and Grafana through Routes and uses oc exec for the Kafka and Postgres checks" %} + +## Installing an operator with OLM + +On minikube the Strimzi operator is a Helm release. On OpenShift an operator is a **Subscription** +to a package in a catalog, and OLM does the install. The Subscription pins the exact version: + +```yaml +spec: + name: amq-streams + source: redhat-operators + sourceNamespace: openshift-marketplace + channel: amq-streams-3.2.x + startingCSV: amqstreams.v3.2.1-14 + installPlanApproval: Manual +``` + +`startingCSV` names the ClusterServiceVersion (CSV) to install, and `installPlanApproval: Manual` +makes OLM propose an InstallPlan and wait. `deploy.sh` approves only the InstallPlan that lists the +pinned CSV, then waits for the CSV to reach `Succeeded`. A later InstallPlan, such as an upgrade OLM +proposes afterwards, stays unapproved, so the version moves only when someone edits the pin. + +The operator has its own namespace, `cndp-amq-streams`, and its own OperatorGroup with an empty +`spec`, which means AllNamespaces. The operator therefore watches `cndp-05` without a second +OperatorGroup there. Teardown removes the Subscription, the CSV, its InstallPlans and the OperatorGroup, +then the two namespaces, the CRDs the CSV owns and OLM's `Operator` object for the package, and +reverts the registry route if setup enabled it. A cluster-wide operator also manages any Kafka +cluster it can see, so `deploy.sh` first takes an inventory and refuses to continue if it finds +another project's AMQ Streams or Strimzi Subscription or CSV, a Kafka resource outside `cndp-05`, +Strimzi CRDs that our Subscription did not install, or a Strimzi cluster-operator Deployment in +another namespace. + +The catalog is the authority on what is newest, and it can only be read from a running cluster. +On the recorded run, `oc --context crc-admin get packagemanifest amq-streams -n openshift-marketplace` reported: + +| Field | Value | +|-------|-------| +| Default channel | `stable` | +| Channel used | `amq-streams-3.2.x`, the newest minor channel | +| `currentCSV` of that channel | `amqstreams.v3.2.1-14`, the pinned `startingCSV` | +| CSV phase after install | `Succeeded` | +| Kafka version in the bundle | 4.2.0 (the Kafka resource's `.status.kafkaVersion` reports `4.2.0`) | + +The default channel `stable` follows the newest release the catalog offers. The pin names the +minor channel instead, so a new minor release in `stable` does not change what this appendix installs. + +## restricted-v2 and the images + +The `restricted-v2` SCC is the default for ordinary workloads. It requires `runAsNonRoot`, forbids +privilege escalation, drops all capabilities, applies the runtime-default seccomp profile and +assigns the container a user ID from the project's range, with group 0. The minikube base already +sets that security context and never names a user ID, so the OpenShift overlays add no `runAsUser`, +`fsGroup` or SCC binding. Nothing in `openshift/` runs `oc adm policy`. + +The images were already compatible. Each Containerfile ends in a numeric non-root `USER` and +copies files with `--chown=:0 --chmod=g=u`, so the arbitrary user ID, which is always in group 0, +can read and write what the image owns. Before the run, `skopeo inspect --config` on each of the +fifteen local images (five languages, three services) reported a numeric, non-zero user: + +| Languages | `USER` in the image | +|-----------|---------------------| +| Spring Boot, Quarkus | 185 | +| .NET, Python, Go | 1001 | + +On the cluster, the check is the `openshift.io/scc` annotation on each pod: the matrix fails a language +unless every application pod reports `restricted-v2`. On the recorded run every pod in `cndp-05` +reported it, for all five languages. The Go run, as one example: + +| Pod | SCC | +|-----|-----| +| `cndp-dual-role-0` (Kafka) | `restricted-v2` | +| `cndp-postgres-0` | `restricted-v2` | +| `lgtm-6dcf64f6ff-62xv5` | `restricted-v2` | +| `order-service-8545b6f5b5-6jf2j` | `restricted-v2` | +| `shipping-consumer-78bc8fb84-dglkn` | `restricted-v2` | +| `notification-consumer-854cb78476-zmqdf` | `restricted-v2` | + +### Postgres + +`quay.io/sclorg/postgresql-18-c10s` is the upstream of Red Hat's `rhel10/postgresql-18`, and was +used to rehearse the manifests offline with Docker, as an arbitrary user ID in group 0 with the +hook, SQL and configuration mounted read-only exactly as the StatefulSet mounts them. The cluster +pulls the Red Hat image from `registry.redhat.io` with the cluster's pull secret. + +The image reads `POSTGRESQL_USER`, `POSTGRESQL_PASSWORD` and `POSTGRESQL_DATABASE`, keeps its data in +`/var/lib/pgsql/data`, and sources shell files from fixed directories. Two properties shaped the +manifest: + +- `POSTGRESQL_USER` is not a superuser. The schema therefore runs as appuser, which also makes + appuser the owner of the tables. The extension `uuid-ossp` is a trusted extension, so the database + owner may create it. +- Files in `postgresql-init/` run before the role and the database exist, so the schema hook is + `postgresql-start/10-cndp-schema.sh`, which runs after them on every start. The SQL uses + `IF NOT EXISTS`, so a restart changes nothing. The same SQL files the compose stack mounts are + mounted from one ConfigMap, so the schema has a single source. + +The offline rehearsal confirmed that the hook runs, that appuser owns `orders`, `shipments` and +`notifications`, that `uuid-ossp` loads, that `log_statement` is `all`, and that a restart is a +no-op. The image's default access rule for local connections is `trust`, so +`psql -U appuser -d appdb` inside the pod works without a password, which is how `verify.sh` +queries the database; connections over the network use a password. + +### The observability image + +`docker.io/grafana/otel-lgtm:0.36.0` is built to run as root. Rather than reach for an SCC +exception first, the image was run as an arbitrary user ID in group 0 with a read-only root +filesystem, to find out what it writes. Three paths: + +| Path | Used by | Mount | +|------|---------|-------| +| `/data` | Loki, Tempo, Prometheus, Pyroscope and Grafana state | emptyDir | +| `/tmp` | the readiness marker | emptyDir | +| `/var/tempo` | Tempo's shutdown marker | emptyDir | + +One more change: Grafana tries to replace its bundled data source plugins from grafana.com at start, +which needs a writable `/otel-lgtm` tree. On a read-only root it removes the bundled plugin and then +fails, which leaves the Loki, Tempo and Prometheus data sources unregistered. Setting +`GF_PLUGINS_PREINSTALL_DISABLED=true` skips that update, and the bundled plugins stay. With those +four changes Grafana's `/api/health` answers, all four data sources report healthy, and an OTLP trace +posted to port 4318 is readable from Tempo. On the cluster the `lgtm` pod ran under `restricted-v2` +with no exception, and Grafana 13.2.3 answered `/api/health` through its Route. + +An SCC exception was not needed. If a future image version needs root, the fallback is a scoped +exception, never a cluster-wide one: a ServiceAccount `lgtm` in the project, a namespaced RoleBinding +to the ClusterRole `system:openshift:scc:anyuid`, and `runAsUser: 0` on that one container. It was +not exercised in this run. + +## The manifests + +``` +openshift/ + operators/ namespace, OperatorGroup, Subscription + infra/ Kafka and LGTM from ../k8s/infra with patches, Postgres, the Grafana Route + components/openshift/ the order-service Route, ClusterIP Service, pullPolicy Always + overlays/ + spring-boot/ quarkus/ dotnet/ python/ go/ +``` + +Nothing is copied from `k8s/`. `infra/` lists `../../k8s/infra/kafka.yaml` and `lgtm.yaml` as +resources and patches them: the Kafka version and `metadataVersion` for the AMQ Streams bundle, the +`lgtm-grafana` Service from NodePort to ClusterIP, and the restricted-v2 security context and +emptyDir volumes for the observability pod. Each overlay is the minikube overlay of the same language +plus a **Component**, a reusable kustomize fragment that adds the Route and rewrites the Service and +the pull policy, plus an `images:` block that points the three images at the internal registry. +Because the Postgres manifest is the only one that differs by image, it is the only manifest of +its own. The minikube directory renders byte for byte as before. + +{% include codetabs.html langs="Spring Boot|Quarkus|.NET|Python|Go" %} + +```yaml +# openshift/overlays/spring-boot/kustomization.yaml (images) +- name: localhost/cndp-05/spring-boot-order-service + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/spring-boot-order-service + newTag: v1 +# ...the same mapping for shipping-consumer and notification-consumer +``` + +```yaml +# openshift/overlays/quarkus/kustomization.yaml (images) +- name: localhost/cndp-05/quarkus-order-service + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/quarkus-order-service + newTag: v1 +# ...the same mapping for shipping-consumer and notification-consumer +``` + +```yaml +# openshift/overlays/dotnet/kustomization.yaml (images) +- name: localhost/cndp-05/dotnet-order-service + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/dotnet-order-service + newTag: v1 +# ...the same mapping for shipping-consumer and notification-consumer +``` + +```yaml +# openshift/overlays/python/kustomization.yaml (images) +- name: localhost/cndp-05/python-order-service + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/python-order-service + newTag: v1 +# ...the same mapping for shipping-consumer and notification-consumer +``` + +```yaml +# openshift/overlays/go/kustomization.yaml (images) +- name: localhost/cndp-05/go-order-service + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/go-order-service + newTag: v1 +# ...the same mapping for shipping-consumer and notification-consumer +``` + +The environment variables each language reads come from the minikube overlay and are not repeated +here. `infra/` also reads the schema from `../../db/init`, so it renders with the load restrictor +lifted, like the minikube infrastructure: + +```bash +oc kustomize --load-restrictor LoadRestrictionsNone openshift/infra | oc --context crc-admin apply -f - +``` + +The pull policy is `Always` because the tag stays `v1` across runs: with `IfNotPresent`, a node +that cached an earlier push would keep running the old image. + +## Getting the images in + +The Docker builds are the ones from Appendix R, with the same names and the same host BuildKit +caches. The Containerfile table moved into `k8s/images.sh`, which both build scripts source, so +the eighteen rows exist once. The difference is the destination. `docker push` to the registry's +default route needs the ingress CA trusted by the Docker daemon, which means a root-owned +`/etc/docker/certs.d` entry or an `insecure-registries` setting: a permanent change to the host. +`skopeo copy` reads the image straight from the Docker daemon with `docker-daemon:` and takes the CA +from a directory the user owns. + +{% include excalidraw.html + file="32-openshift-image-flow" + alt="A Containerfile feeds docker build, which uses the host BuildKit cache and produces an image named localhost/cndp-05/language-service:v1 in the Docker image store. skopeo copy reads it from the docker daemon and pushes it over HTTPS to the internal registry, where it becomes an ImageStream tag v1. The pod pulls it with imagePullPolicy Always. The credential is a ten-minute token for the builder service account, held in a shell variable and passed through a pipe." + caption="Figure S.2 — skopeo pushes from the Docker image store to the internal registry; the credential never touches a file or an argument list" %} + +The registry needs a credential, and the design keeps it out of every place a secret leaks to: + +```bash +token="$(oc --context crc-admin create token builder -n cndp-05 --duration=10m)" +skopeo copy --dest-cert-dir "$STATE/registry-ca" \ + --dest-authfile <(printf '{"auths":{"%s":{"auth":"%s"}}}' "$REGISTRY_ROUTE" \ + "$(printf 'builder:%s' "$token" | base64 -w0)") \ + "docker-daemon:localhost/cndp-05/go-order-service:v1" \ + "docker://$REGISTRY_ROUTE/cndp-05/go-order-service:v1" +unset token +``` + +The token is the short-lived token of the project's `builder` service account, held only in a shell +variable. `skopeo` reads its credentials from an authfile, and the authfile here is a process +substitution, a pipe, so no file contains the token. `printf` is a Bash builtin, so the token is in no +process's argument list either, which is what `--dest-creds` and `--dest-registry-token` would +expose to `ps` and `/proc/*/cmdline`. `skopeo login` is not used because it writes an authfile to disk. +The ImageStreams are created by the first push. + +The registry's default route is off in a fresh cluster. `setup-crc.sh` enables it only if it is off +and annotates the registry configuration, so `teardown.sh` reverts exactly what the run changed and +leaves a route that was already there alone. + +## Traces across Kafka + +The check that matters for an event-driven example is not that each service exports spans, but that +one trace crosses the topic. Spring Boot (through the OpenTelemetry agent), Quarkus, Python and +Go propagate the W3C trace context in the Kafka record headers. The .NET services did not: +`Confluent.Kafka` has no built-in tracing, so the order-service's span and the consumers' spans were +three unrelated traces. This appendix fixes that in the code, not in the check. + +The order-service starts a producer span and injects the context into the record headers: + +```csharp +Activity? publish = null; +try +{ + publish = orderSource.StartActivity("order.placed publish", ActivityKind.Producer); + var message = new Message { Value = json, Headers = new Headers() }; + Propagators.DefaultTextMapPropagator.Inject( + new PropagationContext(publish?.Context ?? Activity.Current?.Context ?? default, Baggage.Current), + message.Headers, + (headers, key, value) => headers.Add(key, Encoding.UTF8.GetBytes(value))); + await producer.ProduceAsync("order.placed", message); +} +catch (Exception ex) +{ + publish?.SetStatus(ActivityStatusCode.Error, ex.Message); +} +finally +{ + publish?.Dispose(); +} +``` + +Each consumer extracts it and starts its span as a child: + +```csharp +var parent = Propagators.DefaultTextMapPropagator.Extract( + default, + cr.Message.Headers, + (headers, key) => headers is null + ? Array.Empty() + : headers.Where(h => h.Key == key) + .Select(h => h.GetValueBytes()) + .Where(b => b is not null) + .Select(b => Encoding.UTF8.GetString(b!))); +using var span = source.StartActivity("process_shipment", ActivityKind.Consumer, parent.ActivityContext); +``` + +The service names stay `order-service`, `shipping-consumer` and `notification-consumer`. On compose, +the .NET example passes `verify.sh` 10 of 10 and Tempo holds a trace that contains all three +services. + +On OpenShift, `verify-matrix.sh` keeps the evidence. After each language's `verify.sh` it asks Tempo, +through the `lgtm` pod, for traces whose `service.name` is `order-service`, reads each one and +keeps the first that also contains a consumer service in `linked-trace.json`. Tempo indexes with a +delay, so the query retries for up to three minutes. + +On the recorded run, every language produced a trace that Tempo returned with all three services. +`linked-trace.json` for the Go run: + +```json +{ + "traceId": "880f0a8a4840bccad3e06572ffd601a5", + "services": [ + "notification-consumer", + "order-service", + "shipping-consumer" + ] +} +``` + +The other four languages recorded the same three services, each under its own trace ID. + +## Run it + +Stop minikube and any compose stack first, then, from `examples/05-event-driven/`: + +```bash +openshift/setup-crc.sh # crc start, ingress CA, registry route +openshift/build-push-images.sh go # docker build, then skopeo copy +openshift/deploy.sh go # operator, infrastructure, Go overlay +RUNTIME=kubernetes KUBECTL=oc KUBE_CONTEXT=crc-admin KUBE_NAMESPACE=cndp-05 \ + BASE=https://order-service-cndp-05.apps-crc.testing \ + CURL_OPTS="--cacert $HOME/.local/state/cndp-05-crc/ingress-ca.crt" ./verify.sh +``` + +`verify.sh` is unchanged. `KUBECTL=oc` and `KUBE_CONTEXT=crc-admin` select the cluster, and the +Route's certificate is verified against the cluster's ingress CA, which `setup-crc.sh` reads from +the `default-ingress-cert` ConfigMap. Nothing in the OpenShift directory uses `curl -k`. Plain HTTP +on the Route answers with a redirect to HTTPS. + +Recorded output for Go on OpenShift Local (the other four languages print the same ten checks and +the same result line): + +``` +==> Polling https://order-service-cndp-05.apps-crc.testing/healthz + ok: order-service is healthy through the Route +==> Verifying Example 05: Event-Driven Architecture + + ✓ order-service healthz + ✓ POST /orders returns 201 + ✓ order visible in GET /orders + → waiting for consumers to process events... + ✓ order.placed topic exists in Kafka + ✓ shipment created for order + ✓ notification created for order + ✓ shipping-group consumer group exists + ✓ notification-group consumer group exists + → testing idempotent consumers (re-sending duplicate event)... + ✓ shipping remains idempotent (still 1 row after duplicate) + ✓ notification remains idempotent (still 1 row after duplicate) + +==> Results: 10 passed, 0 failed +==> PASS +``` + +To run all five languages in sequence, with a fresh project for each: + +```bash +OUT=/tmp/cndp-ocp-matrix openshift/verify-matrix.sh +``` + +Each language must pass `verify.sh`, show a linked trace, and have every application pod under +`restricted-v2`. Between languages only the `cndp-05` project is removed; the operator stays, so +the second language skips the install. + +Recorded results, 2026-10-11: + +| Language | Result | Trace | SCC | Time | +|----------|--------|-------|-----|------| +| Spring Boot | 10 passed, 0 failed | linked | restricted-v2 | 139 s | +| Quarkus | 10 passed, 0 failed | linked | restricted-v2 | 123 s | +| .NET | 10 passed, 0 failed | linked | restricted-v2 | 125 s | +| Python | 10 passed, 0 failed | linked | restricted-v2 | 121 s | +| Go | 10 passed, 0 failed | linked | restricted-v2 | 120 s | + +Plain HTTP on the order-service Route answered `302` with a `Location` of the HTTPS URL, and +Grafana's `/api/health` through its Route returned `"database": "ok"` with version 13.2.3. + +## Clean up + +```bash +openshift/teardown.sh # remove everything, then crc stop +openshift/teardown.sh --namespace-only # only the cndp-05 project +``` + +Order matters because OLM-managed resources have finalizers that need their operator. The script +deletes KafkaTopics and KafkaUsers, then the Kafka and KafkaNodePool resources while the operator +still runs, then the project. It then removes the Subscription, the CSV, its InstallPlans, the +OperatorGroup, the CRDs the CSV owns (read from the CSV's `.spec.customresourcedefinitions.owned`) +and the operator namespace. OLM labels each CRD it installs for this Subscription with +`operators.coreos.com/amq-streams.cndp-amq-streams`, and the script also removes CRDs that carry that +label, so a CRD kept by an earlier run is still found after the CSV is gone. The CRDs go only when +something of ours exists, and only after a foreign check: another project's Subscription, CSV or +Kafka custom resource keeps them in place, the script prints what it kept and why, and carries on. +No other `*.strimzi.io` CRD is ever removed. It +reverts the registry route only if `setup-crc.sh` enabled it. A step that fails or times out is +reported and the script continues, so the route is reverted and CRC is stopped anyway; the exit code +is non-zero if a step failed or something of Example 05's is left. A leftover check lists those +items, and lists items that are not Example 05's as left untouched. The last step is +`crc stop`: CRC is stopped, never deleted, so the next start is quick. A second run finds nothing to do +and exits 0. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `setup-crc.sh` refuses to start | A minikube profile or a container is running | Stop it yourself; the script never stops anything | +| CRC is sized too small | The VM is below 12 vCPU or 32768 MiB | While CRC is stopped: `crc config set cpus 12`, `crc config set memory 32768` | +| `deploy.sh` stops at "another Kafka operator" | A different AMQ Streams or Strimzi installation is present | Run that project's teardown; this one would otherwise manage its clusters | +| InstallPlan never appears | `redhat-operators` reports ready before it serves packages | `deploy.sh` waits for the package; check `oc --context crc-admin get packagemanifest amq-streams -n openshift-marketplace` | +| `oc` calls fail with connection refused, timeouts or `ServiceUnavailable` for a few minutes after `crc start` or after `setup-crc.sh` enables the registry route | The API servers roll out again after the first settled check, and `crc status` reports Starting meanwhile | `setup-crc.sh` waits until the cluster operators stay settled for 300 s; `deploy.sh` waits for them and retries each `oc apply`; if you interrupt them, wait for `oc --context crc-admin get co` to show all Available and none Progressing | +| `deploy.sh` reports a foreign operator that is its own | The AllNamespaces install copies the CSV into every namespace | The check ignores copies with `-l '!olm.copiedFrom'`; use the same selector when you list CSVs yourself | +| `x509: certificate signed by unknown authority` | The ingress CA is missing or stale | Run `setup-crc.sh` again; it rewrites the CA file. Do not use `-k` | +| `skopeo` cannot push | The registry default route is off, or the token expired | `setup-crc.sh` enables the route; the token is created per image and lasts ten minutes | +| `ImagePullBackOff` on an app pod | The image was not pushed for this language | `openshift/build-push-images.sh ` | +| Pod rejected by an SCC | A manifest added `runAsUser`, `fsGroup` or a privileged field outside the project's range | Remove it; the overlays set none and the assigned UID works | +| Kafka never becomes Ready | The operator is not running, or the Kafka version is not in the bundle | `oc --context crc-admin -n cndp-amq-streams get pods`; `oc --context crc-admin -n cndp-05 describe kafka cndp` | +| Namespace stuck terminating | A KafkaTopic finalizer outlived the operator | `teardown.sh` deletes the topics while the operator runs; do not remove the operator first | + +### Cross-check it yourself + +Place an order through the Route, then run +`oc --context crc-admin -n cndp-05 exec pod/cndp-postgres-0 -- psql -U appuser -d appdb -c "SELECT * FROM shipments"` +and confirm one row for the order. Then read the pod's SCC with +`oc --context crc-admin -n cndp-05 get pod -l app.kubernetes.io/name=order-service -o jsonpath='{.items[0].metadata.annotations.openshift\.io/scc}'` +and confirm `restricted-v2`. The `restricted-v2` SCC admits only a user ID inside the project's +assigned range (the `openshift.io/sa.scc.uid-range` annotation on the namespace), so a fixed +`runAsUser` outside that range is refused when the pod is admitted. That rejection was not exercised +on the recorded run; read the ReplicaSet's events if you try it. + +The manifests and scripts are in [`examples/05-event-driven/openshift/`](https://github.com/patterncatalyst/cloud-native-design-patterns/tree/main/examples/05-event-driven/openshift/). + +--- +*Verification status: verified — [`examples/05-event-driven/openshift/`](https://github.com/patterncatalyst/cloud-native-design-patterns/tree/main/examples/05-event-driven/openshift/) passes 10/10 checks in each of the five languages on OpenShift Local 4.22.14 (crc 2.64.0, Kubernetes v1.35.6, AMQ Streams `amqstreams.v3.2.1-14`, Kafka 4.2.0, 2026-10-11), using the same `verify.sh` as compose with `RUNTIME=kubernetes` over HTTPS. Every pod ran under `restricted-v2`, Tempo held a trace with all three services for each language, and teardown left the cluster empty and stopped. The SCC rejection of a fixed root user ID was not exercised.* diff --git a/_example_pages/prerequisites.md b/_example_pages/prerequisites.md index 80ebcdf0..062a79d3 100644 --- a/_example_pages/prerequisites.md +++ b/_example_pages/prerequisites.md @@ -79,7 +79,8 @@ docker run --rm hello-world The examples run on `docker compose` and need no cluster. Appendix R runs Example 05 on minikube; install these tools when you reach it. minikube uses the Docker driver on the same -Docker Engine as compose, with the `default` Docker context. +Docker Engine as compose, with the `default` Docker context. Appendix S runs the same example on +OpenShift Local; its tools are in the last subsection. ### minikube @@ -120,12 +121,50 @@ tar -xzf "helm-v${HELM_VERSION}-linux-amd64.tar.gz" linux-amd64/helm sudo install linux-amd64/helm /usr/local/bin/helm ``` +### OpenShift Local (Appendix S) + +OpenShift Local (CRC) runs OpenShift 4.22 in a virtual machine on Fedora or RHEL, so the host +needs KVM. Pinned to crc 2.64.0, with the archive checked against the SHA-256 file Red Hat +publishes. Run one cluster at a time: stop minikube before `crc start`. + +```bash +CRC_VERSION=2.64.0 +mkdir -p ~/.local/bin && cd "$(mktemp -d)" +base="https://mirror.openshift.com/pub/openshift-v4/clients/crc/${CRC_VERSION}" +curl -fsSLO "${base}/crc-linux-amd64.tar.xz" +curl -fsSL "${base}/sha256sum.txt" | grep ' crc-linux-amd64.tar.xz$' | sha256sum -c - +tar -xJf crc-linux-amd64.tar.xz --strip-components=1 --wildcards '*/crc' +install crc ~/.local/bin/crc +sudo dnf install -y skopeo jq +crc setup +``` + +`crc setup` also makes `api.crc.testing` and `*.apps-crc.testing` resolve on the host, which is +what lets the Routes work without tunnels. `oc` comes with CRC: `eval "$(crc oc-env)"`. + +Download your pull secret from the Red Hat Hybrid Cloud Console (a free Red Hat account is +enough) to a file, and give crc its path once. The scripts never open the file, and nothing in +this book prints it: + +```bash +crc config set pull-secret-file ~/Downloads/pull-secret.txt +crc config set cpus 12 +crc config set memory 32768 +crc config set disk-size 80 +``` + +Sizing takes effect only while CRC is stopped. The scripts check it and never change it. They +use the `crc-admin` kubeconfig context that `crc start` writes, so no password is needed. If you +want the web console login, run `crc console --credentials` yourself. + Verify: ```bash minikube version kubectl version --client helm version --short +crc version +skopeo --version ``` --- @@ -252,6 +291,7 @@ container. |-------|--------|------|-----| | Docker compose examples | ~2-4 GB | ~3 GB for images | 2+ cores | | Minikube, Example 05 (optional) | 8 GB for the node | 40 GB (profile disk) | 4+ cores | +| OpenShift Local, Example 05 (optional) | 32 GB for the VM (32768 MiB) | 80 GB (VM disk) | 12 vCPU | The LGTM observability stack (Grafana, Loki, Tempo, Mimir) is the heaviest shared component at ~1.5 GB. @@ -281,7 +321,7 @@ for cmd in curl jq git python3 conftest newman; do printf " %-10s %s\n" "$cmd" "$(command -v $cmd >/dev/null && echo 'ok' || echo 'MISSING')" done printf "\nOptional:\n" -for cmd in minikube kubectl helm hey ghz; do +for cmd in minikube kubectl helm crc skopeo hey ghz; do printf " %-10s %s\n" "$cmd" "$(command -v $cmd >/dev/null && echo 'ok' || echo 'not installed')" done ``` diff --git a/_plans/modernization-plan.md b/_plans/modernization-plan.md index fa1b754a..7e53ad24 100644 --- a/_plans/modernization-plan.md +++ b/_plans/modernization-plan.md @@ -31,8 +31,8 @@ Each step gets its own branch, a PR, green CI and a squash-merge before the next | S6 | chore/go-toolchain | 2+4 | done (18/18 Go + 06 in all six languages PASS after the Opus review; closes #34) | | S7 | chore/cpp-toolchain-ubi10 | 2+4 | done (111/111 PASS after two Opus repair rounds; closes #35) | | S8 | chore/infra-images | 2 | done (111/111 PASS; two Opus repair rounds; PR #66; lgtm-skills #45 + r5.x; main run green, Actions cache 2.4 GB) | -| S9 | docs/minikube-docker-driver (+ 05 on minikube) | 7 | executed (awaiting validation) | -| S10 | feature/openshift-local-appendix | 8 | pending | +| S9 | docs/minikube-docker-driver (+ 05 on minikube) | 7 | done (PR #67) | +| S10 | feature/openshift-local-appendix | 8 | validated (PR pending) | | S11 | docs/professional-voice | 9 | pending | | S12 | docs/deck-refresh | 10 (expanded 2026-10-11) | pending | @@ -84,6 +84,11 @@ Each step gets its own branch, a PR, green CI and a squash-merge before the next - S8: 0 :latest, -latest or kafka-ui; full matrix green. - S9: minikube appendix with evidence; profile stopped. - S10: Appendix S with evidence for 5 languages; scrub clean; CRC stopped and clean. + - Executed 2026-10-11 (awaiting validation). Evidence: `/tmp/claude-1000/-home-rsedor-Dev/0dea024d-0bf0-4ae5-bddd-7dec0cf60b22/scratchpad/s10/live/`. crc 2.64.0, OpenShift 4.22.14, Kubernetes v1.35.6, AMQ Streams `amqstreams.v3.2.1-14` (channel `amq-streams-3.2.x`, default `stable`), Kafka 4.2.0. + - verify.sh 10 passed, 0 failed in spring-boot, quarkus, dotnet, python and go (139, 123, 125, 121, 120 s); every pod on `restricted-v2` (no anyuid needed); linked traces with all three services per language; Route HTTP 302 to https; Grafana 13.2.3 health ok. + - Three script fixes from the live run: inventory under `pipefail` on a clean cluster, waiting for cluster operators after the registry route change, ignoring OLM-copied CSVs (`-l '!olm.copiedFrom'`). + - Teardown removed the operator, 10 CRDs, namespaces and the registry route change; `crc stop` (not deleted); second teardown exit 0; current-context unset and `~/.kube/config` sha256 unchanged. + - Details: `_plans/s10-openshift-local-plan.md`, "Live results". - S11: voice scan reports zero ban-tier hits and is enforced in CI. - S12 (scope expanded by the user 2026-10-11): review every presentation in `lgtm-presentation/` (the six Designing-Cloud-Native-APIs decks, K8s-Environment-Access-Policies, Network-Policy-Process-Advisory, and their .md/.docx companions) for: - version changes (every version on a slide matches the S2–S8 pins); diff --git a/_plans/s10-openshift-local-plan.md b/_plans/s10-openshift-local-plan.md new file mode 100644 index 00000000..bff6a359 --- /dev/null +++ b/_plans/s10-openshift-local-plan.md @@ -0,0 +1,152 @@ +# S10 plan — Example 05 on OpenShift Local, Appendix S + +Approved by the user 2026-10-11. Opus plan; Sonnet executes on `feature/openshift-local-appendix`; Opus validates. + +## User decisions (2026-10-11) + +1. **.NET linked trace: fix it.** Add W3C trace-context inject/extract to the three .NET 05 services; re-verify .NET 05 on Docker (10/10, linked trace in Tempo). +2. **No registry token on disk, ever (not even tmpfs).** The token lives only in shell memory. `skopeo copy` reads credentials from a process-substitution pipe: `--dest-authfile <(printf '{"auths":{"%s":{"auth":"%s"}}}' "$REGISTRY_ROUTE" "$(printf 'builder:%s' "$token" | base64 -w0)")`. `printf` is a bash builtin, so the token never appears in any argv (`/proc/*/cmdline`). Never `skopeo login` (it writes a file), never `--dest-creds`/`--dest-registry-token` (argv). If skopeo cannot read the authfile from a pipe, stop and report; do not fall back to a file. +3. **otel-lgtm:** restricted-v2 with emptyDir mounts if the offline rehearsal passes; otherwise the scoped anyuid exception (ServiceAccount `lgtm`, namespaced RoleBinding to ClusterRole `system:openshift:scc:anyuid`, `runAsUser: 0` only), documented in the appendix. Never `oc adm policy add-scc-to-user`. + +## Findings that shape the design + +1. AMQ Streams on OCP 4.22: newest CSV `amqstreams.v3.2.1-14`, channel `amq-streams-3.2.x` (also `amq-streams-3.x`, `stable`), all install modes. Bundled broker images are Kafka 4.1 and 4.2 only, so OpenShift runs **Kafka 4.2.0** (`metadataVersion: 4.2-IV1`) while minikube runs 4.3.1. The appendix explains the difference. +2. Postgres: `registry.redhat.io/rhel10/postgresql-18:10.2-1791491063` (sclorg). Env `POSTGRESQL_USER/PASSWORD/DATABASE`; data `/var/lib/pgsql/data`; init via sourced `*.sh` hooks in `/opt/app-root/src/postgresql-init/`; extra config in `/opt/app-root/src/postgresql-cfg/`. `POSTGRESQL_USER` is not a superuser, so the schema runs as appuser. Offline rehearsal image: `quay.io/sclorg/postgresql-18-c10s` (scratch only, never committed). +3. `docker.io/grafana/otel-lgtm:0.36.0` runs as root (workdir `/otel-lgtm`, `run-all.sh`). +4. All five languages' 05 Containerfiles end in a numeric non-root `USER` with `--chown=:0 --chmod=g=u`; expected restricted-v2-ready with no change. Live gate: each pod's `openshift.io/scc` annotation. +5. .NET 05 does not propagate trace context through Kafka (decision 1). Spring Boot (OTel agent) and Quarkus propagate automatically; confirm live. +6. `~/.kube/config` has no current-context. `crc start` adds `crc-admin` and may set current-context, so restore must handle "unset". +7. `docker push` to the default route would need root-owned `/etc/docker/certs.d` or insecure-registries (permanent host change). Use skopeo 1.22.3 from `docker-daemon:` with `--dest-cert-dir` holding the ingress CA (public, user-owned dir). +8. CRDs-catalog commit `63669a570e231d4f1f8396d229a1de512bcf0a34` has `operators.coreos.com/subscription_v1alpha1.json`, `operatorgroup_v1.json`, `kafka.strimzi.io/*`, and OpenShift schemas only under `openshift/v4.15-strict/{{.ResourceKind}}_{{.Group}}_{{.ResourceAPIVersion}}.json` (route, imagestream). +9. CRC config is already 12 vCPU / 32768 MiB / 80 GB with a pull-secret-file. No change. Scripts never run `crc config set`. +10. crc 2.64.0 is newest (`crc-linux-amd64.tar.xz` sha256 `bd170b5c66b1c703f292168249408c95c3c1d101ea93e17ca0387ab3715d0c90`); `oc` 4.22.17 (kustomize 5.7.1). + +## Approach + +Layout `examples/05-event-driven/openshift/`, reusing `../k8s` via kustomize (no copies): + +- `operators/`: `namespace.yaml` (`cndp-amq-streams`), `operatorgroup.yaml` (`spec: {}`, AllNamespaces), `subscription.yaml` (package `amq-streams`, source `redhat-operators`, channel `amq-streams-3.2.x`, `startingCSV: amqstreams.v3.2.1-14`, `installPlanApproval: Manual`), label `app.kubernetes.io/part-of: cndp-05`. +- `components/openshift/` (`kind: Component`): Route `order-service` (edge, `insecureEdgeTerminationPolicy: Redirect`, targetPort `http`); every Deployment `imagePullPolicy: Always`; Service `order-service` to ClusterIP without nodePort. +- `overlays/{spring-boot,quarkus,dotnet,python,go}`: `namespace: cndp-05`, `resources: [../../../k8s/overlays/]`, `components: [../../components/openshift]`, `images:` mapping `localhost/cndp-05/-` to `image-registry.openshift-image-registry.svc:5000/cndp-05/-` (tag `v1`). No cpp. +- `infra/`: reuse `../../k8s/infra/kafka.yaml` and `lgtm.yaml`; patch Kafka `version: 4.2.0` + `metadataVersion: 4.2-IV1`; patch Service `lgtm-grafana` to ClusterIP; lgtm restricted-v2 securityContext + emptyDir `/data`, `/tmp` (or the decision-3 fallback); own `postgres.yaml` (StatefulSet `cndp-postgres`, Service `cndp-postgres-rw:5432`, PVC at `/var/lib/pgsql/data`, readiness `pg_isready -U appuser -d appdb`, restricted-v2); secretGenerator `cndp-postgres-app` as in k8s; configMapGenerator `postgres-init` from `../../db/init/*.sql` (single-sourced schema); ConfigMap `postgres-openshift` with hook `10-cndp-schema.sh` (`psql -v ON_ERROR_STOP=1 -U "$POSTGRESQL_USER" -d "$POSTGRESQL_DATABASE" -f` each SQL) and `cndp.conf` (`log_statement = 'all'`); Route `lgtm-grafana` (edge, Redirect). +- Images: same docker builds and tags as S9 (`localhost/cndp-05/-:v1`); Containerfile table moves to sourced `k8s/images.sh` used by both build scripts; push with `skopeo copy docker-daemon:… docker://$REGISTRY_ROUTE/cndp-05/-:v1 --dest-cert-dir "$STATE/registry-ca"` and the decision-2 pipe authfile. Token: `token="$(oc --context crc-admin create token builder -n cndp-05 --duration=10m)"`, unset after use. ImageStreams auto-created on push. +- Registry default route: enable only if off, annotate `cndp-05/enabled-default-route=true`; teardown reverts only with that annotation. +- Ingress CA from configmap `default-ingress-cert` (openshift-config-managed) into `$STATE/ingress-ca.crt`; `$STATE=${XDG_STATE_HOME:-~/.local/state}/cndp-05-crc`. +- Context: every call `oc --context crc-admin` (`ocx`/`ocn` wrappers); never `use-context`. `setup-crc.sh` records current-context before `crc start` and restores it in an EXIT trap (`set current-context` or `unset current-context`), then asserts. `crc start` stdout to /dev/null, stderr filtered for password/kubeadmin. +- verify.sh reused unchanged: `RUNTIME=kubernetes KUBECTL=oc KUBE_CONTEXT=crc-admin KUBE_NAMESPACE=cndp-05 BASE=https://order-service-cndp-05.apps-crc.testing` with `--cacert` via the script's curl options (generalize minimally only if needed; docker path must still pass). +- Linked-trace evidence in verify-matrix (not verify.sh): Tempo search inside the lgtm pod for order-service plus a consumer in one trace; `linked-trace.json`. +- Operator in its own namespace `cndp-amq-streams` with its own OperatorGroup (AllNamespaces) so teardown removes it all. + +Rejected: copying k8s manifests; Helm chart; `docker push`; in-cluster BuildConfigs; quarkus-openshift S2I; `openshift-operators`; SingleNamespace OperatorGroup; anyuid first; `docker.io/library/postgres` under anyuid; a dedicated KUBECONFIG for `crc start`; token in argv or in any file. + +## Steps (checkpoint commit after each) + +0. This file; `modernization-plan.md` S9 row done (PR #67), S10 in progress, pins (AMQ Streams CSV/channel, Kafka 4.2.0 on OCP, rhel10/postgresql-18 tag, skopeo 1.22.3, crc sha256, openshift CRDs-catalog location). +1. Re-check pins at execution start (crc releases, amq-streams bundles for 4.22, postgresql-18 tags, otel-lgtm tags, CRDs-catalog paths). +2. .NET 05 trace propagation (decision 1); compose verify 10/10 + linked trace; `docker compose down -v`. +3. `k8s/images.sh` extraction; same 18 (context, Containerfile) rows. +4. Manifests + offline checks: render with kubectl 1.36.5 and `oc kustomize` (identical); k8s/ renders byte-identical to main; kubeconform v0.8.0 strict locally; rehearsals (a) otel-lgtm as UID 1000700000:0 read-only root + tmpfs, (b) sclorg postgres-18 hook/ownership/uuid-ossp/trust/log_statement, (c) 15 app images numeric non-zero User. +5. Scripts in `openshift/`: `lib.sh`, `setup-crc.sh`, `build-push-images.sh`, `deploy.sh`, `verify-matrix.sh`, `teardown.sh [--namespace-only|--keep-running]`, `README.md`. shellcheck clean; never print secrets; inventory before/after (report foreign items, never delete them; refuse on a foreign AMQ Streams/Strimzi install); teardown order: KafkaTopics/KafkaUsers, Kafka/KafkaNodePool, namespace cndp-05, then Subscription, CSV, InstallPlans, OperatorGroup, owned + strimzi.io CRDs, namespace cndp-amq-streams, registry route revert, leftover check, `crc stop`; idempotent; never `crc delete`. +6. verify.sh: comments only unless live requires a minimal generalization. +7. CI: `ci_changed_projects.py` (`k8s` kind covers `k8s` and `openshift` dirs; k8s/ and db/ changes also select openshift; deleted manifests still select); `examples.yml` k8s job renamed "Kubernetes and OpenShift manifests", openshift branch with the extra schema location, kubernetes-version per `oc version`, grep assertions (5 overlays, internal-registry images, Always, no NodePort, edge+Redirect Routes, Manual + startingCSV, no runAsUser except lgtm fallback). actionlint clean. +8. Diagrams `scripts/gen_32_diagrams.py` → `assets/diagrams/32-openshift-topology` and `32-openshift-image-flow` (.svg + .excalidraw); QA-render. +9. Docs: `_docs/32-appendix-s-openshift-local.md` (marker S, order 32); index.html + README (A–S, nineteen); `_docs/05-event-driven.md` cross-check; example and k8s READMEs; both prerequisites files (OpenShift Local section, crc sha256 check, pull secret by path only, skopeo/jq, resource row); CLAUDE.md S10 rules. validate_site 0 errors, Jekyll build, voice scan 0 ban-tier. +10. Live run on CRC (main loop): preconditions (minikube stopped, docker ps empty, context recorded), setup, live pin confirmations, matrix for all 5, full teardown + `crc stop`, context unchanged, scrub clean, evidence into `modernization-plan.md`. +11. Fill recorded output into Appendix S. +12. Opus validator, up to two repair rounds, PR, CI green, squash-merge, S10 row done. + +## Acceptance criteria + +- `openshift/` renders (operators raw, infra, 5 overlays) identically with kubectl 1.36.5 and oc 4.22 kustomize; kubeconform v0.8.0 `-strict`: 0 invalid, 0 missing schemas. +- `k8s/` renders byte-identical to main; build table unchanged (18 rows). +- Exactly 5 overlays; 3 internal-registry `:v1` images each with `imagePullPolicy: Always`; no NodePort under `openshift/`; Routes `order-service` and `lgtm-grafana` edge + Redirect; no `:latest`; every pulled image fully qualified. +- Subscription Manual with `startingCSV` = live-confirmed newest CSV; only that InstallPlan approved. +- No `runAsUser`/`fsGroup`/anyuid except the documented lgtm fallback; no `oc adm policy`, no cluster-scoped binding created. +- Live: verify.sh 10/10 for spring-boot, quarkus, dotnet, python, go on CRC; each language has a linked trace (order-service + ≥1 consumer); every app pod SCC `restricted-v2`. +- Grafana `/api/health` 200 via Route with `--cacert`; HTTP redirects. No port-forward, tunnel, `-k`/`--insecure` in `openshift/`. +- Registry token never written to any file and never in argv; no `skopeo login`. +- After teardown: no `cndp-05`/`cndp-amq-streams` namespaces, amq-streams Subscription, `amqstreams.*` CSV/InstallPlan, `*.strimzi.io` CRD, our OperatorGroup, or cndp ClusterRoleBinding; registry route restored; CRC Stopped (not deleted); second teardown exits 0. +- current-context identical before and after; foreign items reported and untouched. +- Scrub finds nothing (tokens, `"auths"`, kubeadmin password, private keys) in evidence or `git diff main`. +- .NET 05 compose verify still 10/10 with a linked trace. +- Appendix S order 32 marker S; glossary order 99; figures S.1, S.2; index/README A–S; prerequisites and CLAUDE.md updated; validate_site 0 errors; Jekyll builds; voice scan 0 ban-tier. +- Selector: `k8s/base/x.yaml` selects 05/k8s and 05/openshift; `openshift/overlays/go/kustomization.yaml` only 05/openshift; `openshift/deploy.sh` only scripts; deleted `openshift/infra/route-grafana.yaml` selects 05/openshift. actionlint clean. +- PR checks site, content-ok, examples-ok green; squash-merged. + +## Risks + +- Kafka CR unsupported-version condition (check CSV `STRIMZI_KAFKA_IMAGES` first); Kafka CLI path in AMQ image (`/opt/kafka/bin`); sclorg hook ownership / uuid-ossp / socket auth (rehearsal b); otel-lgtm hidden write paths (rehearsal a, Tempo query fails loudly); nested `images:` not rewritten (render assertion); `crc start` context/password leak (trap + filter + scrub); builder SA timing (`wait_for`); route TLS not signed by ingress CA (loud x509; never disable TLS verify without asking); skopeo cannot read a pipe authfile (stop and report); AllNamespaces operator touching foreign Kafka (inventory refusal); KafkaTopic finalizer (delete topics while operator runs); TraceQL `&&` unsupported (intersect trace IDs); kubeconform kubernetes-version for OCP 4.22 (from `oc version`); full CI matrix on this PR (expected). + +## Live pin confirmations + +packagemanifest `amq-streams` channels/currentCSV; CSV `STRIMZI_KAFKA_IMAGES`; `/opt/kafka/bin` in broker pod; postgresql-18 pull via cluster pull secret; `oc version` server Kubernetes version; registry default-route initial state; `builder` SA in new projects; context name `crc-admin`; route host `-cndp-05.apps-crc.testing`; pod SCC annotations; TraceQL `&&`. + +## Status + +| Step | Status | +|------|--------| +| 0 plan | done | +| 1 pins re-check | done (offline): crc 2.64.0 and kubeconform v0.8.0 still newest, otel-lgtm 0.36.0 newest tag, CRDs-catalog route/imagestream schemas present at the pinned commit; the AMQ Streams CSV and the registry.redhat.io postgresql-18 tag need a login or the cluster, so they stay in the live confirmations | +| 2 .NET trace propagation | done (a0f03c4): compose verify 10/10, one trace holds order-service, shipping-consumer and notification-consumer | +| 3 images.sh | done (8e2992a): `k8s/images.sh` sourced by both build scripts, 18 rows unchanged | +| 4 manifests and rehearsals | done (9b19ce3): see "Offline results" | +| 5 scripts | done (7646ef7): lib.sh, setup-crc.sh, build-push-images.sh, deploy.sh, verify-matrix.sh, teardown.sh, README.md; shellcheck 0.11.0 clean | +| 6 verify.sh | done (de36779): comments only | +| 7 CI | done (f62dc44): selector and `k8s` job; actionlint 1.7.12 clean | +| 8 diagrams | done (d059628): S.1 and S.2 | +| 9 docs | done: Appendix S, index, README, chapter 05, READMEs, prerequisites, CLAUDE.md | +| 10 live | done 2026-10-11: see "Live results" | +| 11 fill recorded output | done: every live-result placeholder replaced in Appendix S and the chapter 05 footer | +| 12 validator, PR, merge | pending; repair round 1 applied (see below) | + +## Offline results (steps 4 to 9) + +- Renders: `operators`, `infra` and five overlays are identical with kubectl 1.36.5 and oc 4.22.17 kustomize; kubeconform v0.8.0 (checksum verified) `-strict -kubernetes-version 1.35.6`: 0 invalid, 0 errors, 0 skipped. The k8s overlays and infra render byte-identical to main. The Kubernetes version 1.35.6 is a to-confirm against `oc version` on the cluster. +- otel-lgtm 0.36.0 as `--user 1000700000:0 --read-only --cap-drop ALL --security-opt no-new-privileges`: writes `/data` (exec), `/tmp`, `/var/tempo`; needs `GF_PLUGINS_PREINSTALL_DISABLED=true` (otherwise Grafana removes the bundled data source plugins and fails to replace them on a read-only tree). Grafana `/api/health` ok, four data sources healthy, OTLP POST to 4318 accepted and the trace readable from Tempo. emptyDir path chosen; anyuid fallback not needed. +- sclorg postgresql-18-c10s (digest sha256:1da2e7d2f7ede454d37b73a874d78d307c4d776762206f887b586f0e9fa11549, rehearsal only): `postgresql-init` hooks run before the role and database exist, so the schema hook is `postgresql-start/10-cndp-schema.sh` (runs every start, SQL is idempotent). appuser owns the three tables, `uuid-ossp` loads, local socket auth is `trust` (no password), `log_statement` is `all`, restart is a no-op. +- All 15 app images report a numeric non-zero `User` (185 for Spring Boot and Quarkus, 1001 for .NET, Python and Go). +- skopeo 1.22.3 reads a process-substitution `--dest-authfile` (pushed to a local htpasswd registry; an unauthenticated read failed with 401). +- Not verifiable offline: the Red Hat `rhel10/postgresql-18:10.2-1791491063` tag (registry.redhat.io needs a login), the AMQ Streams CSV, and Kafka CR `kafka.strimzi.io/v1` acceptance by AMQ Streams 3.2. + +## Live results (step 10, 2026-10-11) + +Evidence (outside the repo): `/tmp/claude-1000/-home-rsedor-Dev/0dea024d-0bf0-4ae5-bddd-7dec0cf60b22/scratchpad/s10/live/` (`matrix/results.tsv`, `matrix/logs/`, `matrix/evidence//`, `setup3.log`, `teardown.log`). + +- crc 2.64.0, OpenShift 4.22.14, Kubernetes v1.35.6. +- AMQ Streams packagemanifest: default channel `stable`; `amq-streams-3.2.x` currentCSV `amqstreams.v3.2.1-14` (newest); CSV Succeeded; Kafka 4.2.0. +- All five languages: 10 passed, 0 failed (spring-boot 139 s, quarkus 123 s, dotnet 125 s, python 121 s, go 120 s). +- Every pod (Kafka, Postgres, lgtm, three app pods) on `restricted-v2`; the otel-lgtm anyuid fallback was not needed. +- Linked traces in Tempo with order-service, shipping-consumer and notification-consumer for every language. +- Plain HTTP on the Route answers 302 to https; Grafana `/api/health` ok (13.2.3). +- Teardown removed the operator, 10 CRDs, both namespaces and reverted the registry route; crc stopped, not deleted; a second teardown exited 0. current-context unset before and after; `~/.kube/config` sha256 identical. +- Not exercised live: the `runAsUser: 0` rejection; the appendix states only what `restricted-v2` documents (UID range). + +Script bugs found and fixed during the live run (a379989, 1f77342, 82fdd88): + +1. The inventory failed under `pipefail` on a clean cluster. +2. The scripts did not wait for cluster operators to settle after enabling the registry default route; `crc status` reports Starting during the API-server rollout. +3. The foreign-operator check counted OLM-copied CSVs (AllNamespaces copies the CSV into every namespace); now filtered with `-l '!olm.copiedFrom'`. + +`matrix-run1/` exposed these bugs and is not the recorded output. Items 2 and 3 are rows in the Appendix S troubleshooting table. + +## Repair round 1 (validator defects D1 to D11) + +- D1: `wait_cluster_stable ` in lib.sh (settled for a continuous 300 s, checked every 10 s, 1800 s overall); setup-crc.sh uses it after the registry route step, also after a fresh `crc start`. deploy.sh and build-push-images.sh wait for `cluster_settled` first; every apply goes through `apply_retry` (5 attempts, waits for settle between). +- D2/D8: teardown removes only CRDs owned by our CSV, only if our Subscription or CSV existed and the foreign check (`foreign_kafka_items`, OLM copies excluded, plus custom resources of the owned kinds outside cndp-05) is empty; otherwise it keeps them and says why. No `*.strimzi.io` sweep. Removed counts are re-listed; the leftover check separates "of ours" (fails the run) from "not ours, left untouched". +- D3: full mode has an EXIT trap that runs `crc stop`; waits warn and continue; exit is non-zero on any failed step or leftover of ours. `--keep-running` and `--namespace-only` never stop CRC. +- D4/D7: `--context crc-admin` on every oc command in Appendix S and the openshift README; Appendix S wording fixed (`.status.kafkaVersion`, teardown scope). +- D5: top-level `k8s/`/`openshift/` scripts select `scripts` only; deeper `.sh` select `scripts` and the manifest directory. +- D6: setup-crc.sh checks the pull-secret path (never reads it); failure hint no longer suggests a bare `crc start` without a warning. +- D9: python3 dropped from `require_tools` (no openshift script or verify.sh uses it). +- D10: setup-crc.sh exit trap exits non-zero when the current-context cannot be restored. +- D11: .NET 05 consumers skip null header values and set `ActivityStatusCode.Error` on failed processing; OrderService sets it on a failed publish. Compose verify 10/10; linked trace (order-service, shipping-consumer, notification-consumer) found in Tempo; compose down -v, `docker ps` empty. +- Live re-run (cold start, teardown) is left to the orchestrator. + +## Live verification after repair round 1 (2026-10-11) + +- Cold start (CRC Stopped, then `setup-crc.sh`, which held a 300 s settled window): all five languages 10 passed, 0 failed (spring-boot 162 s, quarkus 118 s, dotnet 117 s, python 116 s, go 114 s); linked traces with all three services; all 30 pods `restricted-v2`. Evidence: session scratchpad `s10/final/`. +- Foreign-CRD probe: with a KafkaTopic in a separate namespace, teardown kept all 10 CRDs and named the item. That exposed orphaned CRDs once the probe was gone (the CSV no longer existed); fixed by also selecting CRDs carrying OLM's `operators.coreos.com/amq-streams.` label (94ac394). The orphans were removed live (`teardown-orphans.log`). +- `deploy.sh` fails fast when the three ImageStreamTags are missing (94ac394); inventory lists `strimzi.io` CRDs. +- Final recheck (Opus): merge verdict; D1 to D11 fixed; cold-start python cycle passed. Its follow-ups, fixed in 0c874e7: refuse Strimzi CRDs not installed by our Subscription and Strimzi cluster-operator Deployments in other namespaces; teardown removes OLM's `Operator` object `amq-streams.cndp-amq-streams`; setup waits for a settled cluster before its first reads and annotates the registry config before patching; waits use wall-clock deadlines and `--request-timeout`; docs snippets and CLAUDE.md updated. +- Last cold cycle on 0c874e7: spring-boot and go 10 passed, 0 failed (166 s, 116 s), linked traces, `restricted-v2`; teardown removed the OLM Operator object, 10 CRDs and both namespaces, reverted the route, `crc stop`; second teardown exit 0; current-context unset and `~/.kube/config` sha256 unchanged (026ef3ce…721a). Evidence: `s10/last/`. +- Not ours, left untouched: stale OLM `Operator` objects from other projects on this CRC (reported to the user). diff --git a/assets/diagrams/32-openshift-image-flow.excalidraw b/assets/diagrams/32-openshift-image-flow.excalidraw new file mode 100644 index 00000000..9838923d --- /dev/null +++ b/assets/diagrams/32-openshift-image-flow.excalidraw @@ -0,0 +1,1180 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + { + "id": "r910650665", + "type": "rectangle", + "x": 10, + "y": 10, + "width": 540, + "height": 210, + "angle": 0, + "strokeColor": "#dddddd", + "backgroundColor": "#eef4fb", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 675611582, + "versionNonce": 491159061, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1285556647", + "type": "text", + "x": 22, + "y": 16, + "width": 40, + "height": 15.0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1104857976, + "versionNonce": 59655352, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Host", + "fontSize": 12, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Host", + "lineHeight": 1.25, + "baseline": 12 + }, + { + "id": "r214769757", + "type": "rectangle", + "x": 570, + "y": 10, + "width": 300, + "height": 210, + "angle": 0, + "strokeColor": "#dddddd", + "backgroundColor": "#fff8ef", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1171126489, + "versionNonce": 137354440, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1365448873", + "type": "text", + "x": 582, + "y": 16, + "width": 99.00000000000001, + "height": 15.0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 614911056, + "versionNonce": 766231494, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "OpenShift Local", + "fontSize": 12, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "OpenShift Local", + "lineHeight": 1.25, + "baseline": 12 + }, + { + "id": "r1021759609", + "type": "rectangle", + "x": 30, + "y": 55, + "width": 150, + "height": 55, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 900800337, + "versionNonce": 1759304934, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1855973053", + "type": "text", + "x": 40, + "y": 63, + "width": 100.10000000000001, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 508115244, + "versionNonce": 61708553, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Containerfile", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Containerfile", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t132959376", + "type": "text", + "x": 40, + "y": 83, + "width": 151.25, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 447764749, + "versionNonce": 164790219, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "per language, per service", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "per language, per service", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r659226618", + "type": "rectangle", + "x": 210, + "y": 55, + "width": 150, + "height": 55, + "angle": 0, + "strokeColor": "#e8870c", + "backgroundColor": "#fff8ef", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 137698139, + "versionNonce": 597605455, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1280138879", + "type": "text", + "x": 220, + "y": 63, + "width": 92.4, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 267834655, + "versionNonce": 1414315186, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "docker build", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "docker build", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t215652681", + "type": "text", + "x": 220, + "y": 83, + "width": 114.95, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 3007537, + "versionNonce": 491817133, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "host BuildKit cache", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "host BuildKit cache", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1179822025", + "type": "rectangle", + "x": 390, + "y": 55, + "width": 140, + "height": 55, + "angle": 0, + "strokeColor": "#2f6db5", + "backgroundColor": "#eef4fb", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1005755888, + "versionNonce": 1867874605, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1587208774", + "type": "text", + "x": 400, + "y": 63, + "width": 138.60000000000002, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 108132681, + "versionNonce": 1299404164, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Docker image store", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Docker image store", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t75752407", + "type": "text", + "x": 400, + "y": 83, + "width": 127.05000000000001, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 710822372, + "versionNonce": 1547818063, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "localhost/cndp-05/...", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "localhost/cndp-05/...", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r974323116", + "type": "rectangle", + "x": 590, + "y": 55, + "width": 130, + "height": 55, + "angle": 0, + "strokeColor": "#888888", + "backgroundColor": "#f4f4f4", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 676662632, + "versionNonce": 440631272, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t219370773", + "type": "text", + "x": 600, + "y": 63, + "width": 130.9, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 392474825, + "versionNonce": 537236557, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Internal registry", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Internal registry", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1533623981", + "type": "text", + "x": 600, + "y": 83, + "width": 90.75000000000001, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 973283258, + "versionNonce": 973702717, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "ImageStream :v1", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "ImageStream :v1", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r294221387", + "type": "rectangle", + "x": 740, + "y": 55, + "width": 110, + "height": 55, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "#111111", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 333425546, + "versionNonce": 431443215, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1235101375", + "type": "text", + "x": 750, + "y": 63, + "width": 40, + "height": 17.5, + "angle": 0, + "strokeColor": "#ffffff", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 517756340, + "versionNonce": 437819298, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Pod", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Pod", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1015856312", + "type": "text", + "x": 750, + "y": 83, + "width": 78.65, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1714856642, + "versionNonce": 707032591, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "pulls, Always", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "pulls, Always", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1598874933", + "type": "rectangle", + "x": 390, + "y": 140, + "width": 140, + "height": 60, + "angle": 0, + "strokeColor": "#999999", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 497953760, + "versionNonce": 1882858767, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t531599164", + "type": "text", + "x": 400, + "y": 148, + "width": 84.7, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1847386254, + "versionNonce": 1936389204, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "skopeo copy", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "skopeo copy", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1955374059", + "type": "text", + "x": 400, + "y": 168, + "width": 139.15, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 330496628, + "versionNonce": 1113578827, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "docker-daemon: to route", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "docker-daemon: to route", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r86533548", + "type": "rectangle", + "x": 590, + "y": 140, + "width": 260, + "height": 60, + "angle": 0, + "strokeColor": "#999999", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1020480428, + "versionNonce": 188431092, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t39054949", + "type": "text", + "x": 600, + "y": 148, + "width": 200.20000000000002, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 434767438, + "versionNonce": 1270938729, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Token: builder, 10 minutes", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Token: builder, 10 minutes", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1669705858", + "type": "text", + "x": 600, + "y": 168, + "width": 169.4, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1071432250, + "versionNonce": 1310147432, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "shell variable and pipe only", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "shell variable and pipe only", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1927547590", + "type": "rectangle", + "x": 30, + "y": 250, + "width": 820, + "height": 45, + "angle": 0, + "strokeColor": "#999999", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1750792467, + "versionNonce": 1422791781, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1969561655", + "type": "text", + "x": 40, + "y": 258, + "width": 808.5000000000001, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1351585739, + "versionNonce": 1759089683, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Rejected: docker push (needs a root-owned certs.d entry), in-cluster builds, skopeo login (writes a file)", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Rejected: docker push (needs a root-owned certs.d entry), in-cluster builds, skopeo login (writes a file)", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "a1823447170", + "type": "arrow", + "x": 180, + "y": 82, + "width": 30, + "height": 0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1845753307, + "versionNonce": 1375890445, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 30, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "a1390832599", + "type": "arrow", + "x": 360, + "y": 82, + "width": 30, + "height": 0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1341332392, + "versionNonce": 1974597078, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 30, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "a607653682", + "type": "arrow", + "x": 530, + "y": 82, + "width": 60, + "height": 0, + "angle": 0, + "strokeColor": "#b8650a", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1613570628, + "versionNonce": 1074265525, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 60, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "t1794267737", + "type": "text", + "x": 560.0, + "y": 68.0, + "width": 40, + "height": 13.75, + "angle": 0, + "strokeColor": "#b8650a", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1335581726, + "versionNonce": 1552132397, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "HTTPS", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "HTTPS", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "a1448375376", + "type": "arrow", + "x": 720, + "y": 82, + "width": 20, + "height": 0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1418241542, + "versionNonce": 1854704201, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 20, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "a1671505739", + "type": "arrow", + "x": 460, + "y": 110, + "width": 0, + "height": 30, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 927671176, + "versionNonce": 206921496, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0, + 30 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "a1014099759", + "type": "arrow", + "x": 590, + "y": 170, + "width": 60, + "height": 0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1375371553, + "versionNonce": 277072812, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -60, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + } + ], + "appState": { + "viewBackgroundColor": "#ffffff", + "gridSize": null + }, + "files": {} +} \ No newline at end of file diff --git a/assets/diagrams/32-openshift-image-flow.svg b/assets/diagrams/32-openshift-image-flow.svg new file mode 100644 index 00000000..de96ee95 --- /dev/null +++ b/assets/diagrams/32-openshift-image-flow.svg @@ -0,0 +1,38 @@ + + + +Host + +OpenShift Local + +Containerfile +per language, per service + +docker build +host BuildKit cache + +Docker image store +localhost/cndp-05/... + +Internal registry +ImageStream :v1 + +Pod +pulls, Always + +skopeo copy +docker-daemon: to route + +Token: builder, 10 minutes +shell variable and pipe only + +Rejected: docker push (needs a root-owned certs.d entry), in-cluster builds, skopeo login (writes a file) + + + +HTTPS +HTTPS + + + + \ No newline at end of file diff --git a/assets/diagrams/32-openshift-topology.excalidraw b/assets/diagrams/32-openshift-topology.excalidraw new file mode 100644 index 00000000..39251238 --- /dev/null +++ b/assets/diagrams/32-openshift-topology.excalidraw @@ -0,0 +1,1959 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + { + "id": "r5661631", + "type": "rectangle", + "x": 10, + "y": 10, + "width": 200, + "height": 420, + "angle": 0, + "strokeColor": "#dddddd", + "backgroundColor": "#eef4fb", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1974392346, + "versionNonce": 1483876009, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t260414580", + "type": "text", + "x": 22, + "y": 16, + "width": 40, + "height": 15.0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1087834558, + "versionNonce": 269736587, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Host", + "fontSize": 12, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Host", + "lineHeight": 1.25, + "baseline": 12 + }, + { + "id": "r1806899972", + "type": "rectangle", + "x": 230, + "y": 10, + "width": 640, + "height": 420, + "angle": 0, + "strokeColor": "#dddddd", + "backgroundColor": "#f7f7f7", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1761514812, + "versionNonce": 595015879, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1417008682", + "type": "text", + "x": 242, + "y": 16, + "width": 264.0, + "height": 15.0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 346997921, + "versionNonce": 754906408, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "OpenShift Local (CRC VM, OpenShift 4.22)", + "fontSize": 12, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "OpenShift Local (CRC VM, OpenShift 4.22)", + "lineHeight": 1.25, + "baseline": 12 + }, + { + "id": "r1528932943", + "type": "rectangle", + "x": 250, + "y": 40, + "width": 160, + "height": 380, + "angle": 0, + "strokeColor": "#dddddd", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1825984498, + "versionNonce": 1891765394, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1268598475", + "type": "text", + "x": 262, + "y": 46, + "width": 105.60000000000001, + "height": 15.0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 9462106, + "versionNonce": 647231258, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "cndp-amq-streams", + "fontSize": 12, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "cndp-amq-streams", + "lineHeight": 1.25, + "baseline": 12 + }, + { + "id": "r295114170", + "type": "rectangle", + "x": 430, + "y": 40, + "width": 425, + "height": 380, + "angle": 0, + "strokeColor": "#dddddd", + "backgroundColor": "#fff8ef", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1697396831, + "versionNonce": 1265041864, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1001239507", + "type": "text", + "x": 442, + "y": 46, + "width": 217.8, + "height": 15.0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 469689840, + "versionNonce": 494818192, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "namespace cndp-05 (restricted-v2)", + "fontSize": 12, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "namespace cndp-05 (restricted-v2)", + "lineHeight": 1.25, + "baseline": 12 + }, + { + "id": "r1376315142", + "type": "rectangle", + "x": 25, + "y": 60, + "width": 170, + "height": 55, + "angle": 0, + "strokeColor": "#2f6db5", + "backgroundColor": "#eef4fb", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 373692045, + "versionNonce": 1105565397, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1951947296", + "type": "text", + "x": 35, + "y": 68, + "width": 115.50000000000001, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1317081748, + "versionNonce": 1120876527, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "curl, verify.sh", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "curl, verify.sh", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t375175668", + "type": "text", + "x": 35, + "y": 88, + "width": 114.95, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1111170847, + "versionNonce": 431858865, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "--cacert ingress CA", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "--cacert ingress CA", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1434891049", + "type": "rectangle", + "x": 25, + "y": 135, + "width": 170, + "height": 55, + "angle": 0, + "strokeColor": "#2f6db5", + "backgroundColor": "#eef4fb", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1342928594, + "versionNonce": 706613185, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1641182787", + "type": "text", + "x": 35, + "y": 143, + "width": 53.900000000000006, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 701067159, + "versionNonce": 1639162881, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Browser", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Browser", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t892642005", + "type": "text", + "x": 35, + "y": 163, + "width": 108.9, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1400238015, + "versionNonce": 1352743490, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "lgtm-grafana Route", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "lgtm-grafana Route", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r38754784", + "type": "rectangle", + "x": 25, + "y": 210, + "width": 170, + "height": 55, + "angle": 0, + "strokeColor": "#888888", + "backgroundColor": "#f4f4f4", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1259144739, + "versionNonce": 1265741055, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1828750800", + "type": "text", + "x": 35, + "y": 218, + "width": 169.4, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1911596152, + "versionNonce": 1087204960, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "oc --context crc-admin", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "oc --context crc-admin", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t589760515", + "type": "text", + "x": 35, + "y": 238, + "width": 84.7, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1345283914, + "versionNonce": 1453791471, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "exec into pods", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "exec into pods", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r873114449", + "type": "rectangle", + "x": 25, + "y": 285, + "width": 170, + "height": 55, + "angle": 0, + "strokeColor": "#999999", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1907826396, + "versionNonce": 899069907, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t296820827", + "type": "text", + "x": 35, + "y": 293, + "width": 130.9, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1216713179, + "versionNonce": 273189603, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "minikube profiles", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "minikube profiles", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1910136136", + "type": "text", + "x": 35, + "y": 313, + "width": 42.35, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1382319992, + "versionNonce": 1423129261, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "stopped", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "stopped", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1713385802", + "type": "rectangle", + "x": 265, + "y": 125, + "width": 130, + "height": 60, + "angle": 0, + "strokeColor": "#e8870c", + "backgroundColor": "#fff8ef", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1838740449, + "versionNonce": 936751982, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1608887420", + "type": "text", + "x": 275, + "y": 133, + "width": 92.4, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1734652986, + "versionNonce": 1804560643, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Subscription", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Subscription", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1388085130", + "type": "text", + "x": 275, + "y": 153, + "width": 108.9, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 584801323, + "versionNonce": 1533728468, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Manual, pinned CSV", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Manual, pinned CSV", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1674999485", + "type": "rectangle", + "x": 265, + "y": 310, + "width": 130, + "height": 60, + "angle": 0, + "strokeColor": "#e8870c", + "backgroundColor": "#fff8ef", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 880603028, + "versionNonce": 742273219, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1519704052", + "type": "text", + "x": 275, + "y": 318, + "width": 84.7, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1331267802, + "versionNonce": 1083278018, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "AMQ Streams", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "AMQ Streams", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t738385661", + "type": "text", + "x": 275, + "y": 338, + "width": 72.60000000000001, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1051512627, + "versionNonce": 85148413, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "operator 3.2", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "operator 3.2", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1632906909", + "type": "rectangle", + "x": 450, + "y": 80, + "width": 190, + "height": 55, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "#111111", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 600700308, + "versionNonce": 874943756, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1691110196", + "type": "text", + "x": 460, + "y": 88, + "width": 100.10000000000001, + "height": 17.5, + "angle": 0, + "strokeColor": "#ffffff", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1510708944, + "versionNonce": 177095036, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "order-service", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "order-service", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t877952113", + "type": "text", + "x": 460, + "y": 108, + "width": 90.75000000000001, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 780030322, + "versionNonce": 363960115, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Route, edge TLS", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Route, edge TLS", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1125063341", + "type": "rectangle", + "x": 660, + "y": 80, + "width": 180, + "height": 55, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 539394891, + "versionNonce": 786685317, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t427562664", + "type": "text", + "x": 670, + "y": 88, + "width": 130.9, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1804291043, + "versionNonce": 1994633192, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "shipping-consumer", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "shipping-consumer", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t759930297", + "type": "text", + "x": 670, + "y": 108, + "width": 127.05000000000001, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 632235390, + "versionNonce": 1781293610, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "notification-consumer", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "notification-consumer", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r363095899", + "type": "rectangle", + "x": 450, + "y": 180, + "width": 190, + "height": 60, + "angle": 0, + "strokeColor": "#e8870c", + "backgroundColor": "#fff8ef", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 909010902, + "versionNonce": 1438305169, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t42786820", + "type": "text", + "x": 460, + "y": 188, + "width": 123.20000000000002, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 327856656, + "versionNonce": 409430822, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Kafka cndp 4.2.0", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Kafka cndp 4.2.0", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t790039327", + "type": "text", + "x": 460, + "y": 208, + "width": 151.25, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1328455563, + "versionNonce": 485678920, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "cndp-kafka-bootstrap:9092", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "cndp-kafka-bootstrap:9092", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r623366137", + "type": "rectangle", + "x": 660, + "y": 180, + "width": 180, + "height": 60, + "angle": 0, + "strokeColor": "#e8870c", + "backgroundColor": "#fff8ef", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 399447588, + "versionNonce": 1084781712, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1359444550", + "type": "text", + "x": 670, + "y": 188, + "width": 154.0, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 183785493, + "versionNonce": 1522716562, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Postgres 18 (sclorg)", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Postgres 18 (sclorg)", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1184635546", + "type": "text", + "x": 670, + "y": 208, + "width": 127.05000000000001, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 778879870, + "versionNonce": 695022289, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "cndp-postgres-rw:5432", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "cndp-postgres-rw:5432", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r604907478", + "type": "rectangle", + "x": 450, + "y": 285, + "width": 190, + "height": 60, + "angle": 0, + "strokeColor": "#888888", + "backgroundColor": "#f4f4f4", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1929497403, + "versionNonce": 733827556, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1668428429", + "type": "text", + "x": 460, + "y": 293, + "width": 123.20000000000002, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1247033944, + "versionNonce": 1594485000, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "LGTM (otel-lgtm)", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "LGTM (otel-lgtm)", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1220602704", + "type": "text", + "x": 460, + "y": 313, + "width": 145.20000000000002, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 415261631, + "versionNonce": 80975342, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "lgtm:4318, Grafana Route", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "lgtm:4318, Grafana Route", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r217163189", + "type": "rectangle", + "x": 660, + "y": 285, + "width": 180, + "height": 60, + "angle": 0, + "strokeColor": "#999999", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1650712437, + "versionNonce": 1043377149, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t390215541", + "type": "text", + "x": 670, + "y": 293, + "width": 184.8, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 715837131, + "versionNonce": 453895172, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Secret cndp-postgres-app", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Secret cndp-postgres-app", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t687978482", + "type": "text", + "x": 670, + "y": 313, + "width": 108.9, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1254349433, + "versionNonce": 1674853318, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "username, password", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "username, password", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "a576394665", + "type": "arrow", + "x": 195, + "y": 87, + "width": 255, + "height": 20, + "angle": 0, + "strokeColor": "#b8650a", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 624143510, + "versionNonce": 1869821509, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 255, + 20 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "a661583263", + "type": "arrow", + "x": 195, + "y": 162, + "width": 255, + "height": 153, + "angle": 0, + "strokeColor": "#b8650a", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1674403405, + "versionNonce": 217166341, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 255, + 153 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "a261316217", + "type": "arrow", + "x": 195, + "y": 237, + "width": 255, + "height": 22, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 414864211, + "versionNonce": 19890967, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 255, + -22 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "a858218968", + "type": "arrow", + "x": 330, + "y": 310, + "width": 120, + "height": 85, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1934719536, + "versionNonce": 334963604, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 120, + -85 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "t791382376", + "type": "text", + "x": 390.0, + "y": 253.5, + "width": 42.35, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 777738145, + "versionNonce": 1876364501, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "manages", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "manages", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "a1754830603", + "type": "arrow", + "x": 330, + "y": 185, + "width": 0, + "height": 125, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1143244368, + "versionNonce": 1754342744, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0, + 125 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "t145205742", + "type": "text", + "x": 330.0, + "y": 233.5, + "width": 72.60000000000001, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 838107312, + "versionNonce": 405771684, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "OLM installs", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "OLM installs", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "a1883533006", + "type": "arrow", + "x": 545, + "y": 135, + "width": 0, + "height": 45, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1540448280, + "versionNonce": 1252941857, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0, + 45 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "t756124848", + "type": "text", + "x": 545.0, + "y": 143.5, + "width": 42.35, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1027521173, + "versionNonce": 8459247, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "produce", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "produce", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "a261223154", + "type": "arrow", + "x": 750, + "y": 135, + "width": 0, + "height": 45, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 530980036, + "versionNonce": 509300264, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0, + 45 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "t94705884", + "type": "text", + "x": 750.0, + "y": 143.5, + "width": 40, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1755198300, + "versionNonce": 1144431130, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "insert", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "insert", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "a1846180035", + "type": "arrow", + "x": 640, + "y": 208, + "width": 20, + "height": 0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 991930716, + "versionNonce": 167692156, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 20, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "t1571832893", + "type": "text", + "x": 25, + "y": 405, + "width": 586.85, + "height": 13.75, + "angle": 0, + "strokeColor": "#888888", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 419855096, + "versionNonce": 1077577225, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Routes terminate TLS at the router; HTTP is redirected. No port-forward, tunnel or insecure curl.", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Routes terminate TLS at the router; HTTP is redirected. No port-forward, tunnel or insecure curl.", + "lineHeight": 1.25, + "baseline": 11 + } + ], + "appState": { + "viewBackgroundColor": "#ffffff", + "gridSize": null + }, + "files": {} +} \ No newline at end of file diff --git a/assets/diagrams/32-openshift-topology.svg b/assets/diagrams/32-openshift-topology.svg new file mode 100644 index 00000000..9cd9b447 --- /dev/null +++ b/assets/diagrams/32-openshift-topology.svg @@ -0,0 +1,64 @@ + + + +Host + +OpenShift Local (CRC VM, OpenShift 4.22) + +cndp-amq-streams + +namespace cndp-05 (restricted-v2) + +curl, verify.sh +--cacert ingress CA + +Browser +lgtm-grafana Route + +oc --context crc-admin +exec into pods + +minikube profiles +stopped + +Subscription +Manual, pinned CSV + +AMQ Streams +operator 3.2 + +order-service +Route, edge TLS + +shipping-consumer +notification-consumer + +Kafka cndp 4.2.0 +cndp-kafka-bootstrap:9092 + +Postgres 18 (sclorg) +cndp-postgres-rw:5432 + +LGTM (otel-lgtm) +lgtm:4318, Grafana Route + +Secret cndp-postgres-app +username, password + + + + +manages +manages + +OLM installs +OLM installs + +produce +produce + +insert +insert + +Routes terminate TLS at the router; HTTP is redirected. No port-forward, tunnel or insecure curl. + \ No newline at end of file diff --git a/examples/05-event-driven/README.md b/examples/05-event-driven/README.md index f10a3cff..84551385 100644 --- a/examples/05-event-driven/README.md +++ b/examples/05-event-driven/README.md @@ -81,6 +81,21 @@ k8s/setup-profile.sh && k8s/build-images.sh go && k8s/deploy.sh go RUNTIME=kubernetes BASE=http://127.0.0.1:30080 ./verify.sh ``` +## Run on OpenShift Local + +The same services (Spring Boot, Quarkus, .NET, Python and Go) and the same `verify.sh` run on +OpenShift Local with AMQ Streams for Kafka, the Red Hat Postgres image, pods under the +`restricted-v2` SCC and edge-TLS Routes. See [`openshift/README.md`](openshift/README.md) for the +commands and Appendix S in the site for the design. It needs CRC sized 12 vCPU and 32768 MiB, and it +refuses to start while minikube or any container is running. + +```bash +openshift/setup-crc.sh && openshift/build-push-images.sh go && openshift/deploy.sh go +RUNTIME=kubernetes KUBECTL=oc KUBE_CONTEXT=crc-admin BASE=https://order-service-cndp-05.apps-crc.testing \ + CURL_OPTS="--cacert $HOME/.local/state/cndp-05-crc/ingress-ca.crt" ./verify.sh +openshift/teardown.sh +``` + ## Verify From the example root (not the language directory): @@ -94,7 +109,7 @@ cd .. # if you're in a language directory Open Grafana at http://localhost:3000: -- **Tempo** — traces from order-service, shipping-consumer, notification-consumer +- **Tempo** — traces from order-service, shipping-consumer, notification-consumer (the trace context travels in the Kafka record headers, so one trace spans the producer and both consumers) - **Loki** — search for `order.placed` to see the event flow across services ## Ports diff --git a/examples/05-event-driven/dotnet/NotificationConsumer/Program.cs b/examples/05-event-driven/dotnet/NotificationConsumer/Program.cs index 8477f8e6..ddefdc81 100644 --- a/examples/05-event-driven/dotnet/NotificationConsumer/Program.cs +++ b/examples/05-event-driven/dotnet/NotificationConsumer/Program.cs @@ -1,8 +1,11 @@ +using System.Diagnostics; using System.Runtime.InteropServices; +using System.Text; using System.Text.Json; using Confluent.Kafka; using Npgsql; using OpenTelemetry; +using OpenTelemetry.Context.Propagation; using OpenTelemetry.Exporter; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -22,7 +25,7 @@ o.Protocol = OtlpExportProtocol.HttpProtobuf; }) .Build(); -var tracer = tracerProvider!.GetTracer("notification-consumer"); +var source = new ActivitySource("notification-consumer"); var dsBuilder = new NpgsqlDataSourceBuilder(dbUrl); await using var dataSource = dsBuilder.Build(); @@ -66,12 +69,25 @@ catch (ConsumeException) { Thread.Sleep(1000); continue; } if (cr is null) continue; - using var span = tracer.StartActiveSpan("send_notification"); + // Continue the producer's trace: read the W3C traceparent that order-service wrote into the record headers. + var parent = Propagators.DefaultTextMapPropagator.Extract( + default, + cr.Message.Headers, + (headers, key) => headers is null + ? Array.Empty() + : headers.Where(h => h.Key == key) + .Select(h => h.GetValueBytes()) + .Where(b => b is not null) + .Select(b => Encoding.UTF8.GetString(b!))); + Baggage.Current = parent.Baggage; + using var span = source.StartActivity("send_notification", ActivityKind.Consumer, parent.ActivityContext); + span?.SetTag("messaging.system", "kafka"); + span?.SetTag("messaging.destination.name", "order.placed"); try { using var doc = JsonDocument.Parse(cr.Message.Value); var orderId = doc.RootElement.GetProperty("id").GetString()!; - span.SetAttribute("order.id", orderId); + span?.SetTag("order.id", orderId); await using var conn = await dataSource.OpenConnectionAsync(cts.Token); await using var cmd = conn.CreateCommand(); @@ -85,6 +101,7 @@ } catch (Exception ex) when (ex is not OperationCanceledException) { + span?.SetStatus(ActivityStatusCode.Error, ex.Message); Console.WriteLine($"Error processing message: {ex.Message}"); } } diff --git a/examples/05-event-driven/dotnet/OrderService/Program.cs b/examples/05-event-driven/dotnet/OrderService/Program.cs index 98625c22..0c8b9d02 100644 --- a/examples/05-event-driven/dotnet/OrderService/Program.cs +++ b/examples/05-event-driven/dotnet/OrderService/Program.cs @@ -1,10 +1,17 @@ +using System.Diagnostics; +using System.Text; using System.Text.Json; using Confluent.Kafka; using Npgsql; +using OpenTelemetry; +using OpenTelemetry.Context.Propagation; using OpenTelemetry.Exporter; using OpenTelemetry.Resources; using OpenTelemetry.Trace; +// Producer spans come from this source; AddSource below makes the SDK record and export them. +var orderSource = new ActivitySource("order-service"); + var builder = WebApplication.CreateBuilder(args); builder.WebHost.UseUrls("http://+:8080"); @@ -12,6 +19,7 @@ .ConfigureResource(r => r.AddService("order-service")) .WithTracing(t => t .AddAspNetCoreInstrumentation() + .AddSource("order-service") // 4318 is the OTLP/HTTP port: the exporter defaults to gRPC, and a code-set endpoint is used as given. .AddOtlpExporter(o => { @@ -69,15 +77,30 @@ if (producer is not null) { + Activity? publish = null; try { - await producer.ProduceAsync("order.placed", - new Message { Value = json }); + // Confluent.Kafka has no built-in tracing: write the W3C traceparent into the record headers + // so the consumers can continue this trace. + publish = orderSource.StartActivity("order.placed publish", ActivityKind.Producer); + publish?.SetTag("messaging.system", "kafka"); + publish?.SetTag("messaging.destination.name", "order.placed"); + var message = new Message { Value = json, Headers = new Headers() }; + Propagators.DefaultTextMapPropagator.Inject( + new PropagationContext(publish?.Context ?? Activity.Current?.Context ?? default, Baggage.Current), + message.Headers, + (headers, key, value) => headers.Add(key, Encoding.UTF8.GetBytes(value))); + await producer.ProduceAsync("order.placed", message); } catch (Exception ex) { + publish?.SetStatus(ActivityStatusCode.Error, ex.Message); app.Logger.LogWarning("Kafka publish failed: {Error}", ex.Message); } + finally + { + publish?.Dispose(); + } } return Results.Json(order, statusCode: 201); diff --git a/examples/05-event-driven/dotnet/ShippingConsumer/Program.cs b/examples/05-event-driven/dotnet/ShippingConsumer/Program.cs index d568d8a8..be12cc9c 100644 --- a/examples/05-event-driven/dotnet/ShippingConsumer/Program.cs +++ b/examples/05-event-driven/dotnet/ShippingConsumer/Program.cs @@ -1,8 +1,11 @@ +using System.Diagnostics; using System.Runtime.InteropServices; +using System.Text; using System.Text.Json; using Confluent.Kafka; using Npgsql; using OpenTelemetry; +using OpenTelemetry.Context.Propagation; using OpenTelemetry.Exporter; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -22,7 +25,7 @@ o.Protocol = OtlpExportProtocol.HttpProtobuf; }) .Build(); -var tracer = tracerProvider!.GetTracer("shipping-consumer"); +var source = new ActivitySource("shipping-consumer"); var dsBuilder = new NpgsqlDataSourceBuilder(dbUrl); await using var dataSource = dsBuilder.Build(); @@ -66,12 +69,25 @@ catch (ConsumeException) { Thread.Sleep(1000); continue; } if (cr is null) continue; - using var span = tracer.StartActiveSpan("process_shipment"); + // Continue the producer's trace: read the W3C traceparent that order-service wrote into the record headers. + var parent = Propagators.DefaultTextMapPropagator.Extract( + default, + cr.Message.Headers, + (headers, key) => headers is null + ? Array.Empty() + : headers.Where(h => h.Key == key) + .Select(h => h.GetValueBytes()) + .Where(b => b is not null) + .Select(b => Encoding.UTF8.GetString(b!))); + Baggage.Current = parent.Baggage; + using var span = source.StartActivity("process_shipment", ActivityKind.Consumer, parent.ActivityContext); + span?.SetTag("messaging.system", "kafka"); + span?.SetTag("messaging.destination.name", "order.placed"); try { using var doc = JsonDocument.Parse(cr.Message.Value); var orderId = doc.RootElement.GetProperty("id").GetString()!; - span.SetAttribute("order.id", orderId); + span?.SetTag("order.id", orderId); await using var conn = await dataSource.OpenConnectionAsync(cts.Token); await using var cmd = conn.CreateCommand(); @@ -85,6 +101,7 @@ } catch (Exception ex) when (ex is not OperationCanceledException) { + span?.SetStatus(ActivityStatusCode.Error, ex.Message); Console.WriteLine($"Error processing message: {ex.Message}"); } } diff --git a/examples/05-event-driven/k8s/README.md b/examples/05-event-driven/k8s/README.md index f317cdf2..a7466036 100644 --- a/examples/05-event-driven/k8s/README.md +++ b/examples/05-event-driven/k8s/README.md @@ -2,7 +2,8 @@ The same three services and the same `verify.sh`, on a single-node Kubernetes cluster: minikube with the Docker driver and containerd, Kubernetes v1.36.5. Appendix R in the -site walks through the design; this page is the command reference. +site walks through the design; this page is the command reference. The OpenShift Local variant +(Appendix S) reuses these manifests from [`../openshift`](../openshift/README.md). ## Layout @@ -13,6 +14,7 @@ site walks through the design; this page is the command reference. | `base/` | The three Deployments and the order-service NodePort Service | | `overlays//` | Image names and the environment variables that language reads | | `setup-profile.sh` | Create or start the `cndp-k8s` profile with the NodePorts published on 127.0.0.1 | +| `images.sh` | The image table (18 Containerfile rows), sourced by this directory's and `../openshift`'s build scripts | | `build-images.sh` | `docker build` each image, then `minikube image load` it | | `deploy.sh` | Strimzi operator, infrastructure, one language overlay | | `verify-matrix.sh` | Build, deploy, verify and tear down each language in turn | diff --git a/examples/05-event-driven/k8s/build-images.sh b/examples/05-event-driven/k8s/build-images.sh index a4709a82..cf8754f0 100755 --- a/examples/05-event-driven/k8s/build-images.sh +++ b/examples/05-event-driven/k8s/build-images.sh @@ -8,32 +8,19 @@ set -euo pipefail # shellcheck source=lib.sh # shellcheck source-path=SCRIPTDIR source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=images.sh +# shellcheck source-path=SCRIPTDIR +source "$(dirname "${BASH_SOURCE[0]}")/images.sh" [[ $# -ge 1 ]] || fail "usage: build-images.sh ... (${LANGS[*]})" require_profile_running -SERVICES=(order-service shipping-consumer notification-consumer) - -# build_args prints " " relative to the example directory. -# .NET builds from one context with a Containerfile per service; the others have a -# directory per service. -build_args() { - local lang="$1" svc="$2" short - if [[ "$lang" == dotnet ]]; then - short="${svc%%-*}" - echo "dotnet Containerfile.${short}" - else - echo "${lang}/${svc} Containerfile" - fi -} - for lang in "$@"; do valid_lang "$lang" || fail "unknown language '$lang' (${LANGS[*]})" for svc in "${SERVICES[@]}"; do - read -r ctx cf <<<"$(build_args "$lang" "$svc")" - image="localhost/cndp-05/${lang}-${svc}:${IMAGE_TAG}" + image="$(image_ref "$lang" "$svc")" log "build $image" - docker build -t "$image" -f "$EXAMPLE_DIR/$ctx/$cf" "$EXAMPLE_DIR/$ctx" + build_image "$lang" "$svc" log "load $image into $PROFILE" minikube -p "$PROFILE" image load --overwrite=true "$image" done diff --git a/examples/05-event-driven/k8s/images.sh b/examples/05-event-driven/k8s/images.sh new file mode 100755 index 00000000..e5854a41 --- /dev/null +++ b/examples/05-event-driven/k8s/images.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# The Example 05 image table, shared by k8s/build-images.sh (minikube) and +# openshift/build-push-images.sh (OpenShift Local). Source it; do not run it. +# Needs EXAMPLE_DIR (the 05-event-driven directory) and IMAGE_TAG from the caller. +# +# 6 languages x 3 services = 18 images, all built with docker so the host BuildKit caches apply. +# Names: localhost/cndp-05/-:. + +# shellcheck disable=SC2034 # SERVICES is used by the scripts that source this file +SERVICES=(order-service shipping-consumer notification-consumer) + +# build_args prints " " relative to the example directory. +# .NET builds from one context with a Containerfile per service; the others have a +# directory per service. +build_args() { + local lang="$1" svc="$2" short + if [[ "$lang" == dotnet ]]; then + short="${svc%%-*}" + echo "dotnet Containerfile.${short}" + else + echo "${lang}/${svc} Containerfile" + fi +} + +# image_ref prints the local image name. +image_ref() { echo "localhost/cndp-05/${1}-${2}:${IMAGE_TAG}"; } + +# build_image builds one image from its Containerfile. +build_image() { + local lang="$1" svc="$2" ctx cf + read -r ctx cf <<<"$(build_args "$lang" "$svc")" + docker build -t "$(image_ref "$lang" "$svc")" -f "$EXAMPLE_DIR/$ctx/$cf" "$EXAMPLE_DIR/$ctx" +} diff --git a/examples/05-event-driven/openshift/README.md b/examples/05-event-driven/openshift/README.md new file mode 100644 index 00000000..9ea176ab --- /dev/null +++ b/examples/05-event-driven/openshift/README.md @@ -0,0 +1,92 @@ +# Example 05 on OpenShift Local + +The same three services and the same `verify.sh`, on OpenShift Local (CRC) with Red Hat's +operator, Postgres and registry model. Appendix S in the site walks through the design; this +page is the command reference. It reuses the minikube manifests in `../k8s` through kustomize +and changes only what OpenShift needs. + +## Layout + +| Path | Purpose | +|------|---------| +| `operators/` | Namespace `cndp-amq-streams`, an AllNamespaces OperatorGroup and the AMQ Streams Subscription (pinned CSV, Manual approval) | +| `infra/` | Kafka and LGTM from `../k8s/infra` with patches, a Postgres StatefulSet on the Red Hat sclorg image, the Grafana Route | +| `components/openshift/` | Kustomize component: the `order-service` Route, ClusterIP Service, `imagePullPolicy: Always` | +| `overlays//` | The `../k8s` overlay plus the component, with images from the internal registry (five languages, no C++) | +| `lib.sh` | Shared settings and helpers: the `crc-admin` context, `wait_for`, operator install, inventory, secret scrub | +| `setup-crc.sh` | Start CRC, record and restore the current-context, fetch the ingress CA, enable the registry route if it is off | +| `build-push-images.sh` | `docker build`, then `skopeo copy` to the internal registry with the `builder` token | +| `deploy.sh` | Operator, infrastructure, one language overlay, health poll through the Route | +| `verify-matrix.sh` | Build, push, deploy, verify, collect evidence for each language; project removed between languages | +| `teardown.sh` | Remove everything, revert the registry route, `crc stop` | + +## Prerequisites + +Docker Engine, `oc`, `crc` 2.64.0 and `skopeo` (see [Prerequisites](../../PREREQUISITES.md)). CRC must +be sized 12 vCPU and 32768 MiB with a pull secret configured; the scripts never change the size and +never open the pull secret. Run one workshop at a time: the scripts refuse to start while a +minikube profile or any container is running, and they never stop them for you. + +## Run it + +```bash +cd examples/05-event-driven +openshift/setup-crc.sh # crc start, ingress CA, registry route +openshift/build-push-images.sh go # docker build, skopeo copy +openshift/deploy.sh go # operator, infrastructure, Go overlay +RUNTIME=kubernetes KUBECTL=oc KUBE_CONTEXT=crc-admin KUBE_NAMESPACE=cndp-05 \ + BASE=https://order-service-cndp-05.apps-crc.testing \ + CURL_OPTS="--cacert $HOME/.local/state/cndp-05-crc/ingress-ca.crt" ./verify.sh +openshift/teardown.sh # remove everything, then crc stop +``` + +All five languages in sequence: `OUT=/tmp/cndp-ocp-matrix openshift/verify-matrix.sh`. + +Every command names the `crc-admin` context (`oc --context crc-admin`). Nothing runs +`oc config use-context`. `setup-crc.sh` records the kubeconfig's current-context before +`crc start` and restores it afterwards, including the case where none was set. + +## Credentials + +No password, token or pull secret is written to a file or passed as an argument. + +- `crc start` output is discarded or filtered, so the kubeadmin password never reaches a terminal or log. + To log in to the web console yourself, run `crc console --credentials`. +- The registry token comes from `oc --context crc-admin create token builder --duration=10m` into a shell variable. + `skopeo copy` reads it through a process-substitution authfile, so it is neither on disk nor in + any argument list. `skopeo login` is not used because it writes a file. + +## Reach it from the host + +Routes on the cluster's wildcard domain, TLS verified against the ingress CA: + +| URL | Service | +|-----|---------| +| `https://order-service-cndp-05.apps-crc.testing` | order-service | +| `https://lgtm-grafana-cndp-05.apps-crc.testing` | Grafana (anonymous admin, as in compose) | + +Plain HTTP is redirected to HTTPS. There is no port-forward and no tunnel. + +## Clean up + +```bash +openshift/teardown.sh # all of it, then crc stop +openshift/teardown.sh --keep-running # leave CRC running +openshift/teardown.sh --namespace-only # only the cndp-05 project +``` + +Only the CRDs the AMQ Streams CSV owns, or that carry OLM's `operators.coreos.com/amq-streams.cndp-amq-streams` +label, are removed, and only when something of ours exists and no other project's Kafka operator or +Kafka resource uses them; otherwise they are kept and the script says +why. Other `*.strimzi.io` CRDs and everything else that is not Example 05's are reported as untouched. +A failed step is reported and the script continues; the exit code is non-zero if a step failed or +something of ours is left. Full mode always ends with `crc stop` (an EXIT trap); `--keep-running` and +`--namespace-only` leave CRC running. CRC is stopped, never deleted. + +## Render without a cluster + +```bash +oc kustomize openshift/operators +oc kustomize --load-restrictor LoadRestrictionsNone openshift/infra +oc kustomize openshift/overlays/go +``` diff --git a/examples/05-event-driven/openshift/build-push-images.sh b/examples/05-event-driven/openshift/build-push-images.sh new file mode 100755 index 00000000..99904e30 --- /dev/null +++ b/examples/05-event-driven/openshift/build-push-images.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Build the Example 05 images with docker (host BuildKit caches apply) and push them to the +# OpenShift internal registry through its default route. Names in the registry: +# /cndp-05/-:v1, which the overlays pull as +# image-registry.openshift-image-registry.svc:5000/cndp-05/-:v1. +# +# openshift/build-push-images.sh go # one language +# openshift/build-push-images.sh go python # several +# +# Why skopeo and not `docker push`: pushing to the default route needs the ingress CA trusted by +# the docker daemon (a root-owned /etc/docker/certs.d entry) or an insecure-registries setting, both +# permanent host changes. skopeo takes the CA from a user-owned directory instead. +set -euo pipefail +# shellcheck source=lib.sh +# shellcheck source-path=SCRIPTDIR +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +[[ $# -ge 1 ]] || fail "usage: build-push-images.sh ... (${LANGS[*]})" +for lang in "$@"; do valid_lang "$lang" || fail "unknown language '$lang' (${LANGS[*]})"; done +require_crc +[[ -s "$INGRESS_CA" && -d "$REGISTRY_CA_DIR" ]] || fail "ingress CA missing in $STATE. Run openshift/setup-crc.sh first" +REGISTRY_ROUTE="$(registry_route)" +[[ -n "$REGISTRY_ROUTE" ]] || fail "the internal registry has no default route. Run openshift/setup-crc.sh first" + +wait_for 1800 "cluster operators settled" cluster_settled + +step "Namespace $NAMESPACE and the builder service account" +apply_retry -f "$K8S_DIR/namespace.yaml" +wait_for 120 "service account builder exists" ocn get serviceaccount builder + +# push_image pushes one local image. The registry token is used once and never stored. +# +# Credentials, by design (S10 plan, decision 2): the short-lived token of the builder service +# account is held in a shell variable only. skopeo reads its credentials from an authfile, and +# the authfile here is a process substitution (a pipe), so no file with the token ever exists. +# printf is a bash builtin, so the token is not in any process's argument list either, which is +# what `--dest-creds` or `--dest-registry-token` would expose to `ps` and /proc/*/cmdline. +# `skopeo login` is not used because it writes an authfile to disk. +push_image() { + local lang="$1" svc="$2" token + token="$(oc --context "$OCP_CONTEXT" create token builder -n "$NAMESPACE" --duration=10m)" \ + || fail "cannot create a token for the builder service account" + skopeo copy \ + --dest-cert-dir "$REGISTRY_CA_DIR" \ + --dest-authfile <(printf '{"auths":{"%s":{"auth":"%s"}}}' "$REGISTRY_ROUTE" "$(printf 'builder:%s' "$token" | base64 -w0)") \ + "docker-daemon:$(image_ref "$lang" "$svc")" \ + "docker://$REGISTRY_ROUTE/$NAMESPACE/${lang}-${svc}:${IMAGE_TAG}" >/dev/null \ + || { unset token; fail "push failed for ${lang}-${svc}"; } + unset token +} + +for lang in "$@"; do + for svc in "${SERVICES[@]}"; do + step "build $(image_ref "$lang" "$svc")" + build_image "$lang" "$svc" + done + for svc in "${SERVICES[@]}"; do + step "push ${lang}-${svc}:${IMAGE_TAG} to $REGISTRY_ROUTE/$NAMESPACE" + push_image "$lang" "$svc" + done +done + +step "ImageStreams" +ocn get imagestream -o custom-columns=NAME:.metadata.name,TAGS:.status.tags[*].tag --no-headers diff --git a/examples/05-event-driven/openshift/components/openshift/kustomization.yaml b/examples/05-event-driven/openshift/components/openshift/kustomization.yaml new file mode 100644 index 00000000..8d1b1e1c --- /dev/null +++ b/examples/05-event-driven/openshift/components/openshift/kustomization.yaml @@ -0,0 +1,23 @@ +# What changes between the minikube base and OpenShift, independent of language: +# - a Route replaces the NodePort, so the Service becomes ClusterIP +# - images come from the internal registry, which an updated v1 tag must refresh +apiVersion: kustomize.config.k8s.io/v1alpha1 +kind: Component +resources: + - route.yaml +patches: + - target: + kind: Deployment + patch: |- + - op: replace + path: /spec/template/spec/containers/0/imagePullPolicy + value: Always + - target: + kind: Service + name: order-service + patch: |- + - op: replace + path: /spec/type + value: ClusterIP + - op: remove + path: /spec/ports/0/nodePort diff --git a/examples/05-event-driven/openshift/components/openshift/route.yaml b/examples/05-event-driven/openshift/components/openshift/route.yaml new file mode 100644 index 00000000..71f41b54 --- /dev/null +++ b/examples/05-event-driven/openshift/components/openshift/route.yaml @@ -0,0 +1,17 @@ +# order-service is published through an edge-terminated Route on the cluster's wildcard +# domain: https://order-service-cndp-05.apps-crc.testing. Plain HTTP requests are redirected. +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: order-service + labels: + app.kubernetes.io/name: order-service +spec: + to: + kind: Service + name: order-service + port: + targetPort: http + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect diff --git a/examples/05-event-driven/openshift/deploy.sh b/examples/05-event-driven/openshift/deploy.sh new file mode 100755 index 00000000..39583c06 --- /dev/null +++ b/examples/05-event-driven/openshift/deploy.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Deploy Example 05 for one language into the cndp-05 project on OpenShift Local. +# +# Order: inventory and foreign-operator check, the AMQ Streams operator (pinned CSV, Manual approval, +# one InstallPlan approved), namespace, infrastructure (Kafka, Postgres, LGTM) and wait for it, then the +# language overlay and a health poll through the Route. The images must already be pushed: +# openshift/build-push-images.sh . +# +# openshift/deploy.sh go +set -euo pipefail +# shellcheck source=lib.sh +# shellcheck source-path=SCRIPTDIR +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +[[ $# -eq 1 ]] || fail "usage: deploy.sh (${LANGS[*]})" +lang="$1" +valid_lang "$lang" || fail "unknown language '$lang' (${LANGS[*]})" +require_crc +[[ -s "$INGRESS_CA" ]] || fail "ingress CA missing in $STATE. Run openshift/setup-crc.sh first" + +step "Waiting for the cluster to be stable" +wait_for 1800 "cluster operators settled" cluster_settled + +step "Inventory before" +inventory +refuse_foreign_kafka_operator + +step "AMQ Streams operator $CSV" +install_operator +wait_for 120 "Kafka CRD established" ocx wait --for=condition=Established crd/kafkas.kafka.strimzi.io --timeout=5s + +step "Namespace $NAMESPACE and infrastructure" +apply_retry -f "$K8S_DIR/namespace.yaml" +render "$OPENSHIFT_DIR/infra" | apply_retry -f - +echo " waiting for Kafka (up to 900 s)" +ocn wait kafka/cndp --for=condition=Ready --timeout=900s \ + || fail "Kafka cndp is not Ready (oc --context $OCP_CONTEXT -n $NAMESPACE describe kafka cndp)" +ocn rollout status statefulset/cndp-postgres --timeout=300s +ocn rollout status deployment/lgtm --timeout=300s + +step "Overlay $lang" +# The images live in the project's ImageStreams, which go away with the project. Fail early +# rather than wait for a rollout that can only end in ImagePullBackOff. +for svc in "${SERVICES[@]}"; do + ocn get istag "${lang}-${svc}:${IMAGE_TAG}" >/dev/null 2>&1 \ + || fail "image ${lang}-${svc}:${IMAGE_TAG} is not in project $NAMESPACE. Run openshift/build-push-images.sh $lang first" +done +render "$OPENSHIFT_DIR/overlays/$lang" | apply_retry -f - +for d in shipping-consumer notification-consumer order-service; do + ocn rollout status "deployment/$d" --timeout=420s +done + +host="$(ocn get route order-service -o jsonpath='{.spec.host}')" +step "Polling https://$host/healthz" +for _ in $(seq 1 60); do + if curl -sf --cacert "$INGRESS_CA" "https://$host/healthz" | grep -q '"status":"ok"'; then + ok "order-service is healthy through the Route" + exit 0 + fi + sleep 2 +done +ocn get pods -o wide >&2 +fail "order-service did not answer /healthz through https://$host" diff --git a/examples/05-event-driven/openshift/infra/kustomization.yaml b/examples/05-event-driven/openshift/infra/kustomization.yaml new file mode 100644 index 00000000..f1e9173b --- /dev/null +++ b/examples/05-event-driven/openshift/infra/kustomization.yaml @@ -0,0 +1,60 @@ +# Shared infrastructure for Example 05 on OpenShift: Kafka (AMQ Streams), Postgres (sclorg) and +# the LGTM stack. Kafka and LGTM are the minikube manifests with patches; Postgres is its own +# manifest because the image differs. Render with the schema from ../../db/init and the minikube +# files, which sit outside this directory: +# oc kustomize --load-restrictor LoadRestrictionsNone infra | oc --context crc-admin apply -f - +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: cndp-05 +labels: + - pairs: + app.kubernetes.io/part-of: cndp-05 + includeSelectors: false +resources: + - ../../k8s/infra/kafka.yaml + - ../../k8s/infra/lgtm.yaml + - postgres.yaml + - route-grafana.yaml +secretGenerator: + - name: cndp-postgres-app + literals: + - username=appuser + - password=apppass + options: + disableNameSuffixHash: true +configMapGenerator: + - name: postgres-init + files: + - ../../db/init/00-extensions.sql + - ../../db/init/01-schema.sql + options: + disableNameSuffixHash: true + - name: postgres-openshift + files: + - postgres/10-cndp-schema.sh + - postgres/cndp.conf + options: + disableNameSuffixHash: true +patches: + # The AMQ Streams 3.2 bundle ships Kafka 4.1 and 4.2 broker images; minikube runs 4.3.1. + - target: + kind: Kafka + name: cndp + patch: |- + - op: replace + path: /spec/kafka/version + value: 4.2.0 + - op: add + path: /spec/kafka/metadataVersion + value: 4.2-IV1 + # A Route replaces the NodePort. + - target: + kind: Service + name: lgtm-grafana + patch: |- + - op: replace + path: /spec/type + value: ClusterIP + - op: remove + path: /spec/ports/0/nodePort + - path: lgtm-restricted.yaml diff --git a/examples/05-event-driven/openshift/infra/lgtm-restricted.yaml b/examples/05-event-driven/openshift/infra/lgtm-restricted.yaml new file mode 100644 index 00000000..50afd66b --- /dev/null +++ b/examples/05-event-driven/openshift/infra/lgtm-restricted.yaml @@ -0,0 +1,44 @@ +# otel-lgtm under restricted-v2 (arbitrary UID in group 0). Running the image read-only as such a +# UID showed that these are the only paths it writes: +# /data Loki, Tempo, Prometheus, Pyroscope and Grafana state +# /tmp readiness marker +# /var/tempo Tempo live-store shutdown marker +# GF_PLUGINS_PREINSTALL_DISABLED stops Grafana from replacing its bundled data source +# plugins at startup: that update needs a writable /otel-lgtm tree and would otherwise +# leave the Loki, Tempo and Prometheus data sources unregistered. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lgtm +spec: + template: + spec: + containers: + - name: lgtm + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault + env: + - name: GF_PLUGINS_PREINSTALL_DISABLED + value: "true" + volumeMounts: + - name: data + mountPath: /data + - name: tmp + mountPath: /tmp + - name: tempo-run + mountPath: /var/tempo + volumes: + - name: data + emptyDir: + sizeLimit: 4Gi + - name: tmp + emptyDir: + sizeLimit: 64Mi + - name: tempo-run + emptyDir: + sizeLimit: 64Mi diff --git a/examples/05-event-driven/openshift/infra/postgres.yaml b/examples/05-event-driven/openshift/infra/postgres.yaml new file mode 100644 index 00000000..e7acaac0 --- /dev/null +++ b/examples/05-event-driven/openshift/infra/postgres.yaml @@ -0,0 +1,109 @@ +# Postgres 18 from the Red Hat sclorg image (rhel10/postgresql-18). Same demo +# credentials and the same Service name as compose and minikube. The schema is the +# shared ../../db/init/*.sql, applied by a postgresql-start hook as appuser. +apiVersion: v1 +kind: Service +metadata: + name: cndp-postgres-rw +spec: + selector: + app.kubernetes.io/name: cndp-postgres + ports: + - name: postgres + port: 5432 + targetPort: postgres +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: cndp-postgres +spec: + serviceName: cndp-postgres-rw + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: cndp-postgres + template: + metadata: + labels: + app.kubernetes.io/name: cndp-postgres + spec: + containers: + - name: postgres + image: registry.redhat.io/rhel10/postgresql-18:10.2-1791491063 + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault + env: + - name: POSTGRESQL_DATABASE + value: appdb + - name: POSTGRESQL_USER + valueFrom: + secretKeyRef: + name: cndp-postgres-app + key: username + - name: POSTGRESQL_PASSWORD + valueFrom: + secretKeyRef: + name: cndp-postgres-app + key: password + ports: + - name: postgres + containerPort: 5432 + startupProbe: + exec: + command: ["pg_isready", "-U", "appuser", "-d", "appdb"] + periodSeconds: 5 + failureThreshold: 36 + readinessProbe: + exec: + command: ["pg_isready", "-U", "appuser", "-d", "appdb"] + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 12 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 1Gi + volumeMounts: + - name: data + mountPath: /var/lib/pgsql/data + - name: schema + mountPath: /opt/app-root/schema + readOnly: true + - name: hooks + mountPath: /opt/app-root/src/postgresql-start + readOnly: true + - name: config + mountPath: /opt/app-root/src/postgresql-cfg + readOnly: true + volumes: + - name: schema + configMap: + name: postgres-init + - name: hooks + configMap: + name: postgres-openshift + items: + - key: 10-cndp-schema.sh + path: 10-cndp-schema.sh + - name: config + configMap: + name: postgres-openshift + items: + - key: cndp.conf + path: cndp.conf + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi diff --git a/examples/05-event-driven/openshift/infra/postgres/10-cndp-schema.sh b/examples/05-event-driven/openshift/infra/postgres/10-cndp-schema.sh new file mode 100644 index 00000000..6643462f --- /dev/null +++ b/examples/05-event-driven/openshift/infra/postgres/10-cndp-schema.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Sourced by run-postgresql from postgresql-start/, after initdb has created the role +# and the database from POSTGRESQL_USER, POSTGRESQL_PASSWORD and POSTGRESQL_DATABASE. +# (postgresql-init/ runs earlier, before the role exists, so it cannot be used here.) +# The sclorg image sources this file on every start; the schema uses IF NOT EXISTS, +# so a restart is a no-op. Running as appuser makes appuser the owner of the tables. +# uuid-ossp is a trusted extension, so the database owner may create it. +# shellcheck shell=bash +for sql in /opt/app-root/schema/*.sql; do + echo "applying ${sql##*/}" + psql -v ON_ERROR_STOP=1 -U "$POSTGRESQL_USER" -d "$POSTGRESQL_DATABASE" -f "$sql" +done diff --git a/examples/05-event-driven/openshift/infra/postgres/cndp.conf b/examples/05-event-driven/openshift/infra/postgres/cndp.conf new file mode 100644 index 00000000..ace330c5 --- /dev/null +++ b/examples/05-event-driven/openshift/infra/postgres/cndp.conf @@ -0,0 +1,3 @@ +# Included by the sclorg image from postgresql-cfg/*.conf. Same settings as the compose and minikube Postgres. +log_statement = 'all' +shared_buffers = 128MB diff --git a/examples/05-event-driven/openshift/infra/route-grafana.yaml b/examples/05-event-driven/openshift/infra/route-grafana.yaml new file mode 100644 index 00000000..b7e76b90 --- /dev/null +++ b/examples/05-event-driven/openshift/infra/route-grafana.yaml @@ -0,0 +1,14 @@ +# Grafana through an edge Route: https://lgtm-grafana-cndp-05.apps-crc.testing +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: lgtm-grafana +spec: + to: + kind: Service + name: lgtm-grafana + port: + targetPort: grafana + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect diff --git a/examples/05-event-driven/openshift/lib.sh b/examples/05-event-driven/openshift/lib.sh new file mode 100755 index 00000000..0c7b5f34 --- /dev/null +++ b/examples/05-event-driven/openshift/lib.sh @@ -0,0 +1,236 @@ +# shellcheck shell=bash +# Shared settings and helpers for the Example 05 OpenShift Local scripts. Source it; do not run it. +# +# Every oc call names the crc-admin context explicitly (ocx, ocn). Nothing runs +# `oc config use-context`: only setup-crc.sh touches the current-context, to restore +# the value it recorded before `crc start`. The kubeadmin password, tokens and the +# pull secret never appear in a file, a log or an argument list. + +# shellcheck disable=SC2034 # variables are used by the scripts that source this file +OCP_CONTEXT="${OCP_CONTEXT:-crc-admin}" +NAMESPACE="${NAMESPACE:-cndp-05}" +OPERATOR_NAMESPACE="${OPERATOR_NAMESPACE:-cndp-amq-streams}" +IMAGE_TAG="v1" +# The label OLM puts on the CRDs our Subscription (package amq-streams in $OPERATOR_NAMESPACE) installs. +OLM_CRD_LABEL="operators.coreos.com/amq-streams.${OPERATOR_NAMESPACE}" +LANGS=(spring-boot quarkus dotnet python go) +STATE="${XDG_STATE_HOME:-$HOME/.local/state}/cndp-05-crc" +INGRESS_CA="$STATE/ingress-ca.crt" +REGISTRY_CA_DIR="$STATE/registry-ca" +ROUTE_ANNOTATION="cndp-05/enabled-default-route" + +OPENSHIFT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +EXAMPLE_DIR="$(dirname "$OPENSHIFT_DIR")" +K8S_DIR="$EXAMPLE_DIR/k8s" + +# The operator pin lives in one place, the Subscription manifest. +CSV="$(awk '$1 == "startingCSV:" {print $2}' "$OPENSHIFT_DIR/operators/subscription.yaml")" + +# shellcheck source=../k8s/images.sh +# shellcheck source-path=SCRIPTDIR +source "$K8S_DIR/images.sh" + +step() { printf '\n==> %s\n' "$*"; } +ok() { printf ' ok: %s\n' "$*"; } +warn() { printf ' warning: %s\n' "$*" >&2; } +fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +ocx() { oc --context "$OCP_CONTEXT" "$@"; } +ocn() { oc --context "$OCP_CONTEXT" -n "$NAMESPACE" "$@"; } + +# Render a kustomization. infra reads ../../db/init and ../../k8s/infra, outside its directory. +render() { oc kustomize --load-restrictor LoadRestrictionsNone "$1"; } + +valid_lang() { + local l + for l in "${LANGS[@]}"; do [[ "$l" == "$1" ]] && return 0; done + return 1 +} + +# wait_for : poll every 5 s until the command succeeds. +# The deadline is wall-clock time (SECONDS), so time spent inside slow oc calls counts too. +wait_for() { + local timeout="$1" what="$2" deadline=$(( SECONDS + $1 )) + shift 2 + until "$@" >/dev/null 2>&1; do + (( SECONDS >= deadline )) && fail "timed out after ${timeout}s waiting for $what" + sleep 5 + done + ok "$what" +} + +gone() { ! ocx get "$@" >/dev/null 2>&1; } +has_crd() { ocx get crd "$1" >/dev/null 2>&1; } + +# Running minikube profiles, one per line (empty when minikube is absent or all are stopped). +running_minikube_profiles() { + command -v minikube >/dev/null 2>&1 || return 0 + minikube profile list -o json 2>/dev/null \ + | jq -r '.valid[]? | select(.Status == "OK" or .Status == "Running") | .Name' 2>/dev/null || true +} + +# One workshop at a time: no other cluster and no containers. +require_alone() { + local running containers + running="$(running_minikube_profiles)" + [[ -z "$running" ]] || fail "minikube profile(s) running: $(tr '\n' ' ' <<<"$running"). Stop them first (minikube stop -p ); run one cluster at a time" + containers="$(docker ps --format '{{.Names}}' 2>/dev/null || true)" + [[ -z "$containers" ]] || fail "containers are running (one workshop at a time): $(tr '\n' ' ' <<<"$containers")" +} + +crc_running() { crc status 2>/dev/null | grep -q 'OpenShift:.*Running'; } +crc_vm_running() { crc status 2>/dev/null | grep -q 'CRC VM:.*Running'; } + +# Every ClusterOperator Available, not Progressing, not Degraded. A configuration change such as +# enabling the registry's default route rolls the API servers, and crc status reports "Starting" +# until that finishes. +cluster_settled() { + ocx get clusteroperators --request-timeout=20s -o json 2>/dev/null | jq -e '[.items[].status.conditions + | map({(.type): .status}) | add + | select(.Available != "True" or .Progressing != "False" or .Degraded != "False")] | length == 0' >/dev/null +} + +# wait_cluster_stable : cluster_settled must hold continuously for seconds +# (checked every 10 s; any check that is not settled restarts the window). A start from a stopped +# cluster settles once, then the API servers roll again a few minutes later; one passing check is +# not enough. Fails only when seconds pass without a full window. +wait_cluster_stable() { + local timeout="$1" window="$2" deadline=$(( SECONDS + $1 )) since="" + while (( SECONDS < deadline )); do + if cluster_settled; then + [[ -n "$since" ]] || since=$SECONDS + if (( SECONDS - since >= window )); then + ok "cluster operators stable for ${window}s" + return 0 + fi + else + since="" + fi + sleep 10 + done + fail "cluster operators did not stay settled for ${window}s within ${timeout}s" +} + +# apply_retry : oc apply through the crc-admin context, up to 5 attempts. After a +# failure (for example ServiceUnavailable while an API server rolls) it waits for the cluster to +# settle and tries again. Reads manifests from stdin when called with -f -; stdin is buffered once. +apply_retry() { + local attempt err input="" has_stdin=0 + if [[ " $* " == *" -f - "* ]]; then has_stdin=1; input="$(cat)"; fi + for attempt in 1 2 3 4 5; do + if (( has_stdin )); then + err="$(ocx apply "$@" <<<"$input" 2>&1 >/dev/null)" && return 0 + else + err="$(ocx apply "$@" 2>&1 >/dev/null)" && return 0 + fi + warn "oc apply failed (attempt $attempt of 5); waiting for the cluster to settle" + wait_for 900 "cluster operators settled" cluster_settled + done + fail "oc apply failed after 5 attempts: ${err}" +} + +# require_tools: the host tools every script needs. +require_tools() { + local t + for t in oc crc jq skopeo docker curl base64; do + command -v "$t" >/dev/null 2>&1 || fail "$t not found on PATH" + done +} + +# require_crc: CRC is running, the crc-admin context exists and points at OpenShift Local's API. +# Nothing is switched: the context is used by name on every call. +require_crc() { + require_tools + crc_vm_running || fail "OpenShift Local is not running. Run openshift/setup-crc.sh first" + crc_running || wait_for 1200 "OpenShift settled (crc status Running)" crc_running + oc config get-contexts "$OCP_CONTEXT" -o name >/dev/null 2>&1 \ + || fail "kubeconfig context $OCP_CONTEXT not found (crc start writes it)" + local api + api="$(ocx whoami --show-server 2>/dev/null)" || fail "cannot reach the OpenShift API through context $OCP_CONTEXT" + [[ "$api" == *api.crc.testing* ]] || fail "context $OCP_CONTEXT points at $api, not OpenShift Local" +} + +# The kubeconfig's current-context, empty when unset. +current_context() { oc config view -o jsonpath='{.current-context}' 2>/dev/null || true; } + +# The default route of the internal registry, empty when not enabled. +registry_route() { + ocx get route default-route -n openshift-image-registry -o jsonpath='{.spec.host}' 2>/dev/null || true +} + +# Subscriptions, ClusterServiceVersions and non-system namespaces on the cluster: what is here +# before and after, so items that belong to someone else are reported and never touched. +inventory() { + echo " subscriptions:" + ocx get subscriptions.operators.coreos.com -A --no-headers 2>/dev/null | awk '{print " " $1 "/" $2}' + echo " csvs (excluding packageserver):" + ocx get csv -A -l '!olm.copiedFrom' --no-headers 2>/dev/null | awk '$2 !~ /^packageserver/ {print " " $1 "/" $2}' + echo " namespaces (excluding platform):" + ocx get ns -o name 2>/dev/null | grep -v -E '^namespace/(openshift|kube-|default$|hostpath-provisioner$)' | sed 's/^namespace\// /' || true + echo " strimzi.io CRDs:" + ocx get crd -o name 2>/dev/null | grep -E '\.strimzi\.io$' | sed 's/^customresourcedefinition.apiextensions.k8s.io\// /' || true + return 0 +} + +# foreign_kafka_items: print one line per AMQ Streams or Strimzi item that is not ours: a Subscription +# or CSV outside the operator namespace (OLM-copied CSVs are excluded) and any Kafka custom resource +# outside the example namespace. Ours (cndp-amq-streams/amq-streams, Kafka cndp in cndp-05) is not listed. +foreign_kafka_items() { + ocx get subscriptions.operators.coreos.com -A -o json 2>/dev/null | jq -r --arg ns "$OPERATOR_NAMESPACE" \ + '.items[] | select((.spec.name == "amq-streams" or .spec.name == "strimzi-kafka-operator") and .metadata.namespace != $ns) | "subscription \(.metadata.namespace)/\(.metadata.name)"' + ocx get csv -A -l '!olm.copiedFrom' --no-headers 2>/dev/null | awk -v ns="$OPERATOR_NAMESPACE" '$2 ~ /^(amqstreams|strimzi-cluster-operator)/ && $1 != ns {print "csv " $1 "/" $2}' + if has_crd kafkas.kafka.strimzi.io; then + ocx get kafka -A --no-headers 2>/dev/null | awk -v ns="$NAMESPACE" '$1 != ns {print "kafka " $1 "/" $2}' + fi + # Strimzi installed without OLM (Helm or plain YAML): its CRDs lack our OLM label, and OLM would + # adopt and relabel them if our Subscription installed over them. + ocx get crd -l "!$OLM_CRD_LABEL" -o name 2>/dev/null | grep -E '\.strimzi\.io$' | sed 's/^customresourcedefinition.apiextensions.k8s.io\//crd (not installed by our Subscription) /' || true + ocx get deployment -A -l strimzi.io/kind=cluster-operator --no-headers 2>/dev/null \ + | awk -v ns="$OPERATOR_NAMESPACE" '$1 != ns {print "cluster-operator deployment " $1 "/" $2}' +} + +# Fail when AMQ Streams or Strimzi is already installed by someone else: an AllNamespaces +# operator would also manage their Kafka clusters. +refuse_foreign_kafka_operator() { + local foreign + foreign="$(foreign_kafka_items)" + [[ -z "$foreign" ]] || fail "another Kafka operator or cluster is installed; this run would share it: $(tr '\n' ';' <<<"$foreign"). Run that project's teardown first" +} + +# approve_install_plan : approve only the InstallPlan in the operator namespace that lists . +# Any other plan (an upgrade OLM proposes later) stays unapproved. +install_plan_for() { + ocx get installplan -n "$OPERATOR_NAMESPACE" -o json 2>/dev/null | jq -r --arg c "$1" \ + '.items[] | select(.spec.clusterServiceVersionNames | index($c)) | .metadata.name' | head -1 +} +csv_succeeded() { + [[ "$(ocx get csv "$1" -n "$OPERATOR_NAMESPACE" -o jsonpath='{.status.phase}' 2>/dev/null)" == Succeeded ]] +} +plan_proposed() { [[ -n "$(install_plan_for "$1")" ]]; } + +# install_operator: apply operators/ (namespace, OperatorGroup, Subscription with Manual approval and +# startingCSV), approve the one InstallPlan for $CSV, wait for the CSV. Idempotent. +install_operator() { + local plan + if csv_succeeded "$CSV"; then ok "$CSV already Succeeded"; return 0; fi + # redhat-operators reports READY before it serves packages, so wait for the package itself. + wait_for 300 "OperatorHub serves amq-streams" ocx get packagemanifest amq-streams -n openshift-marketplace + render "$OPENSHIFT_DIR/operators" | apply_retry -f - + wait_for 300 "InstallPlan for $CSV proposed" plan_proposed "$CSV" + plan="$(install_plan_for "$CSV")" + ocx patch installplan "$plan" -n "$OPERATOR_NAMESPACE" --type merge -p '{"spec":{"approved":true}}' >/dev/null \ + || fail "approve InstallPlan $plan" + wait_for 600 "$CSV Succeeded" csv_succeeded "$CSV" +} + +# Fail when text on stdin or files under a directory look like secret material. Patterns: OpenShift +# tokens, bound service account tokens (JWTs), private keys and registry auth JSON. +SECRET_PATTERN='sha256~[A-Za-z0-9_-]{20,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.|BEGIN [A-Z ]*PRIVATE KEY|"auths"|kubeadmin-password' +scrub_dir() { + local dir="$1" + if grep -rlE "$SECRET_PATTERN" "$dir" >&2; then + echo "secret-like material found in the files above" >&2 + return 1 + fi + return 0 +} diff --git a/examples/05-event-driven/openshift/operators/kustomization.yaml b/examples/05-event-driven/openshift/operators/kustomization.yaml new file mode 100644 index 00000000..f458ac1f --- /dev/null +++ b/examples/05-event-driven/openshift/operators/kustomization.yaml @@ -0,0 +1,8 @@ +# The AMQ Streams operator in its own namespace with its own OperatorGroup, so +# teardown.sh can remove all of it. Apply with: oc kustomize operators | oc apply -f - +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - operatorgroup.yaml + - subscription.yaml diff --git a/examples/05-event-driven/openshift/operators/namespace.yaml b/examples/05-event-driven/openshift/operators/namespace.yaml new file mode 100644 index 00000000..6f66ede8 --- /dev/null +++ b/examples/05-event-driven/openshift/operators/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: cndp-amq-streams + labels: + app.kubernetes.io/part-of: cndp-05 diff --git a/examples/05-event-driven/openshift/operators/operatorgroup.yaml b/examples/05-event-driven/openshift/operators/operatorgroup.yaml new file mode 100644 index 00000000..e7d90897 --- /dev/null +++ b/examples/05-event-driven/openshift/operators/operatorgroup.yaml @@ -0,0 +1,11 @@ +# An empty spec selects every namespace (AllNamespaces), which is the install mode +# the AMQ Streams operator supports alongside OwnNamespace. The operator watches +# cndp-05 without a second OperatorGroup there. +apiVersion: operators.coreos.com/v1 +kind: OperatorGroup +metadata: + name: cndp-amq-streams + namespace: cndp-amq-streams + labels: + app.kubernetes.io/part-of: cndp-05 +spec: {} diff --git a/examples/05-event-driven/openshift/operators/subscription.yaml b/examples/05-event-driven/openshift/operators/subscription.yaml new file mode 100644 index 00000000..90fbe8e5 --- /dev/null +++ b/examples/05-event-driven/openshift/operators/subscription.yaml @@ -0,0 +1,18 @@ +# Manual approval plus startingCSV pins the operator: OLM creates an InstallPlan for +# exactly this CSV and waits. deploy.sh approves that one InstallPlan and no other. +# The CSV and channel come from `oc get packagemanifest amq-streams`; the live run +# confirms them on the cluster (S10 plan, "Live pin confirmations"). +apiVersion: operators.coreos.com/v1alpha1 +kind: Subscription +metadata: + name: amq-streams + namespace: cndp-amq-streams + labels: + app.kubernetes.io/part-of: cndp-05 +spec: + name: amq-streams + source: redhat-operators + sourceNamespace: openshift-marketplace + channel: amq-streams-3.2.x + startingCSV: amqstreams.v3.2.1-14 + installPlanApproval: Manual diff --git a/examples/05-event-driven/openshift/overlays/dotnet/kustomization.yaml b/examples/05-event-driven/openshift/overlays/dotnet/kustomization.yaml new file mode 100644 index 00000000..41a83859 --- /dev/null +++ b/examples/05-event-driven/openshift/overlays/dotnet/kustomization.yaml @@ -0,0 +1,19 @@ +# The dotnet overlay from ../k8s, with the OpenShift component and images from the internal +# registry. build-push-images.sh pushes localhost/cndp-05/dotnet-:v1 to that registry. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: cndp-05 +resources: + - ../../../k8s/overlays/dotnet +components: + - ../../components/openshift +images: + - name: localhost/cndp-05/dotnet-order-service + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/dotnet-order-service + newTag: v1 + - name: localhost/cndp-05/dotnet-shipping-consumer + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/dotnet-shipping-consumer + newTag: v1 + - name: localhost/cndp-05/dotnet-notification-consumer + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/dotnet-notification-consumer + newTag: v1 diff --git a/examples/05-event-driven/openshift/overlays/go/kustomization.yaml b/examples/05-event-driven/openshift/overlays/go/kustomization.yaml new file mode 100644 index 00000000..af982396 --- /dev/null +++ b/examples/05-event-driven/openshift/overlays/go/kustomization.yaml @@ -0,0 +1,19 @@ +# The go overlay from ../k8s, with the OpenShift component and images from the internal +# registry. build-push-images.sh pushes localhost/cndp-05/go-:v1 to that registry. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: cndp-05 +resources: + - ../../../k8s/overlays/go +components: + - ../../components/openshift +images: + - name: localhost/cndp-05/go-order-service + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/go-order-service + newTag: v1 + - name: localhost/cndp-05/go-shipping-consumer + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/go-shipping-consumer + newTag: v1 + - name: localhost/cndp-05/go-notification-consumer + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/go-notification-consumer + newTag: v1 diff --git a/examples/05-event-driven/openshift/overlays/python/kustomization.yaml b/examples/05-event-driven/openshift/overlays/python/kustomization.yaml new file mode 100644 index 00000000..90c8084d --- /dev/null +++ b/examples/05-event-driven/openshift/overlays/python/kustomization.yaml @@ -0,0 +1,19 @@ +# The python overlay from ../k8s, with the OpenShift component and images from the internal +# registry. build-push-images.sh pushes localhost/cndp-05/python-:v1 to that registry. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: cndp-05 +resources: + - ../../../k8s/overlays/python +components: + - ../../components/openshift +images: + - name: localhost/cndp-05/python-order-service + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/python-order-service + newTag: v1 + - name: localhost/cndp-05/python-shipping-consumer + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/python-shipping-consumer + newTag: v1 + - name: localhost/cndp-05/python-notification-consumer + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/python-notification-consumer + newTag: v1 diff --git a/examples/05-event-driven/openshift/overlays/quarkus/kustomization.yaml b/examples/05-event-driven/openshift/overlays/quarkus/kustomization.yaml new file mode 100644 index 00000000..5d04ad84 --- /dev/null +++ b/examples/05-event-driven/openshift/overlays/quarkus/kustomization.yaml @@ -0,0 +1,19 @@ +# The quarkus overlay from ../k8s, with the OpenShift component and images from the internal +# registry. build-push-images.sh pushes localhost/cndp-05/quarkus-:v1 to that registry. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: cndp-05 +resources: + - ../../../k8s/overlays/quarkus +components: + - ../../components/openshift +images: + - name: localhost/cndp-05/quarkus-order-service + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/quarkus-order-service + newTag: v1 + - name: localhost/cndp-05/quarkus-shipping-consumer + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/quarkus-shipping-consumer + newTag: v1 + - name: localhost/cndp-05/quarkus-notification-consumer + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/quarkus-notification-consumer + newTag: v1 diff --git a/examples/05-event-driven/openshift/overlays/spring-boot/kustomization.yaml b/examples/05-event-driven/openshift/overlays/spring-boot/kustomization.yaml new file mode 100644 index 00000000..13514a96 --- /dev/null +++ b/examples/05-event-driven/openshift/overlays/spring-boot/kustomization.yaml @@ -0,0 +1,19 @@ +# The spring-boot overlay from ../k8s, with the OpenShift component and images from the internal +# registry. build-push-images.sh pushes localhost/cndp-05/spring-boot-:v1 to that registry. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: cndp-05 +resources: + - ../../../k8s/overlays/spring-boot +components: + - ../../components/openshift +images: + - name: localhost/cndp-05/spring-boot-order-service + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/spring-boot-order-service + newTag: v1 + - name: localhost/cndp-05/spring-boot-shipping-consumer + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/spring-boot-shipping-consumer + newTag: v1 + - name: localhost/cndp-05/spring-boot-notification-consumer + newName: image-registry.openshift-image-registry.svc:5000/cndp-05/spring-boot-notification-consumer + newTag: v1 diff --git a/examples/05-event-driven/openshift/setup-crc.sh b/examples/05-event-driven/openshift/setup-crc.sh new file mode 100755 index 00000000..b1697acf --- /dev/null +++ b/examples/05-event-driven/openshift/setup-crc.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Start OpenShift Local for Example 05 and prepare it: the ingress CA for curl and skopeo, and the +# internal registry's default route (enabled only if it is off). +# +# openshift/setup-crc.sh +# +# Preconditions it enforces: no minikube profile running, no containers running, CRC sized at +# least 12 vCPU and 32768 MiB (it never runs `crc config set`; change the size yourself while CRC +# is stopped). `crc start` can change kubectl's current-context; this script records the value +# first and restores it on exit, including "no current-context". +# +# crc start prints the kubeadmin and developer passwords. Its stdout is discarded and its stderr is +# filtered, so neither reaches a terminal, log or file. The pull secret is read by crc from the path +# in `crc config get pull-secret-file`; this script never opens it. +set -euo pipefail +# shellcheck source=lib.sh +# shellcheck source-path=SCRIPTDIR +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +require_tools +require_alone + +step "Sizing" +cpus="$(crc config get cpus 2>/dev/null | awk '{print $NF}')" +memory="$(crc config get memory 2>/dev/null | awk '{print $NF}')" +[[ "$cpus" =~ ^[0-9]+$ && "$memory" =~ ^[0-9]+$ ]] || fail "cannot read crc cpus and memory (crc config get cpus)" +(( cpus >= 12 && memory >= 32768 )) \ + || fail "CRC is sized $cpus vCPU / $memory MiB; Example 05 needs 12 / 32768. While CRC is stopped: crc config set cpus 12; crc config set memory 32768" +ok "$cpus vCPU, $memory MiB" + +step "Current-context" +had_context=0 +prior_context="$(current_context)" +[[ -z "$prior_context" ]] || had_context=1 +restore_context() { + local now + if (( had_context )); then + oc config set current-context "$prior_context" >/dev/null 2>&1 || true + else + oc config unset current-context >/dev/null 2>&1 || true + fi + now="$(current_context)" + if [[ "$now" != "$prior_context" ]]; then + printf 'ERROR: current-context changed from "%s" to "%s" and could not be restored\n' "$prior_context" "$now" >&2 + return 1 + fi +} +# Bash keeps the script's exit status when the trap returns; exit non-zero explicitly on a mismatch. +on_exit() { + local rc=$? + restore_context || rc=1 + exit "$rc" +} +trap on_exit EXIT +if (( had_context )); then ok "recorded: $prior_context"; else ok "recorded: none set"; fi + +step "OpenShift Local" +freshly_started=0 +if crc_vm_running; then + ok "already running" +else + status_text="$(crc status 2>&1 || true)" + grep -q -i -E 'does not exist|crc setup' <<<"$status_text" && fail "CRC is not set up. Run crc setup, then re-run this script" + pull_secret="$(crc config get pull-secret-file 2>/dev/null | awk '{print $NF}')" + # Only the path is checked; the file is never opened or printed. + [[ -n "$pull_secret" && "$pull_secret" != "" && -f "$pull_secret" ]] \ + || fail "no pull secret configured. Download it from console.redhat.com/openshift/create/local, then: crc config set pull-secret-file " + echo " crc start (this takes several minutes; credentials are not printed)" + # stdout carries the credentials banner; stderr carries progress. Drop any line that names a secret. + crc start 2> >(grep -v -i -E 'password|kubeadmin|developer|token|secret' >&2) >/dev/null \ + || fail "crc start failed. Check crc status and crc logs. Running crc start yourself prints the kubeadmin and developer passwords to your terminal" + freshly_started=1 + ok "started" +fi +wait_for 300 "API reachable through $OCP_CONTEXT" ocx get nodes +require_crc +wait_for 600 "node Ready" bash -c "oc --context '$OCP_CONTEXT' get nodes --no-headers | grep -q ' Ready'" +ok "server: $(ocx version -o json 2>/dev/null | jq -r '"Kubernetes " + .serverVersion.gitVersion' 2>/dev/null || echo unknown)" + +# The reads and the registry patch below need a settled API; the long stability window comes later. +wait_for 1800 "cluster operators settled" cluster_settled + +step "Ingress CA" +mkdir -p "$STATE" "$REGISTRY_CA_DIR" +# The CA is public; the directories are owned by the current user. +ocx get configmap default-ingress-cert -n openshift-config-managed -o jsonpath='{.data.ca-bundle\.crt}' >"$INGRESS_CA" \ + || fail "cannot read the ingress CA (configmap default-ingress-cert)" +grep -q 'BEGIN CERTIFICATE' "$INGRESS_CA" || fail "$INGRESS_CA holds no certificate" +cp "$INGRESS_CA" "$REGISTRY_CA_DIR/ca.crt" +ok "$INGRESS_CA" + +step "Internal registry route" +default_route="$(ocx get configs.imageregistry.operator.openshift.io/cluster -o jsonpath='{.spec.defaultRoute}')" +if [[ "$default_route" == true ]]; then + ok "default route already enabled (left as found)" +else + # Annotate first: if the patch then fails, teardown still knows the route is ours to revert. + ocx annotate configs.imageregistry.operator.openshift.io/cluster "$ROUTE_ANNOTATION=true" --overwrite >/dev/null + ocx patch configs.imageregistry.operator.openshift.io/cluster --type merge -p '{"spec":{"defaultRoute":true}}' >/dev/null + ok "default route enabled (annotated so teardown reverts it)" +fi +wait_for 300 "registry route exists" bash -c "[[ -n \"\$(oc --context '$OCP_CONTEXT' get route default-route -n openshift-image-registry -o jsonpath='{.spec.host}' 2>/dev/null)\" ]]" +ok "registry: $(registry_route)" +# After a start from a stopped cluster the API servers roll again a few minutes after the first +# settled check, and the same happens after the route change: require a continuous window. +if (( freshly_started )) || [[ "$default_route" != true ]]; then + echo " waiting for the cluster to stay settled for 300 s (up to 1800 s)" + wait_cluster_stable 1800 300 +else + wait_for 1200 "cluster operators settled" cluster_settled +fi + +step "Inventory" +inventory +echo +echo "next: openshift/verify-matrix.sh [lang...] or openshift/deploy.sh " diff --git a/examples/05-event-driven/openshift/teardown.sh b/examples/05-event-driven/openshift/teardown.sh new file mode 100755 index 00000000..1a0b43c1 --- /dev/null +++ b/examples/05-event-driven/openshift/teardown.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# Return OpenShift Local to the state it had before Example 05, then stop it. CRC is never deleted. +# +# openshift/teardown.sh # remove everything, revert the registry route, crc stop +# openshift/teardown.sh --keep-running # same, leave CRC running +# openshift/teardown.sh --namespace-only # only the cndp-05 project; operator and CRC stay (used between languages) +# +# Order matters. Custom resources go while their operator still runs, or their finalizers wait for an +# operator that is gone: +# 1. KafkaTopics and KafkaUsers, then Kafka and KafkaNodePool, then the cndp-05 project +# 2. the Subscription, the CSV, its InstallPlans, the OperatorGroup, the CRDs the CSV owns, then the +# cndp-amq-streams namespace. The CRDs go only when our Subscription or CSV existed and nothing +# foreign depends on them: another project's Subscription, CSV or Kafka custom resource keeps +# them in place (reported, not removed). No other *.strimzi.io CRD is touched. +# 3. the registry default route, only if setup-crc.sh enabled it (annotation cndp-05/enabled-default-route) +# 4. a leftover check: items of ours still present fail the run; items that are not ours are listed +# as left untouched +# 5. crc stop +# A step that fails or times out is reported and the script continues, so the registry route is +# reverted and CRC is stopped anyway (an EXIT trap runs crc stop in full mode). The exit code is +# non-zero when a step failed or something of ours was left behind. --keep-running and +# --namespace-only never stop CRC. +# Idempotent: a second run finds nothing to do and exits 0, also when CRC is already stopped. +set -uo pipefail +# shellcheck source=lib.sh +# shellcheck source-path=SCRIPTDIR +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +mode=full +stop_crc=1 +case "${1:-}" in + "") ;; + --keep-running) stop_crc=0 ;; + --namespace-only) mode=namespace; stop_crc=0 ;; + *) fail "usage: teardown.sh [--keep-running | --namespace-only]" ;; +esac + +failed=0 +problem() { warn "$*"; failed=1; } +# soft_wait : wait_for, but a timeout is a warning, not an exit. +soft_wait() { ( wait_for "$@" ) || problem "continuing after: $2"; } + +require_tools +if ! crc_vm_running; then + echo "OpenShift Local is not running; nothing to tear down." + exit 0 +fi + +# Full mode: whatever happens from here, CRC is stopped on the way out. +finish() { + local rc=$? + (( failed )) && rc=1 + if (( stop_crc )) && crc_vm_running; then + if crc stop >/dev/null 2>&1; then + printf ' ok: OpenShift Local stopped (not deleted)\n' + else + printf 'ERROR: crc stop failed\n' >&2 + rc=1 + fi + fi + exit "$rc" +} +trap finish EXIT +require_crc + +kafka_pods_gone() { [[ -z "$(ocx get pod -n "$NAMESPACE" -l strimzi.io/cluster=cndp -o name 2>/dev/null)" ]]; } + +step "1/5 Project $NAMESPACE" +if ocx get namespace "$NAMESPACE" >/dev/null 2>&1; then + if has_crd kafkas.kafka.strimzi.io; then + # KafkaTopics first: their finalizer hangs the namespace once the operator is gone. + ocn delete kafkatopics.kafka.strimzi.io,kafkausers.kafka.strimzi.io --all --wait=true --timeout=120s >/dev/null 2>&1 || true + ocn delete kafka,kafkanodepool --all --wait=true --timeout=300s >/dev/null 2>&1 || warn "deleting Kafka did not finish; continuing" + soft_wait 300 "Kafka pods gone" kafka_pods_gone + fi + ocx delete namespace "$NAMESPACE" --wait=false >/dev/null 2>&1 || problem "delete namespace $NAMESPACE failed" + soft_wait 600 "namespace $NAMESPACE deleted" gone namespace "$NAMESPACE" +else + ok "no project $NAMESPACE" +fi + +if [[ "$mode" == namespace ]]; then + echo + echo "Kept the operator in $OPERATOR_NAMESPACE and CRC running." + exit 0 +fi + +step "2/5 AMQ Streams operator" +csv="$(ocx get subscriptions.operators.coreos.com amq-streams -n "$OPERATOR_NAMESPACE" -o jsonpath='{.status.installedCSV}' 2>/dev/null || true)" +[[ -n "$csv" ]] || csv="$CSV" +# OLM labels every CRD our Subscription installed with operators.coreos.com/.. +# The label names our operator namespace, so another project's install carries a different one, and +# it outlives the CSV: a CRD kept by an earlier run (because foreign items used it) is still found. +olm_crds() { ocx get crd -l "$OLM_CRD_LABEL" -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null | grep -v '^$' || true; } +# What of ours exists: the Subscription or the CSV in our namespace, or CRDs carrying our label. +# Without any of them, no CRD is ours to remove. +ours_existed=0 +if ocx get subscriptions.operators.coreos.com amq-streams -n "$OPERATOR_NAMESPACE" >/dev/null 2>&1 \ + || ocx get csv "$csv" -n "$OPERATOR_NAMESPACE" >/dev/null 2>&1 \ + || [[ -n "$(olm_crds)" ]]; then + ours_existed=1 +fi +# The CRDs the CSV owns (read before the CSV is gone) plus those carrying our label. +owned="$( + { ocx get csv "$csv" -n "$OPERATOR_NAMESPACE" -o jsonpath='{range .spec.customresourcedefinitions.owned[*]}{.name}{"\n"}{end}' 2>/dev/null; olm_crds; } \ + | grep -v '^$' | sort -u || true +)" + +# Foreign check, before any CRD goes: another project's Kafka operator or Kafka resources, or custom +# resources of the owned kinds outside our project (cndp-05 is already deleted at this point). +foreign="$(foreign_kafka_items)" +for crd in $owned; do + has_crd "$crd" || continue + instances="$(ocx get "$crd" -A --no-headers -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name 2>/dev/null | awk -v k="${crd%%.*}" '{print k " " $1 "/" $2}')" + [[ -z "$instances" ]] || foreign+=$'\n'"$instances" +done +foreign="$(grep -v '^$' <<<"$foreign" | sort -u || true)" + +ocx delete subscriptions.operators.coreos.com amq-streams -n "$OPERATOR_NAMESPACE" --ignore-not-found >/dev/null 2>&1 || problem "delete Subscription failed" +ocx delete csv "$csv" -n "$OPERATOR_NAMESPACE" --ignore-not-found --wait=true --timeout=180s >/dev/null 2>&1 || problem "delete CSV $csv failed" +for plan in $(ocx get installplan -n "$OPERATOR_NAMESPACE" -o json 2>/dev/null | jq -r --arg c "$csv" '.items[] | select(.spec.clusterServiceVersionNames | index($c)) | .metadata.name'); do + ocx delete installplan "$plan" -n "$OPERATOR_NAMESPACE" --ignore-not-found >/dev/null 2>&1 || problem "delete InstallPlan $plan failed" +done +ocx delete operatorgroup cndp-amq-streams -n "$OPERATOR_NAMESPACE" --ignore-not-found >/dev/null 2>&1 || problem "delete OperatorGroup failed" +ok "$csv, its InstallPlans and OperatorGroup removed" + +kept_crds="" +if (( ! ours_existed )); then + ok "no Subscription or CSV of ours in $OPERATOR_NAMESPACE: no CRD removed" +elif [[ -z "$owned" ]]; then + warn "neither the CSV's owned CRD list nor CRDs labelled $OLM_CRD_LABEL were found: no CRD removed" + failed=1 +elif [[ -n "$foreign" ]]; then + kept_crds="$owned" + warn "kept $(wc -w <<<"$owned") CRD(s) the CSV owns, because items that are not ours use them:" + while IFS= read -r line; do echo " $line" >&2; done <<<"$foreign" + echo " remove them with that project's teardown, then re-run this script" >&2 +else + before=0 + for crd in $owned; do has_crd "$crd" && before=$(( before + 1 )); done + # shellcheck disable=SC2086 # one CRD name per word + ocx delete crd $owned --ignore-not-found --wait=true --timeout=180s >/dev/null 2>&1 || true + after=0 + for crd in $owned; do has_crd "$crd" && after=$(( after + 1 )); done + ok "$(( before - after )) of $before owned CRD(s) removed" + (( after == 0 )) || problem "$after owned CRD(s) still present" +fi +if ocx get namespace "$OPERATOR_NAMESPACE" >/dev/null 2>&1; then + ocx delete namespace "$OPERATOR_NAMESPACE" --wait=false >/dev/null 2>&1 || problem "delete namespace $OPERATOR_NAMESPACE failed" + soft_wait 300 "namespace $OPERATOR_NAMESPACE deleted" gone namespace "$OPERATOR_NAMESPACE" +fi +# OLM keeps a cluster-scoped Operator object per installed package and namespace. It outlives the +# Subscription and CSV, so remove it once nothing of ours is left. OLM recreates it while labelled +# CRDs exist, so it stays when CRDs were kept for foreign items. +olm_operator="amq-streams.$OPERATOR_NAMESPACE" +if [[ -z "$kept_crds" ]] && ocx get operators.operators.coreos.com "$olm_operator" >/dev/null 2>&1; then + ocx delete operators.operators.coreos.com "$olm_operator" --ignore-not-found >/dev/null 2>&1 \ + || problem "delete OLM Operator object $olm_operator failed" + ok "OLM Operator object $olm_operator removed" +fi + +step "3/5 Registry default route" +registry_config=configs.imageregistry.operator.openshift.io/cluster +if [[ "$(ocx get "$registry_config" -o json 2>/dev/null | jq -r --arg k "$ROUTE_ANNOTATION" '.metadata.annotations[$k] // ""')" == true ]]; then + if ocx patch "$registry_config" --type merge -p '{"spec":{"defaultRoute":false}}' >/dev/null 2>&1 \ + && ocx annotate "$registry_config" "${ROUTE_ANNOTATION}-" >/dev/null 2>&1; then + ok "default route disabled again" + else + problem "could not revert the registry default route" + fi +else + ok "default route left as found" +fi + +step "4/5 Leftover check" +ours="$( + ocx get subscriptions.operators.coreos.com amq-streams -n "$OPERATOR_NAMESPACE" --no-headers -o name 2>/dev/null + ocx get csv "$csv" -n "$OPERATOR_NAMESPACE" --no-headers -o name 2>/dev/null + ocx get operatorgroup cndp-amq-streams -n "$OPERATOR_NAMESPACE" --no-headers -o name 2>/dev/null + ocx get ns -o name 2>/dev/null | grep -E "^namespace/($NAMESPACE|$OPERATOR_NAMESPACE)$" + for crd in $owned; do + # CRDs kept on purpose because of foreign items are reported below, not counted as ours. + grep -qxF "$crd" <<<"$kept_crds" && continue + has_crd "$crd" && echo "crd/$crd" + done + ocx get clusterrolebinding -o name 2>/dev/null | grep -E 'cndp' + [[ -n "$kept_crds" ]] || ocx get operators.operators.coreos.com "$olm_operator" -o name 2>/dev/null +)" +ours="$(grep -v '^$' <<<"$ours" || true)" +not_ours="$( + ocx get subscriptions.operators.coreos.com -A --no-headers 2>/dev/null | awk '{print "subscription " $1 "/" $2}' + ocx get csv -A -l '!olm.copiedFrom' --no-headers 2>/dev/null | awk '$2 !~ /^packageserver/ {print "csv " $1 "/" $2}' + for crd in $kept_crds; do echo "crd/$crd (kept: used by items that are not ours)"; done +)" +not_ours="$(grep -v '^$' <<<"$not_ours" || true)" +if [[ -z "$ours" ]]; then + ok "nothing of Example 05 is left" +else + failed=1 + echo " of ours, still present:" >&2 + while IFS= read -r line; do echo " $line" >&2; done <<<"$ours" +fi +if [[ -n "$not_ours" ]]; then + echo " not ours, left untouched:" + while IFS= read -r line; do echo " $line"; done <<<"$not_ours" +fi +echo " cluster inventory:" +inventory + +# The state directory holds only the public ingress CA. +rm -rf "$STATE" + +if (( stop_crc )); then + step "5/5 crc stop" + # The EXIT trap runs crc stop and reports it. +fi diff --git a/examples/05-event-driven/openshift/verify-matrix.sh b/examples/05-event-driven/openshift/verify-matrix.sh new file mode 100755 index 00000000..a9e6c828 --- /dev/null +++ b/examples/05-event-driven/openshift/verify-matrix.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Build, push, deploy and verify Example 05 on OpenShift Local, one language at a time, and collect +# evidence. Results go to a TSV; logs and evidence go under $OUT (default outside the repository). +# +# openshift/verify-matrix.sh # all five languages +# openshift/verify-matrix.sh go python # these languages +# OUT=/path/to/dir openshift/verify-matrix.sh +# +# Per language the run must pass all of: verify.sh (10 checks, over HTTPS through the Route with the +# ingress CA), a linked trace in Tempo (order-service and a consumer in one trace, evidence file +# linked-trace.json), and the restricted-v2 SCC on every application pod. Between languages only the +# cndp-05 project is removed; the operator stays. Run openshift/setup-crc.sh first and +# openshift/teardown.sh when done. Exit status is 1 if any language fails or the evidence scrub finds a secret. +set -uo pipefail +# shellcheck source=lib.sh +# shellcheck source-path=SCRIPTDIR +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +OUT="${OUT:-${TMPDIR:-/tmp}/cndp-ocp-matrix}" +mkdir -p "$OUT/logs" "$OUT/evidence" +RESULTS="$OUT/results.tsv" +: >"$RESULTS" + +selected=("$@") +[[ ${#selected[@]} -gt 0 ]] || selected=("${LANGS[@]}") +for l in "${selected[@]}"; do valid_lang "$l" || fail "unknown language '$l' (${LANGS[*]})"; done +require_crc +require_alone +[[ -s "$INGRESS_CA" ]] || fail "ingress CA missing in $STATE. Run openshift/setup-crc.sh first" + +# linked_trace : poll Tempo (inside the lgtm pod) for up to 180 s for a trace that contains +# order-service and at least one consumer. Tempo indexes with a delay. TraceQL && is not relied on: +# the search finds traces by service.name and each trace is read to list its services. +linked_trace() { + local out="$1" deadline end start ids id services + deadline=$(( SECONDS + 180 )) + while (( SECONDS < deadline )); do + end="$(date +%s)" + start=$(( end - 3600 )) + ids="$(ocn exec deploy/lgtm -- curl -s -G 'http://localhost:3200/api/search' \ + --data-urlencode 'tags=service.name=order-service' \ + --data-urlencode "start=$start" --data-urlencode "end=$end" --data-urlencode limit=20 2>/dev/null \ + | jq -r '.traces[]?.traceID' 2>/dev/null)" + for id in $ids; do + services="$(ocn exec deploy/lgtm -- curl -s "http://localhost:3200/api/traces/$id" 2>/dev/null \ + | jq -r '[.batches[]?.resource.attributes[]? | select(.key == "service.name") | .value.stringValue] | unique | join(",")' 2>/dev/null)" + if [[ "$services" == *order-service* && "$services" == *-consumer* ]]; then + jq -n --arg id "$id" --arg s "$services" '{traceId: $id, services: ($s | split(","))}' >"$out" + return 0 + fi + done + sleep 10 + done + return 1 +} + +# scc_table : pod and SCC for every pod; succeeds when every application pod is restricted-v2. +scc_table() { + local dir="$1" + ocn get pods -o json | jq -r '.items[] | [.metadata.name, (.metadata.annotations["openshift.io/scc"] // "none")] | @tsv' >"$dir/scc.tsv" + ! awk -F'\t' '$1 ~ /^(order-service|shipping-consumer|notification-consumer)-/ && $2 != "restricted-v2" {bad=1} END {exit !bad}' "$dir/scc.tsv" +} + +evidence() { # evidence + local dir="$OUT/evidence/$1" host + mkdir -p "$dir" + { + ocn get pods,svc,pvc,route -o wide + ocn get kafka,kafkanodepool + } >"$dir/resources.txt" 2>&1 + ocn get kafka cndp -o jsonpath='{.status.kafkaVersion}{"\n"}' >"$dir/kafka-version.txt" 2>&1 + ocn get kafka cndp -o jsonpath='{.status.conditions}' >"$dir/kafka-status.json" 2>&1 + ocn get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[0].image}{"\n"}{end}' >"$dir/images.txt" 2>&1 + ocx get csv "$CSV" -n "$OPERATOR_NAMESPACE" -o jsonpath='{.metadata.name}{"\t"}{.status.phase}{"\n"}' >"$dir/csv.txt" 2>&1 + ocx version -o json 2>/dev/null | jq -r '"Kubernetes " + .serverVersion.gitVersion + " (OpenShift " + (.openshiftVersion // "unknown") + ")"' >"$dir/versions.txt" 2>&1 + local d + for d in order-service shipping-consumer notification-consumer; do + ocn logs "deployment/$d" --tail=60 >"$dir/$d.log" 2>&1 + done + host="$(ocn get route lgtm-grafana -o jsonpath='{.spec.host}' 2>/dev/null)" + curl -s --cacert "$INGRESS_CA" "https://$host/api/health" >"$dir/grafana-health.json" 2>&1 + host="$(ocn get route order-service -o jsonpath='{.spec.host}' 2>/dev/null)" + # Plain HTTP must redirect to HTTPS (insecureEdgeTerminationPolicy: Redirect). + curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "http://$host/healthz" >"$dir/http-redirect.txt" 2>&1 +} + +failed=0 +for lang in "${selected[@]}"; do + log="$OUT/logs/$lang.log" + start=$(date +%s) + status=PASS + host="order-service-$NAMESPACE.apps-crc.testing" + if ! "$OPENSHIFT_DIR/build-push-images.sh" "$lang" >"$log" 2>&1; then + status=BUILD-FAIL + elif ! "$OPENSHIFT_DIR/deploy.sh" "$lang" >>"$log" 2>&1; then + status=DEPLOY-FAIL + elif ! (cd "$EXAMPLE_DIR" && RUNTIME=kubernetes KUBECTL=oc KUBE_CONTEXT="$OCP_CONTEXT" KUBE_NAMESPACE="$NAMESPACE" \ + BASE="https://$host" CURL_OPTS="--cacert $INGRESS_CA" timeout 900 ./verify.sh) >>"$log" 2>&1; then + status=VERIFY-FAIL + fi + mkdir -p "$OUT/evidence/$lang" + scc=- + trace=- + if [[ "$status" == PASS || "$status" == VERIFY-FAIL ]]; then + scc=ok + trace=ok + scc_table "$OUT/evidence/$lang" || scc=FAIL + linked_trace "$OUT/evidence/$lang/linked-trace.json" || trace=FAIL + evidence "$lang" + if [[ "$status" == PASS && ( "$scc" != ok || "$trace" != ok ) ]]; then status=EVIDENCE-FAIL; fi + fi + if [[ "$status" != PASS ]]; then failed=1; fi + "$OPENSHIFT_DIR/teardown.sh" --namespace-only >>"$log" 2>&1 || true + secs=$(( $(date +%s) - start )) + summary="$(grep -E '==> Results:' "$log" | tail -1 | sed 's/.*Results: //' || true)" + printf '%-12s\t%s\t%s\ttrace %s\tscc %s\t%ss\n' "$lang" "$status" "${summary:--}" "$trace" "$scc" "$secs" | tee -a "$RESULTS" +done + +echo +column -t -s $'\t' "$RESULTS" +if ! scrub_dir "$OUT"; then + echo "ERROR: the scrub found secret-like material under $OUT" >&2 + failed=1 +fi +exit "$failed" diff --git a/examples/05-event-driven/verify.sh b/examples/05-event-driven/verify.sh index 562a9d9d..f66cf36b 100755 --- a/examples/05-event-driven/verify.sh +++ b/examples/05-event-driven/verify.sh @@ -10,7 +10,11 @@ CURL_OPTS="${CURL_OPTS:-}" # an engine. RUNTIME=docker (default) calls the docker CLI on compose containers. # RUNTIME=kubernetes calls `$KUBECTL exec` against the pods that stand in for # those containers: ctr_target maps a container name (cndp-kafka, cndp-postgres) -# to its pod. Set KUBECTL=oc for OpenShift. +# to its pod. Set KUBECTL=oc for OpenShift Local, with KUBE_CONTEXT=crc-admin and a pod +# target that exists there (the defaults match the AMQ Streams and sclorg pods). The Routes +# serve a certificate signed by the cluster's ingress CA, so pass it through curl: +# RUNTIME=kubernetes KUBECTL=oc KUBE_CONTEXT=crc-admin BASE=https://order-service-cndp-05.apps-crc.testing \ +# CURL_OPTS="--cacert $HOME/.local/state/cndp-05-crc/ingress-ca.crt" ./verify.sh KUBECTL="${KUBECTL:-kubectl}" KUBE_CONTEXT="${KUBE_CONTEXT:-cndp-k8s}" KUBE_NAMESPACE="${KUBE_NAMESPACE:-cndp-05}" diff --git a/examples/PREREQUISITES.md b/examples/PREREQUISITES.md index ef3b2ce1..c114dc66 100644 --- a/examples/PREREQUISITES.md +++ b/examples/PREREQUISITES.md @@ -75,7 +75,8 @@ docker run --rm hello-world The examples run on `docker compose` and need no cluster. Appendix R runs Example 05 on minikube; install these tools when you reach it. minikube uses the Docker driver on the same -Docker Engine as compose, with the `default` Docker context. +Docker Engine as compose, with the `default` Docker context. Appendix S runs the same example on +OpenShift Local; its tools are in the last subsection. ### minikube @@ -116,12 +117,50 @@ tar -xzf "helm-v${HELM_VERSION}-linux-amd64.tar.gz" linux-amd64/helm sudo install linux-amd64/helm /usr/local/bin/helm ``` +### OpenShift Local (Appendix S) + +OpenShift Local (CRC) runs OpenShift 4.22 in a virtual machine on Fedora or RHEL, so the host +needs KVM. Pinned to crc 2.64.0, with the archive checked against the SHA-256 file Red Hat +publishes. Run one cluster at a time: stop minikube before `crc start`. + +```bash +CRC_VERSION=2.64.0 +mkdir -p ~/.local/bin && cd "$(mktemp -d)" +base="https://mirror.openshift.com/pub/openshift-v4/clients/crc/${CRC_VERSION}" +curl -fsSLO "${base}/crc-linux-amd64.tar.xz" +curl -fsSL "${base}/sha256sum.txt" | grep ' crc-linux-amd64.tar.xz$' | sha256sum -c - +tar -xJf crc-linux-amd64.tar.xz --strip-components=1 --wildcards '*/crc' +install crc ~/.local/bin/crc +sudo dnf install -y skopeo jq +crc setup +``` + +`crc setup` also makes `api.crc.testing` and `*.apps-crc.testing` resolve on the host, which is +what lets the Routes work without tunnels. `oc` comes with CRC: `eval "$(crc oc-env)"`. + +Download your pull secret from the Red Hat Hybrid Cloud Console (a free Red Hat account is +enough) to a file, and give crc its path once. The scripts never open the file, and nothing in +this book prints it: + +```bash +crc config set pull-secret-file ~/Downloads/pull-secret.txt +crc config set cpus 12 +crc config set memory 32768 +crc config set disk-size 80 +``` + +Sizing takes effect only while CRC is stopped. The scripts check it and never change it. They +use the `crc-admin` kubeconfig context that `crc start` writes, so no password is needed. If you +want the web console login, run `crc console --credentials` yourself. + Verify: ```bash minikube version kubectl version --client helm version --short +crc version +skopeo --version ``` --- @@ -248,6 +287,7 @@ container. |-------|--------|------|-----| | Docker compose examples | ~2-4 GB | ~3 GB for images | 2+ cores | | Minikube, Example 05 (optional) | 8 GB for the node | 40 GB (profile disk) | 4+ cores | +| OpenShift Local, Example 05 (optional) | 32 GB for the VM (32768 MiB) | 80 GB (VM disk) | 12 vCPU | The LGTM observability stack (Grafana, Loki, Tempo, Mimir) is the heaviest shared component at ~1.5 GB. @@ -277,7 +317,7 @@ for cmd in curl jq git python3 conftest newman; do printf " %-10s %s\n" "$cmd" "$(command -v $cmd >/dev/null && echo 'ok' || echo 'MISSING')" done printf "\nOptional:\n" -for cmd in minikube kubectl helm hey ghz; do +for cmd in minikube kubectl helm crc skopeo hey ghz; do printf " %-10s %s\n" "$cmd" "$(command -v $cmd >/dev/null && echo 'ok' || echo 'not installed')" done ``` diff --git a/index.html b/index.html index f4a0edd8..5b818b24 100644 --- a/index.html +++ b/index.html @@ -24,7 +24,7 @@

{{ site.title }}

6One book, six languages — Spring Boot, Quarkus, .NET, Python, C++, Go — behind one tab
-
13Chapters from cloud-native principles through anti-patterns, plus eighteen deep-dive appendices A–R and an acronym glossary
+
13Chapters from cloud-native principles through anti-patterns, plus nineteen deep-dive appendices A–S and an acronym glossary
📊Runs on Docker Engine locally and plain Kubernetes — no managed cloud on the primary path
21Runnable examples with automated verification — Spring Boot, Quarkus, .NET, Python, C++, and Go
diff --git a/scripts/ci_changed_projects.py b/scripts/ci_changed_projects.py index 33f1c5dd..80a51bb4 100755 --- a/scripts/ci_changed_projects.py +++ b/scripts/ci_changed_projects.py @@ -16,8 +16,11 @@ python directories containing requirements.txt cpp directories containing conanfile.py (each also has a Containerfile) compose compose*.yaml files - scripts verify.sh files and examples//k8s/*.sh - k8s examples//k8s directories (kustomize manifests, rendered and validated) + scripts verify.sh files and examples//{k8s,openshift}/**/*.sh (shellcheck). A script directly + in k8s/ or openshift/ selects only scripts; a deeper one is rendered into a manifest + (a ConfigMap), so it selects scripts and the k8s directory too. + k8s examples//k8s and examples//openshift directories (kustomize manifests, + rendered and validated). openshift reuses k8s, so a k8s change selects both. Everything is selected when the base is unknown (workflow_dispatch, all-zero `before` SHA of a first push, a base that cannot be diffed) and when a shared @@ -65,8 +68,11 @@ # Changes that cannot affect a build or a config check. INERT = ("*.md", "*.svg", "*.excalidraw", "*.png") INERT_DIRS = ("db", "collections", "grafana", "otelcol") -# examples//k8s holds kustomize manifests and the scripts that drive minikube. +# examples//k8s holds the kustomize manifests and scripts for minikube; examples//openshift +# holds the OpenShift Local ones and builds on the k8s manifests. K8S_DIR = "k8s" +OPENSHIFT_DIR = "openshift" +K8S_KIND_DIRS = (K8S_DIR, OPENSHIFT_DIR) # Files under examples/_infra/ are fragments pulled in by the examples' compose @@ -83,15 +89,15 @@ def is_compose(path: str) -> bool: def is_k8s_script(path: str) -> bool: parts = path.split("/") - return len(parts) >= 4 and parts[2] == K8S_DIR and path.endswith(".sh") + return len(parts) >= 4 and parts[2] in K8S_KIND_DIRS and path.endswith(".sh") def k8s_dirs(files: list[str]) -> set[str]: - """examples//k8s directories among tracked files.""" + """examples//k8s and examples//openshift directories among tracked files.""" out = set() for f in files: parts = f.split("/") - if len(parts) >= 4 and parts[0] == EXAMPLES and parts[2] == K8S_DIR: + if len(parts) >= 4 and parts[0] == EXAMPLES and parts[2] in K8S_KIND_DIRS: out.add("/".join(parts[:3])) return out @@ -162,20 +168,24 @@ def select_for_changes(changed: list[str]) -> dict[str, set[str]]: if exists: sel["scripts"].add(f) continue - # The schema is rendered into the k8s manifests, so a change to it selects them too. + # The schema is rendered into the k8s and openshift manifests, so a change to it selects both. if len(parts) >= 4 and parts[2] == "db": - k8s = f"{parts[0]}/{parts[1]}/{K8S_DIR}" - if k8s in k8s_dirs(files): - sel["k8s"].add(k8s) - if len(parts) >= 4 and parts[2] == K8S_DIR: - if is_k8s_script(f): - if exists: - sel["scripts"].add(f) - elif not name.endswith(".md"): + for d in K8S_KIND_DIRS: + kdir = f"{parts[0]}/{parts[1]}/{d}" + if kdir in k8s_dirs(files): + sel["k8s"].add(kdir) + if len(parts) >= 4 and parts[2] in K8S_KIND_DIRS: + top_level_script = is_k8s_script(f) and len(parts) == 4 + if is_k8s_script(f) and exists: + sel["scripts"].add(f) + if not top_level_script and not name.endswith(".md"): # A deleted manifest still selects the directory, if it still exists. - k8s = "/".join(parts[:3]) - if k8s in k8s_dirs(files): - sel["k8s"].add(k8s) + # openshift renders the k8s manifests, so a k8s change selects it as well. + chosen = [parts[2]] + ([OPENSHIFT_DIR] if parts[2] == K8S_DIR else []) + for d in chosen: + kdir = f"{parts[0]}/{parts[1]}/{d}" + if kdir in k8s_dirs(files): + sel["k8s"].add(kdir) continue if any(fnmatch.fnmatch(name, pat) for pat in INERT) or any(p in INERT_DIRS for p in parts[2:-1]): continue diff --git a/scripts/gen_32_diagrams.py b/scripts/gen_32_diagrams.py new file mode 100644 index 00000000..4147fe7a --- /dev/null +++ b/scripts/gen_32_diagrams.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Generate diagrams for Appendix S: Running Example 05 on OpenShift Local.""" +import generate_diagram as g +g.OUT = "../assets/diagrams" + +# ── Figure S.1 — cluster topology and host access ─────────────────────── +g.emit("32-openshift-topology", 880, 440, + bands=[ + {"x": 10, "y": 10, "w": 200, "h": 420, "fill": "#eef4fb", + "label": "Host"}, + {"x": 230, "y": 10, "w": 640, "h": 420, "fill": "#f7f7f7", + "label": "OpenShift Local (CRC VM, OpenShift 4.22)"}, + {"x": 250, "y": 40, "w": 160, "h": 380, "fill": "#ffffff", + "label": "cndp-amq-streams"}, + {"x": 430, "y": 40, "w": 425, "h": 380, "fill": "#fff8ef", + "label": "namespace cndp-05 (restricted-v2)"}, + ], + nodes=[ + {"x": 25, "y": 60, "w": 170, "h": 55, "style": "user", + "lines": ["curl, verify.sh", "--cacert ingress CA"]}, + {"x": 25, "y": 135, "w": 170, "h": 55, "style": "user", + "lines": ["Browser", "lgtm-grafana Route"]}, + {"x": 25, "y": 210, "w": 170, "h": 55, "style": "kernel", + "lines": ["oc --context crc-admin", "exec into pods"]}, + {"x": 25, "y": 285, "w": 170, "h": 55, "style": "ghost", + "lines": ["minikube profiles", "stopped"]}, + {"x": 265, "y": 125, "w": 130, "h": 60, "style": "accent", + "lines": ["Subscription", "Manual, pinned CSV"]}, + {"x": 265, "y": 310, "w": 130, "h": 60, "style": "accent", + "lines": ["AMQ Streams", "operator 3.2"]}, + {"x": 450, "y": 80, "w": 190, "h": 55, "style": "ink", + "lines": ["order-service", "Route, edge TLS"]}, + {"x": 660, "y": 80, "w": 180, "h": 55, "style": "box", + "lines": ["shipping-consumer", "notification-consumer"]}, + {"x": 450, "y": 180, "w": 190, "h": 60, "style": "accent", + "lines": ["Kafka cndp 4.2.0", "cndp-kafka-bootstrap:9092"]}, + {"x": 660, "y": 180, "w": 180, "h": 60, "style": "accent", + "lines": ["Postgres 18 (sclorg)", "cndp-postgres-rw:5432"]}, + {"x": 450, "y": 285, "w": 190, "h": 60, "style": "kernel", + "lines": ["LGTM (otel-lgtm)", "lgtm:4318, Grafana Route"]}, + {"x": 660, "y": 285, "w": 180, "h": 60, "style": "sub", + "lines": ["Secret cndp-postgres-app", "username, password"]}, + ], + edges=[ + {"x1": 195, "y1": 87, "x2": 450, "y2": 107, "amber": True}, + {"x1": 195, "y1": 162, "x2": 450, "y2": 315, "amber": True}, + {"x1": 195, "y1": 237, "x2": 450, "y2": 215, "dashed": True}, + {"x1": 330, "y1": 310, "x2": 450, "y2": 225, "dashed": True, "label": "manages", "lx": 20, "ly": 10}, + {"x1": 330, "y1": 185, "x2": 330, "y2": 310, "dashed": True, "label": "OLM installs", "lx": 0, "ly": 40}, + {"x1": 545, "y1": 135, "x2": 545, "y2": 180, "label": "produce"}, + {"x1": 750, "y1": 135, "x2": 750, "y2": 180, "label": "insert"}, + {"x1": 640, "y1": 208, "x2": 660, "y2": 208}, + ], + notes=[ + {"x": 25, "y": 415, "text": "Routes terminate TLS at the router; HTTP is redirected. No port-forward, tunnel or insecure curl.", + "size": 11, "color": "#888888"}, + ], +) + +# ── Figure S.2 — image flow ───────────────────────────────────────────── +g.emit("32-openshift-image-flow", 880, 320, + bands=[ + {"x": 10, "y": 10, "w": 540, "h": 210, "fill": "#eef4fb", + "label": "Host"}, + {"x": 570, "y": 10, "w": 300, "h": 210, "fill": "#fff8ef", + "label": "OpenShift Local"}, + ], + nodes=[ + {"x": 30, "y": 55, "w": 150, "h": 55, "style": "box", + "lines": ["Containerfile", "per language, per service"]}, + {"x": 210, "y": 55, "w": 150, "h": 55, "style": "accent", + "lines": ["docker build", "host BuildKit cache"]}, + {"x": 390, "y": 55, "w": 140, "h": 55, "style": "user", + "lines": ["Docker image store", "localhost/cndp-05/..."]}, + {"x": 590, "y": 55, "w": 130, "h": 55, "style": "kernel", + "lines": ["Internal registry", "ImageStream :v1"]}, + {"x": 740, "y": 55, "w": 110, "h": 55, "style": "ink", + "lines": ["Pod", "pulls, Always"]}, + {"x": 390, "y": 140, "w": 140, "h": 60, "style": "sub", + "lines": ["skopeo copy", "docker-daemon: to route"]}, + {"x": 590, "y": 140, "w": 260, "h": 60, "style": "sub", + "lines": ["Token: builder, 10 minutes", "shell variable and pipe only"]}, + {"x": 30, "y": 250, "w": 820, "h": 45, "style": "ghost", + "lines": ["Rejected: docker push (needs a root-owned certs.d entry), in-cluster builds, skopeo login (writes a file)"]}, + ], + edges=[ + {"x1": 180, "y1": 82, "x2": 210, "y2": 82}, + {"x1": 360, "y1": 82, "x2": 390, "y2": 82}, + {"x1": 530, "y1": 82, "x2": 590, "y2": 82, "amber": True, "label": "HTTPS", "lx": 0, "ly": -14}, + {"x1": 720, "y1": 82, "x2": 740, "y2": 82}, + {"x1": 460, "y1": 110, "x2": 460, "y2": 140, "dashed": True}, + {"x1": 590, "y1": 170, "x2": 530, "y2": 170, "dashed": True}, + ], + notes=[], +) +print("Done — generated 2 diagrams for Appendix S")