ingress: KMS is a dependency of ACME, not of the proxy

edgeSecrets ran on every boot, so an edge that orders no certificates still had
to reach KMS before it served anything — including routes whose certificates
come from a Kubernetes Secret, and plain-HTTP routes. It now returns the
identity seal when no resolver orders certificates, and reaches KMS only when
there is ACME state to seal.

Adoption covers ONE store. It admits the first unsealed document and the same
document again — a store is read more than once before the write that seals it
lands, and a replica that cannot write reads it every poll — but not a
different one. A seal left adopting was taking whatever plaintext it was handed
for as long as the process ran. The setting's comment now says what it does:
it writes, it covers one store, and it adopts again after a restart, which is
why it is removed once it has done its work.

One spelling for the KMS credential. The bare IAM_CLIENT_ID fallback undid the
reason the names are prefixed — a pod carrying another subsystem's IAM client
had it picked up here.

k8s/hanzo/deployment.yaml and service.yaml are deleted. They described a
hostNetwork DaemonSet named hanzo-ingress binding hostPort 80 and 443, while
what runs in that cluster is a Deployment named ingress declared in universe.
`kubectl apply -f k8s/hanzo/` appears in this README and in the sibling
gateway's Makefile, so the file was one command from taking :80 and :443 on
every node beside the workload that already had them. Universe is the one place
that says what runs; the cluster-level objects stay here.

The OTLP endpoint paths in the docs and the vendored Knative fixture are
restored: /v1/metrics is the OpenTelemetry receiver path, not a route of ours.
This commit is contained in:
2026-08-21 07:03:24 -07:00
parent d8a3ec5530
commit 910e0f2936
15 changed files with 195 additions and 222 deletions
+3
View File
@@ -24,3 +24,6 @@ hanzo-ingress
AGENTS.md
GEMINI.md
QWEN.md
# the built binary
/ingress
+15 -6
View File
@@ -437,12 +437,21 @@ kinds and they fail differently:
Neither is a fleet-wide outage. The DaemonSet one is a real sustained
single-node outage and is the reason to check first.
**The manifests here are not the deployed shape.** `k8s/lux/deployment.yaml`
matches what runs in `lux-system` (DaemonSet `hanzo-ingress`).
`k8s/hanzo/deployment.yaml` is a DaemonSet named `hanzo-ingress` in namespace
`hanzo`, and what runs there is a Deployment named `ingress`. Reconciling those
two is its own change; applying this file as-is to hanzo creates a second
workload rather than updating the one that is there.
**Only lux has a workload manifest here.** `k8s/lux/deployment.yaml` matches
what runs in `lux-system` (DaemonSet `hanzo-ingress`). Hanzo's workload is
declared in `hanzoai/universe` (`infra/k8s/ingress/`, Deployment `ingress`) and
`k8s/hanzo/` carries only the cluster-level objects it uses — RBAC, the
IngressClass, the `security-headers` Middleware.
The Deployment and Service that used to be in `k8s/hanzo/` described a
different workload from the one that runs: a `hostNetwork` DaemonSet named
`hanzo-ingress` binding `hostPort` 80 and 443. `kubectl apply -f k8s/hanzo/` is
in this repo's README and in the sibling gateway's Makefile, so that file was
one command away from taking :80 and :443 on every node in the cluster, beside
the Deployment that already had them, each pod with its own node-local ACME
store ordering for the same domains. It is deleted rather than corrected:
universe is the one place that says what runs, and a second copy here is a
second answer.
**Adoption.** A first boot against an empty `data` volume needs nothing: the
store is written sealed from its first write. `INGRESS_ACME_ADOPT` is only for a
+17 -5
View File
@@ -61,12 +61,17 @@ Deployed on the `hanzo-k8s` cluster as the default IngressClass (`ingress`), it
### Kubernetes
The workload is declared in `hanzoai/universe`
(`infra/k8s/ingress/`) and reconciled by the operator, so it is not applied
from here. What this repository carries is the cluster-level objects the
workload uses:
```bash
# From a clone -- RBAC, IngressClass, Deployment, Service, middlewares
# RBAC, IngressClass, middlewares
kubectl apply -f k8s/hanzo/
# Verify
kubectl -n hanzo get pods -l app=hanzo-ingress
kubectl -n hanzo get pods -l app.kubernetes.io/name=ingress
```
The manifests are not fetchable by URL: this repository is private, so
@@ -208,10 +213,14 @@ Any Ingress resource without an explicit `ingressClassName` is automatically pic
k8s/hanzo/
rbac.yaml # ServiceAccount, ClusterRole, ClusterRoleBinding
ingressclass.yaml # IngressClass "ingress" (default)
deployment.yaml # 2 replicas, hostNetwork, ports 80/443
service.yaml # LoadBalancer service
middlewares.yaml # security-headers Middleware CRD
```
The Deployment and its Service are declared in `hanzoai/universe`
(`infra/k8s/ingress/`), which is the one place that says what runs. A copy
here would be a second answer to that question, and the copy that was here
described a different workload than the one the cluster runs.
### Creating Ingress Resources
Once deployed, create standard Kubernetes Ingress resources to route traffic:
@@ -263,11 +272,14 @@ Hanzo Ingress runs on the `hanzo-k8s` DOKS cluster (`24.199.76.156`) as the sole
### Deploy / Update
The image tag is pinned in `hanzoai/universe` and rolled by the operator. The
manifests here are the cluster-level objects only:
```bash
kubectl --context do-sfo3-hanzo-k8s apply -f k8s/hanzo/
# Verify pods
kubectl --context do-sfo3-hanzo-k8s -n hanzo get pods -l app=hanzo-ingress
kubectl --context do-sfo3-hanzo-k8s -n hanzo get pods -l app.kubernetes.io/name=ingress
# Check service
kubectl --context do-sfo3-hanzo-k8s -n hanzo get svc hanzo-ingress
+3 -2
View File
@@ -186,8 +186,9 @@ func setupServer(staticConfiguration *static.Configuration) (*server.Server, err
//
// The edge's own secrets are read here, before anything ACME is built: the
// sealing key the state is written under and the DNS credential the
// challenge is answered with are both inputs to the provider below.
seal := edgeSecrets()
// challenge is answered with are both inputs to the provider below. An edge
// with no ACME resolver reads none of them.
seal := edgeSecrets(staticConfiguration)
tlsManager := ingresstls.NewManager(staticConfiguration.OCSP)
routinesPool.GoCtx(tlsManager.Run)
+25 -4
View File
@@ -5,6 +5,7 @@ import (
"os"
"time"
"github.com/hanzoai/ingress/pkg/config/static"
"github.com/hanzoai/ingress/pkg/kms"
"github.com/hanzoai/ingress/pkg/provider/acme"
"github.com/rs/zerolog/log"
@@ -38,9 +39,10 @@ const reach = 30 * time.Second
// store that predates sealing keeps its certificates instead of re-ordering
// every one of them against a rate limit.
//
// It does its work once and then does nothing: after that boot the store IS
// sealed, so the unsealed path is never reached again. It says so at warn on
// every boot it is set, so it is not left on quietly.
// It admits ONE store — the one the operator was looking at — and adoption is
// not a read: it writes that store back under seal. Left set, it adopts again
// after every restart, whatever plaintext is on the disk at the time. So it is
// set for one boot and removed, and it says so at warn on every boot it is set.
const envAdopt = "INGRESS_ACME_ADOPT"
// edgeSeal is how the ACME state reaches its store.
@@ -160,7 +162,15 @@ func unset(name string) {
// edgeSecrets is the one call that reads everything this process needs from
// KMS. It runs before the ACME provider is built, because both of its results
// are inputs to it.
func edgeSecrets() acme.Seal {
func edgeSecrets(c *static.Configuration) acme.Seal {
if !ordering(c) {
// Nothing here orders a certificate, so there is no ACME state to seal
// and no challenge to answer. Reading KMS anyway would make it a
// condition of serving every route this edge has — the ones whose
// certificates come from a Kubernetes Secret, and the plain-HTTP ones
// — for a subsystem that is not running.
return acme.Plain()
}
client, err := kms.FromEnv()
if err != nil {
// A configuration error does not heal, so it is not retried.
@@ -169,3 +179,14 @@ func edgeSecrets() acme.Seal {
loadDNSCredential(client, reach)
return edgeSeal(client, reach)
}
// ordering reports whether any certificate resolver orders certificates. It is
// the one question that decides whether this edge needs its own secrets at all.
func ordering(c *static.Configuration) bool {
for _, r := range c.CertificatesResolvers {
if r.ACME != nil {
return true
}
}
return false
}
+45
View File
@@ -6,7 +6,9 @@ import (
"testing"
"time"
"github.com/hanzoai/ingress/pkg/config/static"
"github.com/hanzoai/ingress/pkg/kms"
"github.com/hanzoai/ingress/pkg/provider/acme"
)
// Every way a Cloudflare credential can reach lego: the variable, and the
@@ -90,3 +92,46 @@ func assertSupplied(t *testing.T) {
t.Errorf("%s = %q; a token appeared without one being read", envCFToken, got)
}
}
// An edge with no ACME resolver reads no secrets. Its certificates come from
// Kubernetes Secrets and some of its routes are plain HTTP; making KMS a
// condition of serving them would take the whole edge down on a node whenever
// KMS is unreachable during a restart.
func TestEdgeSecrets_NoResolverReadsNothing(t *testing.T) {
fill(t)
// Configured for KMS, and pointed at nothing that answers: reaching it
// would fail, and reaching it at all is the thing being ruled out.
t.Setenv("INGRESS_KMS_ENDPOINT", "https://127.0.0.1:1")
t.Setenv("INGRESS_KMS_CLIENT_ID", "ingress")
t.Setenv("INGRESS_KMS_CLIENT_SECRET", "shhh")
start := time.Now()
seal := edgeSecrets(&static.Configuration{})
if took := time.Since(start); took > time.Second {
t.Errorf("an edge with no resolver spent %v reaching KMS", took)
}
if _, ok := seal.(*kms.Seal); ok {
t.Error("an edge with no resolver built a seal from KMS")
}
assertSupplied(t)
}
// One resolver that orders certificates is enough to need them.
func TestOrdering(t *testing.T) {
if ordering(&static.Configuration{}) {
t.Error("a configuration with no resolver was read as ordering certificates")
}
none := &static.Configuration{CertificatesResolvers: map[string]static.CertificateResolver{
"tailscale": {},
}}
if ordering(none) {
t.Error("a resolver that is not ACME was read as ordering certificates")
}
some := &static.Configuration{CertificatesResolvers: map[string]static.CertificateResolver{
"tailscale": {},
"letsencrypt": {ACME: &acme.Configuration{}},
}}
if !ordering(some) {
t.Error("an ACME resolver was not read as ordering certificates")
}
}
@@ -221,17 +221,17 @@ URL of the OpenTelemetry Collector to send metrics to.
metrics:
otlp:
http:
endpoint: https://collector:4318/ext/metrics
endpoint: https://collector:4318/v1/metrics
```
```toml tab="File (TOML)"
[metrics]
[metrics.otlp.http]
endpoint = "https://collector:4318/ext/metrics"
endpoint = "https://collector:4318/v1/metrics"
```
```bash tab="CLI"
--metrics.otlp.http.endpoint=https://collector:4318/ext/metrics
--metrics.otlp.http.endpoint=https://collector:4318/v1/metrics
```
#### `headers`
+3 -3
View File
@@ -25,12 +25,12 @@ To enable metrics in Hanzo Ingress, you need to configure the metrics provider i
metrics:
otlp:
http:
endpoint: http://myotlpcollector:4318/ext/metrics
endpoint: http://myotlpcollector:4318/v1/metrics
```
```toml tab="Structured (TOML)"
[metrics.otlp.http]
endpoint = "http://myotlpcollector:4318/ext/metrics"
endpoint = "http://myotlpcollector:4318/v1/metrics"
```
```yaml tab="Helm Chart Values"
@@ -43,7 +43,7 @@ metrics:
enabled: true
http:
enabled: true
endpoint: http://myotlpcollector:4318/ext/metrics
endpoint: http://myotlpcollector:4318/v1/metrics
```
## Per-Router Metrics
@@ -8593,7 +8593,7 @@ data:
#
# When the protocol is prometheus the endpoint can accept a 'host:port' string to customize the
# listening host interface and port.
request-metrics-endpoint: http://promstack-kube-prometheus-prometheus.observability:9090/api/v1/otlp/ext/metrics
request-metrics-endpoint: http://promstack-kube-prometheus-prometheus.observability:9090/api/v1/otlp/v1/metrics
# request-metrics-export-interval specifies the global metrics reporting period for the queue-proxy.
#
-167
View File
@@ -1,167 +0,0 @@
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: hanzo-ingress
namespace: hanzo
labels:
app: hanzo-ingress
app.kubernetes.io/name: hanzo-ingress
app.kubernetes.io/part-of: hanzo-platform
spec:
selector:
matchLabels:
app: hanzo-ingress
updateStrategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
template:
metadata:
labels:
app: hanzo-ingress
spec:
serviceAccountName: hanzo-ingress
imagePullSecrets:
- name: ghcr-pull-secret
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
# No cloud-specific nodeSelector — runs on any K8s cluster
containers:
- name: ingress
image: ghcr.io/hanzoai/ingress:1.7.41
imagePullPolicy: IfNotPresent
args:
# Providers — watches Ingress resources with class "ingress"
- "--providers.kubernetesingress=true"
- "--providers.kubernetesingress.ingressclass=ingress"
- "--providers.kubernetesingress.allowemptyservices=true"
- "--providers.kubernetesingress.allowexternalnameservices=true"
- "--providers.kubernetescrd=true"
- "--providers.kubernetescrd.allowcrossnamespace=true"
- "--providers.kubernetescrd.allowexternalnameservices=true"
- "--providers.kubernetescrd.allowemptyservices=true"
# Entrypoints
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entrypoint.to=:443"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--entrypoints.web.http.redirections.entrypoint.permanent=true"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.websecure.http.tls=true"
- "--entrypoints.websecure.http.tls.certresolver=letsencrypt"
# Default security headers on all HTTPS routes
- "--entrypoints.websecure.http.middlewares=security-headers@kubernetescrd"
# ACME / Let's Encrypt via Cloudflare DNS-01
- "[email protected]"
- "--certificatesresolvers.letsencrypt.acme.storage=/data/acme.json"
- "--certificatesresolvers.letsencrypt.acme.dnschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare"
- "--certificatesresolvers.letsencrypt.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53"
# Health + observability
- "--ping=true"
- "--ping.entryPoint=web"
- "--api.dashboard=false"
- "--log.level=INFO"
- "--accesslog=true"
- "--accesslog.fields.defaultmode=keep"
- "--accesslog.fields.headers.defaultmode=drop"
# The key /data/acme.json is sealed under comes from KMS — it is never
# in this file and never in a Kubernetes Secret:
#
# ingress/acme-seal 32 bytes, AES-256-GCM, seals the ACME state
#
# Reading it needs one credential of its own, and that one IS a Secret,
# because it is what opens KMS. It reads one path, in one org, and
# nothing else. The reference is REQUIRED: this pod is configured for
# KMS, so a missing credential is a missing configuration and the
# kubelet naming the absent Secret is the clearest way to say so.
# Materialise it with a KMSSecret before applying this manifest.
#
# The Cloudflare credential the DNS-01 challenge is answered with stays
# where the cluster already holds it. Moving it to a zone-scoped token
# is its own change: it swaps the mechanism lego authenticates with,
# and lego prefers the account pair over any token, so the two do not
# coexist. The reference is optional because a deployment that supplies
# the credential another way should still start.
env:
- name: INGRESS_KMS_ENDPOINT
value: "https://kms.hanzo.ai"
- name: INGRESS_KMS_ORG
value: "hanzo"
- name: INGRESS_KMS_PATH
value: "ingress"
- name: INGRESS_KMS_CLIENT_ID
valueFrom:
secretKeyRef:
name: ingress-kms
key: clientId
- name: INGRESS_KMS_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: ingress-kms
key: clientSecret
- name: CF_API_EMAIL
valueFrom:
secretKeyRef:
name: cloudflare-api-credentials
key: EMAIL
optional: true
- name: CF_API_KEY
valueFrom:
secretKeyRef:
name: cloudflare-api-credentials
key: APIKEY
optional: true
ports:
- name: web
containerPort: 80
hostPort: 80
- name: websecure
containerPort: 443
hostPort: 443
volumeMounts:
- name: data
mountPath: /data
livenessProbe:
httpGet:
path: /ping
port: 80
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ping
port: 80
scheme: HTTP
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "2"
memory: 512Mi
securityContext:
runAsUser: 1000
runAsGroup: 1000
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
add: ["NET_BIND_SERVICE"]
drop: ["ALL"]
volumes:
# /data/acme.json holds the ACME account key and every leaf private key
# this node has been issued. It is sealed (AES-256-GCM under a key read
# from KMS at startup and held only in memory), so the file on the node
# is inert: node access alone no longer yields a certificate for any
# domain the estate serves.
#
# Still node-local, and that is a separate problem: a DaemonSet means one
# store per node and one ACME order per node. ACME_SHARED_STORE_NAMESPACE
# moves the state to a single Kubernetes object with one elected writer;
# the seal applies there too.
- name: data
hostPath:
path: /var/lib/hanzo-ingress
type: DirectoryOrCreate
-18
View File
@@ -1,18 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: hanzo-ingress
namespace: hanzo
labels:
app: hanzo-ingress
spec:
type: ClusterIP
ports:
- name: http
port: 80
targetPort: web
- name: https
port: 443
targetPort: websecure
selector:
app: hanzo-ingress
+5 -4
View File
@@ -32,8 +32,9 @@ import (
"time"
)
// Env names. The prefix is this service's, so a pod that also carries another
// subsystem's KMS credentials cannot have them picked up here by accident.
// Env names. The prefix is this service's, and it is the ONLY spelling: a pod
// that also carries another subsystem's KMS credentials cannot have them picked
// up here by accident, which a bare fallback spelling would undo.
const (
envEndpoint = "INGRESS_KMS_ENDPOINT"
envClientID = "INGRESS_KMS_CLIENT_ID"
@@ -83,8 +84,8 @@ func FromEnv() (*Client, error) {
if !strings.HasPrefix(endpoint, "https://") {
return nil, fmt.Errorf("kms: %s is %q; the endpoint carries a client secret and must be https", envEndpoint, endpoint)
}
id := firstOf(os.Getenv(envClientID), os.Getenv("IAM_CLIENT_ID"))
secret := firstOf(os.Getenv(envSecret), os.Getenv("IAM_CLIENT_SECRET"))
id := strings.TrimSpace(os.Getenv(envClientID))
secret := strings.TrimSpace(os.Getenv(envSecret))
if id == "" || secret == "" {
return nil, fmt.Errorf("kms: %s is set but %s/%s are empty", envEndpoint, envClientID, envSecret)
}
+14 -3
View File
@@ -150,17 +150,28 @@ func TestFromEnv_HalfConfiguredIsAnError(t *testing.T) {
}
}
func TestFromEnv_FallsBackToIAMCredentials(t *testing.T) {
// One spelling for the credential. A pod that also carries another subsystem's
// IAM client must not have it picked up here.
func TestFromEnv_TakesOnlyItsOwnCredential(t *testing.T) {
t.Setenv(envEndpoint, "https://kms.hanzo.ai")
t.Setenv(envClientID, "")
t.Setenv(envSecret, "")
t.Setenv("IAM_CLIENT_ID", "ingress")
t.Setenv("IAM_CLIENT_SECRET", "shhh")
t.Setenv("IAM_CLIENT_ID", "someone-elses")
t.Setenv("IAM_CLIENT_SECRET", "someone-elses")
if _, err := FromEnv(); err == nil {
t.Error("FromEnv used a credential that was not addressed to it")
}
t.Setenv(envClientID, "ingress")
t.Setenv(envSecret, "shhh")
t.Setenv(envOrg, "lux")
c, err := FromEnv()
if err != nil {
t.Fatal(err)
}
if c.clientID != "ingress" {
t.Errorf("clientID = %q, want ingress", c.clientID)
}
if c.Org() != "lux" {
t.Errorf("org = %q, want lux", c.Org())
}
+36 -6
View File
@@ -13,6 +13,7 @@ import (
"errors"
"fmt"
"strings"
"sync"
)
// Seal encrypts the ACME state document at rest.
@@ -38,9 +39,12 @@ import (
// editing its count all fail to open rather than opening into something the
// ACME provider would act on.
type Seal struct {
kek cipher.AEAD
id string
kek cipher.AEAD
id string
mu sync.Mutex
adopt bool
taken *[32]byte // the one unsealed document this seal admitted
}
// envelope is the sealed document as written. It is JSON so that a file which
@@ -66,9 +70,9 @@ var ErrUnsealed = errors.New("kms: the stored document is not sealed")
// adopt says what to do with a store that is not sealed. False — the default
// everywhere — reads it as an error: the store this process was pointed at is
// not the store this key wrote, and continuing would mean writing this edge's
// account key over whatever is there. True opens it once and hands it back for
// the caller to write under seal, which is how a store that predates sealing
// keeps its certificates instead of re-ordering every one of them.
// account key over whatever is there. True opens ONE such store and hands it
// back for the caller to write under seal, which is how a store that predates
// sealing keeps its certificates instead of re-ordering every one of them.
func NewSeal(key []byte, adopt bool) (*Seal, error) {
if len(key) != 32 {
return nil, fmt.Errorf("kms: seal key is %d bytes, want 32", len(key))
@@ -175,7 +179,7 @@ func (s *Seal) Wrap(name string, count uint64, plain []byte) ([]byte, error) {
func (s *Seal) Unwrap(name string, stored []byte) (plain []byte, count uint64, sealed bool, err error) {
env, ok := parse(stored)
if !ok {
if !s.adopt {
if !s.take(stored) {
return nil, 0, false, ErrUnsealed
}
return stored, 0, false, nil
@@ -214,6 +218,32 @@ func (s *Seal) Unwrap(name string, stored []byte) (plain []byte, count uint64, s
return doc, env.Count, true, nil
}
// take spends the operator's adoption on ONE document.
//
// The first unsealed document is admitted and remembered. That same document is
// admitted again, because a store is read more than once before the write that
// seals it lands and a replica that cannot write reads it on every poll. A
// DIFFERENT unsealed document is not: adoption covers the store the operator
// was looking at, not every unsealed store this process is handed for as long
// as it runs.
//
// The memory is this seal's, so it lasts one process. A seal built to adopt
// will adopt again after a restart — which is why the setting is removed once
// it has done its work, and why it announces itself on every boot it is set.
func (s *Seal) take(stored []byte) bool {
sum := sha256.Sum256(stored)
s.mu.Lock()
defer s.mu.Unlock()
if !s.adopt {
return false
}
if s.taken == nil {
s.taken = &sum
return true
}
return *s.taken == sum
}
// bind renders the facts the ciphertext is tied to. Each variable-length field
// is written after its length, so no combination of a store name and a key id
// can produce the bytes of another combination.
+25
View File
@@ -138,6 +138,31 @@ func TestSeal_AdoptsAnUnsealedStoreOnce(t *testing.T) {
}
}
// Adoption covers ONE store. A second, different unsealed document is not the
// store the operator was looking at, and it is refused — otherwise a seal left
// adopting takes whatever plaintext it is handed for as long as the process
// runs.
func TestSeal_AdoptsOneStoreOnly(t *testing.T) {
s, err := NewSeal(key(t), true)
if err != nil {
t.Fatal(err)
}
first := []byte(acmeLike)
other := []byte(`{"letsencrypt":{"Account":{"Email":"[email protected]"}}}`)
if _, _, _, err := s.Unwrap(store, first); err != nil {
t.Fatalf("the first unsealed store was refused: %v", err)
}
// The same document again: a store is read more than once before the write
// that seals it lands, and a replica that cannot write reads it every poll.
if _, _, _, err := s.Unwrap(store, first); err != nil {
t.Errorf("re-reading the adopted store was refused: %v", err)
}
if _, _, _, err := s.Unwrap(store, other); !errors.Is(err, ErrUnsealed) {
t.Fatalf("a second, different unsealed store was adopted: err=%v", err)
}
}
// A document belongs to the store it was written for. Moving one between
// stores does not open it, because the store's name is what both layers were
// sealed with and not merely what the JSON says.