From 27374f812806e4bc4632e03731fad58232dafcbd Mon Sep 17 00:00:00 2001 From: "Yoshiaki Ueda (bootjp)" Date: Sun, 24 May 2026 08:23:33 +0900 Subject: [PATCH 1/2] feat(encryption): Stage 6D-6b - elastickv-admin enable-storage-envelope CLI subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 6D-6b adds the operator-facing CLI surface for the EnableStorageEnvelope RPC that landed in 6D-6a. The remaining 6D-6c slice will wire the cipher + capability-fanout closure in main.go and add the end-to-end integration test. ## CLI wiring (cmd/elastickv-admin/) - encryption.go dispatch picks up `enable-storage-envelope`; the help string in the -h/--help/help branches lists the new subcommand. - encryption_mutators.go ships runEncryptionEnableStorageEnvelope + parseEnableStorageEnvelopeArgs + printEnableStorageEnvelopeResult. - CLI-side validation duplicates the §6.1 proposer_node_id != 0 sentinel and §4.1 16-bit proposer_local_epoch bound so an operator with a misconfigured shell variable fails fast before the round-trip; the server re-validates as the source of truth. ## Output shape (shell-friendly) Stable line prefixes so automation can switch on column 1 of the result line: - Fresh success: `enabled applied_index=N` followed by a `capability summary:` block listing each probed member. - Idempotent retry (§6.4 was_already_active=true): `already-active applied_index=N` where N is the ORIGINAL StorageEnvelopeCutoverIndex (NOT this call's Raft index). Capability summary intentionally omitted per §3.1. - Defensive fallback (§6.4 cutover_index_unknown=true): a `warning: cutover_index_unknown=true (sidecar may have been hand-edited or rolled back)` line BEFORE the already-active result so operators grepping on the warning substring can flag clusters that need investigation. ## Tests (cmd/elastickv-admin/encryption_test.go) - stubMutatorServer extended with EnableStorageEnvelope: new enableEnvelopeCalls slice captures the proto requests for wire-level assertions; new enableEnvelopeResp field lets tests pin the exact response shape (fresh-success vs idempotent vs defensive). - TestRunEncryptionEnableStorageEnvelope_HappyPath - fresh success: asserts the `enabled` prefix, the per-member verdict rows in the capability summary, and the wire-level proto request matches the flag inputs. - TestRunEncryptionEnableStorageEnvelope_IdempotentRetry - was_already_active=true response: asserts the `already-active` prefix, the absence of the capability summary header, and the absence of any warning. - TestRunEncryptionEnableStorageEnvelope_DefensiveCutoverIndexUnknown - was_already_active=true + cutover_index_unknown=true: asserts the warning line precedes the already-active result. - TestRunEncryptionEnableStorageEnvelope_RejectsZeroProposerNodeID - §6.1 sentinel rejection at the CLI side before any RPC. - TestRunEncryptionEnableStorageEnvelope_RejectsBadEpoch - §4.1 16-bit bound at the CLI side. - TestEncryptionMain_EnableStorageEnvelopeSubcommand - dispatch routing pin: a typo in encryptionMain's switch statement would route to the default branch and the user would see "unknown subcommand" rather than the runner's --proposer-node-id error. ## Caller audit (semantic change) This PR is purely additive: - The new subcommand is reachable only via `elastickv-admin encryption enable-storage-envelope` — no existing dispatch path is touched. - stubMutatorServer gains two new fields with zero-value defaults; existing tests (rotate-dek, register-writer, bootstrap) continue to use the same fixture unchanged. - main.go does NOT wire the EnableStorageEnvelope server method's capability-fanout closure yet (lands in 6D-6c), so the new subcommand will refuse with FailedPrecondition until 6D-6c ships. Operator-inert at this stage. ## Verification - go test -race -timeout 60s ./cmd/elastickv-admin/... - all green - golangci-lint --new-from-rev=origin/main - 0 issues Refs: design doc §3.1 (Stage 6D-6b slice of the §7 decomposition table; 6D-6c remains). --- cmd/elastickv-admin/encryption.go | 42 ++-- cmd/elastickv-admin/encryption_mutators.go | 120 ++++++++++ cmd/elastickv-admin/encryption_test.go | 210 +++++++++++++++++- ...5_18_partial_6d_enable_storage_envelope.md | 18 +- 4 files changed, 365 insertions(+), 25 deletions(-) diff --git a/cmd/elastickv-admin/encryption.go b/cmd/elastickv-admin/encryption.go index 0d2bb3c3..52a282b2 100644 --- a/cmd/elastickv-admin/encryption.go +++ b/cmd/elastickv-admin/encryption.go @@ -27,36 +27,44 @@ const encryptionDialTimeout = 5 * time.Second // // PR-A wired `status`. PR-B added `rotate-dek` and // `register-writer`. PR-C adds `bootstrap`; Stage 6 adds -// `enable-storage-envelope` and `enable-raft-envelope`. ResyncSidecar -// is a server-side §5.5 fallback (no CLI surface). +// `enable-storage-envelope` (this PR — 6D-6b) and +// `enable-raft-envelope`. ResyncSidecar is a server-side §5.5 +// fallback (no CLI surface). func encryptionMain(args []string) error { if len(args) == 0 { return errors.New("usage: elastickv-admin encryption [flags]") } sub, rest := args[0], args[1:] - switch sub { - case "status": - return runEncryptionStatus(rest, os.Stdout) - case "rotate-dek": - return runEncryptionRotateDEK(rest, os.Stdout) - case "register-writer": - return runEncryptionRegisterWriter(rest, os.Stdout) - case "bootstrap": - return runEncryptionBootstrap(rest, os.Stdout) - case "probe-node-id": - return runEncryptionProbeNodeID(rest, os.Stdout) - case "-h", "--help", "help": + if handler, ok := encryptionSubcommands()[sub]; ok { + return handler(rest, os.Stdout) + } + if sub == "-h" || sub == "--help" || sub == "help" { // `-h` is the universal "show usage" affordance for CLI // subcommands; returning nil keeps the exit code at 0 // so shell scripts using $? to detect success do not // trip on a help request. - _, err := fmt.Fprintln(os.Stdout, "usage: elastickv-admin encryption [flags]\n\nsubcommands:\n status\n rotate-dek\n register-writer\n bootstrap\n probe-node-id") + _, err := fmt.Fprintln(os.Stdout, "usage: elastickv-admin encryption [flags]\n\nsubcommands:\n status\n rotate-dek\n register-writer\n bootstrap\n enable-storage-envelope\n probe-node-id") if err != nil { return errors.Wrap(err, "write usage") } return nil - default: - return errors.Errorf("encryption: unknown subcommand %q (supported: status, rotate-dek, register-writer, bootstrap, probe-node-id)", sub) + } + return errors.Errorf("encryption: unknown subcommand %q (supported: status, rotate-dek, register-writer, bootstrap, enable-storage-envelope, probe-node-id)", sub) +} + +// encryptionSubcommands is the dispatch table for the encryption +// CLI's runner subcommands (excluding the -h / --help / help +// branch, which renders its own usage string). Pulled out of +// encryptionMain so the dispatch body stays under the +// cyclomatic-complexity budget as new subcommands land. +func encryptionSubcommands() map[string]func(args []string, out io.Writer) error { + return map[string]func(args []string, out io.Writer) error{ + "status": runEncryptionStatus, + "rotate-dek": runEncryptionRotateDEK, + "register-writer": runEncryptionRegisterWriter, + "bootstrap": runEncryptionBootstrap, + "enable-storage-envelope": runEncryptionEnableStorageEnvelope, + "probe-node-id": runEncryptionProbeNodeID, } } diff --git a/cmd/elastickv-admin/encryption_mutators.go b/cmd/elastickv-admin/encryption_mutators.go index 5927dab0..a12619f1 100644 --- a/cmd/elastickv-admin/encryption_mutators.go +++ b/cmd/elastickv-admin/encryption_mutators.go @@ -105,6 +105,126 @@ func parseRotateDEKArgs(args []string) (*parsedRotateDEK, *encryptionEndpointFla }, endpoint, nil } +// runEncryptionEnableStorageEnvelope invokes +// EncryptionAdmin.EnableStorageEnvelope on the configured +// endpoint. The Stage 6D-4 / 6D-6a server method composes the +// §3.2 sequence (leader gate → bootstrap gate → §6.4 idempotent +// short-circuit → §4 capability fan-out → propose +// RotateSubEnableStorageEnvelope → discriminate fresh-success +// vs. §2.1 #3 stale-DEKID race); this CLI surface only +// dispatches the RPC and renders the result. +// +// Output discriminates the §3.1 was_already_active flag because +// an automation script should be able to tell whether THIS +// invocation proposed the cutover or hit the §6.4 +// idempotent-retry path against a previously-active cluster: +// +// fresh: "enabled storage envelope applied_index=N +// capability summary: ..." +// already-on: "storage envelope already active (idempotent +// retry) applied_index=N" +// defensive: "warning: cutover_index_unknown=true" +// emitted on top of the already-on shape when +// the §6.4 hand-edited / schema-rollback hedge +// fires. +func runEncryptionEnableStorageEnvelope(args []string, out io.Writer) error { + req, endpoint, err := parseEnableStorageEnvelopeArgs(args) + if err != nil { + return err + } + if req == nil { + // Help requested; usage was already written by flag parser. + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), *endpoint.timeout) + defer cancel() + client, closeFn, err := dialEncryption(ctx, endpoint) + if err != nil { + return err + } + defer func() { + if err := closeFn(); err != nil { + fmt.Fprintf(os.Stderr, "encryption: close connection: %v\n", err) + } + }() + resp, err := client.EnableStorageEnvelope(ctx, req) + if err != nil { + return errors.Wrap(err, "EnableStorageEnvelope") + } + return printEnableStorageEnvelopeResult(out, resp) +} + +// parseEnableStorageEnvelopeArgs returns the validated proto +// request and the shared endpoint flags. A nil request with no +// error means the caller requested --help; the caller then +// exits 0. +func parseEnableStorageEnvelopeArgs(args []string) (*pb.EnableStorageEnvelopeRequest, *encryptionEndpointFlags, error) { + fs := flag.NewFlagSet("encryption enable-storage-envelope", flag.ContinueOnError) + endpoint := newEncryptionEndpointFlags(fs) + proposerNodeID := fs.Uint64("proposer-node-id", 0, "The proposer's 64-bit full_node_id (registered in §4.1 writer registry); MUST be non-zero (0 is the §6.1 not-capable sentinel)") + proposerLocalEpoch := fs.Uint("proposer-local-epoch", 0, "The proposer's local_epoch at proposal time (0..0xFFFF)") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil, endpoint, nil + } + return nil, nil, errors.Wrap(err, "parse flags") + } + if *proposerNodeID == 0 { + // §6.1 sentinel check duplicated on the CLI side so the + // operator fails fast before a round-trip; the server + // re-validates as the source of truth. + return nil, nil, errors.New("encryption: --proposer-node-id is required and must be non-zero (0 is the §6.1 not-capable sentinel)") + } + if err := requireUint16Plus1(*proposerLocalEpoch, "proposer-local-epoch"); err != nil { + return nil, nil, err + } + return &pb.EnableStorageEnvelopeRequest{ + ProposerNodeId: *proposerNodeID, + ProposerLocalEpoch: narrowUint32(*proposerLocalEpoch), + }, endpoint, nil +} + +// printEnableStorageEnvelopeResult renders the §3.1 response in +// a shell-friendly shape. Lines start with a stable prefix +// (`enabled` / `already-active`) so scripts can `awk` on column +// 1 to discriminate the §6.4 idempotency outcomes without +// parsing the full message. +func printEnableStorageEnvelopeResult(out io.Writer, resp *pb.EnableStorageEnvelopeResponse) error { + if resp.GetWasAlreadyActive() { + if resp.GetCutoverIndexUnknown() { + // §6.4 defensive fallback fires only when a sidecar + // reports StorageEnvelopeActive=true with + // StorageEnvelopeCutoverIndex=0 — operationally + // impossible under normal apply but hedged against + // schema rollback / hand-edited sidecars. Surface + // the warning so operators can investigate. + if _, err := fmt.Fprintln(out, "warning: cutover_index_unknown=true (sidecar may have been hand-edited or rolled back)"); err != nil { + return errors.Wrap(err, "write warning") + } + } + if _, err := fmt.Fprintf(out, "already-active applied_index=%d\n", resp.GetAppliedIndex()); err != nil { + return errors.Wrap(err, "write result") + } + return nil + } + if _, err := fmt.Fprintf(out, "enabled applied_index=%d\n", resp.GetAppliedIndex()); err != nil { + return errors.Wrap(err, "write result") + } + if len(resp.GetCapabilitySummary()) == 0 { + return nil + } + if _, err := fmt.Fprintln(out, "capability summary:"); err != nil { + return errors.Wrap(err, "write capability summary header") + } + for _, v := range resp.GetCapabilitySummary() { + if _, err := fmt.Fprintf(out, " full_node_id=%d encryption_capable=%t build_sha=%s sidecar_present=%t\n", + v.GetFullNodeId(), v.GetEncryptionCapable(), v.GetBuildSha(), v.GetSidecarPresent()); err != nil { + return errors.Wrap(err, "write capability row") + } + } + return nil +} + // runEncryptionRegisterWriter invokes // EncryptionAdmin.RegisterEncryptionWriter for a single // (dek_id, full_node_id, local_epoch) triple. Multi-writer diff --git a/cmd/elastickv-admin/encryption_test.go b/cmd/elastickv-admin/encryption_test.go index 08f63bf0..cd1b0536 100644 --- a/cmd/elastickv-admin/encryption_test.go +++ b/cmd/elastickv-admin/encryption_test.go @@ -173,11 +173,13 @@ func (s *stubSidecarErrorServer) GetSidecarState(context.Context, *pb.Empty) (*p // inherit Unimplemented defaults. type stubMutatorServer struct { pb.UnimplementedEncryptionAdminServer - rotateCalls []*pb.RotateDEKRequest - registerCalls []*pb.RegisterEncryptionWriterRequest - bootstrapCalls []*pb.BootstrapEncryptionRequest - appliedIndex uint64 - returnErr error + rotateCalls []*pb.RotateDEKRequest + registerCalls []*pb.RegisterEncryptionWriterRequest + bootstrapCalls []*pb.BootstrapEncryptionRequest + enableEnvelopeCalls []*pb.EnableStorageEnvelopeRequest + enableEnvelopeResp *pb.EnableStorageEnvelopeResponse + appliedIndex uint64 + returnErr error } func (s *stubMutatorServer) RotateDEK(_ context.Context, req *pb.RotateDEKRequest) (*pb.RotateDEKResponse, error) { @@ -204,6 +206,23 @@ func (s *stubMutatorServer) BootstrapEncryption(_ context.Context, req *pb.Boots return &pb.BootstrapEncryptionResponse{AppliedIndex: s.appliedIndex}, nil } +func (s *stubMutatorServer) EnableStorageEnvelope(_ context.Context, req *pb.EnableStorageEnvelopeRequest) (*pb.EnableStorageEnvelopeResponse, error) { + s.enableEnvelopeCalls = append(s.enableEnvelopeCalls, req) + if s.returnErr != nil { + return nil, s.returnErr + } + // enableEnvelopeResp lets tests pin the exact response shape + // (fresh-success vs idempotent-retry vs defensive + // cutover_index_unknown). When nil the stub defaults to the + // fresh-success shape with appliedIndex; this keeps the + // existing rotate / register / bootstrap fixtures unchanged + // while the cutover tests opt in to the richer shape. + if s.enableEnvelopeResp != nil { + return s.enableEnvelopeResp, nil + } + return &pb.EnableStorageEnvelopeResponse{AppliedIndex: s.appliedIndex}, nil +} + func TestRunEncryptionBootstrap_HappyPath(t *testing.T) { t.Parallel() stub := &stubMutatorServer{appliedIndex: 117} @@ -599,3 +618,184 @@ func TestEncryptionMain_ProbeNodeIDSubcommand(t *testing.T) { t.Errorf("dispatch reached wrong handler: got %v", err) } } + +// TestRunEncryptionEnableStorageEnvelope_HappyPath pins the +// §3.1 fresh-success rendering: enabled prefix + applied_index +// + the per-member capability summary. A regression that +// dropped the summary or printed the wrong applied_index would +// trip the substring assertions; the wire-level proto assertion +// pins the request shape so the CLI cannot silently mis-marshal +// the proposer identity fields. +func TestRunEncryptionEnableStorageEnvelope_HappyPath(t *testing.T) { + t.Parallel() + stub := &stubMutatorServer{ + appliedIndex: 1234, + enableEnvelopeResp: &pb.EnableStorageEnvelopeResponse{ + AppliedIndex: 1234, + WasAlreadyActive: false, + CapabilitySummary: []*pb.CapabilityVerdict{ + {FullNodeId: 11, EncryptionCapable: true, BuildSha: "build-n1", SidecarPresent: true}, + {FullNodeId: 22, EncryptionCapable: true, BuildSha: "build-n2", SidecarPresent: true}, + }, + }, + } + addr := startCustomEncryptionAdminTestServer(t, stub) + var buf bytes.Buffer + err := runEncryptionEnableStorageEnvelope([]string{ + "--endpoint", addr, + "--timeout", "3s", + "--proposer-node-id", "11", + "--proposer-local-epoch", "7", + }, &buf) + if err != nil { + t.Fatalf("runEncryptionEnableStorageEnvelope: %v", err) + } + out := buf.String() + if !strings.HasPrefix(out, "enabled applied_index=1234") { + t.Errorf("output prefix missing fresh-success shape, got:\n%s", out) + } + if !strings.Contains(out, "full_node_id=11") || !strings.Contains(out, "build_sha=build-n1") { + t.Errorf("output missing first verdict, got:\n%s", out) + } + if !strings.Contains(out, "full_node_id=22") || !strings.Contains(out, "build_sha=build-n2") { + t.Errorf("output missing second verdict, got:\n%s", out) + } + if len(stub.enableEnvelopeCalls) != 1 { + t.Fatalf("EnableStorageEnvelope calls=%d, want 1", len(stub.enableEnvelopeCalls)) + } + call := stub.enableEnvelopeCalls[0] + if call.ProposerNodeId != 11 || call.ProposerLocalEpoch != 7 { + t.Errorf("EnableStorageEnvelope call=%+v does not match flag inputs", call) + } +} + +// TestRunEncryptionEnableStorageEnvelope_IdempotentRetry pins +// the §6.4 was_already_active=true rendering: the output uses a +// distinct prefix ("already-active") so a shell script can +// switch on column 1 of the result line to discriminate +// fresh-success from a no-op retry without parsing the full +// message. The applied_index reports the ORIGINAL cutover +// index from sidecar.StorageEnvelopeCutoverIndex (not this +// call's Raft index). +func TestRunEncryptionEnableStorageEnvelope_IdempotentRetry(t *testing.T) { + t.Parallel() + stub := &stubMutatorServer{ + enableEnvelopeResp: &pb.EnableStorageEnvelopeResponse{ + AppliedIndex: 555, + WasAlreadyActive: true, + CutoverIndexUnknown: false, + CapabilitySummary: nil, // §3.1: empty on retries + }, + } + addr := startCustomEncryptionAdminTestServer(t, stub) + var buf bytes.Buffer + err := runEncryptionEnableStorageEnvelope([]string{ + "--endpoint", addr, + "--timeout", "3s", + "--proposer-node-id", "11", + "--proposer-local-epoch", "7", + }, &buf) + if err != nil { + t.Fatalf("runEncryptionEnableStorageEnvelope: %v", err) + } + out := buf.String() + if !strings.HasPrefix(out, "already-active applied_index=555") { + t.Errorf("output prefix missing already-active shape, got:\n%s", out) + } + if strings.Contains(out, "capability summary") { + t.Errorf("idempotent retry must NOT print the capability summary header, got:\n%s", out) + } + if strings.Contains(out, "warning:") { + t.Errorf("idempotent retry without cutover_index_unknown must NOT emit a warning, got:\n%s", out) + } +} + +// TestRunEncryptionEnableStorageEnvelope_DefensiveCutoverIndexUnknown +// pins the §6.4 defensive-fallback warning: a sidecar reporting +// StorageEnvelopeActive=true with StorageEnvelopeCutoverIndex=0 +// (operationally impossible under normal apply but hedged +// against schema rollback / hand-edited sidecars) triggers a +// "warning: cutover_index_unknown=true" line BEFORE the +// already-active result. Operators can grep on the warning +// substring to flag clusters that need investigation. +func TestRunEncryptionEnableStorageEnvelope_DefensiveCutoverIndexUnknown(t *testing.T) { + t.Parallel() + stub := &stubMutatorServer{ + enableEnvelopeResp: &pb.EnableStorageEnvelopeResponse{ + AppliedIndex: 900, + WasAlreadyActive: true, + CutoverIndexUnknown: true, + }, + } + addr := startCustomEncryptionAdminTestServer(t, stub) + var buf bytes.Buffer + if err := runEncryptionEnableStorageEnvelope([]string{ + "--endpoint", addr, + "--timeout", "3s", + "--proposer-node-id", "11", + "--proposer-local-epoch", "7", + }, &buf); err != nil { + t.Fatalf("runEncryptionEnableStorageEnvelope: %v", err) + } + out := buf.String() + if !strings.Contains(out, "warning: cutover_index_unknown=true") { + t.Errorf("output missing defensive-fallback warning, got:\n%s", out) + } + if !strings.Contains(out, "already-active applied_index=900") { + t.Errorf("output missing already-active shape, got:\n%s", out) + } +} + +// TestRunEncryptionEnableStorageEnvelope_RejectsZeroProposerNodeID +// pins the §6.1 sentinel rejection on the CLI side: passing +// --proposer-node-id=0 (or omitting the flag) MUST refuse +// before the RPC round-trip so an operator with a misconfigured +// shell variable fails fast. The server re-validates the same +// sentinel as the source of truth. +func TestRunEncryptionEnableStorageEnvelope_RejectsZeroProposerNodeID(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + err := runEncryptionEnableStorageEnvelope([]string{ + "--endpoint", "127.0.0.1:1", + "--proposer-node-id", "0", + "--proposer-local-epoch", "7", + }, &buf) + if err == nil { + t.Fatal("runEncryptionEnableStorageEnvelope returned nil, want error on --proposer-node-id=0") + } + if !strings.Contains(err.Error(), "proposer-node-id") { + t.Errorf("error %q does not hint at the rejected flag", err) + } +} + +// TestRunEncryptionEnableStorageEnvelope_RejectsBadEpoch pins +// the §4.1 16-bit bound on the CLI side. Same source-of-truth +// posture as RotateDEK / BootstrapEncryption. +func TestRunEncryptionEnableStorageEnvelope_RejectsBadEpoch(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + err := runEncryptionEnableStorageEnvelope([]string{ + "--endpoint", "127.0.0.1:1", + "--proposer-node-id", "11", + "--proposer-local-epoch", "70000", // > 0xFFFF + }, &buf) + if err == nil || !strings.Contains(err.Error(), "16-bit bound") { + t.Fatalf("runEncryptionEnableStorageEnvelope error=%v, want bound-violation", err) + } +} + +// TestEncryptionMain_EnableStorageEnvelopeSubcommand pins the +// dispatch entry in encryptionMain. A typo in the switch +// statement would route enable-storage-envelope to the default +// branch and the user would see "unknown subcommand" rather +// than the runner's "--proposer-node-id is required" error. +func TestEncryptionMain_EnableStorageEnvelopeSubcommand(t *testing.T) { + t.Parallel() + err := encryptionMain([]string{"enable-storage-envelope"}) + if err == nil { + t.Fatal("enable-storage-envelope with no flags: want error, got nil") + } + if !strings.Contains(err.Error(), "proposer-node-id") { + t.Errorf("dispatch reached wrong handler: got %v", err) + } +} diff --git a/docs/design/2026_05_18_partial_6d_enable_storage_envelope.md b/docs/design/2026_05_18_partial_6d_enable_storage_envelope.md index 71ff540e..a614678a 100644 --- a/docs/design/2026_05_18_partial_6d_enable_storage_envelope.md +++ b/docs/design/2026_05_18_partial_6d_enable_storage_envelope.md @@ -2,7 +2,7 @@ | Field | Value | |---|---| -| Status | partial — 6D-1 (doc), 6D-2 (startup guards), 6D-3 (capability fan-out helper), 6D-4 (cutover wire + apply dispatch), 6D-5 (storage-layer toggle), 6D-6a (EnableStorageEnvelope server method) shipped; 6D-6b (CLI), 6D-6c (main.go wiring + integration test) remain | +| Status | partial — 6D-1 (doc), 6D-2 (startup guards), 6D-3 (capability fan-out helper), 6D-4 (cutover wire + apply dispatch), 6D-5 (storage-layer toggle), 6D-6a (EnableStorageEnvelope server method), 6D-6b (CLI subcommand) shipped; 6D-6c (main.go wiring + integration test) remain | | Date | 2026-05-18 | | Parent design | [`2026_04_29_partial_data_at_rest_encryption.md`](2026_04_29_partial_data_at_rest_encryption.md) | | Blockers (now satisfied) | 6B (KEK plumbing), 6C-1 / 6C-2 (startup guards), 6C-2d (`ErrSidecarBehindRaftLog` wiring) | @@ -57,10 +57,22 @@ vs. stale-DEKID race. The 6D-6b CLI and 6D-6c main.go wiring + integration test slice on top of this server method. +- **6D-6b** (CLI subcommand) — + `elastickv-admin encryption enable-storage-envelope` dispatch + + runner (`cmd/elastickv-admin/encryption_mutators.go`). + Renders the §3.1 response with stable line prefixes + (`enabled applied_index=N` for fresh success, `already-active + applied_index=N` for the §6.4 idempotent-retry path) so + automation can switch on column 1; the §6.4 defensive + `cutover_index_unknown=true` fallback surfaces as a preceding + `warning:` line. CLI-side validation duplicates the §6.1 + `proposer_node_id != 0` sentinel and §4.1 16-bit + `proposer_local_epoch` bound so an operator with a + misconfigured shell variable fails fast before the round-trip; + the server re-validates as the source of truth. + ## Open milestones -- **6D-6b** — `elastickv-admin enable-storage-envelope` CLI - subcommand that drives the server method end-to-end. - **6D-6c** — main.go production wiring: cipher + WithEncryption + WithStorageEnvelopeGate threaded from the sidecar, plus the CapabilityFanout closure bound to the live Raft membership From 099cbd8b5b2561de8766179da6b8504390c19f36 Mon Sep 17 00:00:00 2001 From: "Yoshiaki Ueda (bootjp)" Date: Sun, 24 May 2026 08:41:41 +0900 Subject: [PATCH 2/2] fix(encryption): PR820 round-1 - gemini medium + coderabbit minor docstring drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 review surfaced two documentation-accuracy findings plus one quick-win style suggestion. Addressing the docs; skipping the slog suggestion because the other mutator runners (runEncryptionRotateDEK, runEncryptionRegisterWriter, runEncryptionBootstrap) all use the same fmt.Fprintf(os.Stderr, ...) close-error pattern - changing only the new runner would introduce local style drift. ## gemini medium - runEncryptionEnableStorageEnvelope docstring drift Pre-fix: the docstring example output strings referenced 'enabled storage envelope applied_index=N' / 'storage envelope already active (idempotent retry)' but the actual printEnableStorageEnvelopeResult implementation uses the more concise 'enabled' / 'already-active' prefixes that the CLI tests pin as the wire-level shell-script contract. Fix: rewrite the docstring's example block to match the implementation verbatim. The new block names all three outcomes (fresh / already-on / defensive) with the exact prefixes a script will see, including the parenthetical '(sidecar may have been hand-edited or rolled back)' suffix on the warning line. ## coderabbit minor - encryption.go Stage-6 comment drift Pre-fix: the dispatch comment said 'Stage 6 adds enable-storage-envelope and enable-raft-envelope' but this PR wires only enable-storage-envelope; enable-raft-envelope is a §7.1 Phase 2 surface that lands in Stage 6E. Fix: rewrite the dispatch comment's lifecycle history to match shipped reality. Calls out 6D-2 (probe-node-id) which was missing from the original comment, names 6D-6b as the PR that wires enable-storage-envelope, and defers enable-raft-envelope to Stage 6E. ## coderabbit quick-win (skipped) - slog vs fmt.Fprintf in close-err path Pre-fix and current state: the new runEncryptionEnableStorageEnvelope uses the same fmt.Fprintf(os.Stderr, ...) close-error path as runEncryptionRotateDEK / runEncryptionRegisterWriter / runEncryptionBootstrap. Coderabbit suggested swapping in slog.Error with structured keys. Skipped for this PR: changing only this one site creates local style drift across the four mutator runners. The other three runners would need the same conversion, and that scope is better landed as a separate cleanup PR that touches all four together. Recording the suggestion here so a future 'CLI logging consistency' pass can pick it up. ## Caller audit Docstring-only changes; no behaviour change, no test impact. ## Verification - go test -race -timeout 60s ./cmd/elastickv-admin/... - all green - golangci-lint --new-from-rev=origin/main - 0 issues --- cmd/elastickv-admin/encryption.go | 9 +++++---- cmd/elastickv-admin/encryption_mutators.go | 15 +++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cmd/elastickv-admin/encryption.go b/cmd/elastickv-admin/encryption.go index 52a282b2..911d6d17 100644 --- a/cmd/elastickv-admin/encryption.go +++ b/cmd/elastickv-admin/encryption.go @@ -26,10 +26,11 @@ const encryptionDialTimeout = 5 * time.Second // the two surfaces do not share a global flag namespace. // // PR-A wired `status`. PR-B added `rotate-dek` and -// `register-writer`. PR-C adds `bootstrap`; Stage 6 adds -// `enable-storage-envelope` (this PR — 6D-6b) and -// `enable-raft-envelope`. ResyncSidecar is a server-side §5.5 -// fallback (no CLI surface). +// `register-writer`. PR-C adds `bootstrap`. 6D-2 added the +// `probe-node-id` collision-mitigation helper. 6D-6b (this PR) +// adds `enable-storage-envelope`; the §7.1 Phase 2 +// `enable-raft-envelope` lands in Stage 6E. ResyncSidecar is a +// server-side §5.5 fallback (no CLI surface). func encryptionMain(args []string) error { if len(args) == 0 { return errors.New("usage: elastickv-admin encryption [flags]") diff --git a/cmd/elastickv-admin/encryption_mutators.go b/cmd/elastickv-admin/encryption_mutators.go index a12619f1..64ffc0ed 100644 --- a/cmd/elastickv-admin/encryption_mutators.go +++ b/cmd/elastickv-admin/encryption_mutators.go @@ -119,14 +119,13 @@ func parseRotateDEKArgs(args []string) (*parsedRotateDEK, *encryptionEndpointFla // invocation proposed the cutover or hit the §6.4 // idempotent-retry path against a previously-active cluster: // -// fresh: "enabled storage envelope applied_index=N -// capability summary: ..." -// already-on: "storage envelope already active (idempotent -// retry) applied_index=N" -// defensive: "warning: cutover_index_unknown=true" -// emitted on top of the already-on shape when -// the §6.4 hand-edited / schema-rollback hedge -// fires. +// fresh: "enabled applied_index=N +// capability summary: ..." +// already-on: "already-active applied_index=N" +// defensive: "warning: cutover_index_unknown=true (sidecar may +// have been hand-edited or rolled back)" +// emitted ON TOP OF the already-on shape when the +// §6.4 hand-edited / schema-rollback hedge fires. func runEncryptionEnableStorageEnvelope(args []string, out io.Writer) error { req, endpoint, err := parseEnableStorageEnvelopeArgs(args) if err != nil {