From 0847e9f562fb34a7f03dac1c0f3fa2bc71d47b7e Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 3 Jun 2026 08:00:49 -0700 Subject: [PATCH 1/3] feat(cli): list all namespaces by default, drop -A yacd's identity model puts one environment per namespace (up NAME -> ns NAME, devnet -> ns devnet), so a namespace-scoped `list` default was routinely empty/misleading -- notably `yacd devnet` then `yacd list` returned nothing. Default `list` to all namespaces (empty namespace is already the adapter's all-namespaces convention) and keep -n to scope to one. Removes the -A/--all-namespaces flag and the kubeconfig default-namespace fallback, which list no longer consults. Co-Authored-By: Claude Opus 4.8 (1M context) --- cli/internal/cli/list.go | 28 ++++++++++--------------- cli/internal/cli/list_test.go | 39 ++++++++++++++++------------------- 2 files changed, 29 insertions(+), 38 deletions(-) diff --git a/cli/internal/cli/list.go b/cli/internal/cli/list.go index 0c9e9d6a..cd01d506 100644 --- a/cli/internal/cli/list.go +++ b/cli/internal/cli/list.go @@ -14,20 +14,19 @@ import ( ) // newListCommand wires the `yacd list` subcommand. It lists CardanoNetworks -// in the active namespace (or across all namespaces with -A) and projects -// each into name/namespace/mode/ready/endpoints, rendered as a table or, with -// --json, as machine-readable JSON. +// across all namespaces by default, or a single namespace when one is given +// with -n, and projects each into name/namespace/mode/ready/endpoints, +// rendered as a table or, with --json, as machine-readable JSON. func newListCommand(commandContext *commandContext) *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "List YACD environments in the cluster", + Short: "List YACD environments across all namespaces (or one with -n)", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { runtimeConfig, err := loadRuntimeConfig(commandContext.viper) if err != nil { return err } - allNamespaces := commandContext.viper.GetBool("all-namespaces") jsonOutput := commandContext.viper.GetBool("json") kubeClient, _, err := commandContext.resolveKubeClient(runtimeConfig) @@ -35,13 +34,8 @@ func newListCommand(commandContext *commandContext) *cobra.Command { return err } - namespace := "" - if !allNamespaces { - namespace = strings.TrimSpace(runtimeConfig.Namespace) - if namespace == "" { - namespace = kubeClient.DefaultNamespace() - } - } + // An empty namespace lists across all namespaces; -n scopes to one. + namespace := strings.TrimSpace(runtimeConfig.Namespace) networks, err := kubeClient.ListCardanoNetworks(cmd.Context(), namespace) if err != nil { @@ -64,11 +58,10 @@ func newListCommand(commandContext *commandContext) *cobra.Command { return nil } - return printList(commandContext.out, items, namespace, allNamespaces) + return printList(commandContext.out, items, namespace) }, } - cmd.Flags().BoolP("all-namespaces", "A", false, "List CardanoNetworks across all namespaces") cmd.Flags().Bool("json", false, "Print machine-readable JSON") return cmd @@ -167,11 +160,12 @@ func endpointURL(endpoint *yacdv1alpha1.ServiceEndpointStatus) string { // printList renders the projected items as an aligned table. An empty result // is reported explicitly, with the search scope, so the user can tell "none" -// from a filtering error. -func printList(out io.Writer, items []listItem, namespace string, allNamespaces bool) error { +// from a filtering error. A non-empty namespace means the result was scoped to +// that namespace; an empty namespace means all namespaces were searched. +func printList(out io.Writer, items []listItem, namespace string) error { if len(items) == 0 { message := "No CardanoNetworks found." - if !allNamespaces { + if namespace != "" { message = fmt.Sprintf("No CardanoNetworks found in namespace %q.", namespace) } if _, err := fmt.Fprintln(out, message); err != nil { diff --git a/cli/internal/cli/list_test.go b/cli/internal/cli/list_test.go index 036e2483..0aea8023 100644 --- a/cli/internal/cli/list_test.go +++ b/cli/internal/cli/list_test.go @@ -63,7 +63,6 @@ func TestListRendersTable(t *testing.T) { } client := newKubeMock(t) - client.EXPECT().DefaultNamespace().Return("default-ns").Maybe() client.EXPECT().ListCardanoNetworks(mock.Anything, "team-a").Return(networks, nil) var stdout bytes.Buffer @@ -90,12 +89,18 @@ func TestListRendersTable(t *testing.T) { assert.Contains(t, output, "false") } -func TestListUsesDefaultNamespaceWhenUnset(t *testing.T) { - t.Setenv("YACD_NAMESPACE", "") +func TestListDefaultsToAllNamespaces(t *testing.T) { + t.Parallel() + + networks := []yacdv1alpha1.CardanoNetwork{ + listTestNetwork("team-a", "devnet", yacdv1alpha1.CardanoNetworkModeLocal, true), + listTestNetwork("team-b", "preview", yacdv1alpha1.CardanoNetworkModePublic, false), + } + // No -n and no DefaultNamespace() expectation: list must search every + // namespace by passing an empty namespace, never the kubeconfig default. client := newKubeMock(t) - client.EXPECT().DefaultNamespace().Return("default-ns").Once() - client.EXPECT().ListCardanoNetworks(mock.Anything, "default-ns").Return(nil, nil) + client.EXPECT().ListCardanoNetworks(mock.Anything, "").Return(networks, nil) var stdout bytes.Buffer root := NewRootCommand(Options{ @@ -106,20 +111,16 @@ func TestListUsesDefaultNamespaceWhenUnset(t *testing.T) { root.SetArgs([]string{"list"}) require.NoError(t, root.ExecuteContext(context.Background())) - assert.Contains(t, stdout.String(), `No CardanoNetworks found in namespace "default-ns".`) + output := stdout.String() + assert.Contains(t, output, "team-a") + assert.Contains(t, output, "team-b") } -func TestListAllNamespacesPassesEmptyNamespace(t *testing.T) { +func TestListEmptyResultAllNamespaces(t *testing.T) { t.Parallel() - networks := []yacdv1alpha1.CardanoNetwork{ - listTestNetwork("team-a", "devnet", yacdv1alpha1.CardanoNetworkModeLocal, true), - listTestNetwork("team-b", "preview", yacdv1alpha1.CardanoNetworkModePublic, false), - } - client := newKubeMock(t) - client.EXPECT().DefaultNamespace().Return("default-ns").Maybe() - client.EXPECT().ListCardanoNetworks(mock.Anything, "").Return(networks, nil) + client.EXPECT().ListCardanoNetworks(mock.Anything, "").Return([]yacdv1alpha1.CardanoNetwork{}, nil) var stdout bytes.Buffer root := NewRootCommand(Options{ @@ -127,20 +128,17 @@ func TestListAllNamespacesPassesEmptyNamespace(t *testing.T) { Viper: viper.New(), KubeClientFactory: kubeClientFactory(client), }) - root.SetArgs([]string{"list", "-A"}) + root.SetArgs([]string{"list"}) require.NoError(t, root.ExecuteContext(context.Background())) - output := stdout.String() - assert.Contains(t, output, "team-a") - assert.Contains(t, output, "team-b") + assert.Equal(t, "No CardanoNetworks found.\n", stdout.String()) } func TestListEmptyResultReportsNone(t *testing.T) { t.Parallel() client := newKubeMock(t) - client.EXPECT().DefaultNamespace().Return("default-ns").Maybe() - client.EXPECT().ListCardanoNetworks(mock.Anything, mock.Anything).Return([]yacdv1alpha1.CardanoNetwork{}, nil) + client.EXPECT().ListCardanoNetworks(mock.Anything, "team-a").Return([]yacdv1alpha1.CardanoNetwork{}, nil) var stdout bytes.Buffer root := NewRootCommand(Options{ @@ -163,7 +161,6 @@ func TestListJSONOutputShape(t *testing.T) { } client := newKubeMock(t) - client.EXPECT().DefaultNamespace().Return("default-ns").Maybe() client.EXPECT().ListCardanoNetworks(mock.Anything, "team-a").Return(networks, nil) var stdout bytes.Buffer From 64c0f3aad1d72f65862861f5d7cb957149ed5c50 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 3 Jun 2026 10:38:09 -0700 Subject: [PATCH 2/3] feat(cli): topup self-forwards the faucet; LOVELACE is positional topup could not reach the faucet on its own: the URL it targets comes from CardanoNetwork status, which is the in-cluster Service URL unreachable from the host. Users had to wrap it in `yacd run` (and the documented form even passed a redundant --faucet-url "$YACD_FAUCET_URL"). Now, with no faucet URL override, topup opens a short-lived port-forward to the faucet itself (reusing the connect/run forward machinery via a new forwardEndpoints helper), POSTs, and tears it down. The same session forwards Kupo, so `topup --await` no longer needs --kupo-url standalone. An explicit --faucet-url or ambient YACD_FAUCET_URL (inside `yacd run`) still skips self-forwarding, and the trust gate is unchanged: loopback is exempt, remote requires the trust flags. The secret is still read only after the trust gate. LOVELACE becomes a required positional argument (the flag was always required); --address stays a required flag. Breaking, safe pre-1.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 +- cli/internal/cli/forward.go | 41 +++++-- cli/internal/cli/topup.go | 170 +++++++++++++++++++-------- cli/internal/cli/topup_await_test.go | 62 ++++++++-- cli/internal/cli/topup_test.go | 153 ++++++++++++++++++------ docs/host-access.md | 32 +++-- 6 files changed, 337 insertions(+), 127 deletions(-) diff --git a/README.md b/README.md index a7b8f19f..3c3620dc 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,9 @@ go run ./cli/cmd/yacd run phase4-smoke -- go test ./e2e/... # Or hold the forwards open in one terminal and work in another: go run ./cli/cmd/yacd connect phase4-smoke -# Fund a checked-in address and wait for on-chain confirmation: -go run ./cli/cmd/yacd run phase4-smoke -- sh -c \ - 'yacd topup phase4-smoke --address addr_test... --lovelace 1000000 --faucet-url "$YACD_FAUCET_URL" --await' +# Fund an address and wait for on-chain confirmation. topup forwards the faucet +# (and Kupo, for --await) itself, so it needs no `yacd run` wrapper: +go run ./cli/cmd/yacd topup phase4-smoke 1000000 --address addr_test... --await # cardano-cli reaches the node over its local socket, so use exec (in-pod): go run ./cli/cmd/yacd exec phase4-smoke -- cardano-cli query tip --testnet-magic 42 diff --git a/cli/internal/cli/forward.go b/cli/internal/cli/forward.go index 48669d96..8a04792f 100644 --- a/cli/internal/cli/forward.go +++ b/cli/internal/cli/forward.go @@ -45,17 +45,7 @@ func connectNetwork(ctx context.Context, kubeClient kube.Client, namespace strin return nil, err } - specs := forwardSpecs(network) - if len(specs) == 0 { - return nil, fmt.Errorf("cardanonetwork %s/%s publishes no chain-API endpoints to forward", namespace, name) - } - - podName, err := kubeClient.PrimaryPodName(ctx, namespace, name) - if err != nil { - return nil, err - } - - session, err := kubeClient.Forward(ctx, namespace, podName, specs) + session, endpoints, err := forwardEndpoints(ctx, kubeClient, network, namespace, name) if err != nil { return nil, err } @@ -71,13 +61,38 @@ func connectNetwork(ctx context.Context, kubeClient kube.Client, namespace strin _ = session.Close() return nil, err } + + return &connectedSession{session: session, env: env, endpoints: endpoints}, nil +} + +// forwardEndpoints forwards a ready network's published chain-API endpoints and +// returns the live session plus the token-free loopback endpoints document. It +// reads no Secret, so callers (notably topup) can run their trust gate before +// fetching any token. The caller owns the returned session and must Close it; +// forwardEndpoints closes it itself only when a later step here fails. +func forwardEndpoints(ctx context.Context, kubeClient kube.Client, network *yacdv1alpha1.CardanoNetwork, namespace string, name string) (kube.ForwardSession, endpointsDocument, error) { + specs := forwardSpecs(network) + if len(specs) == 0 { + return nil, endpointsDocument{}, fmt.Errorf("cardanonetwork %s/%s publishes no chain-API endpoints to forward", namespace, name) + } + + podName, err := kubeClient.PrimaryPodName(ctx, namespace, name) + if err != nil { + return nil, endpointsDocument{}, err + } + + session, err := kubeClient.Forward(ctx, namespace, podName, specs) + if err != nil { + return nil, endpointsDocument{}, err + } + endpoints, err := newEndpointsDocument(network, session.LocalPort) if err != nil { _ = session.Close() - return nil, err + return nil, endpointsDocument{}, err } - return &connectedSession{session: session, env: env, endpoints: endpoints}, nil + return session, endpoints, nil } // forwardSpecs returns the port-forward specs for a network's published diff --git a/cli/internal/cli/topup.go b/cli/internal/cli/topup.go index ddb96a97..e786c043 100644 --- a/cli/internal/cli/topup.go +++ b/cli/internal/cli/topup.go @@ -1,8 +1,11 @@ package cli import ( + "context" "encoding/json" "fmt" + "io" + "strconv" "strings" "time" @@ -12,16 +15,19 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// newTopUpCommand wires the `yacd topup NAME` subcommand. The command -// flow is: resolve the target faucet URL (preferring the cluster-published -// endpoint unless --faucet-url overrides it), gate token transmission -// through validateFaucetURLTrust, fetch the auth token from the published -// Secret, then POST to the faucet. +// newTopUpCommand wires the `yacd topup NAME LOVELACE` subcommand. The command +// flow is: resolve how to reach the faucet (a short-lived self-managed +// port-forward by default, or the --faucet-url / ambient YACD_FAUCET_URL +// override), gate token transmission through validateFaucetURLTrust, fetch the +// auth token from the published Secret, then POST to the faucet. Self-forwarding +// is what lets topup run on the host without a `yacd run` wrapper: the faucet +// URL the controller publishes is the in-cluster Service URL, which the host +// cannot reach directly. func newTopUpCommand(commandContext *commandContext) *cobra.Command { cmd := &cobra.Command{ - Use: "topup NAME", + Use: "topup NAME LOVELACE", Short: "Submit a faucet top-up", - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { runtimeConfig, err := loadRuntimeConfig(commandContext.viper) if err != nil { @@ -31,36 +37,44 @@ func newTopUpCommand(commandContext *commandContext) *cobra.Command { if err != nil { return err } + lovelace, err := strconv.ParseInt(strings.TrimSpace(args[1]), 10, 64) + if err != nil { + return fmt.Errorf("invalid LOVELACE %q: must be a positive integer", args[1]) + } + if lovelace <= 0 { + return fmt.Errorf("LOVELACE must be greater than 0") + } destinationAddress := strings.TrimSpace(commandContext.viper.GetString("address")) - lovelace := commandContext.viper.GetInt64("lovelace") source := strings.TrimSpace(commandContext.viper.GetString("source")) - faucetURL := strings.TrimSpace(commandContext.viper.GetString("faucet-url")) - customFaucetURL := faucetURL != "" + // faucet-url falls back to the YACD_FAUCET_URL contract variable + // through viper's AutomaticEnv, so an override is in effect both when + // --faucet-url is passed and when topup runs under `yacd run` (which + // sets YACD_FAUCET_URL). Either way we skip self-forwarding. + overrideFaucetURL := strings.TrimSpace(commandContext.viper.GetString("faucet-url")) trustFaucetURL := commandContext.viper.GetBool("trust-faucet-url") allowInsecureFaucetURL := commandContext.viper.GetBool("allow-insecure-faucet-url") jsonOutput := commandContext.viper.GetBool("json") awaitConfirm := commandContext.viper.GetBool("await") awaitTimeout := commandContext.viper.GetDuration("await-timeout") - // kupo-url falls back to the YACD_KUPO_URL contract variable through - // viper's AutomaticEnv, so topup --await works unchanged under - // `yacd run` (which sets YACD_KUPO_URL). + // kupo-url falls back to YACD_KUPO_URL through AutomaticEnv. When we + // self-forward below and no Kupo URL was supplied, we derive it from + // the forwarded loopback Kupo so `topup --await` works standalone. kupoURL := strings.TrimSpace(commandContext.viper.GetString("kupo-url")) if destinationAddress == "" { return fmt.Errorf("--address is required") } - if lovelace <= 0 { - return fmt.Errorf("--lovelace must be greater than 0") - } if awaitConfirm { - if kupoURL == "" { - return fmt.Errorf("--await requires a Kupo URL: pass --kupo-url or run under `yacd run`, which sets YACD_KUPO_URL") - } if awaitTimeout <= 0 { return fmt.Errorf("--await-timeout must be greater than 0") } - if err := validateKupoURL(kupoURL); err != nil { - return err + // Validate an explicitly supplied Kupo URL before any cluster + // contact. A URL derived from the self-forward below is + // constructed safe and needs no re-validation. + if kupoURL != "" { + if err := validateKupoURL(kupoURL); err != nil { + return err + } } } @@ -86,18 +100,30 @@ func newTopUpCommand(commandContext *commandContext) *cobra.Command { if network.Status.Faucet == nil || strings.TrimSpace(network.Status.Faucet.AuthSecretName) == "" { return fmt.Errorf("cardanonetwork %s/%s does not publish a faucet auth Secret", namespace, name) } - // Security-relevant default: when the user did not pass - // --faucet-url, target the URL the cluster published. The - // override path below is what triggers the trust gate. - if faucetURL == "" { - faucetURL = statusFaucetURL + + // Resolve how to reach the faucet without reading the token yet, so + // the trust gate always runs before any Secret read. + transport, err := resolveFaucetTransport(cmd.Context(), kubeClient, network, namespace, name, overrideFaucetURL, kupoURL, awaitConfirm) + if err != nil { + return err + } + if transport.session != nil { + defer func() { _ = transport.session.Close() }() } + kupoURL = transport.kupoURL + + if awaitConfirm && kupoURL == "" { + // Only reachable on the override path: an explicit --faucet-url + // suppresses the self-forward, so no Kupo endpoint was forwarded. + return fmt.Errorf("--await requires a Kupo URL: pass --kupo-url, or drop --faucet-url so topup forwards Kupo for you") + } + if err := validateFaucetURLTrust( - faucetURL, + transport.faucetURL, statusFaucetURL, namespace, network.Status.Faucet.AuthSecretName, - customFaucetURL, + transport.custom, trustFaucetURL, allowInsecureFaucetURL, ); err != nil { @@ -109,7 +135,7 @@ func newTopUpCommand(commandContext *commandContext) *cobra.Command { return err } - result, err := postTopUp(cmd.Context(), commandContext.httpClient, faucetURL, strings.TrimSpace(token), topUpHTTPPayload{ + result, err := postTopUp(cmd.Context(), commandContext.httpClient, transport.faucetURL, strings.TrimSpace(token), topUpHTTPPayload{ Address: destinationAddress, Lovelace: lovelace, Source: source, @@ -133,32 +159,11 @@ func newTopUpCommand(commandContext *commandContext) *cobra.Command { } } - if jsonOutput { - encoded, err := json.MarshalIndent(result, "", " ") - if err != nil { - return fmt.Errorf("marshal top-up JSON: %w", err) - } - if _, err := fmt.Fprintf(commandContext.out, "%s\n", encoded); err != nil { - return fmt.Errorf("write top-up JSON: %w", err) - } - return nil - } - - if _, err := fmt.Fprintf(commandContext.out, "Submitted top-up %s\nSource: %s\nLovelace: %d\nDestination: %s\n", result.TxID, result.Source, result.Lovelace, result.DestinationAddress); err != nil { - return fmt.Errorf("write top-up result: %w", err) - } - if awaitConfirm { - if _, err := fmt.Fprintf(commandContext.out, "Confirmed on-chain.\n"); err != nil { - return fmt.Errorf("write top-up confirmation: %w", err) - } - } - - return nil + return printTopUpResult(commandContext.out, result, jsonOutput, awaitConfirm) }, } cmd.Flags().String("address", "", "Destination Cardano testnet address") - cmd.Flags().Int64("lovelace", 0, "Exact lovelace amount to send") cmd.Flags().String("source", "", "Faucet source name, for example utxo1") cmd.Flags().String("faucet-url", "", "Override the faucet URL from CardanoNetwork status") cmd.Flags().Bool("trust-faucet-url", false, "Allow sending the faucet auth token to a custom non-loopback URL") @@ -171,6 +176,69 @@ func newTopUpCommand(commandContext *commandContext) *cobra.Command { return cmd } +// topupTransport is the resolved way topup reaches the faucet: the faucet URL to +// POST to, whether it is a user override (and therefore subject to the trust +// gate), the Kupo URL to use for --await, and an optional live port-forward +// session the caller must Close. +type topupTransport struct { + faucetURL string + custom bool + kupoURL string + session kube.ForwardSession +} + +// resolveFaucetTransport decides how topup reaches the faucet without reading +// any Secret, so the caller can run the trust gate before fetching the token. An +// override URL — explicit --faucet-url or ambient YACD_FAUCET_URL — is used as-is +// and marked custom. Otherwise topup opens a short-lived port-forward (which also +// covers Kupo for --await) and returns the loopback URLs plus the live session; +// the loopback faucet URL is trust-gate-exempt. +func resolveFaucetTransport(ctx context.Context, kubeClient kube.Client, network *yacdv1alpha1.CardanoNetwork, namespace string, name string, overrideURL string, kupoURL string, awaitConfirm bool) (topupTransport, error) { + if overrideURL != "" { + return topupTransport{faucetURL: overrideURL, custom: true, kupoURL: kupoURL}, nil + } + + session, endpoints, err := forwardEndpoints(ctx, kubeClient, network, namespace, name) + if err != nil { + return topupTransport{}, err + } + if strings.TrimSpace(endpoints.FaucetURL) == "" { + _ = session.Close() + return topupTransport{}, fmt.Errorf("cardanonetwork %s/%s does not publish a faucet endpoint to forward", namespace, name) + } + if awaitConfirm && kupoURL == "" { + kupoURL = endpoints.KupoURL + } + + return topupTransport{faucetURL: endpoints.FaucetURL, kupoURL: kupoURL, session: session}, nil +} + +// printTopUpResult renders the faucet response as JSON (--json) or as a short +// human-readable block, noting on-chain confirmation when --await ran. +func printTopUpResult(out io.Writer, result topUpHTTPResult, jsonOutput bool, awaitConfirm bool) error { + if jsonOutput { + encoded, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("marshal top-up JSON: %w", err) + } + if _, err := fmt.Fprintf(out, "%s\n", encoded); err != nil { + return fmt.Errorf("write top-up JSON: %w", err) + } + return nil + } + + if _, err := fmt.Fprintf(out, "Submitted top-up %s\nSource: %s\nLovelace: %d\nDestination: %s\n", result.TxID, result.Source, result.Lovelace, result.DestinationAddress); err != nil { + return fmt.Errorf("write top-up result: %w", err) + } + if awaitConfirm { + if _, err := fmt.Fprintf(out, "Confirmed on-chain.\n"); err != nil { + return fmt.Errorf("write top-up confirmation: %w", err) + } + } + + return nil +} + // requireFaucetReady rejects a CardanoNetwork whose status cannot be // trusted to publish a working faucet. It fails fast on stale status // (observedGeneration < generation), on a Degraded condition, and on a diff --git a/cli/internal/cli/topup_await_test.go b/cli/internal/cli/topup_await_test.go index 7ad8d74a..c3cef234 100644 --- a/cli/internal/cli/topup_await_test.go +++ b/cli/internal/cli/topup_await_test.go @@ -57,12 +57,16 @@ func TestAwaitConfirmationSurfacesLastQueryErrorOnTimeout(t *testing.T) { assert.Contains(t, err.Error(), "kupo unreachable") } -func TestTopUpAwaitRequiresKupoURL(t *testing.T) { +func TestTopUpAwaitRequiresKupoURLWhenFaucetOverridden(t *testing.T) { t.Parallel() - // --await without a Kupo URL must fail before any cluster contact: the - // mock has no expectations, so any client call would fail the test. + // An explicit --faucet-url suppresses the self-forward, so topup cannot + // derive a Kupo URL from a forwarded endpoint; --await then requires + // --kupo-url. The command must fail at the await-kupo check, before the + // trust gate reads any Secret. client := newKubeMock(t) + client.EXPECT().DefaultNamespace().Return("devnet").Maybe() + client.EXPECT().GetCardanoNetwork(mock.Anything, "devnet", "devnet").Return(readyNetwork("devnet"), nil) var stderr bytes.Buffer root := NewRootCommand(Options{ @@ -70,11 +74,45 @@ func TestTopUpAwaitRequiresKupoURL(t *testing.T) { Viper: viper.New(), KubeClientFactory: kubeClientFactory(client), }) - root.SetArgs([]string{"topup", "devnet", "--address", "addr_test1dest", "--lovelace", "2000000", "--await"}) + root.SetArgs([]string{"topup", "devnet", "2000000", "--address", "addr_test1dest", "--faucet-url", "http://127.0.0.1:9", "--await"}) err := root.ExecuteContext(context.Background()) require.Error(t, err) assert.Contains(t, err.Error(), "--await requires a Kupo URL") + client.AssertNotCalled(t, "GetSecretValue", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +// TestTopUpAwaitUsesForwardedKupo proves the standalone --await path: with no +// --kupo-url and no --faucet-url, topup self-forwards and derives the loopback +// Kupo URL from the same session, so --await works without any extra flags. +func TestTopUpAwaitUsesForwardedKupo(t *testing.T) { + t.Parallel() + + client := topupSelfForwardClient(t) + + httpMock := newHTTPMock(t) + httpMock.EXPECT().Do(mock.Anything).Return(successfulTopUpHTTPResponse(), nil) + + confirmer := mocks.NewUTxOConfirmer(t) + confirmer.EXPECT().TransactionIDs(mock.Anything, "addr_test1dest").Return([]string{"abc123"}, nil) + var gotKupoURL string + + root := NewRootCommand(Options{ + Out: &bytes.Buffer{}, + Err: &bytes.Buffer{}, + Viper: viper.New(), + HTTPClient: httpMock, + KubeClientFactory: kubeClientFactory(client), + UTxOConfirmerFactory: func(kupoURL string) UTxOConfirmer { + gotKupoURL = kupoURL + + return confirmer + }, + }) + root.SetArgs([]string{"topup", "devnet", "2000000", "--address", "addr_test1dest", "--await"}) + + require.NoError(t, root.ExecuteContext(context.Background())) + assert.Equal(t, "http://127.0.0.1:40002", gotKupoURL, "--await must reuse the forwarded loopback Kupo") } func TestTopUpAwaitRejectsMalformedKupoURLBeforeClusterContact(t *testing.T) { @@ -103,8 +141,8 @@ func TestTopUpAwaitRejectsMalformedKupoURLBeforeClusterContact(t *testing.T) { KubeClientFactory: kubeClientFactory(client), }) root.SetArgs([]string{ - "topup", "devnet", - "--address", "addr_test1dest", "--lovelace", "2000000", + "topup", "devnet", "2000000", + "--address", "addr_test1dest", "--await", "--kupo-url", tc.kupoURL, }) @@ -146,8 +184,8 @@ func TestTopUpAwaitConfirmsOnChain(t *testing.T) { }, }) root.SetArgs([]string{ - "topup", "devnet", - "--address", "addr_test1dest", "--lovelace", "2000000", + "topup", "devnet", "2000000", + "--address", "addr_test1dest", "--faucet-url", faucetServer.URL, "--await", "--kupo-url", "http://127.0.0.1:1442", }) @@ -194,8 +232,8 @@ func TestTopUpAwaitQueriesRequestedAddressNotEcho(t *testing.T) { UTxOConfirmerFactory: func(string) UTxOConfirmer { return confirmer }, }) root.SetArgs([]string{ - "topup", "devnet", - "--address", "addr_test1dest", "--lovelace", "2000000", + "topup", "devnet", "2000000", + "--address", "addr_test1dest", "--faucet-url", faucetServer.URL, "--await", "--kupo-url", "http://127.0.0.1:1442", }) @@ -233,8 +271,8 @@ func TestTopUpAwaitReadsKupoURLFromEnv(t *testing.T) { }, }) root.SetArgs([]string{ - "topup", "devnet", - "--address", "addr_test1dest", "--lovelace", "2000000", + "topup", "devnet", "2000000", + "--address", "addr_test1dest", "--faucet-url", faucetServer.URL, "--await", }) diff --git a/cli/internal/cli/topup_test.go b/cli/internal/cli/topup_test.go index 6eed3b68..a33ab81c 100644 --- a/cli/internal/cli/topup_test.go +++ b/cli/internal/cli/topup_test.go @@ -12,6 +12,7 @@ import ( "testing" yacdv1alpha1 "github.com/meigma/yacd/api/v1alpha1" + "github.com/meigma/yacd/cli/internal/mocks" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -25,6 +26,30 @@ const ( testTopUpResponse = `{"txId":"abc123","source":"utxo1","sourceAddress":"addr_test1source","destinationAddress":"addr_test1dest","lovelace":2000000}` ) +// topupSelfForwardClient wires a mock kube.Client + ForwardSession for the +// default topup path (no --faucet-url): a ready network, the primary Pod, a +// forward session mapping the published Ogmios/Kupo/faucet ports to fixed local +// ports, and the faucet auth Secret. topup reads the session's loopback URLs but +// never supervises it, so only LocalPort and Close are exercised (no Done). +func topupSelfForwardClient(t *testing.T) *mocks.Client { + t.Helper() + + session := mocks.NewForwardSession(t) + session.EXPECT().LocalPort(int32(1337)).Return(40001, true) + session.EXPECT().LocalPort(int32(1442)).Return(40002, true) + session.EXPECT().LocalPort(int32(8080)).Return(40003, true) + session.EXPECT().Close().Return(nil) + + client := newKubeMock(t) + client.EXPECT().DefaultNamespace().Return("devnet").Maybe() + client.EXPECT().GetCardanoNetwork(mock.Anything, "devnet", "devnet").Return(readyNetwork("devnet"), nil) + client.EXPECT().PrimaryPodName(mock.Anything, "devnet", "devnet").Return("devnet-node-abcde", nil) + client.EXPECT().Forward(mock.Anything, "devnet", "devnet-node-abcde", mock.Anything).Return(session, nil) + client.EXPECT().GetSecretValue(mock.Anything, "devnet", testTopUpAuthSecret, faucetAuthTokenKey).Return(testTopUpToken, nil) + + return client +} + func TestTopUpReadsSecretAndPostsToFaucet(t *testing.T) { t.Parallel() @@ -68,7 +93,9 @@ func TestTopUpReadsSecretAndPostsToFaucet(t *testing.T) { Viper: viper.New(), KubeClientFactory: kubeClientFactory(client), }) - root.SetArgs([]string{"topup", "devnet", "--address", "addr_test1dest", "--lovelace", "2000000", "--source", "utxo2", "--faucet-url", faucetServer.URL, "--json"}) + // An explicit --faucet-url (here a loopback test server) suppresses the + // self-forward, so no Forward expectation is needed. + root.SetArgs([]string{"topup", "devnet", "2000000", "--address", "addr_test1dest", "--source", "utxo2", "--faucet-url", faucetServer.URL, "--json"}) require.NoError(t, root.ExecuteContext(context.Background())) got := <-requests @@ -84,63 +111,63 @@ func TestTopUpReadsSecretAndPostsToFaucet(t *testing.T) { } } -func TestTopUpUsesStatusEndpointByDefault(t *testing.T) { +// TestTopUpSelfForwardsByDefault is the headline behavior: with no --faucet-url +// and no ambient YACD_FAUCET_URL, topup opens its own port-forward and POSTs to +// the loopback faucet URL — no `yacd run` wrapper required. The published +// cluster Service URL is never contacted directly. +func TestTopUpSelfForwardsByDefault(t *testing.T) { t.Parallel() - faucetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprint(w, testTopUpResponse) - })) - t.Cleanup(faucetServer.Close) - - network := readyNetwork("devnet") - network.Status.Endpoints.Faucet.URL = faucetServer.URL + client := topupSelfForwardClient(t) - client := newKubeMock(t) - client.EXPECT().DefaultNamespace().Return("devnet").Maybe() - client.EXPECT().GetCardanoNetwork(mock.Anything, "devnet", "devnet").Return(network, nil) - client.EXPECT(). - GetSecretValue(mock.Anything, "devnet", testTopUpAuthSecret, faucetAuthTokenKey). - Return(testTopUpToken, nil) + httpMock := newHTTPMock(t) + var capturedRequest *http.Request + httpMock.EXPECT().Do(mock.Anything). + Run(func(req *http.Request) { capturedRequest = req }). + Return(successfulTopUpHTTPResponse(), nil) root := NewRootCommand(Options{ Viper: viper.New(), + HTTPClient: httpMock, KubeClientFactory: kubeClientFactory(client), }) - root.SetArgs([]string{"topup", "devnet", "--address", "addr_test1dest", "--lovelace", "2000000"}) + root.SetArgs([]string{"topup", "devnet", "2000000", "--address", "addr_test1dest"}) require.NoError(t, root.ExecuteContext(context.Background())) + require.NotNil(t, capturedRequest) + assert.Equal(t, "127.0.0.1:40003", capturedRequest.URL.Host) + assert.Equal(t, "/v1/topups", capturedRequest.URL.Path) + assert.Equal(t, "Bearer "+testTopUpToken, capturedRequest.Header.Get("Authorization")) } -func TestTopUpAllowsPublishedRemoteFaucetURLByDefault(t *testing.T) { - t.Parallel() +// TestTopUpHonorsAmbientFaucetURLEnv proves that running under `yacd run` (which +// exports YACD_FAUCET_URL) keeps working: the ambient loopback URL is used +// directly and topup does not open a second forward (the mock has no Forward or +// PrimaryPodName expectation). +func TestTopUpHonorsAmbientFaucetURLEnv(t *testing.T) { + faucetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, testTopUpResponse) + })) + t.Cleanup(faucetServer.Close) - network := readyNetwork("devnet") - network.Status.Endpoints.Faucet.URL = "http://devnet-faucet.devnet.svc.cluster.local:8080" + // No t.Parallel: t.Setenv is incompatible with parallel tests. + t.Setenv("YACD_FAUCET_URL", faucetServer.URL) client := newKubeMock(t) client.EXPECT().DefaultNamespace().Return("devnet").Maybe() - client.EXPECT().GetCardanoNetwork(mock.Anything, "devnet", "devnet").Return(network, nil) + client.EXPECT().GetCardanoNetwork(mock.Anything, "devnet", "devnet").Return(readyNetwork("devnet"), nil) client.EXPECT(). GetSecretValue(mock.Anything, "devnet", testTopUpAuthSecret, faucetAuthTokenKey). Return(testTopUpToken, nil) - httpMock := newHTTPMock(t) - var capturedRequest *http.Request - httpMock.EXPECT().Do(mock.Anything). - Run(func(req *http.Request) { capturedRequest = req }). - Return(successfulTopUpHTTPResponse(), nil) - root := NewRootCommand(Options{ Viper: viper.New(), - HTTPClient: httpMock, KubeClientFactory: kubeClientFactory(client), }) - root.SetArgs([]string{"topup", "devnet", "--address", "addr_test1dest", "--lovelace", "2000000"}) + root.SetArgs([]string{"topup", "devnet", "2000000", "--address", "addr_test1dest"}) require.NoError(t, root.ExecuteContext(context.Background())) - require.NotNil(t, capturedRequest) - assert.Equal(t, "devnet-faucet.devnet.svc.cluster.local:8080", capturedRequest.URL.Host) } // TestTopUpRequiresTrustForRemoteCustomFaucetURLBeforeReadingSecret asserts @@ -159,7 +186,7 @@ func TestTopUpRequiresTrustForRemoteCustomFaucetURLBeforeReadingSecret(t *testin Viper: viper.New(), KubeClientFactory: kubeClientFactory(client), }) - root.SetArgs([]string{"topup", "devnet", "--address", "addr_test1dest", "--lovelace", "2000000", "--faucet-url", "https://faucet.example.com"}) + root.SetArgs([]string{"topup", "devnet", "2000000", "--address", "addr_test1dest", "--faucet-url", "https://faucet.example.com"}) err := root.ExecuteContext(context.Background()) require.Error(t, err) @@ -190,7 +217,7 @@ func TestTopUpAllowsTrustedRemoteHTTPSCustomFaucetURL(t *testing.T) { HTTPClient: httpMock, KubeClientFactory: kubeClientFactory(client), }) - root.SetArgs([]string{"topup", "devnet", "--address", "addr_test1dest", "--lovelace", "2000000", "--faucet-url", "https://faucet.example.com", "--trust-faucet-url"}) + root.SetArgs([]string{"topup", "devnet", "2000000", "--address", "addr_test1dest", "--faucet-url", "https://faucet.example.com", "--trust-faucet-url"}) require.NoError(t, root.ExecuteContext(context.Background())) require.NotNil(t, capturedRequest) @@ -209,7 +236,7 @@ func TestTopUpRequiresAllowInsecureForTrustedRemoteHTTPCustomFaucetURL(t *testin Viper: viper.New(), KubeClientFactory: kubeClientFactory(client), }) - root.SetArgs([]string{"topup", "devnet", "--address", "addr_test1dest", "--lovelace", "2000000", "--faucet-url", "http://faucet.example.com", "--trust-faucet-url"}) + root.SetArgs([]string{"topup", "devnet", "2000000", "--address", "addr_test1dest", "--faucet-url", "http://faucet.example.com", "--trust-faucet-url"}) err := root.ExecuteContext(context.Background()) require.Error(t, err) @@ -240,7 +267,7 @@ func TestTopUpAllowsTrustedRemoteHTTPCustomFaucetURLWithInsecureFlag(t *testing. HTTPClient: httpMock, KubeClientFactory: kubeClientFactory(client), }) - root.SetArgs([]string{"topup", "devnet", "--address", "addr_test1dest", "--lovelace", "2000000", "--faucet-url", "http://faucet.example.com", "--trust-faucet-url", "--allow-insecure-faucet-url"}) + root.SetArgs([]string{"topup", "devnet", "2000000", "--address", "addr_test1dest", "--faucet-url", "http://faucet.example.com", "--trust-faucet-url", "--allow-insecure-faucet-url"}) require.NoError(t, root.ExecuteContext(context.Background())) require.NotNil(t, capturedRequest) @@ -268,13 +295,61 @@ func TestTopUpReportsFaucetErrors(t *testing.T) { Viper: viper.New(), KubeClientFactory: kubeClientFactory(client), }) - root.SetArgs([]string{"topup", "devnet", "--address", "addr_test1dest", "--lovelace", "2000000", "--faucet-url", faucetServer.URL}) + root.SetArgs([]string{"topup", "devnet", "2000000", "--address", "addr_test1dest", "--faucet-url", faucetServer.URL}) err := root.ExecuteContext(context.Background()) require.Error(t, err) assert.Contains(t, err.Error(), "HTTP 401: unauthorized: bad token") } +func TestTopUpRejectsInvalidLovelace(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + lovelace string + wantErr string + }{ + {name: "non-integer", lovelace: "abc", wantErr: "invalid LOVELACE"}, + {name: "zero", lovelace: "0", wantErr: "LOVELACE must be greater than 0"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // LOVELACE is parsed before any cluster contact, so the mock needs no + // expectations; a stray client call would fail the test. + client := newKubeMock(t) + + root := NewRootCommand(Options{ + Viper: viper.New(), + KubeClientFactory: kubeClientFactory(client), + }) + root.SetArgs([]string{"topup", "devnet", tc.lovelace, "--address", "addr_test1dest"}) + + err := root.ExecuteContext(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +func TestTopUpRequiresLovelaceArgument(t *testing.T) { + t.Parallel() + + client := newKubeMock(t) + + root := NewRootCommand(Options{ + Viper: viper.New(), + KubeClientFactory: kubeClientFactory(client), + }) + root.SetArgs([]string{"topup", "devnet", "--address", "addr_test1dest"}) + + err := root.ExecuteContext(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "accepts 2 arg(s)") +} + func TestTopUpRejectsStaleOrNotReadyStatus(t *testing.T) { t.Parallel() @@ -332,6 +407,8 @@ func TestTopUpRejectsStaleOrNotReadyStatus(t *testing.T) { network := readyNetwork("devnet") tc.mutate(network) + // Readiness is gated before any forward, so no Forward expectation is + // needed even though no --faucet-url is passed. client := newKubeMock(t) client.EXPECT().DefaultNamespace().Return("devnet").Maybe() client.EXPECT().GetCardanoNetwork(mock.Anything, "devnet", "devnet").Return(network, nil) @@ -340,7 +417,7 @@ func TestTopUpRejectsStaleOrNotReadyStatus(t *testing.T) { Viper: viper.New(), KubeClientFactory: kubeClientFactory(client), }) - root.SetArgs([]string{"topup", "devnet", "--address", "addr_test1dest", "--lovelace", "2000000"}) + root.SetArgs([]string{"topup", "devnet", "2000000", "--address", "addr_test1dest"}) err := root.ExecuteContext(context.Background()) require.Error(t, err) diff --git a/docs/host-access.md b/docs/host-access.md index 071a7a3d..88943624 100644 --- a/docs/host-access.md +++ b/docs/host-access.md @@ -17,7 +17,7 @@ This page is a reference for that contract. It assumes a network is already | `yacd run NAME -- ` | Establish scoped port-forwards to the chain APIs, inject the `YACD_*` environment, run `` on the host, and tear the forwards down when it exits. No command drops into `$SHELL`. The command's exit code is propagated. This is the primary test/CI path. | | `yacd connect NAME` | Hold the forwards open in the foreground (one terminal) while you work in another, writing the loopback URLs to `.yacd//endpoints.json`. Re-establishes dropped forwards; runs until Ctrl-C. | | `yacd exec NAME -- ` | Run `` **inside** the primary node Pod, for tools that reach the node over its local Unix socket. | -| `yacd topup NAME --await …` | Fund an address through the faucet and wait for the funding transaction to be confirmed on-chain. | +| `yacd topup NAME LOVELACE --address ADDR` | Fund an address through the faucet, self-forwarding the faucet so no `yacd run` wrapper is needed. Add `--await` to wait for on-chain confirmation. | ## `run` vs `exec`: which one? @@ -114,20 +114,32 @@ the faucet token**. Its ports are only live while `connect` is running. A clean disconnect removes the file, and a dropped forward removes the stale file before re-establishing, reassigning local ports, and writing a fresh document. -## Funding with `topup --await` +## Funding with `topup` -`yacd topup NAME --address ADDR --lovelace N --await` funds `ADDR` through the -faucet and then polls Kupo until the funding transaction's output appears, so a -test never races chain inclusion. `--await` requires a Kupo URL: pass -`--kupo-url`, or run under `yacd run` (which sets `YACD_KUPO_URL`): +`yacd topup NAME LOVELACE --address ADDR` funds `ADDR` through the faucet. +`topup` reaches the faucet on its own: with no `--faucet-url`, it opens a +short-lived port-forward to the faucet (and Kupo), POSTs, and tears it down — so +it works directly from the host with no `yacd run` wrapper: ```sh -yacd run my-net -- sh -c \ - 'yacd topup my-net --address "$ADDR" --lovelace 1000000 --faucet-url "$YACD_FAUCET_URL" --await' +yacd topup my-net 1000000 --address "$ADDR" ``` -The loopback faucet URL is exempt from the `topup` trust gate, so no -`--trust-faucet-url` flag is needed against a `run`/`connect` forward. +Add `--await` to poll Kupo until the funding transaction's output appears, so a +test never races chain inclusion. When `topup` self-forwards it reuses that same +session's Kupo, so `--await` needs no extra flags: + +```sh +yacd topup my-net 1000000 --address "$ADDR" --await +``` + +`topup` also honors an ambient `YACD_FAUCET_URL`/`YACD_KUPO_URL`, so it still +works unchanged inside `yacd run` (no second forward is opened). The loopback +faucet URL — whether self-forwarded or inherited from `run`/`connect` — is exempt +from the `topup` trust gate, so no `--trust-faucet-url` flag is needed. An +explicit `--faucet-url` suppresses self-forwarding; a custom non-loopback value +then requires `--trust-faucet-url` (and `--allow-insecure-faucet-url` for +`http://`), and `--await` with an override needs an explicit `--kupo-url`. ## See also From 95a2e7fba9c0c8d16cad4f9269566caad26a1456 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 3 Jun 2026 11:10:34 -0700 Subject: [PATCH 3/3] feat(cli): add `init` to print a commented yacd.yaml template New users had no starting point for a config beyond copying an examples/ file and reading Go structs. `yacd init` prints a fully-commented developer Environment template to stdout (`yacd init > yacd.yaml`). The active portion is a batteries-included local devnet (faucet + funded wallet) that renders cleanly through `up`; commented blocks document the rest of the API (Ogmios/ Kupo, node storage/resources/image, public mode + Mithril bootstrap), with not-yet-supported fields flagged. The template is an embedded file, and a test loads it through devconfig.Load to guard the active config against schema drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 9 ++-- cli/internal/cli/embed.go | 8 ++++ cli/internal/cli/init.go | 34 +++++++++++++++ cli/internal/cli/init.yaml | 82 +++++++++++++++++++++++++++++++++++ cli/internal/cli/init_test.go | 49 +++++++++++++++++++++ cli/internal/cli/root.go | 2 + 6 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 cli/internal/cli/init.go create mode 100644 cli/internal/cli/init.yaml create mode 100644 cli/internal/cli/init_test.go diff --git a/README.md b/README.md index 3c3620dc..fa5e5d3d 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ local YACD environment from a checked-in config file. - Local-mode `CardanoNetwork` reconciliation for one primary node with Ogmios as the default chain API, Kupo as the default chain index API, and an opt-in token-protected faucet for local top-ups. -- Developer CLI under `cli/` with `up`, `down`, `list`, `info`, and `topup` - lifecycle commands, plus `run`, `connect`, and `exec` host-access verbs that +- Developer CLI under `cli/` with `init` (print a commented `yacd.yaml` + template), `up`, `down`, `list`, `info`, and `topup` lifecycle commands, plus + `run`, `connect`, and `exec` host-access verbs that bridge the network's chain APIs to your tests through the `YACD_*` environment contract (see [docs/host-access.md](docs/host-access.md)). - Helm chart packaging for the manager deployment. @@ -53,9 +54,11 @@ moon run root:test git diff --check ``` -Render the example local environment without changing the cluster: +Scaffold a commented `yacd.yaml` to start from, or render the example local +environment without changing the cluster: ```sh +go run ./cli/cmd/yacd init > yacd.yaml go run ./cli/cmd/yacd up phase4-smoke -f examples/local/yacd.yaml --dry-run ``` diff --git a/cli/internal/cli/embed.go b/cli/internal/cli/embed.go index ef580a38..69ff8e4b 100644 --- a/cli/internal/cli/embed.go +++ b/cli/internal/cli/embed.go @@ -10,3 +10,11 @@ import _ "embed" // //go:embed devnet.yaml var defaultDevnetEnvYAML []byte + +// defaultInitEnvYAML is the fully-commented developer environment template +// `yacd init` prints to stdout. Its active (uncommented) portion is a valid +// batteries-included local network; commented blocks document the rest of the +// API. init_test.go guards the active config against drift from the real schema. +// +//go:embed init.yaml +var defaultInitEnvYAML []byte diff --git a/cli/internal/cli/init.go b/cli/internal/cli/init.go new file mode 100644 index 00000000..c44e42c1 --- /dev/null +++ b/cli/internal/cli/init.go @@ -0,0 +1,34 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// newInitCommand wires the `yacd init` subcommand. It prints a fully-commented +// developer Environment template to stdout; the active portion is a ready-to-run +// local network, and commented blocks document the rest of the API. Output goes +// to stdout so it composes with a redirect: `yacd init > yacd.yaml`. +func newInitCommand(commandContext *commandContext) *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Print a commented yacd.yaml environment template", + Long: `Print a fully-commented developer environment template to stdout. + +The active configuration is a ready-to-run local devnet (faucet + funded +wallet); commented blocks document the rest of the API. Redirect it to a file +and apply it: + + yacd init > yacd.yaml + yacd up dev -f yacd.yaml`, + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + if _, err := commandContext.out.Write(defaultInitEnvYAML); err != nil { + return fmt.Errorf("write environment template: %w", err) + } + + return nil + }, + } +} diff --git a/cli/internal/cli/init.yaml b/cli/internal/cli/init.yaml new file mode 100644 index 00000000..7120881f --- /dev/null +++ b/cli/internal/cli/init.yaml @@ -0,0 +1,82 @@ +# yacd environment — generated by `yacd init`. +# +# An "Environment" describes one Cardano network. Apply it with: +# yacd up NAME -f yacd.yaml # NAME is also the namespace by default +# (or let `yacd devnet` manage a local cluster + a default network for you). +# +# The active config below is a ready-to-run LOCAL devnet with a faucet and a +# pre-funded wallet. Commented blocks show the rest of the API — uncomment a +# WHOLE block at a time (every field shown in a block is required together). +# More: https://github.com/meigma/yacd and docs/host-access.md. + +apiVersion: yacd.meigma.io/devconfig/v1alpha1 +kind: Environment +spec: + network: + # local generates a private devnet; public joins preview/preprod/mainnet. + mode: local + + # The primary cardano-node, shared by both modes. + node: + version: "11.0.1" # cardano-node release (drives the node image). + port: 3001 # node-to-node TCP port (1-65535). + storage: + size: 2Gi # node DB volume (default 10Gi; raise for public networks). + # storageClassName: standard # pin a Kubernetes StorageClass (default: cluster default). + # image: ghcr.io/my/cardano-node:tag # override the full node image (else derived from version). + # resources: # container requests/limits (omit to use cluster defaults). + # requests: {cpu: "1", memory: 2Gi} + # limits: {cpu: "2", memory: 4Gi} + + # Network-facing chain APIs deployed next to the node. + chainAPI: + # Ogmios (WebSocket bridge) and Kupo (chain indexer) are ENABLED by default. + # Uncomment to pin image/port (keep all three fields together). Kupo needs Ogmios. + # ogmios: + # enabled: true + # image: cardanosolutions/ogmios:v6.14.0 + # port: 1337 + # kupo: + # enabled: true + # image: cardanosolutions/kupo:v2.11.0 + # port: 1442 + + # Faucet: funds addresses on the local network (local mode only; needs Ogmios + Kupo). + faucet: + enabled: true + port: 8080 + defaultSource: utxo1 # generated UTxO source used when a request omits one. + minTopUpLovelace: 1000000 # 1 ADA = 1_000_000 lovelace. + maxTopUpLovelace: 100000000000 # must be >= wallet.fundingLovelace below. + # image: ghcr.io/my/faucet:tag # override the faucet image (else controller default). + + # Pre-funded developer wallet (local mode only; needs faucet + Kupo). + # The controller generates a key once and funds it through the faucet. + wallet: + enabled: true + fundingLovelace: 100000000000 # 100,000 ADA. + + # ---- LOCAL mode (required when mode: local; remove when mode: public) ---- + local: + networkMagic: 42 # testnet magic for the node and client commands. + era: conway # newest ledger era (conway only; babbage is rejected). + timing: + slotLength: 100ms # fast slots for quick local blocks. + epochLength: 500 # slots per epoch. + topology: + pools: + count: 1 # generated stake pools (1 only, currently). + # defaults: {...} # reserved: shared pool economics — not yet supported by the CLI. + # genesis: {...} # reserved: custom genesis preset/params — not yet supported by the CLI. + + # ---- PUBLIC mode (alternative to LOCAL) ---------------------------------- + # To join a public network: set `mode: public`, delete the `local:` block + # above, and uncomment this. Public mode rejects explicit kupo/faucet + # `enabled: true` and the wallet. Mainnet also needs `bootstrap.mithril` and + # `node.storage.size` >= 300Gi. + # public: + # profile: preview # one of: preview | preprod | mainnet. + # bootstrap: # required for mainnet only. + # mithril: + # image: ghcr.io/input-output-hk/mithril-client:main-2478748 + # snapshot: latest # snapshot digest, or "latest". diff --git a/cli/internal/cli/init_test.go b/cli/internal/cli/init_test.go new file mode 100644 index 00000000..728c0105 --- /dev/null +++ b/cli/internal/cli/init_test.go @@ -0,0 +1,49 @@ +package cli + +import ( + "bytes" + "context" + "testing" + + yacdv1alpha1 "github.com/meigma/yacd/api/v1alpha1" + "github.com/meigma/yacd/cli/internal/devconfig" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestInitTemplateLoadsAndValidates guards the embedded init template against +// drift from the real schema: its active (uncommented) portion must parse and +// validate through the same devconfig.Load `yacd up` uses, and must be the +// batteries-included local network `init` promises (faucet + funded wallet). +func TestInitTemplateLoadsAndValidates(t *testing.T) { + t.Parallel() + + env, err := devconfig.Load(bytes.NewReader(defaultInitEnvYAML)) + require.NoError(t, err) + + assert.Equal(t, yacdv1alpha1.CardanoNetworkModeLocal, env.Spec.Network.Mode) + require.NotNil(t, env.Spec.Network.Local) + require.NotNil(t, env.Spec.Network.ChainAPI) + require.NotNil(t, env.Spec.Network.ChainAPI.Faucet) + assert.True(t, env.Spec.Network.ChainAPI.Faucet.Enabled) + require.NotNil(t, env.Spec.Network.ChainAPI.Wallet) + assert.True(t, env.Spec.Network.ChainAPI.Wallet.Enabled) +} + +// TestInitCommandPrintsTemplate proves `yacd init` writes the embedded template +// verbatim to stdout (the redirect target for `yacd init > yacd.yaml`). +func TestInitCommandPrintsTemplate(t *testing.T) { + t.Parallel() + + var stdout bytes.Buffer + root := NewRootCommand(Options{ + Out: &stdout, + Viper: viper.New(), + }) + root.SetArgs([]string{"init"}) + + require.NoError(t, root.ExecuteContext(context.Background())) + require.NotEmpty(t, stdout.Bytes()) + assert.Equal(t, defaultInitEnvYAML, stdout.Bytes()) +} diff --git a/cli/internal/cli/root.go b/cli/internal/cli/root.go index 963e2607..1dadda32 100644 --- a/cli/internal/cli/root.go +++ b/cli/internal/cli/root.go @@ -133,6 +133,8 @@ func NewRootCommand(options Options) *cobra.Command { root.AddCommand(ctx.withManagedReconcile(newExecCommand(ctx))) root.AddCommand(ctx.withManagedReconcile(newConnectCommand(ctx))) root.AddCommand(newDevnetCommand(ctx)) + // init only prints an embedded template — no cluster contact, so no reconcile. + root.AddCommand(newInitCommand(ctx)) return root }