Skip to content

Commit 8016d84

Browse files
authored
feat: genesis ceremony client types and /v0/node-id endpoint (#40)
## Summary - Add four genesis ceremony task builder types (`GenerateIdentityTask`, `GenerateGentxTask`, `UploadGenesisArtifactsTask`, `AssembleAndUploadGenesisTask`) implementing the `TaskBuilder` interface for controller-side plan construction - Add `GET /v0/node-id` server endpoint that derives the Tendermint node ID from `node_key.json` using CometBFT-compatible `SHA256(ed25519_pubkey)[:20]` - Add `GetNodeID()` client method for the controller to collect node IDs during peer assembly - Refactor `SidecarClient` to store `baseURL` and `doer` for direct HTTP calls outside the OpenAPI-generated client These are the seictl-side prerequisites for the network deployment genesis ceremony feature in sei-k8s-controller. Task handlers (the actual `generate-identity`, `generate-gentx`, etc. implementations) will follow in a subsequent PR. ## Design decisions - **`AccountBalance` is a single string** (e.g. `"1000000usei,1000000uusdc"`), not a list of address+amount pairs. Each node discovers its own address during identity generation -- no cross-node coordination needed between the identity and gentx steps. - **`/v0/node-id` is outside the OpenAPI spec** since it is a simple read from disk, not a task. The client uses a direct HTTP call rather than the generated client. - **Node ID derivation** matches CometBFT exactly: `hex(SHA256(ed25519_pubkey)[:20])`, reading the 32-byte public key from the last half of the 64-byte Ed25519 private key in `node_key.json`. ## Test plan - [x] All existing server tests pass (updated `NewServer` calls to include `homeDir`) - [x] All existing client tests pass - [x] `go build ./...` clean - [ ] Validate task builder `ToTaskRequest()` output matches expected wire format
1 parent 0430b64 commit 8016d84

6 files changed

Lines changed: 396 additions & 32 deletions

File tree

serve.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ var serveCmd = cli.Command{
6363

6464
go runSchedulerTicker(ctx, eng)
6565

66-
srv := server.NewServer(":"+port, eng)
66+
srv := server.NewServer(":"+port, eng, homeDir)
6767
if err := srv.ListenAndServe(ctx); err != nil && !errors.Is(err, context.Canceled) {
6868
return fmt.Errorf("server error: %w", err)
6969
}

sidecar/client/client.go

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package client
33
import (
44
"bytes"
55
"context"
6+
"encoding/json"
67
"errors"
78
"fmt"
9+
"io"
810
"net/http"
911
"time"
1012

@@ -19,7 +21,9 @@ var ErrNotFound = errors.New("sidecar: task not found")
1921
// SidecarClient wraps the generated ClientWithResponses with a simpler,
2022
// error-oriented API.
2123
type SidecarClient struct {
22-
inner *ClientWithResponses
24+
inner *ClientWithResponses
25+
baseURL string
26+
doer HttpRequestDoer
2327
}
2428

2529
// Option configures optional SidecarClient parameters.
@@ -47,18 +51,16 @@ func NewSidecarClient(baseURL string, opts ...Option) (*SidecarClient, error) {
4751
fn(&o)
4852
}
4953

50-
var clientOpts []ClientOption
51-
if o.httpClient != nil {
52-
clientOpts = append(clientOpts, WithHTTPClient(o.httpClient))
53-
} else {
54-
clientOpts = append(clientOpts, WithHTTPClient(&http.Client{Timeout: o.timeout}))
54+
httpClient := o.httpClient
55+
if httpClient == nil {
56+
httpClient = &http.Client{Timeout: o.timeout}
5557
}
5658

57-
inner, err := NewClientWithResponses(baseURL, clientOpts...)
59+
inner, err := NewClientWithResponses(baseURL, WithHTTPClient(httpClient))
5860
if err != nil {
5961
return nil, err
6062
}
61-
return &SidecarClient{inner: inner}, nil
63+
return &SidecarClient{inner: inner, baseURL: baseURL, doer: httpClient}, nil
6264
}
6365

6466
// NewSidecarClientFromPodDNS builds a client targeting the sidecar via
@@ -198,6 +200,41 @@ func (c *SidecarClient) Healthz(ctx context.Context) (bool, error) {
198200
}
199201
}
200202

203+
// GetNodeID queries the sidecar's /v0/node-id endpoint and returns the
204+
// Tendermint node ID (hex-encoded). This is a direct HTTP call rather than
205+
// using the generated client, since /v0/node-id is outside the OpenAPI spec.
206+
func (c *SidecarClient) GetNodeID(ctx context.Context) (string, error) {
207+
url := c.baseURL + "/v0/node-id"
208+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
209+
if err != nil {
210+
return "", fmt.Errorf("building node-id request: %w", err)
211+
}
212+
resp, err := c.doer.Do(req)
213+
if err != nil {
214+
return "", fmt.Errorf("querying node-id: %w", err)
215+
}
216+
defer func() { _ = resp.Body.Close() }()
217+
218+
body, err := io.ReadAll(resp.Body)
219+
if err != nil {
220+
return "", fmt.Errorf("reading node-id response: %w", err)
221+
}
222+
if resp.StatusCode != http.StatusOK {
223+
return "", fmt.Errorf("node-id returned %d: %s", resp.StatusCode, bytes.TrimSpace(body))
224+
}
225+
226+
var result struct {
227+
NodeID string `json:"nodeId"`
228+
}
229+
if err := json.Unmarshal(body, &result); err != nil {
230+
return "", fmt.Errorf("parsing node-id response: %w", err)
231+
}
232+
if result.NodeID == "" {
233+
return "", fmt.Errorf("node-id response missing nodeId field")
234+
}
235+
return result.NodeID, nil
236+
}
237+
201238
// ---------------------------------------------------------------------------
202239
// Typed submit methods -- primary public API for task submission.
203240
// Each validates the typed struct and delegates to SubmitTask.

sidecar/client/client_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,3 +473,51 @@ func TestConfigPatchTask_Validate_OK(t *testing.T) {
473473
t.Fatalf("Validate() error = %v", err)
474474
}
475475
}
476+
477+
func TestGetNodeID_OK(t *testing.T) {
478+
want := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
479+
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
480+
if r.URL.Path != "/v0/node-id" || r.Method != http.MethodGet {
481+
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
482+
}
483+
w.Header().Set("Content-Type", "application/json")
484+
_ = json.NewEncoder(w).Encode(map[string]string{"nodeId": want})
485+
}))
486+
487+
got, err := c.GetNodeID(context.Background())
488+
if err != nil {
489+
t.Fatalf("GetNodeID() error = %v", err)
490+
}
491+
if got != want {
492+
t.Errorf("GetNodeID() = %q, want %q", got, want)
493+
}
494+
}
495+
496+
func TestGetNodeID_ServerError(t *testing.T) {
497+
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
498+
http.Error(w, "not ready", http.StatusInternalServerError)
499+
}))
500+
501+
_, err := c.GetNodeID(context.Background())
502+
if err == nil {
503+
t.Fatal("expected error for 500 response")
504+
}
505+
if !strings.Contains(err.Error(), "500") {
506+
t.Errorf("error = %v, expected to contain '500'", err)
507+
}
508+
}
509+
510+
func TestGetNodeID_EmptyNodeID(t *testing.T) {
511+
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
512+
w.Header().Set("Content-Type", "application/json")
513+
_ = json.NewEncoder(w).Encode(map[string]string{"nodeId": ""})
514+
}))
515+
516+
_, err := c.GetNodeID(context.Background())
517+
if err == nil {
518+
t.Fatal("expected error for empty nodeId")
519+
}
520+
if !strings.Contains(err.Error(), "missing nodeId") {
521+
t.Errorf("error = %v, expected to contain 'missing nodeId'", err)
522+
}
523+
}

sidecar/client/tasks.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ const (
3131
TaskTypeSnapshotUpload = string(engine.TaskSnapshotUpload)
3232
TaskTypeResultExport = string(engine.TaskResultExport)
3333
TaskTypeAwaitCondition = string(engine.TaskAwaitCondition)
34+
35+
TaskTypeGenerateIdentity = "generate-identity"
36+
TaskTypeGenerateGentx = "generate-gentx"
37+
TaskTypeUploadGenesisArtifacts = "upload-genesis-artifacts"
38+
TaskTypeAssembleGenesis = "assemble-and-upload-genesis"
3439
)
3540

3641
// Known condition and action values for AwaitConditionTask.
@@ -523,6 +528,149 @@ func ResultExportTaskFromParams(params map[string]interface{}) ResultExportTask
523528
}
524529
}
525530

531+
// GenerateIdentityTask creates validator identity (keys, node ID).
532+
type GenerateIdentityTask struct {
533+
ChainID string
534+
Moniker string
535+
}
536+
537+
func (t GenerateIdentityTask) TaskType() string { return TaskTypeGenerateIdentity }
538+
539+
func (t GenerateIdentityTask) Validate() error {
540+
if t.ChainID == "" {
541+
return fmt.Errorf("generate-identity: missing required field ChainID")
542+
}
543+
if t.Moniker == "" {
544+
return fmt.Errorf("generate-identity: missing required field Moniker")
545+
}
546+
return nil
547+
}
548+
549+
func (t GenerateIdentityTask) ToTaskRequest() TaskRequest {
550+
p := map[string]interface{}{
551+
"chainId": t.ChainID,
552+
"moniker": t.Moniker,
553+
}
554+
return TaskRequest{Type: t.TaskType(), Params: &p}
555+
}
556+
557+
// GenerateGentxTask creates a gentx for the validator. The handler discovers
558+
// the node's own account address from the keys generated during identity
559+
// creation and funds it with AccountBalance before generating the gentx.
560+
type GenerateGentxTask struct {
561+
ChainID string
562+
StakingAmount string
563+
AccountBalance string
564+
GenesisParams string
565+
}
566+
567+
func (t GenerateGentxTask) TaskType() string { return TaskTypeGenerateGentx }
568+
569+
func (t GenerateGentxTask) Validate() error {
570+
if t.ChainID == "" {
571+
return fmt.Errorf("generate-gentx: missing required field ChainID")
572+
}
573+
if t.StakingAmount == "" {
574+
return fmt.Errorf("generate-gentx: missing required field StakingAmount")
575+
}
576+
if t.AccountBalance == "" {
577+
return fmt.Errorf("generate-gentx: missing required field AccountBalance")
578+
}
579+
return nil
580+
}
581+
582+
func (t GenerateGentxTask) ToTaskRequest() TaskRequest {
583+
p := map[string]interface{}{
584+
"chainId": t.ChainID,
585+
"stakingAmount": t.StakingAmount,
586+
"accountBalance": t.AccountBalance,
587+
}
588+
if t.GenesisParams != "" {
589+
p["genesisParams"] = t.GenesisParams
590+
}
591+
return TaskRequest{Type: t.TaskType(), Params: &p}
592+
}
593+
594+
// UploadGenesisArtifactsTask uploads identity.json and gentx.json to S3.
595+
type UploadGenesisArtifactsTask struct {
596+
S3Bucket string
597+
S3Prefix string
598+
S3Region string
599+
NodeName string
600+
}
601+
602+
func (t UploadGenesisArtifactsTask) TaskType() string { return TaskTypeUploadGenesisArtifacts }
603+
604+
func (t UploadGenesisArtifactsTask) Validate() error {
605+
if t.S3Bucket == "" {
606+
return fmt.Errorf("upload-genesis-artifacts: missing required field S3Bucket")
607+
}
608+
if t.S3Region == "" {
609+
return fmt.Errorf("upload-genesis-artifacts: missing required field S3Region")
610+
}
611+
if t.NodeName == "" {
612+
return fmt.Errorf("upload-genesis-artifacts: missing required field NodeName")
613+
}
614+
return nil
615+
}
616+
617+
func (t UploadGenesisArtifactsTask) ToTaskRequest() TaskRequest {
618+
p := map[string]interface{}{
619+
"s3Bucket": t.S3Bucket,
620+
"s3Prefix": t.S3Prefix,
621+
"s3Region": t.S3Region,
622+
"nodeName": t.NodeName,
623+
}
624+
return TaskRequest{Type: t.TaskType(), Params: &p}
625+
}
626+
627+
// GenesisNodeParam is the wire format for nodes[] in assemble-and-upload-genesis.
628+
type GenesisNodeParam struct {
629+
Name string `json:"name"`
630+
}
631+
632+
// AssembleAndUploadGenesisTask collects per-node artifacts and produces final genesis.json.
633+
type AssembleAndUploadGenesisTask struct {
634+
S3Bucket string
635+
S3Prefix string
636+
S3Region string
637+
ChainID string
638+
Nodes []GenesisNodeParam
639+
}
640+
641+
func (t AssembleAndUploadGenesisTask) TaskType() string { return TaskTypeAssembleGenesis }
642+
643+
func (t AssembleAndUploadGenesisTask) Validate() error {
644+
if t.S3Bucket == "" {
645+
return fmt.Errorf("assemble-and-upload-genesis: missing required field S3Bucket")
646+
}
647+
if t.S3Region == "" {
648+
return fmt.Errorf("assemble-and-upload-genesis: missing required field S3Region")
649+
}
650+
if t.ChainID == "" {
651+
return fmt.Errorf("assemble-and-upload-genesis: missing required field ChainID")
652+
}
653+
if len(t.Nodes) == 0 {
654+
return fmt.Errorf("assemble-and-upload-genesis: at least one node is required")
655+
}
656+
return nil
657+
}
658+
659+
func (t AssembleAndUploadGenesisTask) ToTaskRequest() TaskRequest {
660+
nodes := make([]interface{}, len(t.Nodes))
661+
for i, n := range t.Nodes {
662+
nodes[i] = map[string]interface{}{"name": n.Name}
663+
}
664+
p := map[string]interface{}{
665+
"s3Bucket": t.S3Bucket,
666+
"s3Prefix": t.S3Prefix,
667+
"s3Region": t.S3Region,
668+
"chainId": t.ChainID,
669+
"nodes": nodes,
670+
}
671+
return TaskRequest{Type: t.TaskType(), Params: &p}
672+
}
673+
526674
// AwaitConditionTask blocks until a condition is met, then optionally
527675
// executes a post-condition action. Currently supports the "height"
528676
// condition and the "SIGTERM_SEID" action.

0 commit comments

Comments
 (0)