v0.19.0: bootstrap contract v2 — relay hub retrieval passphrase (no host key in guest)

Lockstep with felhom-controller v0.40.0. The agent now bakes a v2 bootstrap.json
carrying only what the controller needs to PULL its config from the hub:
customer.id + hub.url + hub.retrieval_password + the per-guest local_api. Stops
baking the agent's host hub key/host_id (and customer name/domain/email) into the
guest — the controller gets the customer-scoped key from the hub pull.

- internal/provision/doc.go: SchemaV2; DocCustomer{id}; DocHub{url,retrieval_password}.
- backhalf.go: render v2; require customer.id + hub.url + hub.retrieval_password.
- cmd/.../main.go --selftest=provision: new required -hub-password flag; stop
  baking APIKey/HostID; -customer-domain/name/email accepted but not baked.
- configs/build-golden.sh: default CONTROLLER_IMAGE off stale :v0.35.0 -> :0.40.0.
- doc_test.go/backhalf_test.go updated to v2 shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:22:51 +02:00
parent e4dfe5ccc7
commit e5a18194f4
7 changed files with 88 additions and 48 deletions
+27
View File
@@ -3,6 +3,33 @@
All notable changes to **felhom-agent** are recorded here. Update on every code All notable changes to **felhom-agent** are recorded here. Update on every code
change that gets pushed. change that gets pushed.
## v0.19.0 — bootstrap contract v2: agent relays the hub retrieval passphrase (no host key in the guest) (2026-06-11)
Lockstep with `felhom-controller` v0.40.0. Fixes the onboarding 401: a freshly provisioned guest's
controller used to come up with the agent's **host** hub key baked in, which the hub's `/api/v1/report`
(customer-scoped auth) rejects. The agent now bakes a **v2 bootstrap** carrying only what the controller
needs to **pull** its own config from the hub — the agent never touches the customer-scoped key or CF
tokens.
### Changed — bootstrap contract `v1 → v2` (`internal/provision`)
- `SchemaV1 → SchemaV2 = "felhom.bootstrap/v2"`. **`DocCustomer`** drops `name`/`domain`/`email` (keeps
`id`). **`DocHub`** drops `api_key`/`host_id`, adds **`retrieval_password`** (the customer's hub
retrieval passphrase — SECRET). `DocLocalAPI` unchanged. The contract is byte-compatible with the
controller's `internal/bootstrap.Bootstrap` (cross-repo round-trip verified).
- `backhalf.go`: renders the v2 Doc; validation now requires `customer.id` + `hub.url` +
`hub.retrieval_password` (was `customer.id` + `customer.domain`). Write/0600/chown/`pct set` unchanged.
- `cmd/felhom-agent/main.go` `--selftest=provision`: **new required `-hub-password`** flag (the customer's
hub retrieval passphrase; the customer must already exist in the hub). Stops baking `cfg.Hub.APIKey` /
`cfg.Hub.HostID`. `-customer-domain/-name/-email` still accepted (bring-up may use them) but NOT baked.
### Changed — `configs/build-golden.sh`
- Default `CONTROLLER_IMAGE` bumped off the stale `:v0.35.0``:0.40.0` (matches the registry's no-`v`
tag convention; latent footgun fixed).
### Tests
- `doc_test.go`/`backhalf_test.go` updated to the v2 shape (assert no `api_key`/`host_id`,
`retrieval_password` present, `customer` carries only `id`). `go build ./... && go test ./...` green.
## v0.18.0 — slice 10D: DR capstone — identity escrow + restore-mode consumption (agent side) (2026-06-10) ## v0.18.0 — slice 10D: DR capstone — identity escrow + restore-mode consumption (agent side) (2026-06-10)
The agent half of the slice-10 DR capstone (closes slice 10). Grounded by both 10-series spikes The agent half of the slice-10 DR capstone (closes slice 10). Grounded by both 10-series spikes
+25 -21
View File
@@ -42,7 +42,7 @@ import (
// version is the agent version. Overridable at build time with // version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version. // -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
var version = "0.18.0" var version = "0.19.0"
func main() { func main() {
var ( var (
@@ -58,10 +58,11 @@ func main() {
paperkey bool paperkey bool
offline bool offline bool
upload bool upload bool
custID string custID string
custDomain string custDomain string
custName string custName string
custEmail string custEmail string
hubPassword string
blobPath string blobPath string
expectedFP string expectedFP string
keyDest string keyDest string
@@ -70,7 +71,7 @@ func main() {
showVersion bool showVersion bool
) )
flag.StringVar(&cfgPath, "config", envOr("FELHOM_AGENT_CONFIG", "/etc/felhom-agent/agent.json"), "path to the agent config file (JSON)") flag.StringVar(&cfgPath, "config", envOr("FELHOM_AGENT_CONFIG", "/etc/felhom-agent/agent.json"), "path to the agent config file (JSON)")
flag.Var(&selftest, "selftest", "run a self-test and exit: bare/`read` = read-only queries; `task` = reversible mutating exercise (needs -vmid); `hub` = one collect+report; `storage` = observe storage (+ -watch); `backup` = one-shot backup of -vmid; `restore-test` = restore→boot→verify→teardown of -archive (or newest backup); `pbs-verify` = trigger a PBS verify + print snapshot records; `bring-up` = restore→reset identity→size→start link-up of -archive into -vmid (needs -mode/-archive/-vmid; tears down unless -keep); `provision` = full slice-8A chain: bring-up provision + mint token + populate bootstrap config mount (needs -archive/-vmid/-customer-id/-customer-domain; keeps the guest)") flag.Var(&selftest, "selftest", "run a self-test and exit: bare/`read` = read-only queries; `task` = reversible mutating exercise (needs -vmid); `hub` = one collect+report; `storage` = observe storage (+ -watch); `backup` = one-shot backup of -vmid; `restore-test` = restore→boot→verify→teardown of -archive (or newest backup); `pbs-verify` = trigger a PBS verify + print snapshot records; `bring-up` = restore→reset identity→size→start link-up of -archive into -vmid (needs -mode/-archive/-vmid; tears down unless -keep); `provision` = full slice-8A chain: bring-up provision + mint token + populate bootstrap config mount (needs -archive/-vmid/-customer-id/-hub-password; keeps the guest)")
flag.IntVar(&vmid, "vmid", 0, "guest VMID for --selftest=task|backup|bring-up") flag.IntVar(&vmid, "vmid", 0, "guest VMID for --selftest=task|backup|bring-up")
flag.DurationVar(&watch, "watch", 0, "for --selftest=storage: run the watchdog verbose for this duration (e.g. 3m) with the re-mount response live; 0 = observe pass only") flag.DurationVar(&watch, "watch", 0, "for --selftest=storage: run the watchdog verbose for this duration (e.g. 3m) with the re-mount response live; 0 = observe pass only")
flag.StringVar(&archive, "archive", "", "for --selftest=restore-test|bring-up: the backup volid to restore (restore-test: default newest on the local target)") flag.StringVar(&archive, "archive", "", "for --selftest=restore-test|bring-up: the backup volid to restore (restore-test: default newest on the local target)")
@@ -86,10 +87,11 @@ func main() {
flag.StringVar(&keyDest, "keydest", "", "for --selftest=escrow-consume: where to install the recovered key (0600)") flag.StringVar(&keyDest, "keydest", "", "for --selftest=escrow-consume: where to install the recovered key (0600)")
flag.StringVar(&idBundlePath, "identity-bundle", "", "for --selftest=escrow-create: a 0600 JSON file {tunnel_token,pbs_token} to ALSO escrow under R (10D)") flag.StringVar(&idBundlePath, "identity-bundle", "", "for --selftest=escrow-create: a 0600 JSON file {tunnel_token,pbs_token} to ALSO escrow under R (10D)")
flag.StringVar(&directivePath, "directive", "", "for --selftest=escrow-create: a JSON file with the non-secret DR directive (pbs repo/ns, expected fingerprint, tunnel id)") flag.StringVar(&directivePath, "directive", "", "for --selftest=escrow-create: a JSON file with the non-secret DR directive (pbs repo/ns, expected fingerprint, tunnel id)")
flag.StringVar(&custID, "customer-id", "", "for --selftest=provision: the customer id to seed into the guest's bootstrap") flag.StringVar(&custID, "customer-id", "", "for --selftest=provision: the customer id — the hub config-pull target, baked into the guest's bootstrap")
flag.StringVar(&custDomain, "customer-domain", "", "for --selftest=provision: the customer domain to seed") flag.StringVar(&hubPassword, "hub-password", "", "for --selftest=provision: the customer's hub RETRIEVAL PASSPHRASE (SECRET) — baked into bootstrap.json so the controller pulls its config (and the customer-scoped hub key) from the hub. The customer must already exist in the hub.")
flag.StringVar(&custName, "customer-name", "", "for --selftest=provision: the customer display name to seed (optional)") flag.StringVar(&custDomain, "customer-domain", "", "for --selftest=provision: customer domain (accepted; used by bring-up only — NOT baked into v2 bootstrap, the hub provides it)")
flag.StringVar(&custEmail, "customer-email", "", "for --selftest=provision: the customer email to seed (optional)") flag.StringVar(&custName, "customer-name", "", "for --selftest=provision: customer display name (accepted; NOT baked into v2 bootstrap)")
flag.StringVar(&custEmail, "customer-email", "", "for --selftest=provision: customer email (accepted; NOT baked into v2 bootstrap)")
flag.BoolVar(&showVersion, "version", false, "print version and exit") flag.BoolVar(&showVersion, "version", false, "print version and exit")
flag.Parse() flag.Parse()
@@ -132,7 +134,7 @@ func main() {
case "provision": case "provision":
os.Exit(runSelftestProvision(context.Background(), cfg, logger, provisionArgs{ os.Exit(runSelftestProvision(context.Background(), cfg, logger, provisionArgs{
archive: archive, vmid: vmid, hostname: hostname, archive: archive, vmid: vmid, hostname: hostname,
customer: provision.DocCustomer{ID: custID, Domain: custDomain, Name: custName, Email: custEmail}, customerID: custID, hubPassword: hubPassword,
})) }))
case "escrow-create": case "escrow-create":
os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload, idBundlePath, directivePath)) os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload, idBundlePath, directivePath))
@@ -957,10 +959,11 @@ func runSelftestBringUp(ctx context.Context, cfg config.Config, logger *slog.Log
// provisionArgs bundles the --selftest=provision inputs. // provisionArgs bundles the --selftest=provision inputs.
type provisionArgs struct { type provisionArgs struct {
archive string archive string
vmid int vmid int
hostname string hostname string
customer provision.DocCustomer customerID string // baked into bootstrap (the hub config-pull target)
hubPassword string // the customer's hub retrieval passphrase (SECRET) — baked into bootstrap
} }
// runSelftestProvision runs the FULL slice-8A provisioning chain on-demand: the slice-7 bring-up // runSelftestProvision runs the FULL slice-8A provisioning chain on-demand: the slice-7 bring-up
@@ -982,8 +985,8 @@ func runSelftestProvision(ctx context.Context, cfg config.Config, logger *slog.L
fmt.Fprintln(os.Stderr, "selftest=provision requires backup.restore_storage in config") fmt.Fprintln(os.Stderr, "selftest=provision requires backup.restore_storage in config")
return 2 return 2
} }
if a.customer.ID == "" || a.customer.Domain == "" { if a.customerID == "" || a.hubPassword == "" {
fmt.Fprintln(os.Stderr, "selftest=provision requires -customer-id and -customer-domain (so the controller skips setup)") fmt.Fprintln(os.Stderr, "selftest=provision requires -customer-id and -hub-password (the customer's hub retrieval passphrase, so the controller pulls its config)")
return 2 return 2
} }
if err := cfg.LocalAPI.Validate(); err != nil || !cfg.LocalAPI.Enabled() { if err := cfg.LocalAPI.Validate(); err != nil || !cfg.LocalAPI.Enabled() {
@@ -1030,7 +1033,7 @@ func runSelftestProvision(ctx context.Context, cfg config.Config, logger *slog.L
API: px, Queue: queue, Journal: journal, Gate: gate, HostID: cfg.Hub.HostID, Logger: logger, API: px, Queue: queue, Journal: journal, Gate: gate, HostID: cfg.Hub.HostID, Logger: logger,
}) })
fmt.Printf("=== felhom-agent %s selftest=provision (vmid=%d customer=%s) ===\n", version, a.vmid, a.customer.ID) fmt.Printf("=== felhom-agent %s selftest=provision (vmid=%d customer=%s) ===\n", version, a.vmid, a.customerID)
engine.Recover(ctx) engine.Recover(ctx)
fmt.Printf(" --- front half: bring-up (provision) %s → vmid %d ---\n", a.archive, a.vmid) fmt.Printf(" --- front half: bring-up (provision) %s → vmid %d ---\n", a.archive, a.vmid)
res := engine.RunBringUp(ctx, reconcile.BringUpSpec{ res := engine.RunBringUp(ctx, reconcile.BringUpSpec{
@@ -1053,8 +1056,8 @@ func runSelftestProvision(ctx context.Context, cfg config.Config, logger *slog.L
fmt.Println(" --- back half: mint per-guest token + populate bootstrap config mount ---") fmt.Println(" --- back half: mint per-guest token + populate bootstrap config mount ---")
pres, err := bh.Provision(ctx, provision.Input{ pres, err := bh.Provision(ctx, provision.Input{
VMID: a.vmid, VMID: a.vmid,
Customer: a.customer, Customer: provision.DocCustomer{ID: a.customerID},
Hub: provision.DocHub{URL: cfg.Hub.URL, APIKey: cfg.Hub.APIKey, HostID: cfg.Hub.HostID}, Hub: provision.DocHub{URL: cfg.Hub.URL, RetrievalPassword: a.hubPassword},
Endpoint: cfg.LocalAPI.ListenAddr, Endpoint: cfg.LocalAPI.ListenAddr,
Fingerprint: fingerprint, Fingerprint: fingerprint,
}) })
@@ -1066,7 +1069,8 @@ func runSelftestProvision(ctx context.Context, cfg config.Config, logger *slog.L
pres.MountKey, pres.GuestPath, pres.VMID, pres.HostDir) pres.MountKey, pres.GuestPath, pres.VMID, pres.HostDir)
fmt.Printf(" local-api endpoint %s · leaf fp %s · token: minted (not printed)\n", cfg.LocalAPI.ListenAddr, fingerprint) fmt.Printf(" local-api endpoint %s · leaf fp %s · token: minted (not printed)\n", cfg.LocalAPI.ListenAddr, fingerprint)
fmt.Printf("=== selftest=provision OK — guest %d provisioned + bootstrap-mounted (KEPT) ===\n", a.vmid) fmt.Printf("=== selftest=provision OK — guest %d provisioned + bootstrap-mounted (KEPT) ===\n", a.vmid)
fmt.Println(" next: the golden's baked controller-bootstrap unit deploys the controller from the mount on boot.") fmt.Println(" next: reboot the guest → the golden's baked controller-bootstrap unit deploys the controller,")
fmt.Println(" which PULLS its controller.yaml from the hub (retrieval passphrase) and merges in this local_api.")
return 0 return 0
} }
+1 -1
View File
@@ -30,7 +30,7 @@ TEMPLATE="${2:-local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst}"
ROOTFS_STORAGE="${3:-local-lvm}" ROOTFS_STORAGE="${3:-local-lvm}"
ARCHIVE_STORAGE="${4:-local}" ARCHIVE_STORAGE="${4:-local}"
BRIDGE="${5:-vmbr0}" BRIDGE="${5:-vmbr0}"
CONTROLLER_IMAGE="${6:-gitea.dooplex.hu/admin/felhom-controller:v0.35.0}" CONTROLLER_IMAGE="${6:-gitea.dooplex.hu/admin/felhom-controller:0.40.0}"
REGISTRY_HOST="${CONTROLLER_IMAGE%%/*}" REGISTRY_HOST="${CONTROLLER_IMAGE%%/*}"
echo "[golden] creating build LXC $VMID (nesting=1,keyctl=1, unprivileged) …" echo "[golden] creating build LXC $VMID (nesting=1,keyctl=1, unprivileged) …"
+6 -3
View File
@@ -87,8 +87,11 @@ func (b *BackHalf) Provision(ctx context.Context, in Input) (Result, error) {
if in.Endpoint == "" || in.Fingerprint == "" { if in.Endpoint == "" || in.Fingerprint == "" {
return Result{}, fmt.Errorf("provision: needs the local-api endpoint and leaf fingerprint") return Result{}, fmt.Errorf("provision: needs the local-api endpoint and leaf fingerprint")
} }
if in.Customer.ID == "" || in.Customer.Domain == "" { if in.Customer.ID == "" {
return Result{}, fmt.Errorf("provision: needs customer id and domain (so the controller skips setup)") return Result{}, fmt.Errorf("provision: needs a customer id (the hub config-pull target)")
}
if in.Hub.URL == "" || in.Hub.RetrievalPassword == "" {
return Result{}, fmt.Errorf("provision: needs hub url + retrieval passphrase (so the controller can pull its config)")
} }
guestPath := in.GuestPath guestPath := in.GuestPath
if guestPath == "" { if guestPath == "" {
@@ -109,7 +112,7 @@ func (b *BackHalf) Provision(ctx context.Context, in Input) (Result, error) {
// 2. Render the stable bootstrap.json contract (with the token injected). // 2. Render the stable bootstrap.json contract (with the token injected).
doc := Doc{ doc := Doc{
Schema: SchemaV1, Schema: SchemaV2,
Customer: in.Customer, Customer: in.Customer,
Hub: in.Hub, Hub: in.Hub,
LocalAPI: DocLocalAPI{Endpoint: in.Endpoint, Fingerprint: in.Fingerprint, Token: tok}, LocalAPI: DocLocalAPI{Endpoint: in.Endpoint, Fingerprint: in.Fingerprint, Token: tok},
+6 -3
View File
@@ -54,8 +54,8 @@ func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard,
func newInput() Input { func newInput() Input {
return Input{ return Input{
VMID: 8200, VMID: 8200,
Customer: DocCustomer{ID: "cust-8200", Domain: "cust8200.felhom.eu", Name: "Teszt"}, Customer: DocCustomer{ID: "cust-8200"},
Hub: DocHub{URL: "https://hub.felhom.eu", APIKey: "HUBKEY", HostID: "demo-felhom-01"}, Hub: DocHub{URL: "https://hub.felhom.eu", RetrievalPassword: "five-word-passphrase"},
Endpoint: "192.168.0.162:8443", Endpoint: "192.168.0.162:8443",
Fingerprint: "ab12cd", Fingerprint: "ab12cd",
} }
@@ -95,9 +95,12 @@ func TestProvision_WritesChownsAndAttaches(t *testing.T) {
if err := json.Unmarshal(raw, &doc); err != nil { if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("bootstrap not valid JSON: %v", err) t.Fatalf("bootstrap not valid JSON: %v", err)
} }
if doc.Schema != SchemaV1 || doc.Customer.ID != "cust-8200" || doc.LocalAPI.Token != "SECRET-TOKEN-XYZ" { if doc.Schema != SchemaV2 || doc.Customer.ID != "cust-8200" || doc.LocalAPI.Token != "SECRET-TOKEN-XYZ" {
t.Fatalf("bootstrap content wrong: %+v", doc) t.Fatalf("bootstrap content wrong: %+v", doc)
} }
if doc.Hub.URL != "https://hub.felhom.eu" || doc.Hub.RetrievalPassword != "five-word-passphrase" {
t.Fatalf("hub wrong (want url + retrieval_password, no host key): %+v", doc.Hub)
}
if doc.LocalAPI.Endpoint != "192.168.0.162:8443" || doc.LocalAPI.Fingerprint != "ab12cd" { if doc.LocalAPI.Endpoint != "192.168.0.162:8443" || doc.LocalAPI.Fingerprint != "ab12cd" {
t.Fatalf("local_api wrong: %+v", doc.LocalAPI) t.Fatalf("local_api wrong: %+v", doc.LocalAPI)
} }
+15 -13
View File
@@ -10,14 +10,20 @@ package provision
import "encoding/json" import "encoding/json"
// SchemaV1 is the stable agent→controller contract version. It MUST stay byte-compatible with the // SchemaV2 is the stable agent→controller contract version. It MUST stay byte-compatible with the
// controller's internal/bootstrap.SchemaV1 / Bootstrap shape (cross-repo contract; doc_test.go // controller's internal/bootstrap.SchemaV2 / Bootstrap shape (cross-repo contract; doc_test.go
// pins the key set, mirroring the controller's bootstrap_test.go). // pins the key set, mirroring the controller's bootstrap_test.go). v2 changed the contract's
const SchemaV1 = "felhom.bootstrap/v1" // MEANING — the controller now PULLS its full controller.yaml from the hub using a per-customer
// retrieval passphrase (which yields the CUSTOMER-scoped hub key), instead of the agent baking its
// HOST key. So the agent no longer puts the hub api_key / host id (or customer name/domain/email)
// into the guest; it relays only the customer id, the hub URL, the retrieval passphrase, and the
// per-guest local-API handle.
const SchemaV2 = "felhom.bootstrap/v2"
// Doc is the bootstrap.json the agent emits. Field names + json tags MUST match the controller's // Doc is the bootstrap.json the agent emits. Field names + json tags MUST match the controller's
// internal/bootstrap.Bootstrap exactly. It carries ONLY what the controller needs to come up // internal/bootstrap.Bootstrap exactly. It carries ONLY what the controller needs to PULL its config
// configured and reach the agent's local API — no registry credential (image is baked). // (customer id + hub url + retrieval passphrase) and reach the agent's local API — no registry
// credential (image is baked), no customer-scoped hub key, no CF tokens (those come from the pull).
type Doc struct { type Doc struct {
Schema string `json:"schema"` Schema string `json:"schema"`
Customer DocCustomer `json:"customer"` Customer DocCustomer `json:"customer"`
@@ -26,16 +32,12 @@ type Doc struct {
} }
type DocCustomer struct { type DocCustomer struct {
ID string `json:"id"` ID string `json:"id"` // the pull target; the hub provides name/domain/email
Name string `json:"name"`
Domain string `json:"domain"`
Email string `json:"email"`
} }
type DocHub struct { type DocHub struct {
URL string `json:"url"` URL string `json:"url"`
APIKey string `json:"api_key"` RetrievalPassword string `json:"retrieval_password"` // SECRET — pulls the full config (incl. the customer key)
HostID string `json:"host_id"`
} }
type DocLocalAPI struct { type DocLocalAPI struct {
+8 -7
View File
@@ -11,9 +11,9 @@ import (
// bootstrap_test.go ingests the same shape. A drift here (or there) breaks provisioning. // bootstrap_test.go ingests the same shape. A drift here (or there) breaks provisioning.
func TestDoc_ContractKeySet(t *testing.T) { func TestDoc_ContractKeySet(t *testing.T) {
d := Doc{ d := Doc{
Schema: SchemaV1, Schema: SchemaV2,
Customer: DocCustomer{ID: "c", Name: "n", Domain: "d", Email: "e"}, Customer: DocCustomer{ID: "c"},
Hub: DocHub{URL: "u", APIKey: "k", HostID: "h"}, Hub: DocHub{URL: "u", RetrievalPassword: "p"},
LocalAPI: DocLocalAPI{Endpoint: "ep", Fingerprint: "fp", Token: "tok"}, LocalAPI: DocLocalAPI{Endpoint: "ep", Fingerprint: "fp", Token: "tok"},
} }
b, err := d.render() b, err := d.render()
@@ -34,12 +34,13 @@ func TestDoc_ContractKeySet(t *testing.T) {
if err := json.Unmarshal(b, &full); err != nil { if err := json.Unmarshal(b, &full); err != nil {
t.Fatal(err) t.Fatal(err)
} }
assertKeys(t, "customer", full.Customer, []string{"id", "name", "domain", "email"}) // v2: customer carries only id; hub carries url + retrieval_password (NO api_key/host_id).
assertKeys(t, "hub", full.Hub, []string{"url", "api_key", "host_id"}) assertKeys(t, "customer", full.Customer, []string{"id"})
assertKeys(t, "hub", full.Hub, []string{"url", "retrieval_password"})
assertKeys(t, "local_api", full.LocalAPI, []string{"endpoint", "fingerprint", "token"}) assertKeys(t, "local_api", full.LocalAPI, []string{"endpoint", "fingerprint", "token"})
if SchemaV1 != "felhom.bootstrap/v1" { if SchemaV2 != "felhom.bootstrap/v2" {
t.Fatalf("schema drift: %q", SchemaV1) t.Fatalf("schema drift: %q", SchemaV2)
} }
} }