Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,8 @@ export type ErrorCode =
| "DISK_LOW"
| "PREFLIGHT_FAILED"
| "CANTON_OOM"
| "CONTAINER_UNHEALTHY";
| "CONTAINER_UNHEALTHY"
| "LEDGER_UNREACHABLE";

export interface PreflightSection {
title: string;
Expand Down Expand Up @@ -1485,7 +1486,7 @@ export const STEP_LABELS: Record<StepName, string> = {
fetch_splice: "Fetch Splice LocalNet",
persist_state: "Persist state + write overlay",
start_services: "Starting services",
wait_healthy: "Wait for services to become healthy",
wait_healthy: "Wait for services and Ledger API to become healthy",
capture_jwts: "Capture JWTs · register endpoints",
};

Expand Down
8 changes: 5 additions & 3 deletions frontend/src/screens/remediation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ describe("remediationForCode", () => {
"PREFLIGHT_FAILED",
"CANTON_OOM",
"CONTAINER_UNHEALTHY",
"LEDGER_UNREACHABLE",
];

// 7 codes that MUST render a panel — the remaining 2
// (PREFLIGHT_FAILED, undefined) intentionally return null
// because they're too generic to be actionable on their own.
// Codes that MUST render a panel — PREFLIGHT_FAILED and undefined
// intentionally return null because they're too generic to be
// actionable on their own.
const codesWithPanel: ErrorCode[] = [
"PORTS_IN_USE",
"DOCKER_DOWN",
Expand All @@ -43,6 +44,7 @@ describe("remediationForCode", () => {
"DISK_LOW",
"CANTON_OOM",
"CONTAINER_UNHEALTHY",
"LEDGER_UNREACHABLE",
];

it("undefined code returns null (no panel rendered)", () => {
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/screens/remediation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ export function remediationForCode(code?: ErrorCode): Remediation | null {
"Common causes: postgres connection limit, splice waiting on canton, ledger API not ready.",
],
};
case "LEDGER_UNREACHABLE":
return {
title: "Ledger API did not become ready",
steps: [
"Docker reported containers healthy, but the app-provider participant Ledger API did not accept a gRPC probe.",
"Check canton logs: `dpm localnet container logs <inst> canton`.",
"Confirm the instance captured `participant_ledger_app-provider` (`dpm localnet status <inst>`), then retry start or up.",
],
};
case "PREFLIGHT_FAILED":
case undefined:
return null;
Expand Down
10 changes: 5 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/sys v0.45.0
google.golang.org/grpc v1.81.1
golang.org/x/sys v0.46.0
google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
)

Expand All @@ -34,7 +34,7 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
)
20 changes: 10 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -76,19 +76,19 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
Expand Down
8 changes: 8 additions & 0 deletions internal/localnet/coded_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,12 @@ const (
// from a bare wait_healthy timeout — the frontend can offer a
// generic "see logs" affordance instead of suggesting nothing.
ErrCodeContainerUnhealthy = "CONTAINER_UNHEALTHY"

// ErrCodeLedgerUnreachable: Docker containers reported healthy
// but the app-provider Ledger API gRPC probe (dazl-client
// GetLedgerApiVersion + GetLedgerEnd) never succeeded within
// the ledger readiness budget. Remediation: check participant
// logs / `localnet logs`, confirm participant_ledger_app-provider
// was captured, retry start/up.
ErrCodeLedgerUnreachable = "LEDGER_UNREACHABLE"
)
175 changes: 175 additions & 0 deletions internal/localnet/ledger_ready.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package localnet

import (
"context"
"fmt"
"strconv"
"strings"
"time"

"github.com/bitdynamics-ab/canton-devkit/internal/canton/ledger"
"github.com/bitdynamics-ab/canton-devkit/internal/registry"
"github.com/bitdynamics-ab/canton-devkit/internal/splice"
)

// ledgerReadyTimeout caps how long we poll the participant after Docker
// reports healthy. Canton can still be finishing ledger API bind for a
// short window after container healthchecks flip green.
// Package vars (not consts) so unit tests can shrink the budget.
var ledgerReadyTimeout = 5 * time.Minute

var ledgerReadyPollWait = 2 * time.Second

// LedgerReadyResult is what a successful readiness probe observed.
type LedgerReadyResult struct {
Endpoint string
LedgerAPIVersion string
Offset int64
}

// ledgerProbeOptions configures a single dial attempt against a participant.
type ledgerProbeOptions struct {
Endpoint string
Token string
}

// probeLedgerFn is the dial seam. Production uses probeLedger (dazl-client
// via internal/canton/ledger). Unit tests replace it to avoid a live
// participant. go-daml cannot be used here: its generated protos collide
// with dazl-client in the same process (protobuf registry panic).
var probeLedgerFn = probeLedger

// ensureLedgerReadyFn is the seam up/start/restart call after Docker
// WaitForHealthy succeeds. Tests replace it with a no-op so hermetic
// bring-up stubs do not need a reachable Ledger API.
var ensureLedgerReadyFn = ensureLedgerReady

// ensureLedgerReady resolves the app-provider Ledger API endpoint and a
// bearer JWT, then polls GetLedgerApiVersion + GetLedgerEnd until success
// or ctx expires. Role defaults to app-provider (operator node).
//
// ports should already include participant_ledger_app-provider from
// CaptureCantonPorts. creds may be empty — when so, a JWT is signed from
// projectDir's auth env files.
func ensureLedgerReady(
ctx context.Context,
projectDir string,
ports map[string]int,
creds map[string]registry.Credential,
) (LedgerReadyResult, error) {
endpoint, err := ledgerEndpointFromPorts(ports, string(splice.RoleAppProvider))
if err != nil {
return LedgerReadyResult{}, err
}
token, err := ledgerTokenForRole(projectDir, creds, splice.RoleAppProvider)
if err != nil {
return LedgerReadyResult{}, err
}

deadlineCtx, cancel := context.WithTimeout(ctx, ledgerReadyTimeout)
defer cancel()

var lastErr error
for {
res, err := probeLedgerFn(deadlineCtx, ledgerProbeOptions{
Endpoint: endpoint,
Token: token,
})
if err == nil {
return res, nil
}
lastErr = err
select {
case <-deadlineCtx.Done():
if lastErr == nil {
lastErr = deadlineCtx.Err()
}
return LedgerReadyResult{}, fmt.Errorf(
"ledger API at %s (role %s) did not become ready: %w",
endpoint, splice.RoleAppProvider, lastErr)
case <-time.After(ledgerReadyPollWait):
}
}
}

// probeLedger dials the participant with dazl-client and runs the two
// cheap unaries that prove connectivity + auth.
func probeLedger(ctx context.Context, opts ledgerProbeOptions) (LedgerReadyResult, error) {
endpoint := strings.TrimSpace(opts.Endpoint)
if endpoint == "" {
return LedgerReadyResult{}, fmt.Errorf("ledger probe: endpoint is required")
}

client, err := ledger.Dial(ctx, ledger.DialOptions{
Endpoint: endpoint,
Token: ledger.StaticToken(opts.Token),
PlainText: true,
})
if err != nil {
return LedgerReadyResult{}, fmt.Errorf("ledger probe: dial %s: %w", endpoint, err)
}
defer func() { _ = client.Close() }()

ver, err := client.LedgerApiVersion(ctx)
if err != nil {
return LedgerReadyResult{}, fmt.Errorf("ledger probe: GetLedgerApiVersion %s: %w", endpoint, err)
}

end, err := client.LedgerEnd(ctx)
if err != nil {
return LedgerReadyResult{}, fmt.Errorf("ledger probe: GetLedgerEnd %s: %w", endpoint, err)
}

version := ""
if ver != nil {
version = ver.GetVersion()
}

return LedgerReadyResult{
Endpoint: endpoint,
LedgerAPIVersion: version,
Offset: end.Offset,
}, nil
}

func ledgerEndpointFromPorts(ports map[string]int, role string) (string, error) {
if role == "" {
role = string(splice.RoleAppProvider)
}
key := "participant_ledger_" + role
port, ok := ports[key]
if !ok || port <= 0 {
return "", fmt.Errorf(
"ledger endpoint for role %s not captured (missing %s in instance ports)",
role, key)
}
return "localhost:" + strconv.Itoa(port), nil
}

func ledgerTokenForRole(
projectDir string,
creds map[string]registry.Credential,
role splice.Role,
) (string, error) {
if c, ok := creds[string(role)]; ok && strings.TrimSpace(c.JWT) != "" {
return c.JWT, nil
}
if projectDir == "" {
return "", fmt.Errorf("no JWT for role %s and projectDir is empty", role)
}
inputs, err := splice.LoadCredentialInputs(projectDir)
if err != nil {
return "", fmt.Errorf("load credential inputs for ledger probe: %w", err)
}
for _, in := range inputs {
if in.Role != role {
continue
}
tok, err := splice.SignToken(in)
if err != nil {
return "", fmt.Errorf("sign JWT for role %s: %w", role, err)
}
return tok, nil
}
return "", fmt.Errorf("no auth env inputs for role %s in %s", role, projectDir)
}
Loading
Loading