# Reproduce the Harbor example

Verified on September 23, 2026 in a disposable kind cluster on Docker context
`remote-dev-container`. This continues the shipment database from the
[operator example](../../first-schema-with-kubernetes-operator/examples/README.md).

| Component | Tested version |
| --- | --- |
| Publishing CLI | Ptah 0.8.0, commit `061fac0c6b2607f9442b440168fb45c3dfe5ba39` |
| Harbor | 2.15.2 |
| Harbor Helm chart | 1.19.2 |
| Helm | 4.2.0 |
| Kubernetes / kind | 1.37.0 / 0.33.0 |
| Operator and kubectl plugin | commit `6d72944c477c851a19d2662eb345ea313c921dd0` |
| PostgreSQL | 17.11 |

`measured/harbor-runtime.json` records the running Harbor image digests and
chart archive hash. The operator and executor image identities are recorded
in the captured resource states. This operator build was unreleased at the
time of verification; its executor was built from the Ptah release source.

## Prepare Harbor

The existing operator lab supplies the database, application namespace, and
`shipments` resource. Its `demo-database` Secret and `blog-schema-policy`
ConfigMap remain in place. The table initially contains shipment 1 for order 42.

The recorded Harbor installation used:

```console
helm pull harbor --repo https://helm.goharbor.io --version 1.19.2
helm install blog-harbor harbor-1.19.2.tgz --namespace blog-harbor --create-namespace --values values.yaml --wait --timeout 8m
```

Before installing, provision a Secret named `harbor-admin` in `blog-harbor`
with the `HARBOR_ADMIN_PASSWORD` key. The value is not included. The chart
creates a TLS certificate for `harbor.blog-harbor.svc.cluster.local`; its
`blog-harbor-nginx` Secret contains the public `ca.crt`. Export only that public
CA for clients. Never export the TLS private key for this workflow.

`values.yaml` disables persistence and the image vulnerability scanner for
this disposable lab. It is not a production Harbor installation guide. All
Harbor state is removed when the lab is deleted.

Create a private project named `fulfillment`. Create project-level robots:

- `publisher`: repository pull and push.
- `operator`: repository pull only.

The recorded API request bodies are in `publisher-robot-request.json` and
`operator-robot-request.json`. They used Harbor's `POST /api/v2.0/robots`
endpoint with one-day credentials. No returned secrets are included here.
The project was created with `metadata.public` set to `"false"`.

The CLI must reach Harbor's advertised hostname, including its token service.
The recorded CLI ran inside the cluster with the released Linux Ptah binary.
Set `REGISTRY` to `harbor.blog-harbor.svc.cluster.local` and trust the exported
CA through `SSL_CERT_FILE`. Supply the publisher robot through
`PTAH_OCI_USERNAME` and `PTAH_OCI_PASSWORD` in the process environment. Ptah's
native `oci login --password-stdin` was also verified; its captured output
notes that environment credentials take precedence over the stored login.

## Publish and keep the first digest

Run the CLI commands from the directory containing these SQL files:

```console
ptah schema push "oci://$REGISTRY/fulfillment/shipments:candidate" --schema-file schema-v1.sql --dialect postgres --version v1
ptah oci inspect "oci://$REGISTRY/fulfillment/shipments:candidate" --format json
ptah oci resolve "oci://$REGISTRY/fulfillment/shipments:candidate"
```

Set `SCHEMA_V1` to the complete digest reference returned by this run. Use a
fresh repository or new version labels when reproducing it; the recorded v1
and v2 publications are not an instruction to overwrite existing releases.

```console
ptah oci tag "$SCHEMA_V1" production
ptah oci capabilities "$SCHEMA_V1" --format json
```

The capability probe reported `referrers_api: true` for this Harbor installation.
No signature or other referrer was published in this example.

Provision the operator's `harbor-operator` Secret in the application namespace:

| Key | Value |
| --- | --- |
| `username` | The pull-only robot name returned by Harbor |
| `password` | That robot's secret |
| `registry` | Exact authority `harbor.blog-harbor.svc.cluster.local` |
| `caSHA256` | `sha256:` followed by the lowercase SHA-256 of the CA bytes |

Create `harbor-ca` in the same namespace with those exact public certificate
bytes under `ca.crt`. Use your secret-management process for the credentials.
The CA in this lab was bound to
`sha256:6b0503a94723f60eb35b94b77afadafe90e2d9863c14c2663c4714286d6593fa`.
Another installation must use its own certificate and hash.

Update the registry authority and digest in `shipments-v1.json` for your run.
The resource preserves the same physical database, coordination key, and
`OnApproval` policy as the previous article. Do not create a second resource
that competes for the same shipment schema.

```console
kubectl -n "$NAMESPACE" apply -f shipments-v1.json
kubectl -n "$NAMESPACE" wait ptahschema/shipments --for=condition=InSync=True --timeout=180s
```

The recorded run patched the existing resource with `pin-v1.json`. The full
JSON resource expresses the resulting specification. `desired.yaml` is only
the excerpt shown in the article, not a complete Kubernetes resource.

## Move the tag while keeping the resource pinned

```console
ptah schema push "oci://$REGISTRY/fulfillment/shipments:candidate" --schema-file schema-v2.sql --dialect postgres --version v2
ptah oci inspect "oci://$REGISTRY/fulfillment/shipments:candidate" --format json
ptah oci resolve "oci://$REGISTRY/fulfillment/shipments:candidate"
```

Set `SCHEMA_V2` to the new complete digest reference, then promote it:

```console
ptah oci tag "$SCHEMA_V2" production
ptah oci resolve "oci://$REGISTRY/fulfillment/shipments:production"
ptah schema render --schema-file "$SCHEMA_V1" --dialect postgres
ptah schema render --schema-file "oci://$REGISTRY/fulfillment/shipments:production" --dialect postgres
ptah schema pull "$SCHEMA_V1" --out reviewed-v1.hcl
```

The pull destination must not already exist. Its SHA-256 matched the layer
digest recorded in `inspect-v1.json`. Only the v2 render contains
`tracking_code`. The rendered-output text files combine standard output and
diagnostic output; they are evidence, not executable SQL files.

Inspect the operator status after its next observation. Check that `InSync`
belongs to the current resource generation. Run `columns.sql` against the
application database: it must still return only id, order_id, and status.
`operator-after-tag-move.json` records a v1 observation later than the v2 tag
promotion, at the same resource generation as before the promotion.

## Select and approve v2

Replace `desired.ociRef` with `SCHEMA_V2` in `shipments-v2.json`:

```console
kubectl -n "$NAMESPACE" apply -f shipments-v2.json
kubectl -n "$NAMESPACE" wait ptahschema/shipments --for=condition=ApprovalRequired=True --timeout=180s
kubectl ptah plan shipments --current -n "$NAMESPACE" -o sql
kubectl -n "$NAMESPACE" get ptahschema shipments -o yaml
```

The recorded run used the equivalent field update in `pin-v2.json`. The plan
added one nullable text column. Read the SQL and replace the identifiers in
`approval.json` with the current schema UID, plan name, plan UID, and fingerprint.
The committed approval belongs to this recorded run and cannot be reused in
another cluster.

```console
kubectl -n "$NAMESPACE" create -f approval.json
kubectl -n "$NAMESPACE" wait ptahschema/shipments --for=condition=InSync=True --timeout=180s
kubectl ptah schema shipments -n "$NAMESPACE"
```

The approval was consumed. The final resource selected the v2 digest, reported
`ScopedConverged` at generation 3, and recorded an observation at `11:06:04Z`
after the applied status's completion time of `11:05:20Z`. We waited for that
later observation rather than treating any retained status as current proof.

Run `application-query.sql` against the same database. Shipment 1 keeps order
42 and status `queued`, and receives `TRACK-0042`. The lab executed the query
through its existing `demo-psql` client with database connection settings
supplied from a Secret.

## What the evidence establishes

- Native Ptah publication, inspection, tag promotion, resolution, rendering,
  and pulling worked with this authenticated Harbor installation over HTTPS.
- The private project refused an anonymous pull. A client without the test CA
  refused Harbor's certificate.
- An initial operator configuration omitted the `sha256:` prefix from its CA
  grant. The fetch failed before access was permitted. Correcting the Secret
  allowed reconciliation; the failed resource state is retained.
- Promotion preserved the selected manifest digest. The original digest still
  returned its original HCL payload after the tag moved to v2.
- The digest-pinned operator stayed on v1 until its desired reference changed.
  It then required a new plan approval, applied v2, and verified convergence.

`measured/` contains the captured CLI and Kubernetes outputs. Public text has
trailing whitespace removed and local fixture paths replaced with `/examples/`.
`verified.json` records original hashes for normalized output, file hashes,
and hashes of the article's command blocks. Passwords and private keys are not
included. This does not test registry retention, disaster recovery, signatures,
or Harbor vulnerability scanning. Remove your disposable resources afterward.
