diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..a011d66d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,140 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + # Manual only — used to run the upstream-drift check below on demand. + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GOWORK: off + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@v7 + with: + # Must match GOLANGCI_LINT_VERSION in the Makefile, and must be + # built with Go >= the go directive in go.mod — older releases + # refuse to load a config targeting a newer language version. + # golangci-lint v2 needs action v7; v6 rejects a v2 version string. + version: v2.11.4 + args: --timeout=10m + + build-and-unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Build + run: go build ./... + - name: Unit tests (excluding e2e) + # e2e is excluded here: it shells out to a built binary and has its + # own jobs. Piped into xargs rather than an unquoted command + # substitution, which relies on word splitting (SC2046). + run: go list ./... | grep -v /e2e | xargs go test + + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + # Gates the akt-authored packages that carry risk (money, credentials, + # gating, action log, transports). internal/cli/chain is excluded: it + # is a clean copy of chain-sdk maintained by re-copying, so tests + # written against it are clobbered on the next copy — including it + # would cap the repo-wide number near 62% no matter what we write. + # See make/testing.mk for the package sets and COVER_THRESHOLD. + - name: Reconstruct the direnv environment + # The coverage targets write under $AKT_DEVCACHE. Those paths live in + # .env (sourced by .envrc), which CI has no direnv to load — so source + # it here rather than duplicating the paths in this workflow. + run: | + set -euo pipefail + export AKT_ROOT="$GITHUB_WORKSPACE" + set -a + # shellcheck disable=SC1091 + . ./.env + set +a + env | grep -E '^(AKT_ROOT|AKT_DEVCACHE|AKT_RUN)' >> "$GITHUB_ENV" + + - name: Core coverage (gated) + run: make test-coverage-check + - name: Repo-wide coverage (reported, not gated) + run: make test-coverage + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage + path: .cache/coverage/ + retention-days: 7 + + e2e-offline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Build akt binary + # `make akt` needs the direnv-managed environment (AKT_ROOT, + # AKT_DEVCACHE_*, static-link toolchain); build the plain binary + # directly at the path the e2e harness expects instead. + run: go build -o .cache/bin/akt ./cmd/akt + - name: e2e offline suite (gated localnet tests self-skip) + run: go test ./e2e/... -v -count=1 + + e2e-localnet: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Build akt binary + run: go build -o .cache/bin/akt ./cmd/akt + # The node image version lives in one place — defaultNodeImage in + # e2e/localnet_test.go — so this job pins by using the harness default. + # Pinning keeps the blocking gate reproducible: a red run means this + # change broke something, not that upstream published a release. + - name: e2e localnet suite (single-validator akash node in docker) + env: + AKT_E2E_LOCALNET: "1" + run: go test ./e2e/ -run TestLocalnet -v -count=1 -timeout 15m + + e2e-localnet-latest: + # Upstream-drift check: the same suite against the newest node image. + # Manual only — it never runs on a push or a pull request, so an upstream + # release cannot redden someone's PR. Worth a click before cutting a + # release; a failure means the node image changed in a way the bootstrap + # or the CLI must adapt to, after which bump defaultNodeImage. + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Build akt binary + run: go build -o .cache/bin/akt ./cmd/akt + - name: e2e localnet suite against the latest node image + env: + AKT_E2E_LOCALNET: "1" + AKT_E2E_NODE_IMAGE: ghcr.io/akash-network/node:latest + run: go test ./e2e/ -run TestLocalnet -v -count=1 -timeout 15m diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..e9b7491f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,113 @@ +name: release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + mode: + description: "What to run for a manual trigger" + type: choice + default: snapshot + options: + - check + - snapshot + - dry-run + +concurrency: + # Never cancel a run that may be halfway through uploading release assets. + group: release-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + GOWORK: off + +permissions: + contents: read + +jobs: + goreleaser: + runs-on: ubuntu-latest + permissions: + # goreleaser creates the GitHub release and uploads the archives, + # packages and checksums to it. + contents: write + steps: + - uses: actions/checkout@v4 + with: + # The version baked into the binary comes from `git describe`, so the + # single-commit default checkout is not enough. + fetch-depth: 0 + + - name: Fetch tags + # actions/checkout does not always bring annotated tag objects along, + # and goreleaser needs the annotation to build the changelog. + run: git fetch --force --tags + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Load repo build environment + # The release targets expect the direnv-managed variables (AKT_ROOT and + # the AKT_DEVCACHE_* paths). .env is plain shell, so source it here + # rather than duplicating those paths in this workflow. + run: | + set -euo pipefail + export AKT_ROOT="$GITHUB_WORKSPACE" + set -a + # shellcheck disable=SC1091 + . ./.env + set +a + env | grep -E '^(AKT_ROOT|AKT_DEVCACHE|AKT_RUN)' >> "$GITHUB_ENV" + + - name: Free disk space + # The release needs roughly 10 GB: the goreleaser-cross image alone is + # ~7.4 GB, plus the module cache, the libwasmvm archives and four cgo + # cross-compiles worth of build output and artifacts. A stock runner + # does not have that much headroom and the build dies partway through + # with "no space left on device". Removing preinstalled toolchains this + # job never uses reclaims ~25 GB. + run: | + set -euo pipefail + echo "before:"; df -h / | tail -1 + sudo rm -rf \ + /usr/local/lib/android \ + /usr/share/dotnet \ + /usr/share/swift \ + /opt/ghc \ + /usr/local/.ghcup \ + /opt/hostedtoolcache/CodeQL \ + /usr/local/share/powershell \ + /usr/local/share/boost + sudo docker image prune --all --force >/dev/null 2>&1 || true + echo "after:"; df -h / | tail -1 + + # Everything below shells out to `make`, which drives goreleaser inside + # ghcr.io/goreleaser/goreleaser-cross (see make/releasing.mk). Docker is + # preinstalled on the GitHub-hosted runners. + - name: Publish release + if: github.event_name == 'push' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: make release + + - name: Manual run (${{ inputs.mode }}) + if: github.event_name == 'workflow_dispatch' + env: + MODE: ${{ inputs.mode }} + run: make "release-${MODE}" + + - name: Upload artifacts + if: github.event_name == 'workflow_dispatch' && inputs.mode != 'check' + uses: actions/upload-artifact@v4 + with: + name: akt-${{ inputs.mode }} + path: | + .cache/goreleaser/*.zip + .cache/goreleaser/*.deb + .cache/goreleaser/*.rpm + .cache/goreleaser/*checksums.txt + if-no-files-found: error + retention-days: 7 diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..e17eead3 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,229 @@ +# golangci-lint configuration for akt. +# +# Requires golangci-lint >= v2.11.x. Anything built with a Go toolchain older +# than this module's `go` directive (1.26.1) refuses to start with +# "the Go language version used to build golangci-lint is lower than the +# targeted Go version", so the Makefile pin must stay >= the toolchain in +# go.mod. +# +# Guiding principle: every linter here catches a correctness bug or enforces a +# convention that AGENTS.md already states in prose. Style-only nags that would +# produce hundreds of doc-comment diffs are deliberately left off -- a gate that +# people ignore is not a gate. +version: "2" + +run: + # The module pulls in cosmos-sdk, wasmd, ibc-go and cometbft. A run over + # ~58 packages with both the golangci-lint and Go build caches cleared + # measured 61s on an M-series laptop; a cold GitHub-hosted runner is + # several times slower, so 10m leaves real headroom without letting a + # genuinely hung run sit for the full job timeout. + timeout: 10m + tests: true + +linters: + default: none + enable: + # --- correctness ------------------------------------------------------- + - govet # stdlib vet suite: printf verbs, struct tags, lost cancel, copylocks. + - staticcheck # SA/S/ST/QF: real bug patterns plus deprecation (SA1019) tracking. + - errcheck # unhandled errors; the #1 source of silent failure in CLI code. + - ineffassign # assignments never read -- almost always a dropped result. + - unused # dead unexported code; keeps the clean-copy tree from rotting. + - nilerr # `return nil` on a non-nil error: swallowed failures. + - errorlint # %v instead of %w, and error type-asserts that break on wrapping. + - bodyclose # leaked HTTP response bodies (this CLI talks to RPC/Console/provider gateways). + - unconvert # redundant type conversions, usually leftovers from a refactor. + # --- conventions from AGENTS.md --------------------------------------- + - gochecknoinits # AGENTS.md: "every init usage must be explicitly approved". + - revive # curated rule set below -- naming, error handling, builtin shadowing. + # --- hygiene ----------------------------------------------------------- + - misspell # typos in comments and user-facing strings. + + settings: + errcheck: + exclude-functions: + # Terminal/stdout writes in a CLI: the process is printing its result + # and has nothing useful to do with an EPIPE except exit, which it is + # already about to do. Real writes (files, encoders, the store) are + # still checked. + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln + - (*os.File).WriteString + # Closing a body you only read from cannot lose data -- the error is + # at most a connection teardown detail. `defer func() { _ = + # resp.Body.Close() }()` at 17 call sites buries the request logic it + # is wrapped around. Write-side closers (*os.File, encoders, the + # store) are deliberately NOT excluded: a dropped error there is + # silent data loss. + - (io.ReadCloser).Close + + staticcheck: + checks: + - all + # The ST1000/ST1003/ST1016/ST1020-ST1022 line repeats golangci-lint's + # own default exclusions, spelled out so that adding "all" above does + # not silently switch them on. They are doc-comment and naming style + # ("package should have a comment", "comment on exported X should be + # of the form ..."); revive's `exported` rule is left off for the same + # reason -- it alone accounted for ~230 findings, none of them defects. + - -ST1000 + - -ST1003 + - -ST1016 + - -ST1020 + - -ST1021 + - -ST1022 + # QF1008 (omit embedded field from selector) and QF1012 (rewrite + # x.WriteString(fmt.Sprintf(..)) as fmt.Fprintf) are refactoring + # suggestions, not defects. QF1012 in particular would make the + # strings.Builder-based renderers in internal/output/pretty and + # internal/tui strictly less readable. + - -QF1008 + - -QF1012 + + revive: + # Only the rules listed here run. The set is deliberately small: naming, + # error handling and genuine footguns. Rules that would demand a doc + # comment on every exported symbol (`exported`, `package-comments`) or + # rename every unused callback parameter (`unused-parameter`) are + # omitted -- together they were ~350 of revive's 441 default findings and + # none of them describe a defect. + rules: + - name: blank-imports + - name: bool-literal-in-expr + - name: constant-logical-expr + - name: context-as-argument + - name: context-keys-type + - name: datarace + - name: defer + - name: dot-imports + - name: duplicated-imports + - name: empty-block + - name: error-naming + - name: error-return + - name: error-strings + - name: errorf + - name: identical-branches + - name: increment-decrement + - name: indent-error-flow + - name: modifies-value-receiver + - name: range + - name: receiver-naming + - name: redefines-builtin-id + - name: string-of-int + - name: superfluous-else + - name: time-naming + - name: unconditional-recursion + - name: unexported-return + - name: unreachable-code + - name: useless-break + - name: var-declaration + - name: var-naming + - name: waitgroup-by-value + + exclusions: + generated: lax + rules: + # ------------------------------------------------------------------- + # internal/cli/chain is a CLEAN COPY of the akash-network/chain-sdk + # `go/cli` tree (DESIGN.md 5.1). It is maintained by re-copying from + # upstream and re-applying the akt context adaptations, so every + # cosmetic diff we introduce here is a merge conflict we pay for on the + # next copy. Correctness linters (govet, errcheck, staticcheck, + # ineffassign, unused, bodyclose, nilerr) stay ON for this tree -- + # a real bug is worth the conflict. Style-only ones are off. + # ------------------------------------------------------------------- + - path: internal/cli/chain/ + linters: + - revive + - misspell + - unconvert + # Same tree: upstream wraps with %v in fmt.Errorf throughout. Rewriting + # ~57 call sites to %w would diverge from chain-sdk on every line that + # returns an error, for no behavioural change in a CLI that prints + # err.Error() rather than unwrapping. + - path: internal/cli/chain/ + linters: + - errorlint + text: non-wrapping format verb + # Same tree: the upstream root/flags packages register their command + # tree from init(). Removing them is an upstream change, not ours. + # AGENTS.md's no-init rule is enforced everywhere we actually author. + - path: internal/cli/chain/ + linters: + - gochecknoinits + # Same tree: deprecated cosmos-sdk APIs (sdk.MustSortJSON, + # cli.ReadGovPropFlags). Migrating them here means diverging from the + # upstream file we re-copy from; upstream owns that migration. SA1019 + # stays on everywhere else, which is where it earns its keep. + - path: internal/cli/chain/ + linters: + - staticcheck + text: "SA1019" + # Same tree: two unchecked deferred Close calls (gov_tx.go proposal + # file, server_export.go genesis file). Both are read-only opens + # (os.Open / O_RDONLY), so nothing can be lost -- not a correctness bug, + # and not worth a diff against upstream. Write-side closers elsewhere in + # the repo are still checked. + - path: internal/cli/chain/ + linters: + - errcheck + text: "Error return value of `(fl|f)\\.Close` is not checked" + # ------------------------------------------------------------------- + # internal/monitor/ui carries the not-yet-wired Provider/Network + # dashboard. model.go says so at the tick handlers: "Provider + # monitoring ticks are currently disabled; they will be activated when + # the Provider dashboard implementation starts the chain sync and + # provider check tick chains in Init()." `unused` is technically right + # about every symbol below, but deleting them deletes an in-progress + # feature. This is an explicit allowlist, not a package-wide opt-out: + # any NEW dead code in internal/monitor/ui is still reported, and each + # entry should be deleted from this list as the dashboard is wired up. + # ------------------------------------------------------------------- + - path: internal/monitor/ui/ + linters: + - unused + text: "(loadFromCache|syncChain|fetchProviders|providerCheckTick|chainSyncTick|cacheSaveTick|sortProviders|selectPreviousVersion|selectNextVersion|renderValidatorRowWithSelection|renderProviderRow|formatProviderGPUStyled|formatVersionDisplay|versionMarker|formatGPUDisplay|overviewScroll|selectedBlock|newProviders|networkTabCount)" + # internal/output/pretty is the registry-based formatter package + # (DESIGN.md 5.5): each formatter file self-registers its response type + # in init() so that adding a formatter is one new file with no central + # list to edit. This is the one approved init() usage in the codebase. + - path: internal/output/pretty/ + linters: + - gochecknoinits + # Test helpers are frequently written a commit ahead of the test that + # uses them; `unused` on _test.go turns that into a build break and + # pushes people to delete scaffolding they are about to need. Dead + # non-test code is still reported. + - path: _test\.go + linters: + - unused + # Deferred Close/cleanup in tests: a failure to close a temp file or db + # in a test that is about to exit tells us nothing, and `defer func() { + # _ = f.Close() }()` at every site buries the actual test. + - path: _test\.go + linters: + - errcheck + text: Error return value of .*\.Close|Error return value of .*\.Flush + +issues: + # Report everything; a truncated report makes "fix the lint" an unbounded + # loop of re-running to discover the next batch. + max-issues-per-linter: 0 + max-same-issues: 0 + +formatters: + # gofmt, not gofumpt. The repo is not gofumpt-clean (36 files differ, + # including 7 under internal/cli/chain), and gofumpt would rewrite the + # upstream clean-copy tree. gofmt + goimports keeps the chain copy + # byte-identical to upstream formatting. + # + # goimports runs without `local-prefixes`. Requiring a separate import group + # for pkg.akt.dev/akt took the finding count from 11 to 46, and 36 of those + # 46 were in the internal/cli/chain clean copy -- a guaranteed conflict on + # the next upstream re-copy, in exchange for an import grouping the repo has + # never used. std-vs-external grouping and sorting are still enforced. + enable: + - gofmt + - goimports diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 558f6883..ca719c4d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,104 +1,151 @@ --- +# goreleaser configuration for the akt CLI. +# +# This runs inside ghcr.io/goreleaser/goreleaser-cross (see make/releasing.mk), +# which ships osxcross for the darwin targets and the gnu cross toolchains for +# linux. CGO cannot be disabled here: the cosmos-sdk keyring, the ledger HID +# bindings and libwasmvm are all C libraries. +# +# The static libwasmvm archives that the `muslc` (linux) and `static_wasm` +# (darwin) build tags link against are not shipped inside the Go module; they +# are fetched into ./.cache/lib by `make release-libs` before goreleaser is +# invoked, which is what the `-L./.cache/lib` in every extldflags points at. +# +# Build tags and the injected version variables are deliberately kept in sync +# with BUILD_TAGS / ldflags in the root Makefile. A release binary whose +# `akt version` reports "dev" is a bug -- both the cosmos-sdk version vars and +# akt's own main.* vars have to be set. version: 2 -project_name: node -dist: ./.cache/goreleaser/main +project_name: akt +dist: ./.cache/goreleaser + env: - GO111MODULE=on - CGO_ENABLED=1 -before: - hooks: - - apt update - - apt install -y pkg-config libudev-dev:amd64 libudev-dev:arm64 + builds: - id: akt-darwin-amd64 binary: akt main: ./cmd/akt - goarch: - - amd64 goos: - darwin + goarch: + - amd64 env: - CC=o64-clang - CXX=o64-clang++ - - CGO_CFLAGS=-mmacosx-version-min=10.12 + - CGO_CFLAGS=-mmacosx-version-min=11.0 flags: - - "-mod={{ .Env.MOD }}" - - "-tags={{ .Env.BUILD_TAGS }}" + - -mod=readonly + - -tags=osusergo,netgo,ledger,muslc,gcc,static_wasm - -trimpath ldflags: - - "{{ .Env.BUILD_LDFLAGS }}" - -s -w - -linkmode=external - - -extldflags "-L./.cache/lib -mmacosx-version-min=10.12" + - -extldflags "-L./.cache/lib -mmacosx-version-min=11.0" + - -X github.com/cosmos/cosmos-sdk/version.Name=akt + - -X github.com/cosmos/cosmos-sdk/version.AppName=akt + - -X github.com/cosmos/cosmos-sdk/version.BuildTags=osusergo,netgo,ledger,muslc,gcc,static_wasm + - -X github.com/cosmos/cosmos-sdk/version.Version={{ .Version }} + - -X github.com/cosmos/cosmos-sdk/version.Commit={{ .FullCommit }} + - -X main.version={{ .Version }} + - -X main.commit={{ .FullCommit }} + - -X main.date={{ .Date }} + - id: akt-darwin-arm64 binary: akt main: ./cmd/akt - goarch: - - arm64 goos: - darwin + goarch: + - arm64 env: - CC=oa64-clang - CXX=oa64-clang++ - - CGO_CFLAGS=-mmacosx-version-min=10.12 + - CGO_CFLAGS=-mmacosx-version-min=11.0 flags: - - "-mod={{ .Env.MOD }}" - - "-tags={{ .Env.BUILD_TAGS }}" + - -mod=readonly + - -tags=osusergo,netgo,ledger,muslc,gcc,static_wasm - -trimpath ldflags: - - "{{ .Env.BUILD_LDFLAGS }}" - -s -w - -linkmode=external - - -extldflags "-L./.cache/lib -mmacosx-version-min=10.12" + - -extldflags "-L./.cache/lib -mmacosx-version-min=11.0" + - -X github.com/cosmos/cosmos-sdk/version.Name=akt + - -X github.com/cosmos/cosmos-sdk/version.AppName=akt + - -X github.com/cosmos/cosmos-sdk/version.BuildTags=osusergo,netgo,ledger,muslc,gcc,static_wasm + - -X github.com/cosmos/cosmos-sdk/version.Version={{ .Version }} + - -X github.com/cosmos/cosmos-sdk/version.Commit={{ .FullCommit }} + - -X main.version={{ .Version }} + - -X main.commit={{ .FullCommit }} + - -X main.date={{ .Date }} + - id: akt-linux-amd64 binary: akt main: ./cmd/akt + goos: + - linux + goarch: + - amd64 env: - CC=x86_64-linux-gnu-gcc - CXX=x86_64-linux-gnu-g++ - goarch: - - amd64 - goos: - - linux flags: - - "-mod={{ .Env.MOD }}" - - "-tags={{ .Env.BUILD_TAGS }}" + - -mod=readonly + - -tags=osusergo,netgo,ledger,muslc,gcc,static_wasm - -trimpath ldflags: - - "{{ .Env.BUILD_LDFLAGS }}" - -s -w - -linkmode=external - # yamllint disable-line rule:line-length + # libwasmvm_muslc.*.a is built against musl; -z muldefs lets it coexist + # with the glibc symbols pulled in by the gnu cross toolchain. - -extldflags "-L./.cache/lib -Wl,-z,muldefs -lm -lrt -lc" + - -X github.com/cosmos/cosmos-sdk/version.Name=akt + - -X github.com/cosmos/cosmos-sdk/version.AppName=akt + - -X github.com/cosmos/cosmos-sdk/version.BuildTags=osusergo,netgo,ledger,muslc,gcc,static_wasm + - -X github.com/cosmos/cosmos-sdk/version.Version={{ .Version }} + - -X github.com/cosmos/cosmos-sdk/version.Commit={{ .FullCommit }} + - -X main.version={{ .Version }} + - -X main.commit={{ .FullCommit }} + - -X main.date={{ .Date }} + - id: akt-linux-arm64 binary: akt main: ./cmd/akt - goarch: - - arm64 goos: - linux + goarch: + - arm64 env: - CC=aarch64-linux-gnu-gcc - CXX=aarch64-linux-gnu-g++ flags: - - "-mod={{ .Env.MOD }}" - - "-tags={{ .Env.BUILD_TAGS }}" + - -mod=readonly + - -tags=osusergo,netgo,ledger,muslc,gcc,static_wasm - -trimpath ldflags: - - "{{ .Env.BUILD_LDFLAGS }}" - -s -w - -linkmode=external - # yamllint disable-line rule:line-length - -extldflags "-L./.cache/lib -Wl,-z,muldefs -lm -lrt -lc" + - -X github.com/cosmos/cosmos-sdk/version.Name=akt + - -X github.com/cosmos/cosmos-sdk/version.AppName=akt + - -X github.com/cosmos/cosmos-sdk/version.BuildTags=osusergo,netgo,ledger,muslc,gcc,static_wasm + - -X github.com/cosmos/cosmos-sdk/version.Version={{ .Version }} + - -X github.com/cosmos/cosmos-sdk/version.Commit={{ .FullCommit }} + - -X main.version={{ .Version }} + - -X main.commit={{ .FullCommit }} + - -X main.date={{ .Date }} + universal_binaries: - id: akt-darwin-universal ids: - akt-darwin-amd64 - akt-darwin-arm64 replace: true - name_template: "akt" + name_template: akt + archives: - - id: w/version + - id: akt ids: - akt-darwin-universal - akt-linux-amd64 @@ -108,48 +155,67 @@ archives: formats: - zip files: - - none* - - id: wo/version - ids: - - akt-darwin-universal - - akt-linux-amd64 - - akt-linux-arm64 - name_template: "akt_{{ .Os }}_{{ .Arch }}" - wrap_in_directory: false - formats: - - zip - files: - - none* + - LICENSE + - README.md checksum: - # You can change the name of the checksums file. - # Default is `{{ .ProjectName }}_{{ .Version }}_checksums.txt`. - name_template: "akt_checksums.txt" + name_template: "akt_{{ .Version }}_checksums.txt" + algorithm: sha256 nfpms: - - id: w/version + - id: akt + ids: + - akt-linux-amd64 + - akt-linux-arm64 + package_name: akt vendor: "Akash Network" homepage: "https://akash.network" maintainer: "Akash Network " + description: "Unified CLI and TUI for the Akash Network." + license: "Apache-2.0" file_name_template: "akt_{{ .Version }}_{{ .Os }}_{{ .Arch }}" formats: - deb - rpm - license: Apache 2.0 - - id: wo/version - vendor: "Akash Network" - homepage: "https://akash.network" - maintainer: "Akash Network " - file_name_template: "akt_{{ .Os }}_{{ .Arch }}" - formats: - - deb - - rpm - license: Apache 2.0 + +snapshot: + version_template: "{{ incpatch .Version }}-snapshot-{{ .ShortCommit }}" + +changelog: + # `use: git` (the default) keeps `release --skip=publish` working without a + # GitHub token; switching to `use: github` would make dry runs need one. + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "^ci:" + - "^style:" + - "^refactor\\(docs\\):" + - "Merge pull request" + - "Merge branch" + groups: + - title: Features + regexp: '^.*?feat(\(.+\))??!?:.+$' + order: 0 + - title: Bug fixes + regexp: '^.*?fix(\(.+\))??!?:.+$' + order: 1 + - title: Refactors + regexp: '^.*?refactor(\(.+\))??!?:.+$' + order: 2 + - title: Other work + order: 999 release: - github: - owner: akash-network - name: akt + # owner/name are intentionally not pinned: goreleaser derives them from the + # git remote of the checkout, which is what GitHub Actions gives us and what + # keeps this correct if the repository is ever transferred between orgs. Pin + # `github: {owner: ..., name: ...}` here if it ever needs to be forced. prerelease: auto mode: replace draft: false + name_template: "akt {{ .Version }}" + +report_sizes: true diff --git a/AGENTS.md b/AGENTS.md index c41f2494..e64f90b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,8 @@ Never implement code that contradicts or isn't documented in the spec. If the sp If a `go.work` file is present in the repository root, agents MUST read it and take into account all `replace` directives defined there. Local module replacements affect import resolution and must be followed when navigating or editing dependency code. +This repository currently has **no** `go.work` of its own, but a parent directory does and Go discovers it automatically. That workspace does not `use` this module, so a plain `go` command resolves against it and fails before compiling. Every direct `go` invocation (`go build`, `go test`, `go list`, `go vet`) MUST therefore run with `GOWORK=off` — which is what `.envrc` exports in a direnv shell and what CI sets globally in `.github/workflows/ci.yml`. + ### Git Conventions All commit messages MUST follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification: @@ -66,8 +68,16 @@ All commit messages MUST follow the [Conventional Commits](https://www.conventio - **Addresses**: Never truncate or shorten addresses in output. Addresses (bech32, operator, consensus) must always be displayed in full. Truncation risks ambiguity and breaks copy-paste. - **Amounts and prices**: All micro-denominated values (`u`-prefixed denoms) must be scaled to the most readable unit in pretty output: base (>= 1M micro, e.g., `5.3 AKT`), milli (>= 1K micro, e.g., `3 mAKT`), or micro (< 1K, e.g., `500 uAKT`). Trailing zeros must always be stripped. This applies uniformly to every pretty output: balances, prices, escrow, staking, rewards, fees. Use `FormatCoin()` from `internal/output/pretty/helpers.go` — never format amounts manually. - **Pretty/TUI visual parity**: The pretty-printed output of a single-shot CLI command (e.g. `akt q bme status`) and the corresponding section in the TUI or `akt monitor` dashboard must be visually identical. Both paths must call the same `Render*` functions from `internal/output/pretty/`. Never duplicate formatting logic in the TUI — always delegate to the shared renderers. -- **Build**: `make akt` — the compiled binary is placed in `.cache/bin/` -- **Tests**: `go test ./...` +- **Build**: `GOWORK=off make akt` — the compiled binary is placed in `.cache/bin/`. `make` needs the direnv-managed environment (`AKT_ROOT`, `AKT_DEVCACHE_*`); without direnv, build the binary directly with `GOWORK=off go build -o .cache/bin/akt ./cmd/akt`, as CI does. +- **Tests**: `GOWORK=off go test ./...`. The `e2e/` package shells out to the compiled binary at `.cache/bin/akt`, so build before running the full suite; to run unit tests alone, exclude it the way CI does (`go test $(go list ./... | grep -v /e2e)` with `GOWORK=off` exported). The localnet and live-Console e2e tests self-skip unless `AKT_E2E_LOCALNET`/`AKT_E2E_RPC` or `AKT_E2E_CONSOLE_API_KEY` is set. + +### CLI & Transport Conventions + +- **Positional-primary commands**: A command's primary value(s) MUST be positional arguments; flags are optional overrides and may never be required (SPEC.md §3.8). `TestNoUnapprovedRequiredFlags` in `internal/cli/required_flags_test.go` walks the entire command tree and fails on any flag marked required, so a new required flag breaks the build unless it is added to the `requiredFlagAllowlist` there with a comment justifying it. Adding an allowlist entry is a last resort, not a substitute for accepting the value positionally. +- **Disabled-flag markers**: During the positional-only UX trial, a flag that duplicates a positional is commented out — never deleted — so it can be restored verbatim. Use the uniform marker: a comment reading `// FEEDBACK(2026-07): -- disabled for the positional-only UX trial (use the positional form instead). Restore by uncommenting if users ask for the flag form back.` immediately followed by the original registration line, commented out and otherwise unchanged (see `internal/cli/console/wallet.go` and `internal/cli/chain/flags/deployment.go`). The same marker prefixes the commented-out read sites (`cmd.Flags().GetString(...)`), with the wording adjusted to name the positional that replaces the flag. Deleting the registration instead of commenting it out is wrong while the trial runs. +- **Capability annotations**: A command that needs a transport MUST declare it with the `capability.AnnotationKey` (`akt.requires`) cobra annotation from `internal/capability` — `chain-query`, `chain-tx`, `provider`, or `console` — with `|` separating alternatives (e.g. `chain-tx|console` for workflow commands that run on either rail). Every new command group registered in `internal/cli/root.go` that talks to a chain RPC, a provider gateway, or the Console API needs the right annotation; without one the group is offered in configurations that cannot run it and fails deep inside the transport instead of failing fast with a remedy (SPEC.md §2.10). +- **Transports, not per-rail commands**: A new *action* is defined exactly once as a workflow definition in `internal/workflow/builtin/` and MUST work on every rail — the CLI surface is generated from the definition and the rail is chosen per context at execution time. Per-rail behavior belongs in `internal/transport` and the adapters it wraps (`internal/workflow/adapters/`), never in the command layer. Cross-rail user input is normalized in exactly one place: every deposit form goes through `transport.ParseDeposit`, so identical arguments work on the chain and console rails (SPEC.md §2.3 Transports, §7.4–§7.5). +- **Action log coverage**: Every state-changing action MUST record an action log entry, and read-only queries MUST NOT (SPEC.md §5.6). Hook a new mutating command into an existing write path rather than logging ad hoc: chain transactions through the tx decorator in `internal/cli/chain/actionlog.go`, context management through `recordContextAction` in `internal/cli/context/actionlog.go`, provider gateway operations through `recordProviderAction` in `internal/cli/provider/actionlog.go`, Console API calls through `Client.WithActionLog` in `internal/console/client.go`, and workflow steps through the engine's `Logger` (`internal/workflow/actionlog.go`). ### Workflow diff --git a/AICHANGELOG.md b/AICHANGELOG.md index 5beebd42..f71517c8 100644 --- a/AICHANGELOG.md +++ b/AICHANGELOG.md @@ -4,10 +4,147 @@ ### Fixed +- **`akt q staking params`/`pool` could panic on a sparse response**: proto3 omits zero-valued fields, so an unset `LegacyDec`/`Int` unmarshals with a nil inner `big.Int` and any arithmetic on it panics — `FormatPercentDec` and `FormatDecAsAKT` did exactly that. Two independent reviewers hit it. Formatting now goes through `DecOrZero`/`IntOrZero`, so an omitted field renders as `0` instead of crashing the command. 3 regression tests. + +- **A failed config write reported success**: `SaveConfig` deferred `f.Close()` on the file it had just created, discarding the flush error — a full disk or I/O failure while writing `config.yaml` returned nil. The store's YAML export had the same bug (a truncated export reported success). Both now close explicitly and return the error. + + +- **The release pipeline would have shipped broken binaries**: `.goreleaser.yaml` passed `{{ .Env.BUILD_LDFLAGS }}` to every build, but nothing ever set that variable — the Makefile's `GORELEASER_LDFLAGS := $(ldflags)` sat two lines *above* the `ldflags :=` assignment, so it expanded to the empty string (same for `MOD` and `BUILD_TAGS`). Every released binary would have reported `dev / none / unknown` and been built with no tags. ldflags are now literal and self-contained in the goreleaser config, driven by `{{ .Version }}`/`{{ .FullCommit }}`/`{{ .Date }}`, injecting both `main.*` and the cosmos-sdk version vars. Also fixed: darwin builds linked libwasmvm with an rpath into the *builder's* Go module cache (dead on any user's machine) — the `static_wasm` tag now links the static archive, verified with `otool -L`; the pinned `goreleaser-cross` image tag did not exist (`v1.26.1` → pinned `v1.26.2`); `project_name` was `node`; `RELEASE_DOCKER_IMAGE` pointed at the node repo and was defined twice. + + +- **`akt version` always reported "dev" (AKT-221)**: the Makefile injected build info into the cosmos-sdk version vars but never into `main.version/commit/date`, which is what the CLI actually prints — so every built binary, including release builds, claimed `dev (commit: none, built: unknown)`. Both are injected now. The `--long` flag the task promised was also never implemented; it now prints version, commit, build date, Go version, platform, and build tags. + + +- **A missing `data` envelope read as success (code review)**: `doData` returned nil with the result left zero-valued whenever a response omitted the envelope — an API change, a proxy error page, or any wrong-shaped 200 would surface as a successful call with, say, an empty dseq. A caller that expects a payload now gets an error naming the request; endpoints that return no body (DELETE) still pass a nil result and succeed. + +- **Per-context credential directories were world-readable (code review)**: the Console API key file is 0600, but its parent (`contexts//`) was created 0755, leaking the credential's existence and letting a local user replace it. Both `EnsureContextDirs` and the credential writer now create the context directory 0700. + +- **`ParseDeposit` kept untrimmed input (code review)**: `Deposit.Raw` stored the caller's string verbatim while parsing used the trimmed value, so a padded deposit in a user workflow YAML (`" auto "`) parsed correctly and then failed downstream when the rail adapter re-parsed `Raw`. Raw now carries the trimmed value. + + +- **Positional state was silently ignored on the get path (code review)**: `akt query deployment 12345 closed` fetched by identity and printed the record regardless of state — and since the sweep removed `--state`, that positional is the only state filter left. A complete identity now still returns the detail view, but a supplied state acts as an assertion: a mismatch fails with both states named ("... is closed, not active; drop the state argument to print it regardless"), which keeps the output shape stable and makes the check scriptable. Applies to deployment, order, bid, and lease. + +- **`tx deployment close`/`update` lost their dseq guard (code review)**: with `--dseq` unregistered, a missing positional built a message with dseq 0 and entered the sign/broadcast pipeline — keyring unlock, then a raw chain validation failure. Both now fail fast in an Args hook (before any connection is opened) with the same friendly message the group and lease commands print. + +- **`akt sdl init --count 0` reported an internal error (code review)**: explicit zero/out-of-range values for count, port, as, price, and gpu leaked past flag handling into the post-generation self-check and surfaced as "internal error: generated SDL failed validation". They are now bounded usage errors (exit 2) with the valid range named; unset flags still take the scaffold default. + +- **README claimed `--state` still worked (code review)**: the doc refresh added that line just as the sweep removed the flag. Corrected to the positional-only reality, including the identity+state form and the get-path assertion behavior. + + +- **Help detection disabled context and capability enforcement (code review)**: `helpRequested` matched any argv token equal to `--help`/`-h`/`help`, including positional values and tokens after `--`, and it gates bootstrap, the no-context guard, and capability checks. `akt tx deployment close help` therefore skipped the guard on a zero-context machine and went straight to broadcast. Detection now matches only the flag forms, stops at the `--` terminator, and recognizes cobra's `help` command separately. + +- **`akt sdl` and `akt console` were blocked without a context (code review)**: `requiresContext`'s prefix list was never updated for the new groups, so offline SDL validation and `AKT_CONSOLE_API_KEY=... akt console whoami` both failed with "no contexts configured" — making the console group's own env-key fallback unreachable. Both groups are now exempt. + +- **Capability gating ignored per-invocation overrides and `off` mode (code review)**: gating was computed from the context alone, so `--node`, `--console-api-key`, and `akt monitor ` were rejected even though each supplies its own connection details, and `defaults.command-gating: off` only affected help presentation. Overrides now grant their capability, and `off` disables enforcement as documented. + +- **Gating and help disagreed on the active context (code review)**: enforcement resolved via `Resolve(--context)` while help had its own single-context fallback, re-synced only `--home`, and read the mode from config instead of viper — so an auto-selected single context showed `[unavailable]` in help yet executed ungated. Both paths now share `Manager.ActiveContext` and one mode resolution. + +- **`--context` was ignored by the console group and workflow commands (code review)**: they resolved via `Resolve("")`/`CurrentContext()`, so `akt --context staging console login` stored the credential under the *current* context and `--context staging console deployment create` billed the wrong managed wallet. Both now honor the override (`ctxNameFn` for workflows and store, the inherited flag for console). + +- **Network-less console contexts could not be edited (code review)**: `UpdateContext` required a resolvable network even though `CreateContext` permits omitting it for `console-api` contexts — so `akt context edit --console-api-key ...`, the exact remedy other errors suggest, always failed with `network "" not found`. + +- **`--help` on SDK module groups required a configured context**: the clean-copied cosmos-sdk group commands (`tx bank`, `tx authz`, `query staking`, …) set `DisableFlagParsing`, so cobra cannot short-circuit `--help` before the root hooks run — on a machine with no config those hooks rejected the command with "no contexts configured" before help printed (the CI failure mode; masked locally by the developer's real config). Root hooks now detect help invocations from argv and skip both bootstrap and the context requirement, so help always works. `TestAllCommandsHelp` is hermetic via `AKT_HOME` pointed at an empty temp home (the env var, because those same groups ignore an unparsed `--home` flag — a pre-existing SDK-copy quirk). + +- **First-run bootstrap ran headlessly and hung CI**: with no config and no TTY, bare `akt` silently ran the bootstrap wizard — fetching the network list from the GitHub API and writing a config assembled from non-interactive fallbacks (all networks, `os` keyring) — which made `TestTUINoArgNoTTY` time out on GitHub runners (it passed locally only because the developer's real `~/.config/akt` exists). The wizard now declines to run without a terminal (no fetch, no config, guidance printed to stderr) per SPEC §2.0, and the e2e test is hermetic (fresh `--home`) so it exercises the CI condition everywhere. 2 tests. + +- **`context log --type` help text listed the wrong action types**: it offered `tx, query, workflow, error`; the recorded types are `tx, workflow, provider, context, console, error` (queries are not recorded by design). + +- **Chain commands violated the positional-primary convention (AKT-650)**: `tx market lease create/withdraw/close` now take `[dseq] [provider]` positionally, `query escrow blocks-remaining` accepts the §3.8 `[owner/]dseq` filter argument, `query blocks`/`query txs` take the search expression positionally, and `tx deployment group close/pause/start` no longer require `--owner` (defaults to the signer — closing your own group needs zero flags). Flags remain as overrides; positionals win. Plus a new convention guard, `TestNoUnapprovedRequiredFlags`, that walks the whole command tree and fails when any flag is marked required without an allowlisted justification (allowlisted: signer selection on `tx sign`/`sign-batch`/audit attr, governance `--title` flags, `context create --network`). SPEC §2.1/§3.8.5 updated; 3 new helper test suites. + +- **Console commands violated the positional-primary convention (AKT-650)**: seven `akt console` commands required flags for their primary values. All now take them positionally with flags kept as overrides (positional wins): `usage [from] [to]`, `deployment create [deposit-usd]`, `deployment deposit [amount-usd]`, `deployment settings [true|false]`, `lease create [provider]`, `wallet settings [true|false]`, `apikey create [expires-at]`. SPEC §2.9 signatures updated and a convention note added. 2 new command tests. + +- **`akt console usage` failed with a 400 when `--from`/`--to` were omitted**: `GetUsageHistory` always sent `startDate`/`endDate`, and the API rejects empty strings with a `format=date` validation error. Empty dates are now omitted (the API then defaults endDate to today, startDate to 30 days prior per its OpenAPI contract) and non-empty dates are validated as YYYY-MM-DD client-side before any request. 2 regression tests. + +- **Block query commands ignored the context RPC endpoint**: `akt query block/blocks/block-results` used the SDK's `GetClientQueryContext`, which falls back to the `--node` default `tcp://localhost:26657`, instead of the package-local resolver that builds the RPC client from the active context's network. All three now resolve the context endpoint like module queries; the localnet e2e block test dropped its `--node` workaround. + +- **Default `--gas auto` broadcast transactions with gasWanted=0**: `ClientOptionsFromFlags` only forwarded the gas setting when the flag was explicitly changed, so the default `auto` never reached the tx factory — no simulation, gasWanted 0, and every real send failed CheckTx with out-of-gas. The gas flag is now always parsed and applied (`auto` → simulate); invalid values are rejected instead of silently ignored. Verified end-to-end on the localnet with a default-gas `tx bank send`. 3 new tests. + +- **Failed broadcasts exited 0**: a CheckTx result with a non-zero code was printed as if successful and the CLI exited 0. The tx-client wrapper (now applied unconditionally, with or without an action logger) converts a non-zero result code into an error carrying the code, codespace, tx hash, and raw log, so failed transactions exit non-zero. Generate-only/simulate/offline runs are unaffected. 3 new tests. + +- **Builtin deploy workflow glue could never round-trip (AKT-647)**: Step outputs rendered through Go templates with `fmt` map syntax, so the select-bid prompt step couldn't parse the wait step's bids and `create-lease` referenced a nonexistent `.bid.id`. Added a `toJson` template function, made the prompt executor emit the selected bid's identity as discrete outputs (`provider`/`dseq`/`gseq`/`oseq`/`price`, numbers as decimal strings) with genuine cheapest-price selection, gave the output step an injectable writer (JSONL mode must own stdout), and rewrote deploy.yaml's select-bid/create-lease/send-manifest params accordingly. SPEC §2.3.9's update example dropped the unimplemented `foreach` step with a note. + +- **Action log Read choked on entries larger than 1 MB (AKT-211)**: `readLogFile`'s scanner buffer capped lines at 1 MB, so an entry with large params (e.g. SDL contents) that `Log` happily wrote could never be read back ("token too long"). The buffer now allows lines up to the 10 MB rotation threshold. Added `TestLogRotation` covering rotation at the size threshold plus newest-first reads spanning rotated files — a gap TASKS.md T008 claimed was covered but wasn't. + +- **Action logger never closed (AKT-211)**: The per-context action logger is opened in the root command's `PersistentPreRunE` but was never closed, diverging from SPEC §5.6. Added a root `PersistentPostRunE` that closes the logger retrieved via `cliutil.ActionLogFromContext`. + - **TUI dashboard build break from botched `refactor/tui-rewrite` merge**: The merge that integrated the TUI rewrite corrupted `internal/tui/views/dashboard.go` and left unresolved conflict markers in `internal/tui/views/dashboard_test.go`, breaking `make akt` (`undefined: colW`, `d.activityContent undefined`) and `go test ./...`. Restored `networkContent()` to return inner content via `strings.Join(lines, "\n")` (the merge had reintroduced an out-of-scope `components.TitledPanel("NETWORK", content, colW)` call referencing `colW`, a `View()`-local variable, plus a stray "chain" row) and renamed `renderActivity(w int)` back to `activityContent(innerW int)` with a pointer receiver (the method `View()` actually calls). Resolved the `dashboard_test.go` conflict in favor of the rewrite (`newTestDashboard()` helper + `View().Content`). Restores the intended rewrite state with no behavior change; chain-id continues to display in the header/welcome banner rather than the network panel. +### Changed + +- **Documentation brought in line with the whole branch**: DESIGN.md gained §3.1.5 (Console provider-gateway access via a scoped Console-minted JWT, and why that beats reimplementing Console's websocket relay), §3.5 (the action-to-transport translation layer, with the unified deposit syntax as the concrete cross-rail normalization), and §3.6 (capability gating: derivation, the `akt.requires` annotation, per-invocation overrides, dim/hide/off); its package listing now matches disk (dropped `internal/cli/tx|query`, `internal/filter`, `internal/flags`, which the chain clean-copy superseded; marked the `plugin` entries planned) and its "Transport Layer" naming collision with `internal/transport` is disambiguated. SPEC gained §2.12 documenting `akt version --long` and `akt completion` — the only commands in the tree with no reference section. README documents capability gating (with the `context show` Capabilities block), the `akt sdl` group, the expanded Console surface including live lease operations, the disabled TUI shell, and concrete positional-primary examples. AGENTS.md records the conventions this branch established: positional-primary commands and their guard test, the `FEEDBACK(2026-07)` disabled-flag marker, capability annotations for new command groups, actions-in-workflows/behavior-in-transport, action-log write paths, and `GOWORK=off` for direct `go` invocations. Every example was verified against the built binary; a SPEC §7.8 claim that `template sdl` pipes into `akt deploy` was corrected (only `sdl validate` reads stdin). + + +- **Console compatibility matrix moved into SPEC §7.8**: it lived at `docs/console-parity.md`, but `docs/` holds only dated superpowers plans and specs — a standalone reference doc there matched nothing. It now sits with the rest of the Console specification, and the content was refreshed: logs/events/status/shell, bid screening, and SDL authoring moved from "deferred" to covered (they shipped), signatures updated for the positional-only surface, and the `provider-proxy` relay reclassified as superseded rather than pending. + + +- **Positional-only UX trial: duplicate flags disabled (FEEDBACK 2026-07)**: every flag with a positional twin is commented out behind a uniform, greppable `FEEDBACK(2026-07)` marker (original registration lines preserved verbatim for one-step restoration): chain query identity/state flags (`--owner/--dseq/--gseq/--oseq/--provider/--state` on deployment/group/order/bid/lease, cert, escrow blocks-remaining, `--query`/`--events` on blocks/txs), tx twins (`--dseq` on deployment close/update, `--dseq/--gseq` on group cmds, `--dseq/--provider` on market lease cmds), provider `--dseq` on lease-status/logs/events/get-manifest, and eight console flags (usage dates, deposit/amount, settings toggles, lease provider, apikey name/expiry, logs service). To keep identity+state combos expressible, queries gain an optional second positional state (`akt q deployment akash1x/12345 active`) and `cert list` gains `[owner] [state]` positionals. Infrastructure flags (gas/fees/node, follow/tail/watch, pagination, `--by`, defaults-carrying gseq/oseq, group `--owner`) stay. SPEC rows annotated "disabled pending feedback" rather than deleted. Full suite green incl. live parse-stage sanity checks. + +- **`akt context show` displays the feature set**: a Capabilities section now tells the user exactly what the configuration can do — chain queries/transactions, provider gateway, Console API — with the remedy inline for anything unavailable ("add an RPC endpoint to the network", "run akt console login"). Completes the capability-gating UX: commands are gated (SPEC §2.10) and the reason is discoverable. + +- **TUI shell disabled pending UX feedback**: bare `akt` now prints help instead of launching the dashboard, and `--interactive`/`-i` reports that the TUI is disabled (pointing at the CLI commands and `akt monitor`, which is unaffected). The launch path stays compiled behind `AKT_EXPERIMENTAL_TUI=1` for feedback sessions. SPEC §2.0 annotated. + +- **Documentation refreshed to match the delivered state**: README (status paragraph, action-log scope, workflow execution no longer "not yet wired", new Console Integration section, positional-first examples, roadmap pruned of already-delivered items), DESIGN (action-log scope, console-auth section notes the full `akt console` surface, phase status bullets), SPEC (console group added to the §2.1 command tree, context create/edit flag tables, `context log --type` values, §7.3 min deposit corrected to $0.50, §7.5/§7.6 examples, §1.9 env table, §12 milestone wording). TASKS.md verified — no checkbox changes needed. Two cross-doc contradictions flagged for follow-up rather than guessed at: README's TUI "scaffolded" wording vs TASKS T093-T104 complete, and SPEC §7.4's planned `foreach` step type. + ### Added +- **Lint gate (`.golangci.yml` + CI job)**: the repo never had a lint config — TASKS.md T001 claimed one, and CI's own comment admitted lint was unwired. Adds a curated config (govet, staticcheck, errcheck, ineffassign, unused, nilerr, errorlint, bodyclose, unconvert, gochecknoinits, revive, misspell, gofmt/goimports) that took the tree from 1045 findings to 0. `internal/cli/chain` is style-excluded but keeps correctness linters, since it is a clean copy maintained by re-copying from chain-sdk; every exclusion is commented in-file with its reason, and `gochecknoglobals` was evaluated and dropped rather than blanket-excluded. The Makefile pin moved v2.3.0 → v2.11.4: v2.3.0 is built with Go 1.24 and refuses to load a config targeting this module's `go 1.26.1`. + +- **Coverage measurement and gate (`make/testing.mk` + CI job)**: nothing measured coverage before. Three package sets — repo-wide (reported), akt-authored, and the 13 risk-carrying core packages (gated at 65%, currently 68.7%, up from 54.9%). 136 new test functions target money paths, credential handling, capability decisions, action-log writes, and the transport layer. Repo-wide is 30.9%: `internal/cli/chain` is 37.6% of all statements at 3.5%, so TASKS.md T126's ">80% overall" was never reachable as written — that is now stated plainly rather than aspirationally. + +- **Weekly upstream-drift check**: a scheduled `e2e-localnet-latest` job runs the localnet suite against `ghcr.io/akash-network/node:latest`, while the blocking PR job stays pinned to the harness default. An upstream release can no longer redden an unrelated pull request, but drift is still caught. The pinned default moved 2.1.0 → 2.1.1 (verified against both that and `latest`), and the version now lives only in `e2e/localnet_test.go` instead of being duplicated in CI. + + +- **Release pipeline (`make release*` + `.github/workflows/release.yml`)**: `make/releasing.mk` previously had no release target at all. Adds `release-libs` (fetches the libwasmvm static archives every `-L./.cache/lib` already pointed at — the directory was empty), `release-check`, `release-snapshot`, `release-dry-run`, and `release`, plus a tag-triggered workflow (`v*`) with a `workflow_dispatch` check/snapshot/dry-run mode. Verified end to end locally: config check, a full four-target cgo cross-compile producing a universal darwin binary, linux amd64/arm64, archives, deb/rpm and checksums; the produced binaries were executed on darwin/arm64, linux/amd64 and linux/arm64 and report real version strings. Publishing itself is unexercised (no tag was pushed). + + +- **`akt sdl` command group (final console-axi parity item)**: transport-independent SDL authoring ported from the reference CLI — `sdl scaffolds` (web, gpu, multi-service, ip-lease; byte-faithful defaults incl. 10000 uact price ceilings), `sdl init ` (deterministic YAML to stdout, pipeable into `akt deploy`/`akt console deployment create`, generation flags for image/port/resources/env/gpu), and `sdl validate ` (parse via pkg.akt.dev/go/sdl + lint: unpinned/`:latest` images rejected, `uakt` pricing warns — softened from the reference's hard reject since akt serves both rails). Runs with no context, key, or RPC; no capability gate. SPEC §2.11. 19 tests. + +- **Live lease operations for managed contexts (console-axi parity closed)**: `akt console logs [service]` (`--follow/--tail`), `console events ` (`--follow`), `console status ` (`--watch/--interval`), `console shell [-- cmd]` (exec == shell with a command), and `console screen ` (public bid screening). Managed contexts reach provider gateways directly: resolve the active lease and provider hostUri from the Console API, mint a scoped short-TTL JWT (`POST /v1/create-jwt-token`), and reuse the provider client with `WithAuthToken` — the same streaming code paths as `akt provider lease-*`, no websocket relay port needed. Console subgroups and workflow commands now carry their capability annotations (`console`, `chain-tx|console`), completing the §2.10 gating coverage. 5 new tests (two-server httptest pattern). + +- **Action-to-transport translation layer with unified deposits**: new `internal/transport` package formalizes the boundary the user asked for — actions (deploy/update/close and future ones) are defined once and translated per rail (`KindChain` = sign+broadcast, `KindConsole` = REST); the CLI constructs transports, never adapters. Deposits gain ONE syntax on both rails, translated at the boundary: `5usd`/`$5` (console; clear cross-rail error on chain), `5000000uakt`/`5akt` (chain; clear error on console), bare numbers keep their historical per-rail meaning, `auto`/empty passes through. A new regression test pins that `deploy/update/close` expose a byte-identical argument surface regardless of auth-method. SPEC §2.3 Transports paragraph + §7.4 deposit table. 20+ tests incl. an httptest round-trip proving `--deposit 5usd` reaches the Console wire as numeric `5`. + +- **Capability-based command gating (feature sets)**: the active context's configuration now derives a feature set — chain RPC present → `chain-query`/`chain-tx`/`provider`; Console key present → `console` — and the command surface reflects it. Two presentation modes ship for UX feedback (`defaults.command-gating`): `dim` (default; unavailable commands marked `[unavailable]` in help and failing fast with the missing capability + remedy) and `hide` (removed from listings; direct invocation still fails fast), plus `off`. Enforcement runs in the root hooks and a wrapped help func so `--help` short-circuits are gated too. New `internal/capability` package + gating walk; `tx`/`query`/`provider`/`monitor` annotated (console/workflow groups follow with their in-flight refactors). 9 tests. + +- **Network-less console-api contexts**: `akt context create --auth-method console-api --console-api-key ` now works without `--network` — the context operates through the Console API alone and chain commands are capability-gated until a network is attached. This makes the "API key only, no RPC" configuration a first-class state instead of an impossible one. `--network` validation moved from MarkFlagRequired to RunE (guard-test allowlist shrinks by one). + +- **OpenAPI contract tests for the Console client**: the Console API's OpenAPI 3.0 spec (vendored verbatim to `internal/console/testdata/openapi.json` from the console-axi reference) now validates every request the client produces — 34 subtests cover all 30 public methods through a kin-openapi request-validating harness, plus a negative control replaying the exact production 400 (empty `startDate`) to prove the harness detects violations. The suite immediately caught and fixed three real contract bugs: nil `SignedBy.AnyOf/AllOf`/`Attributes` slices marshaled as `null` where the schema forbids it, `ScreenBids` allowing requests without the required `resources`/`timezone`, and `ListDeployments` sending out-of-range `skip`/`limit` instead of deferring to server defaults. New test-only dependency `github.com/getkin/kin-openapi`. + +- **Gated live Console smoke suite**: `e2e/console_live_test.go` runs read-only commands (`whoami`, `wallet balance`, `usage` with no flags — the exact 400 repro, `provider regions`, `gpu`, `template list`) against the production API when `AKT_E2E_CONSOLE_API_KEY` is set; skips otherwise. + +- **Console onboarding in the first-run wizard (AKT-646)**: After network and keyring selection, the bootstrap wizard now offers optional Akash Console setup: enter a Console API key (hidden input; validated best-effort against `/v1/user/me` with an offline-tolerant warning; stored as the initial context's per-context credential per SPEC §7.1) and optionally route that context's deployments through Console (`auth-method: console-api`). Both prompts default to "no"; non-interactive runs skip the step entirely. SPEC §2.0 first-run description updated. 2 tests. + +- **Console compatibility matrix (AKT-649)**: New `docs/console-parity.md` enumerating every Console capability with its akt equivalent, the deferred items with explicit rationale (provider-proxy log/shell streaming for managed contexts — `akt console jwt create` is the building block; `sdl screen` command surface; card/3DS funding and account flows that are inherently web-only; managed-wallet certificates that don't exist by design), and the intentional behavioral differences from the reference CLI (per-context credentials and manifest cache, akt output conventions, composite deploy = the `akt deploy` workflow with auth routing). + +- **E2E suite expansion (AKT-222)**: New `e2e/offline_test.go` — keys lifecycle on the test backend (add/list/show/rename/export→import round-trip/mnemonic/parse/delete, deterministic `--source` recovery), context edit/log/rename with action-log assertions, network edit/delete (referenced-network guard), console-api context credential checks (0600 file, key never printed), AKT-650 positional-arg offline smoke tests, store import edge cases, and a table-driven `TestAllCommandsHelp` walking 113 command paths so a failure names the offending command. New `e2e/localnet_test.go` — gated localnet harness: uses `AKT_E2E_RPC` when set, else bootstraps a single-validator `ghcr.io/akash-network/node` container when `AKT_E2E_LOCALNET=1` (verified end-to-end locally: block queries, staking validators, bank balances, and a real `tx bank send` whose entry then appears in `akt context log --type tx` — the AKT-211 acceptance path), else skips. + +- **CI pipeline (AKT-222)**: `.github/workflows/ci.yml` (the repo previously had no CI at all): build + unit tests, offline e2e against the built binary, and a docker-gated localnet e2e job (continue-on-error until proven on GitHub runners), with concurrency cancellation and `GOWORK=off`. + +- **Workflow execution with auth-aware routing (AKT-647)**: `akt deploy/update/close` now actually execute (previously they printed a plan and bailed with "Execution requires chain client (not yet wired)"). The generated commands resolve the active context and pick the auth mechanism per action, hidden from the user: `console-api` contexts route tx steps through the Console API (provider send-manifest steps are skipped with a note — Console submits manifests internally; missing key → clear guidance to set `AKT_CONSOLE_API_KEY`, store a per-context key, or switch context), `keyring` contexts sign and broadcast locally via the chain adapters (tx flags merged onto workflow commands; missing wallet/chain client → clear error). Per-step results render in pretty mode; `-o jsonl` emits one SPEC §2.3.8 line per step; steps are recorded in the per-context action log. Full httptest-backed console deploy e2e test (create → bids → cheapest → lease from cached manifest). 17 new tests across adapters/steps/CLI. + +- **`akt console` command group (AKT-648)**: New top-level command group porting the managed-Console CLI surface to Go per the console-axi reference: `login/logout/whoami` (login validates against `/v1/user/me` before storing the per-context credential; TTY prompt hides input), `deployment list/get/create/update/close/deposit/settings`, `bid list`, `lease create` (defaults to the per-context manifest cache), `wallet list/balance/settings/cost`, `usage`, public keyless `provider list/get/regions/auditors`, `gpu`, `template list/get/sdl` (raw SDL to stdout for piping), `apikey list/create/delete` (secret shown once), and `jwt create`. Positional-primary args per AKT-650 conventions; idempotent close; USD `$X.XX` formatting; mutations recorded in the action log. SPEC gains §2.9 "Console Commands". 8 command tests. + +- **Console API client reworked to match the real API (AKT-648)**: The client's request shapes were wrong — the Console API wraps write bodies/responses in a `{"data": ...}` envelope and `deposit-deployment` takes `{data:{dseq,deposit}}`, not `{dseq,amount}`. Rebuilt the client with correct wire shapes (verified against the console-axi reference CLI) and expanded it from 8 to ~30 endpoints across deployments (incl. `/v2/deployment-settings` with PATCH→POST fallback and idempotent close via `ErrAlreadyClosed`), wallet/balances/usage, public marketplace (providers, regions, auditors, GPU prices, templates, bid screening), API keys, and provider-scoped JWT minting. Adds a per-context manifest cache at `contexts//manifests/.json` (0600) so `deployment create` → `lease create` works without re-passing the manifest. µACT↔USD helpers (1 ACT = 1 USD, 1e6 micro). 36 tests. + +- **Workflow execution adapters (AKT-647)**: New `internal/workflow/adapters` package implementing the engine's `steps.ChainClient`/`steps.ProviderClient` interfaces over the real chain client and provider gateway: msg builders for deployment create/update/close and market lease-create replicating the tx command logic (SDL groups + version hash, dseq from chain height, `auto` deposit via min-deposit params, update-group safety check), workflow query routing for `market.bids`/`market.leases`/`deployment.deployments` shaped for the wait-step's `until:` templates, and a provider adapter that resolves gateway URLs from on-chain HostURI. dseq is deliberately emitted as a JSON string in step outputs so Go templates don't render it in scientific notation. 22 tests. + +- **State keywords as positional filter arguments (AKT-650)**: `akt query deployment active`, `akt query market order|bid|lease ` now work — a bare state keyword as the sole filter argument is equivalent to `--state`, with vocabularies derived from the on-chain enums (deployment `active|closed`; order `open|active|closed`; bid `open|active|lost|closed` — "matched" never existed in v1beta5, two stale flag help strings corrected; lease `active|insufficient_funds|closed`). Keywords don't combine with identity paths (clear error); a positional state wins over `--state`. SPEC §3.8.2 detection table + §3.8.6 examples updated. 18 new/extended filter test cases. + +- **Positional dseq on tx deployment and provider lease commands (AKT-650)**: `tx deployment close [dseq]`, `update [dseq]`, `group close|pause|start [dseq] [gseq]`, and `provider lease-status|lease-logs|lease-events|get-manifest [dseq]` accept the sequence positionally; the flag remains as an override and the positional wins when both are given. `lease-shell` is excluded (its positionals are the remote command). New pure helpers `DSeqFromArgs`/`GroupSeqsFromArgs` in `internal/cli/chain/flags/args.go` + 11 tests; SPEC §2.1/§2.4 updated. + +- **Console API key as a per-context credential (AKT-646)**: Contexts gain `auth-method` (`keyring`|`console-api`, validated) and `console-api-url` config fields, and a per-context Console API key stored at `contexts//console-api-key` (0600, never in config.yaml). `Manager.Resolve` now populates `AuthMethod` (default keyring), `ConsoleAPIURL` (default `https://console-api.akash.network`), and `ConsoleAPIKey` resolved env-over-file per SPEC §7.1. New `internal/context/credentials.go` (Set/Stored/Resolve helpers; empty key removes the file). `akt context create/edit` gain `--auth-method`, `--console-api-url`, `--console-api-key` (edit with empty string removes the key; action log records only "updated"/"removed", never the value). `akt context show` displays the auth method and Console URL and reports the key only as configured/not set. 7 new tests + updated goldens. + +- **Dynamic workflow command surfacing pinned by tests + spec (AKT-651)**: The workflow-generated command surface (commands exist iff their workflow YAML resolves; built-ins `deploy`/`update`/`close` always resolve per SPEC §2.3.9) was implemented earlier but had no unit tests and no explicit spec wording. Added 6 tests in `internal/cli/workflow/commands_test.go` covering: built-ins-only surface, user workflow appearing/absent (negative case), malformed YAML skipped, generated Use/flags for close and deploy, and user-workflow override of a built-in. SPEC §2.3.1 gains a "Dynamic surfacing" paragraph making the iff-rule normative. + +- **Console API mutations recorded in the action log (AKT-211)**: New `console` action type (SPEC §5.2/§5.6 amended). The Console client's state-changing calls (create/update/close deployment, create lease, deposit) record `type=console` entries with operation, dseq, and success/failure via an optional `WithActionLog` hook — read-only Console queries are not recorded. 2 tests. + +- **Workflow step action-log adapter (AKT-211)**: New `workflow.ActionLogAdapter` implements the engine's `Logger` hook, writing one `type=workflow` entry per completed step (workflow name, run ID, step index/name, status, error, tx hash/height) per SPEC §5.6. The engine already invoked `Logger.LogStep` after every step; this provides the previously missing implementation. Wired into workflow execution when the engine is constructed. 2 tests. + +- **Provider gateway operations recorded in the action log (AKT-211)**: The state-changing provider commands (`send-manifest`, `migrate-hostnames`, `migrate-endpoints`, `lease-shell`) now write `type=provider` entries per SPEC §5.6 with the operation name, provider address, dseq, and success/failure status. Read-only provider queries (status, lease-status, lease-logs, lease-events, get-manifest) are not recorded, consistent with the action log's mutating-actions scope. New helper `recordProviderAction` in `internal/cli/provider/actionlog.go` + 2 tests. + +- **Chain transactions recorded in the action log (AKT-211)**: All `tx *` commands now write `type=tx` entries per SPEC §5.6 through a single choke point — `TxPersistentPreRunE` wraps the discovered chain client in a logging decorator (`internal/cli/chain/actionlog.go`) whose `Tx()` intercepts `BroadcastMsgs`/`BroadcastTx`. Entries record the compact msg type (e.g. `deployment.MsgCloseDeployment`), tx hash, height, gas used, result code, signer account, and — for well-known Akash messages — dseq/gseq/oseq/provider. Failures record `status=failed` with the error; generate-only/simulate/offline runs are not logged. 4 new tests. + +- **Context operations recorded in the action log (AKT-211)**: `context create/use/edit/delete/rename` now write `type=context` entries per SPEC §5.6. Entries are written to the affected context's own log (opened directly, since these commands often run without a logger for the target context); `delete` records into the current context's log because the deleted context's log is removed with it. New helper `recordContextAction` in `internal/cli/context/actionlog.go`; logging is best-effort and never fails the command. 5 new tests in `internal/cli/context/actionlog_test.go`. + - **Phase 5 TUI Mode completion (T064-T105)**: Completed all 20 remaining Phase 5 (User Story 3) tasks. Tests added: navigation behavioral tests (T064) — view switching via number keys, Esc/back, command palette routing, overlay priority, ViewDataRefreshMsg dispatch; command palette tests (T067) — open/close, filter, cursor wrapping, submit message; ListView/DetailView tests (T068) — items, cursor, selection, scrolling, rendering; dashboard tests (T092) — rendering, shortcuts, account/deployment data; E2E TUI tests (T105) — version command, no-TTY safety, context list, completion. Features implemented: ResourceTable sorting and filtering (T086) — Sort() by column, SetFilter() case-insensitive substring search, ClearFilter(), FilteredCount(); deployments view (T093) — escrow balance column, filter cycling (f key), update placeholder (u key); leases view (T094) — GSeq/OSeq columns, Enter detail, logs (l key), filter cycling (f key); providers view (T095) — populated chain attribute data (region, audit); log viewer (T096) — service filter cycling via CycleServiceFilter() and s key; governance/staking action wiring — vote (v key) and delegate (d key) open confirm dialog. Verified already-complete: T065 (theme tests), T066 (keybinding tests), T070 (provider cache tests), T071-T081 (11 design docs), T089 (confirm dialog). Live sync integration (T104) — sync bridge event pipeline fully wired (events → engine → store → TUI refresh within 2s, sync status indicator in header). - **Sync bridge status indicator on dashboard (T12)**: Added a "sync" row to the Network panel on the TUI dashboard showing "Live" (green) when the sync bridge is active or "Offline" (muted) when it is not. New `syncActive` field and `SetSyncBridgeActive()` method on `Dashboard` struct. Wired from `app.go` `Run()` and `RunMonitor()` after successful sync bridge creation. diff --git a/DESIGN.md b/DESIGN.md index de194d7e..7b479038 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -29,9 +29,9 @@ | `akash-network/chain-sdk/go/cli` | All CLI command definitions (tx, query, keys, server) | **Deprecated.** Commands clean-copied into `akt`. Package removed once `akt` reaches parity. | | `akash-network/node` | Blockchain node binary (`akash`). Imports chain-sdk CLI, adds server/genesis/testnet commands. | **Keeps** only node-operator commands: `start`, `comet`, `export`, `prepare-genesis`, `testnet`, `testnetify`, `auth jwt`. Stops exporting user-facing CLI. | | `akash-network/provider` | Provider binary (`provider-services`). Imports chain-sdk CLI, adds provider gateway + operator commands. | **Keeps** only provider-operator commands: `run`, `operator *`, `tools *`, `migrate`. Stops exporting user-facing CLI. | -| `ovrclk/akt` | MVP CLI prototype. Config system, account/network/deploy commands. | Design reference. Concepts (profiles, git-like config) evolved into the context system. | +| `ovrclk/akt` (pre-rewrite) | MVP CLI prototype. Config system, account/network/deploy commands. | Design reference. Concepts (profiles, git-like config) evolved into the context system. Replaced in place by the rewrite below. | | `cloud-j-luna/aktop` | Community TUI for monitoring Akash consensus state, validator voting, and provider operations. | Design reference and prior art for TUI. Its consensus/validator/provider monitoring views inform the TUI design. Functionality subsumed by `akt monitor`. | -| **`akash-network/akt`** | **New.** This repository. | The unified user CLI and TUI. | +| **`ovrclk/akt`** | **New.** This repository, and the rewrite that replaced the prototype above. | The unified user CLI and TUI. Releases publish here; a move to `akash-network/akt` is possible later, but that repository does not exist yet. | ### 1.4 The `monitor` Command @@ -107,6 +107,8 @@ block-beta end ``` +Two distinct things are called *transport* in this document, at different levels. The bottom block is the **wire transport**: the concrete clients (RPC, gRPC, REST, provider gateway, Console API) and their multi-endpoint failover (§5.6). The **transport translation layer** (`internal/transport`, §3.5) sits much higher, between the command layer and core services: it translates abstract workflow actions onto a *rail* — chain or console — and each rail then uses whichever wire transports it needs. + --- ## 3. Core Design Concepts @@ -143,7 +145,7 @@ graph TB subgraph al ["Action Log"] a1["actions.log"] a2["tx msg + response"] - a3["query results"] + a3["console + provider ops"] a4["workflow steps"] end end @@ -173,10 +175,10 @@ graph TB - Sync state metadata. **Action Log** (unique per context): -- Append-only log of all user actions within the context. +- Append-only log of all mutating user actions within the context. - Each entry records what was done, when, and the result. - A transaction action consists of two parts: the tx message and the chain response. -- Query actions, workflow steps, and errors are also logged. +- Workflow steps, provider operations, context changes, Console API calls, and errors are also logged. Read-only queries are not recorded by default. #### 3.1.2 Context Propagation @@ -210,13 +212,26 @@ Each context has an `auth-method` that determines how transactions are signed an - Custodial managed wallet via the [Akash Console API](https://console.akash.network). - The Console backend holds the wallet keys, signs transactions, and broadcasts on the user's behalf. - Authenticated via an API key (created at console.akash.network > Settings > API Keys). -- The API key is supplied via the `AKT_CONSOLE_API_KEY` environment variable or the `--console-api-key` flag. It is **not** persisted in config or keyring. +- The API key is resolved as flag > env > per-context credential: `--console-api-key` (session only), then `AKT_CONSOLE_API_KEY`, then a per-context credential file at `contexts//console-api-key` (mode 0600, managed via `akt context create/edit --console-api-key`). It is never written to config.yaml, never printed, and never logged — each context carries its own key, so switching context switches Console identity. - Deposits are denominated in USD (not uakt) -- the Console handles the conversion. -- Only deployment lifecycle operations are supported through the Console API (create, update, close, bids, leases, deposit). Query commands still work directly against chain RPC. +- For `tx` commands and workflows, only deployment lifecycle operations route through the Console API (create, update, close, bids, leases, deposit). Chain query commands still work directly against chain RPC. The broader Console surface (wallets, usage, provider/GPU/template catalogs, API keys, provider-scoped JWTs) is exposed by the dedicated `akt console` command group. - No keyring, default-account, or provider-defaults are used. The context only needs a network (for query commands) and the API key. A context uses **one** auth method. Users who need both can create separate contexts (e.g., `prod` with keyring auth and `console` with console-api auth), potentially sharing the same network definition. +#### 3.1.5 Console Provider Gateway Access + +A `console-api` context has no wallet, yet the operations users reach for most after a deployment goes live — container logs, cluster events, live lease status, an interactive shell — are served by the **provider's** gateway, not by the Console API. `akt` reaches those gateways directly from a managed context, with no wallet and no local signing key involved (`internal/cli/console/gateway.go`): + +1. `GET /v1/deployments/{dseq}` resolves the deployment and picks its first **active** lease. When no lease is active, the error names the states of the leases that do exist, so the user knows whether to wait or to create one. +2. `GET /v1/providers/{address}` resolves that lease's provider to a gateway `hostUri`. A provider with no `hostUri` in the Console catalog is reported as such rather than producing a connection error later. +3. `POST /v1/create-jwt-token` mints a Console-signed, **scoped, short-TTL** JWT that provider gateways accept as `Authorization: Bearer`. Scope is the narrowest set the command needs (`status`, `logs`, `events`, `shell`), and TTL is 300s for one-shot calls, 3600s for streaming invocations (`--follow`, `--watch`, `shell`). +4. The standard provider REST client is constructed against that gateway — `rest.NewClient(ctx, providerAddr, rest.WithProviderURL(hostUri), rest.WithAuthToken(token))` — and the call proceeds exactly as it would on a keyring context. + +**Why this shape**: Akash Console fronts provider gateways with a server-side `provider-proxy` websocket relay. `akt` is a native client that can reach the gateway directly, so it deliberately does **not** reimplement that relay. Step 4 hands off to the same provider client and the same streaming code paths that back `akt provider lease-status`, `lease-logs`, `lease-events`, and `lease-shell` — one implementation of log streaming, event streaming, and PTY handling, exercised by both rails. The Console-minted JWT simply substitutes for the wallet-signed JWT a keyring context would present, so `akt console status|logs|events|shell` and their `akt provider` counterparts cannot drift apart. + +This is the only point at which a `console-api` context talks to a provider gateway. Deployment lifecycle operations still route through the Console API, which submits manifests internally during lease creation (SPEC §7.4). + ### 3.2 Dual-Mode Architecture ```mermaid @@ -239,6 +254,8 @@ graph LR Both modes share the same core services (context, keyring, client, store). The TUI wraps these services in bubbletea models; the CLI wraps them in cobra command handlers. +The diagram above is the target architecture. The TUI shell is currently **disabled** while UX feedback is collected, so bare `akt` prints help instead of launching the resource browser — see §5.2 for the gate and what remains reachable. + **Smart interactivity**: Commands auto-detect whether a TTY is attached. When interactive, they show prompts, spinners, and colored output. When piped, they output machine-readable formats silently. The `--interactive` and `--yes` flags override this behavior. **Workflow execution modes**: Workflow commands (`akt deploy`, `akt update`, `akt close`) support two output modes: @@ -308,33 +325,121 @@ graph LR **Reconnection**: On WebSocket disconnect, the engine uses exponential backoff (1s, 2s, 4s, ... up to 60s) with jitter. Missed blocks are reconciled by querying the range between last-synced height and current height. +### 3.5 Transport Translation Layer + +An **action** (`deploy`, `update`, `close`, and every action added later) is defined exactly once, as workflow YAML in `internal/workflow/builtin/`, in terms of abstract steps: `tx`, `query`, `wait`, `prompt`, `provider`, `output`. The definition says *what* happens, never *how* it is carried. A **transport** (`internal/transport`) is the boundary that translates those abstract steps onto a concrete backing rail. + +```mermaid +graph LR + WF["Action definition\n(deploy.yaml)\n\nAbstract steps:\n- tx\n- query / wait\n- prompt\n- provider"] --> T{"Transport\nrail chosen per context\nat execution time"} + T -->|"KindChain\nauth-method: keyring"| CH["Chain adapter\n\n- build + sign + broadcast\n- chain RPC/gRPC queries"] + T -->|"KindConsole\nauth-method: console-api"| CO["Console adapter\n\n- msg type maps to REST endpoint\n- manifest cache: create then lease\n- chain queries when available"] + CH --> PG["Provider gateway\n(JWT / mTLS)"] + CO --> API["Console API\n(managed wallet)"] +``` + +`Transport` is a narrow interface: a `Kind()` (`chain` or `console`) plus the workflow engine's `steps.ChainClient`. Three constructors build the concrete rails, each wrapping the corresponding adapter in `internal/workflow/adapters`: + +| Constructor | Rail | Carries steps by | +|---|---|---| +| `NewChain(client)` | `KindChain` | Building, signing, and broadcasting transactions locally through the Akash node client; queries run against chain RPC/gRPC. | +| `NewConsole(consoleClient, chainQueries, root, ctxName)` | `KindConsole` | Mapping each message type to a Console API REST endpoint (SPEC §7.5). Query steps go straight to the chain when a chain client is available; without one, `market.bids` falls back to the Console bids endpoint. `root`/`ctxName` locate the per-context manifest cache that carries the manifest from deployment create to lease create. | +| `NewProvider(clientCtx, authType)` | chain rail only | Provider gateway calls (JWT or mTLS). The console rail has **no** provider client: the Console API submits the manifest internally during lease creation, so provider steps are dropped from the run with a note on stderr rather than failing it. | + +**Why a translation layer and not per-rail commands**: the alternative — a `deploy` that knows about keyrings and a separate Console `deploy` — means every new action is designed twice, and the two surfaces drift on flag names, defaults, argument order, and error text. Here, adding an action is a workflow definition plus (at most) a message mapping in the console adapter. Neither rail's command handler changes, and no rail-specific redesign is required. + +**One argument surface**: the CLI's argument surface is *generated* from the workflow definition (`internal/cli/workflow`). Positional arguments come from the definition's required file param and its optional `dseq` param, and every non-file param also gets a flag carrying the definition's type, default, and description. Because the definition is shared, `akt deploy`, `akt update`, and `akt close` take **identical arguments on both rails** — the rail is a property of the active context (`auth-method`), not of the command line. A user switching from a keyring context to a console-api one types the same command. + +**Cross-rail normalization**: rail-independent argument syntax is translated inside `Transport.BroadcastTx` before delegating to the adapter, so a cross-rail mistake fails at the transport boundary with a clear message rather than deep inside a rail's client — or, worse, on the wire. The concrete case is the deployment deposit, parsed in one place (`transport.ParseDeposit`) and rendered per rail by `Deposit.RailValue`: + +| Form | Examples | Meaning | +|---|---|---| +| USD | `5usd`, `$5`, `5.50usd` | A USD amount. The `usd` unit is case-insensitive and always wins over coin parsing, so a value ending in `usd` is never read as a chain denomination. | +| Coin | `5000000uakt`, `5akt` | A chain coin amount, parsed as a decimal coin. | +| Bare number | `5`, `5.50` | Unit-less: USD on the console rail, rejected on the chain rail (coins have always required a denomination). | +| `auto` / empty | `auto` | Defer to the rail default: the chain-minimum deployment deposit on the chain rail; the console rail has no default and asks for an explicit USD amount. | + +Every form parses on every rail; only the *interpretation* is rail-specific, and each rejection names the rail that would accept the value ("USD deposits require a console-api context; specify a coin amount like `5000000uakt`"). SPEC §7.4 carries the full per-rail acceptance table. The Console minimum is a single exported constant (`transport.MinConsoleDepositUSD`, aliasing `console.MinDepositUSD`) so every surface that enforces it — CLI commands and workflow adapters alike — shares one value. + +### 3.6 Capability Gating + +Not every context can run every command. A context with a Console API key and no network genuinely cannot execute a chain query; a monitoring-only context with an RPC endpoint and no Console credential genuinely cannot call the Console API. Discovering that at the transport boundary — after the user has found the command in help, typed it, and waited — is poor UX. `internal/capability` derives a **feature set** from the resolved context up front, and `internal/cli/gating.go` applies it to the command tree. + +```mermaid +graph LR + RC["Resolved context\n\n- network endpoints\n- console-api-key"] --> RES["capability.Resolve\n\nRPC present yields\nchain-query, chain-tx, provider\n\nkey resolvable yields\nconsole"] + OV["Per-invocation overrides\n\n--node\n--console-api-key\nAKT_CONSOLE_API_KEY\nakt monitor [endpoint]"] --> INV["invocationCapabilities\n(grant, never revoke)"] + RES --> INV + INV --> GATE["Command tree walk\n\nakt.requires annotation\nvs. feature set"] + GATE --> PRES["Presentation\ndim / hide / off"] + GATE --> FAIL["Fail fast\nwith remedy"] +``` + +Capabilities are deliberately coarse — they describe what the *configuration* makes possible, not what will certainly succeed: + +| Capability | Derived from | Declared by | +|---|---|---| +| `chain-query` | network has at least one RPC endpoint | `akt query`, `akt monitor` | +| `chain-tx` | network has at least one RPC endpoint | `akt tx` | +| `provider` | network has at least one RPC endpoint (gateway discovery + wallet auth) | `akt provider` | +| `console` | a Console API key is resolvable (§3.1.4) | `akt console` subcommands | + +`chain-tx` deliberately does not probe for a funded key: opening an OS keyring can prompt for a password, and a help listing must never do that. Key and balance problems remain execution-time failures. `akt sdl` declares nothing at all — SDL scaffolding, validation, and linting run entirely locally, so gating them would be wrong. + +**Declaration**: commands carry their requirement in the cobra annotation `akt.requires`. Alternatives are separated by `|` and any one suffices, which is exactly what the transport layer needs: workflow commands declare `chain-tx|console` because §3.5 lets them run on either rail. An annotation the capability package does not recognize **fails open** — a typo in an annotation must never brick a command. + +**Overrides grant, never revoke**: gating describes the configuration, so an invocation that carries its own connection details must never be rejected by it. `--node` grants the chain capabilities, `--console-api-key` (or `AKT_CONSOLE_API_KEY` in the environment) grants `console`, and a positional endpoint on `akt monitor` grants chain access — `akt monitor ` works with no context at all, consistent with the standalone-operation goal in §1.4. Argument scanning stops at the `--` terminator so a user's shell command cannot masquerade as a flag. Help invocations are never enforced against, because several clean-copied SDK groups disable flag parsing and cobra therefore cannot short-circuit their `--help` before the root hooks run. + +**Presentation**: two modes ship deliberately, selected by `defaults.command-gating` (flag/viper value first, then the config default), because it is not yet obvious which reads better to users and the answer needs feedback rather than a guess. `dim` is the settled default (2026-07); `hide` stays available so the comparison can still be made: + +| Mode | Behavior | +|---|---| +| `dim` (default) | Unavailable commands stay listed with `[unavailable]` prefixed to their short help — the user learns the command exists and why it is off. | +| `hide` | Unavailable commands are removed from help listings entirely. | +| `off` | No gating at all; commands fail wherever the missing transport is first touched (the pre-gating behavior), kept as an escape hatch. | + +An unrecognized mode falls back to `dim`, so a config typo never silently disables the safety net. Presentation and enforcement are separate: in every mode except `off`, invoking a command whose requirement is unsatisfied — including a hidden one invoked directly — fails immediately with the missing capability and its remedy ("requires console (configure a Console API key: `akt console login`, or `akt context edit --console-api-key `)") rather than erroring mid-transport. See SPEC §2.10 for the normative table. + --- ## 4. Package Structure ``` -github.com/akash-network/akt/ +pkg.akt.dev/akt/ # module path (repo: github.com/ovrclk/akt) ├── cmd/ │ └── akt/ # Binary entry point ├── internal/ │ ├── cli/ # CLI mode (cobra commands) │ │ ├── chain/ # Clean-copied chain-sdk go/cli (tx/query) -│ │ ├── tx/ # Transaction commands (per-module) -│ │ ├── query/ # Query commands (per-module) -│ │ ├── workflow/ # Workflow CLI wrappers (deploy, update, close) +│ │ │ └── flags/ # Shared chain tx/query flag definitions +│ │ ├── workflow/ # Workflow commands generated from definitions (§3.5) +│ │ ├── console/ # Console group + gateway access (§3.1.5) +│ │ ├── sdl/ # SDL scaffolds, validation, lint (fully local) │ │ ├── context/ # Context management commands +│ │ ├── network/ # Network management commands │ │ ├── keys/ # Key management commands │ │ ├── provider/ # Provider gateway commands │ │ ├── store/ # Store management commands -│ │ └── plugin/ # Plugin management +│ │ └── plugin/ # Plugin management (planned, §5.4) │ ├── tui/ # TUI mode (bubbletea) │ │ ├── views/ # TUI view models (one per resource type) │ │ ├── components/ # Reusable TUI components +│ │ ├── commands/ # Bubbletea commands (async work) +│ │ ├── data/ # View-model data loading +│ │ ├── keys/ # Keybinding definitions │ │ └── messages/ # Custom bubbletea messages +│ ├── ui/ # Shared presentation layer (CLI + TUI) +│ │ └── theme/ # Unified color palette and base styles +│ ├── glyphs/ # ASCII-safe glyph registry (§3.2) │ ├── context/ # Context management core +│ ├── bootstrap/ # First-run config initialization wizard +│ ├── capability/ # Feature set derived from the context (§3.6) +│ ├── transport/ # Action-to-rail translation layer (§3.5) │ ├── actionlog/ # Action log (unique per context) +│ ├── cliutil/ # Cross-command CLI helpers (status, verbosity, errors) │ ├── workflow/ # Declarative workflow engine │ │ ├── steps/ # Step type implementations +│ │ ├── adapters/ # Rail-backed step clients wrapped by transport/ │ │ └── builtin/ # Embedded default workflow definitions │ ├── codec/ # Application-wide encoding config │ ├── keyring/ # Keyring abstraction @@ -362,9 +467,7 @@ github.com/akash-network/akt/ │ │ ├── provider/ # Provider queries and REST tools │ │ ├── audit/ # Audited provider queries │ │ └── cert/ # Certificate queries -│ ├── plugin/ # Plugin system -│ ├── filter/ # Resource filter argument parsing (§3.8) -│ ├── flags/ # Shared flag definitions +│ ├── plugin/ # Plugin system (planned, §5.4) │ └── output/ # Output formatting │ └── pretty/ # Pretty output for query results (registry-based) ├── pkg/ # Public API (for plugins) @@ -394,6 +497,8 @@ The `chain-sdk` CLI package (`pkg.akt.dev/go/cli`) will be deprecated and eventu - **Lipgloss v2** provides CSS-like styling for terminal output in both modes -- table formatting in CLI, full layout composition in TUI. - **Bubbles v2** provides battle-tested components: table, viewport, text input, spinner, help, key bindings, list, progress bar, paginator. +**Current status of the TUI shell**: the root TUI application (the resource browser reached by bare `akt`) is **disabled** while UX feedback is collected. Bare `akt` prints help, and `--interactive` reports that the TUI is disabled rather than launching it. The code path is kept compiled and reachable behind `AKT_EXPERIMENTAL_TUI=1` so it stays exercisable — and honest — while the decision is open; re-enabling is removing one gate in the root command's `RunE`. This is a shipping decision, not an architectural one: the design above stands, and `akt monitor` (which is a separate bubbletea application, not the shell) is unaffected and fully available. + **Bubbles v2 component usage by UI element:** | UI Element | Bubbles Component | Location | @@ -561,6 +666,8 @@ Send - Key management commands - Output formatting with pretty output: registry-based per-type formatters for all query results, lipgloss color-coded states. `--output json` and `--output yaml` for machine-readable output - Global flags and environment variable support +- Capability gating (§3.6): feature set derived from the context, `akt.requires` annotations on command groups, `defaults.command-gating: dim | hide | off`, per-invocation overrides +- `akt sdl` group: scaffolds, offline validation, and lint — transport-independent and usable with no context at all - Built-in network templates (mainnet, testnet, sandbox) - Version command with build-time injection - Shell completion (bash, zsh, fish) @@ -574,16 +681,20 @@ Send - Store schema versioning and migration framework - Sync engine: WebSocket subscription, event routing, state reconciliation - `akt deploy` workflow command: create deployment, wait for bids, select bid (interactive or auto), create lease, send manifest, wait for active, display endpoint URLs. Workflows support **two execution modes**: TUI mode (interactive, user-friendly progress display) and JSONL mode (`--output jsonl`, JSONL output for automation and scripting). +- Transport translation layer (§3.5): `akt deploy`, `akt update`, and `akt close` execute from a single workflow definition on either the chain rail or the console rail, chosen from the context's `auth-method`. The command surface (positionals, flags, defaults, help) is generated from that definition, so the argument syntax — including the unified `--deposit` forms — is identical on both rails. - Provider gateway client: status, lease-status, lease-logs, lease-events, lease-shell, send-manifest, get-manifest - Provider migration commands: migrate-hostnames, migrate-endpoints - Store export/import commands - Store status command (sync state, record counts) -- Console API client: `auth-method: console-api` context support, API key via `AKT_CONSOLE_API_KEY` env var, deployment operations via Console managed wallet API (`https://console-api.akash.network`) +- Console API client: `auth-method: console-api` context support, API key resolved flag > env > per-context stored credential, deployment operations via Console managed wallet API (`https://console-api.akash.network`), plus the `akt console` command group for the full Console surface +- Console provider gateway access (§3.1.5): `akt console status | logs | events | shell` reach the lease's provider directly from a wallet-less managed context using a Console-minted scoped JWT, reusing the `akt provider lease-*` streaming paths ### Phase 3: TUI Mode **Goal**: A fully interactive terminal UI for real-time Akash management, incorporating the monitoring functionality of [`aktop`](https://github.com/cloud-j-luna/aktop) via the `akt monitor` command. +**Status**: `akt monitor` ships and is unaffected by the gate below. The application shell and its resource views are built but **disabled** pending UX feedback (§5.2) — bare `akt` prints help, and the shell is reachable only via `AKT_EXPERIMENTAL_TUI=1`. + - Bubbletea application shell: header, main area, status bar - Navigation system: resource type selector, breadcrumb trail, back/forward - Resource views: deployments, leases, bids, orders, providers, certificates @@ -665,7 +776,9 @@ Send | (none) | `akt deploy ` | New workflow command | | (none) | `akt update [dseq]` | New workflow command | | (none) | `akt close [dseq]` | New workflow command | -| (none) | `akt` (no subcommand) | Launches TUI mode by default | +| (none) | `akt` (no subcommand) | Designed to launch TUI mode; currently prints help while the TUI shell is disabled (§5.2) | +| (none) | `akt console *` | New Console API command group (§3.1.4-§3.1.5) | +| (none) | `akt sdl *` | New local SDL scaffolding, validation, and lint | | (none) | `akt store *` | New store management | | (none) | `akt plugin *` | New plugin management | diff --git a/Makefile b/Makefile index 9908c853..48882317 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,18 @@ -GOLANGCI_LINT_VERSION ?= v2.3.0 +# AKT_ROOT is normally exported by .envrc (direnv). Fall back to this +# Makefile's own directory so `make` works in a bare checkout — CI has no +# direnv, and without this every `include $(AKT_ROOT)/make/*.mk` resolves to +# an absolute /make/... path that does not exist. +# := not ?=: the value must be captured while MAKEFILE_LIST still ends with +# this file. A deferred (?=) assignment is expanded at first use — inside the +# include lines below — by which point the last word is the included file. +ifeq ($(origin AKT_ROOT), undefined) +AKT_ROOT := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST))))) +endif + +# Must be built with Go >= the go directive in go.mod: older golangci-lint +# releases refuse to load a config targeting a newer language version +# (v2.3.0 is built with go1.24 and cannot run against go 1.26.1). +GOLANGCI_LINT_VERSION ?= v2.11.4 GIT_CHGLOG_VERSION ?= v0.15. GIT_CHGLOG_VERSION_FILE := $(AKT_DEVCACHE_VERSIONS)/git-chglog/$(GIT_CHGLOG_VERSION) @@ -12,6 +26,14 @@ GOMOD ?= readonly UNAME_OS := $(shell uname -s) UNAME_ARCH := $(shell uname -m) +# Version derived from the nearest tag. A checkout without tags (CI does not +# fetch them by default) has no describable version, so fall back rather than +# emitting a git fatal and an empty -X value. +AKT_VERSION := $(shell git describe --tags 2>/dev/null | sed 's/^v//') +ifeq ($(AKT_VERSION),) +AKT_VERSION := dev +endif + GIT_HEAD_COMMIT_LONG := $(shell git log -1 --format='%H') GIT_HEAD_COMMIT_SHORT := $(shell git rev-parse --short HEAD) GIT_HEAD_ABBREV := $(shell git rev-parse --abbrev-ref HEAD) @@ -34,7 +56,10 @@ endif build_tags := $(strip $(BUILD_TAGS)) build_tags_cs := $(subst $(WHITESPACE),$(COMMA),$(build_tags)) -GORELEASER_LDFLAGS := $(ldflags) +# Release builds do NOT reuse the flags computed below: goreleaser cross +# compiles from a container with its own toolchain, so .goreleaser.yaml carries +# its own copy of the build tags and the -X flags. Keep the two in sync -- see +# the header comment in .goreleaser.yaml. ldflags := @@ -47,9 +72,16 @@ endif ldflags += -X github.com/cosmos/cosmos-sdk/version.Name=akt \ -X github.com/cosmos/cosmos-sdk/version.AppName=akt \ -X github.com/cosmos/cosmos-sdk/version.BuildTags="$(build_tags_cs)" \ --X github.com/cosmos/cosmos-sdk/version.Version=$(shell git describe --tags | sed 's/^v//') \ +-X github.com/cosmos/cosmos-sdk/version.Version=$(AKT_VERSION) \ -X github.com/cosmos/cosmos-sdk/version.Commit=$(GIT_HEAD_COMMIT_LONG) +# akt's own build info. The cosmos-sdk vars above feed the SDK's version +# machinery; `akt version` reads these, so both must be injected or the +# binary reports dev/none/unknown regardless of how it was built. +ldflags += -X main.version=$(AKT_VERSION) \ +-X main.commit=$(GIT_HEAD_COMMIT_LONG) \ +-X main.date=$(shell date -u +%Y-%m-%dT%H:%M:%SZ) + # check for nostrip option ifeq (,$(findstring nostrip,$(BUILD_OPTIONS))) ldflags += -s -w @@ -63,9 +95,6 @@ endif ldflags += $(LDFLAGS) ldflags := $(strip $(ldflags)) -GORELEASER_TAGS := $(BUILD_TAGS) -GORELEASER_FLAGS := $(BUILD_FLAGS) -mod=$(GOMOD) -tags='$(build_tags)' - BUILD_FLAGS += -mod=$(GOMOD) -tags='$(build_tags_cs)' -ldflags '$(ldflags)' GO := GO111MODULE=$(GO111MODULE) go @@ -84,3 +113,4 @@ test-e2e: akt include $(AKT_ROOT)/make/setup-cache.mk include $(AKT_ROOT)/make/releasing.mk +include $(AKT_ROOT)/make/testing.mk diff --git a/README.md b/README.md index 848db47f..f0d10aca 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,27 @@ Unified CLI and TUI for the [Akash Network](https://akash.network). ## Overview -`akt` replaces the user-facing CLI functionality currently spread across `akash-network/node` (`akash`), `akash-network/provider` (`provider-services`), and `akash-network/chain-sdk/go/cli` with a single binary. It adds a context system for managing multiple networks/accounts, a local deployment store, an interactive TUI, and `akt monitor` -- a hub-based real-time monitoring tool for network state, provider fleet health, and BME state (incorporating [`aktop`](https://github.com/cloud-j-luna/aktop) functionality). +`akt` replaces the user-facing CLI functionality currently spread across `akash-network/node` (`akash`), `akash-network/provider` (`provider-services`), and `akash-network/chain-sdk/go/cli` with a single binary. It adds a context system for managing multiple networks/accounts, a local deployment store, offline SDL authoring, Akash Console integration, and `akt monitor` -- a hub-based real-time monitoring tool for network state, provider fleet health, and BME state (incorporating [`aktop`](https://github.com/cloud-j-luna/aktop) functionality). -The CLI is designed for **flag-minimal operation**: after initial context configuration, the majority of commands require zero additional flags. Query commands use **positional filter arguments** (`akt query deployment 12345`) instead of flag-based filters (`--owner`, `--dseq`), following the Akash resource hierarchy. +The CLI is designed for **flag-minimal operation with no surprises**: after initial context configuration, the majority of commands require zero additional flags, and commands the active context is not configured to run say so up front instead of failing somewhere inside a transport (see [Capability Gating](#capability-gating)). + +Commands take their primary values **positionally**, following the Akash resource hierarchy, rather than through filter flags: + +```bash +# State keyword, identity + state, dseq, and a date range -- all positional +akt query deployment active +akt query deployment akash1abc.../12345 closed +akt tx deployment close 12345 +akt console usage 2026-01-01 2026-01-31 +``` + +The duplicated identity and filter flags (`--owner`, `--dseq`, `--gseq`, `--oseq`, `--state`, `--provider`, and the Console positional twins such as `--from`/`--to` on `console usage`) are **disabled during a UX trial** (2026-07): they are commented out in code behind `FEEDBACK(2026-07)` markers and can be restored wholesale if feedback calls for it. While the trial runs, the positional form is the only form. Transaction flags (`--from`, `--gas`, `--fees`, ...) are unaffected. See [DESIGN.md](DESIGN.md) for architecture and [SPEC.md](SPEC.md) for the full technical specification. ## Status -Under active development. Phase 1 (Foundation) is in progress. Provider gateway, deployment store, sync engine, and full TUI views are in progress. +Under active development. Phase 1 (Foundation) is complete: context system, key management, all chain tx/query commands, output formatting, action log, and shell completion. Workflow execution (`akt deploy/update/close`) and Akash Console integration have landed -- including live lease operations (logs, events, status, shell) against provider gateways -- along with capability gating, the offline `akt sdl` authoring group, offline e2e suites, and CI. The deployment store, sync engine, and provider gateway commands are implemented. The **TUI shell is disabled pending UX feedback** (see [TUI Shell](#tui-shell)); `akt monitor` is unaffected. ## What's Implemented @@ -28,6 +40,29 @@ Under active development. Phase 1 (Foundation) is in progress. Provider gateway, - Config live-reload via fsnotify - Environment variable overrides (`AKT_CONTEXT`, `AKT_HOME`, `AKT_FROM`, etc.) +### Capability Gating + +The active context's configuration determines a **feature set** -- what `akt` can actually do: + +- A network with at least one RPC endpoint enables `chain-query`, `chain-tx`, and `provider`, which gate the `query`, `tx`, `monitor`, and `provider` command groups. +- A resolvable Console API key enables `console`, which gates the Console-backed commands. + +Commands declare their requirement as a cobra annotation (`internal/capability`), and alternatives are allowed -- the workflow commands need `chain-tx|console` and run on either rail. A command the configuration cannot run is dimmed in help (its summary prefixed with `[unavailable]`) and fails fast naming the missing capability and its remedy: + +``` +akt console whoami is unavailable with the current context configuration: requires +console (configure a Console API key (akt console login, or akt context edit + --console-api-key )) +``` + +Presentation is configurable while UX feedback is collected, via `defaults.command-gating` in `config.yaml`: + +- **`dim`** (default) -- unavailable commands stay listed, marked `[unavailable]`, and fail fast with an explanation. +- **`hide`** -- unavailable commands are removed from help listings; direct invocation still fails fast. +- **`off`** -- no gating; commands fail wherever the missing transport is first touched. + +Per-invocation overrides that carry their own connection details (`--node`, `--console-api-key`, a positional `akt monitor` endpoint) grant the capability they supply, so gating never rejects a command that can connect on its own. Help is never gated. `akt context show` reports the resolved feature set. See [SPEC.md §2.10](SPEC.md#210-capability-gating). + ### Key Management Full key lifecycle: add (mnemonic, ledger, multisig, recover), delete, list, show, export, import, rename, mnemonic generation, and address parsing (bech32/hex). @@ -55,7 +90,7 @@ Subcommands for direct access: `akt monitor network`, `akt monitor provider`, `a ### Action Log -Per-context append-only JSONL log recording all tx, query, workflow, provider, and context operations. Supports filtering by type, time range, deployment sequence, and account. Automatic rotation at 10 MB. +Per-context append-only JSONL log recording every mutating operation: tx, workflow, provider, context, and console actions. Read-only queries are not recorded by design. `akt context log` reads entries newest-first with `--type`, `--since`, and `--limit` filters. Automatic rotation at 10 MB (up to 5 rotated files kept). ### Output Formatting @@ -67,11 +102,35 @@ All commands support `--output pretty|json|yaml` with pretty formatting by defau ### Workflow Engine -Core engine with 3-level definition resolution (per-context > global > embedded), Go template evaluation, 8 step types (tx, query, wait, prompt, provider, output, shell, check), and retry/error handling. Workflows support two execution modes: **TUI mode** (interactive progress display) and **JSONL mode** (`--output jsonl`, JSONL output for CI/CD and scripting). Built-in workflow definitions (deploy, update, close) are not yet wired. +Core engine with 3-level definition resolution (per-context > global > embedded), Go template evaluation, 8 step types (tx, query, wait, prompt, provider, output, shell, check), and retry/error handling. Workflows support two execution modes: **TUI mode** (interactive progress display) and **JSONL mode** (`--output jsonl`, JSONL output for CI/CD and scripting). The built-in workflows (`akt deploy`, `akt update`, `akt close`) execute end-to-end with auth-aware routing: keyring contexts sign and broadcast locally, while console-api contexts route through the Console API (provider manifest steps are skipped -- Console submits manifests internally). Every step is recorded in the action log. + +### SDL Authoring (`akt sdl`) + +Transport-independent SDL scaffolding, generation, and linting. Every `akt sdl` subcommand runs entirely locally: no context, no key, no RPC endpoint, and the group declares no capability requirements. + +- **`akt sdl scaffolds`** -- list the built-in scaffolds (`web`, `gpu`, `multi-service`, `ip-lease`) and the flags each honors. +- **`akt sdl init `** -- print a deployable SDL to stdout, self-checked against the validator before it is printed. Flags (`--image`, `--cpu`, `--memory`, `--gpu-model`, `--price`, ...) are generation parameters with per-scaffold defaults, so the zero-flag invocation always produces valid output. Pipe it into `akt sdl validate -`, or redirect it to a file for `akt deploy` / `akt console deployment create`. +- **`akt sdl validate `** -- validate offline (`-` reads stdin). Parsing uses `pkg.akt.dev/go/sdl`, the same parser behind `akt deploy` and the chain tx commands, followed by lint rules. + +Lint rules: an unpinned image is an **error** -- every service image must carry an explicit tag or `@sha256:` digest, so untagged images and `:latest` are rejected as non-reproducible. For pricing, `uact` passes, `uakt` is a **warning** (valid on-chain, but managed console-api deployments are priced in `uact`), and any other denom is an error. Exit 0 when valid, 1 when not. See [SPEC.md §2.11](SPEC.md#211-sdl-commands). + +### Console Integration (`akt console`) + +Full command group for the [Akash Console](https://console.akash.network) managed-wallet API: + +- **Authentication** -- `login` (validates the key, then stores it for the active context), `logout`, `whoami`. +- **Deployment lifecycle** -- `deployment list/get/create/update/close/deposit/settings`. Deposits and amounts are USD (minimum $0.50), accepted as `5`, `5usd`, or `$5`. +- **Bids and leases** -- `bid list ` and `lease create [provider]`, which defaults to the manifest cached per context by `deployment create`. +- **Wallet and usage** -- `wallet list/balance/settings/cost` and `usage [from] [to]` spend history, all rendered in USD. +- **Keyless marketplace** -- `provider list/get/regions/auditors`, `gpu`, `template list/get/sdl`, and `screen ` bid screening. These hit public endpoints and need neither an API key nor a configured context. +- **Credentials** -- `apikey list/create/delete` (the secret is printed exactly once) and `jwt create` for short-lived provider-scoped tokens. +- **Live lease operations** -- `logs [service]` and `events ` (both `--follow`), `status ` (`--watch`, `--interval`), and `shell [-- command]` (exec is the same command with an explicit command). Each resolves the deployment's active lease, looks up the provider's gateway URI, and mints a scoped JWT via the Console, so **managed contexts reach providers with no wallet and no local key**. One-shot calls use a 300 s token; streaming and interactive modes use 3600 s. + +The API key is stored per context at `contexts//console-api-key` (mode 0600, never written to `config.yaml`, never printed) and resolved `--console-api-key` flag > `AKT_CONSOLE_API_KEY` env var > stored credential, so switching context switches Console identity. Write it with `akt console login` or `akt context edit --console-api-key `; `akt context rename` moves it and `akt context delete` removes it. The first-run bootstrap offers Console onboarding, and state-changing Console calls are recorded in the action log. [SPEC.md §7.8](SPEC.md#78-console-compatibility-matrix) tracks coverage of every Console capability. ### TUI Shell -Bubbletea application with dashboard, view routing (query, tx, top), vim-style `:` command input, `ctrl+p` search dialog, and configurable keybindings. Resource views are scaffolded but not yet populated with live data. +**Disabled pending UX feedback (2026-07).** Bare `akt` prints the help text instead of launching the dashboard, and `-i`/`--interactive` reports that the TUI is disabled. The bubbletea application (dashboard, view routing, vim-style `:` command input, `ctrl+p` search dialog, configurable keybindings) is still compiled in and launches under `AKT_EXPERIMENTAL_TUI=1` for feedback sessions; its resource views are scaffolded but not yet populated with live data. `akt monitor` is a separate application and is unaffected -- it is fully functional. ## Build @@ -81,7 +140,15 @@ Requires Go 1.26+, make, and the [Akash chain SDK](https://github.com/akash-netw make akt ``` -The binary is placed in `.cache/bin/akt`. +The binary is placed in `.cache/bin/akt`. Version, commit, and build date are injected at link time: + +```bash +# Short form: version, commit, build date +akt version + +# Full build info: version, commit, build date, Go version, platform, build tags +akt version --long +``` Run tests: @@ -130,7 +197,7 @@ akt context create prod --network mainnet --default-account alice --set-current # Switch active context akt context use staging -# Show active context details (resolved network, keyring, store path) +# Show active context details (resolved network, keyring, store path, capabilities) akt context show # Edit a context @@ -144,6 +211,18 @@ akt context rename prod production akt context delete staging --yes ``` +`akt context show` ends with a Capabilities block reporting the resolved feature set, naming the remedy for anything the configuration cannot do: + +``` + Capabilities: + Chain queries: available + Chain transactions: available + Provider gateway: available + Console API: unavailable — run akt console login +``` + +Commands outside that set are dimmed in help and refuse to run. Set `defaults.command-gating` to `hide` in `config.yaml` to drop them from help listings entirely, or to `off` to disable gating. + ### Network Management ```bash @@ -189,6 +268,38 @@ akt context keys import alice-backup alice.key akt context keys parse akash1abc... ``` +### SDL Authoring + +`akt sdl` runs entirely locally -- it works before any context exists and never touches the network. + +```bash +# List the built-in scaffolds and the flags each honors +akt sdl scaffolds + +# Generate a web service SDL +akt sdl init web --image nginx:1.27 > deploy.yaml + +# GPU workload with a specific model +akt sdl init gpu --gpu-model h100 --image myorg/model:1.0 > gpu.yaml + +# Generate and validate without touching disk +akt sdl init web --image nginx:1.27 | akt sdl validate - + +# Validate a file (exit 0 valid, exit 1 invalid) +akt sdl validate deploy.yaml +``` + +A valid document prints `valid: 1 service(s), 1 group(s), 0 warning(s)`. Problems are listed with a hint: + +``` +invalid: 1 error(s), 1 warning(s) + error: services/web/image: image "nginx" has no tag; pin an explicit version for reproducible deployments + hint: use "nginx:" instead of an untagged image + warning: profiles/placement/dcloud/pricing: pricing denom "uakt" is on-chain only; managed (console-api) deployments are priced in "uact" (micro-ACT, 1:1 USD) +``` + +Redirect the generated SDL to a file to feed `akt deploy` or `akt console deployment create`; both take a file path. + ### Transactions ```bash @@ -198,11 +309,11 @@ akt tx bank send alice akash1dest... 1000000uakt # Create a deployment akt tx deployment create deployment.yaml -# Close a deployment -akt tx deployment close --dseq 12345 +# Close a deployment (positional dseq) +akt tx deployment close 12345 -# Create a lease -akt tx market lease create --dseq 12345 --gseq 1 --oseq 1 --provider akash1prov... +# Create a lease (positional dseq and provider; gseq/oseq default to 1) +akt tx market lease create 12345 akash1prov... # Delegate to a validator akt tx staking delegate akashvaloper1... 1000000uakt @@ -217,11 +328,11 @@ akt tx cert publish client All transaction commands accept `--from`, `--gas`, `--fees`, `--broadcast-mode`, `--yes`, `--dry-run`, and other standard flags. Defaults come from the active context. -### Workflows (Planned) +### Workflows -Workflow commands orchestrate multi-step operations. They support two execution modes: +Workflow commands orchestrate multi-step operations, routing each step through the active context's auth method (local signing for `keyring`, Console API for `console-api`). They support two execution modes: -**TUI mode** (default, interactive): +**TUI mode** (default, interactive) -- a per-workflow progress display, independent of the [TUI shell](#tui-shell): ```bash # Full deployment lifecycle with interactive bid selection akt deploy deployment.yaml @@ -233,14 +344,11 @@ akt deploy deployment.yaml akt deploy deployment.yaml --bid-select cheapest --yes -o jsonl ``` -Output (one JSON object per line): +Output is one JSON object per line, one line per step (`create-deployment`, `wait-for-bids`, `select-bid`, `create-lease`, `send-manifest`, `display-result`): ```jsonl {"workflow":"deploy","id":"wf_a1b2c3","step":"create-deployment","result":"completed","errors":[],"txs":[{"hash":"ABCD...","height":12345,"gas_used":150000,"code":0}]} {"workflow":"deploy","id":"wf_a1b2c3","step":"wait-for-bids","result":"completed","errors":[],"txs":[]} -{"workflow":"deploy","id":"wf_a1b2c3","step":"select-bid","result":"completed","errors":[],"txs":[]} {"workflow":"deploy","id":"wf_a1b2c3","step":"create-lease","result":"completed","errors":[],"txs":[{"hash":"EFGH...","height":12350,"gas_used":120000,"code":0}]} -{"workflow":"deploy","id":"wf_a1b2c3","step":"send-manifest","result":"completed","errors":[],"txs":[]} -{"workflow":"deploy","id":"wf_a1b2c3","step":"display-result","result":"completed","errors":[],"txs":[]} ``` Parse with `jq`: @@ -252,7 +360,7 @@ akt deploy deployment.yaml --bid-select cheapest --yes -o jsonl \ ### Queries -Akash query commands use a **positional filter argument** instead of `--owner`/`--dseq` flags. The filter follows the resource hierarchy (`owner/dseq/gseq/oseq/provider`) with smart type detection: a bech32 address is an owner, a number is a dseq. When no owner is given, the context's default account is used. Non-identity filters like `--state` remain as flags. See [SPEC.md §3.8](SPEC.md#38-resource-filter-argument) for full details. +Akash query commands use a **positional filter argument** instead of `--owner`/`--dseq` flags (see the UX trial note in the [Overview](#overview)). The filter follows the resource hierarchy (`owner/dseq/gseq/oseq/provider`) with smart type detection: a bech32 address is an owner, a number is a dseq. When no owner is given, the context's default account is used. State keywords are also positional: `akt query deployment active` lists active deployments, and identity + state combine as two positional arguments (`akt query deployment 12345 active`). When the identity pins down a single record, the state argument verifies it — the command fails if the record is in a different state instead of printing it. See [SPEC.md §3.8](SPEC.md#38-resource-filter-argument) for full details. ```bash # Check balances @@ -270,8 +378,8 @@ akt query deployment akash1abc... # Get a specific deployment by owner and dseq akt query deployment akash1abc.../12345 -# List active leases -akt query market lease --state active +# List active leases (positional state keyword) +akt query market lease active # Leases for a specific deployment akt query market lease 12345 @@ -331,15 +439,11 @@ Hub navigation: `Tab`/`Shift-Tab` cycle between Network, Provider, Oracle/BME da # Pretty table (default) -- color-coded states, bold identifiers akt query deployment -# Pretty JSON -- syntax-highlighted -akt query deployment --output json +# JSON, for scripts and pipes +akt query deployment -o json -# Machine-readable YAML +# YAML akt query bank balances akash1abc... -o yaml - -# Machine-readable JSON (for scripts and pipes) -akt query deployment -o json -akt query bank balances akash1abc... -o json ``` ### Action Log @@ -364,8 +468,10 @@ Contexts with `--auth-method console-api` use the [Akash Console Managed Wallet # Set up a console-api context akt context create console --network mainnet --auth-method console-api --set-current -# Set your API key (created at console.akash.network > Settings > API Keys) -export AKT_CONSOLE_API_KEY="your-api-key-here" +# Store your API key as the context credential (created at +# console.akash.network > Settings > API Keys). Validates the key and +# writes it to contexts//console-api-key (0600). +akt console login # Deploy using the Console managed wallet akt tx deployment create deploy.yaml --deposit 5 @@ -374,30 +480,69 @@ akt tx deployment create deploy.yaml --deposit 5 akt query deployment # Update a deployment -akt tx deployment update deploy.yaml --dseq 12345 +akt tx deployment update deploy.yaml 12345 # Close a deployment -akt tx deployment close --dseq 12345 +akt tx deployment close 12345 + +# Drive the Console API directly +akt console deployment list +akt console wallet balance + +# Spend history for an explicit range (positional dates) +akt console usage 2026-01-01 2026-01-31 ``` -Query commands that read directly from the chain (e.g., `akt query bank balances`) still work normally. Commands that require local signing (e.g., `akt tx bank send`, `akt tx gov vote`) are not supported with `console-api` auth -- use a `keyring` context for those. +The API key resolves `--console-api-key` flag > `AKT_CONSOLE_API_KEY` env var > per-context stored credential, so switching context switches Console identity. Store or replace it without the interactive prompt with `akt context edit --console-api-key ` (an empty string removes it). Query commands that read directly from the chain (e.g., `akt query bank balances`) still work normally. Commands that require local signing (e.g., `akt tx bank send`, `akt tx gov vote`) are not supported with `console-api` auth -- use a `keyring` context for those. + +Live lease operations reach the provider gateway directly, authorized by a short-lived Console-minted JWT -- no wallet and no local key involved: + +```bash +# Stream logs for one service +akt console logs 12345 web --follow + +# Last 100 lines across all services +akt console logs 12345 --tail 100 + +# Kubernetes events for the lease +akt console events 12345 --follow + +# One live status snapshot from the provider gateway +akt console status 12345 + +# Re-poll every 30s until Ctrl-C +akt console status 12345 --watch --interval 30s + +# Interactive shell in a container (defaults to /bin/sh) +akt console shell 12345 web + +# Run a single command (a.k.a. exec) +akt console shell 12345 web -- ls -la + +# Which providers can run this SDL? (public endpoint, no key required) +akt console screen deploy.yaml +``` ### TUI Mode -Running `akt` with no subcommand launches the interactive TUI dashboard: +The TUI shell is **disabled pending UX feedback** (2026-07). Running `akt` with no subcommand prints the help text, and `-i`/`--interactive` reports that the TUI is disabled: ```bash +# Prints help akt + +# Launch the TUI anyway, for a feedback session +AKT_EXPERIMENTAL_TUI=1 akt ``` -Navigation: `1`-`6` primary views (Deployments, Leases, Providers, Monitor, Governance, Staking), `q` query panel, `t` tx panel, `:` or `ctrl+p` command palette, `?` help, `esc` back, `ctrl+c` quit. +When enabled, navigation is `1`-`6` primary views (Deployments, Leases, Providers, Monitor, Governance, Staking), `q` query panel, `t` tx panel, `:` or `ctrl+p` command palette, `?` help, `esc` back, `ctrl+c` quit. `akt monitor` is a separate application and remains fully functional. ## Roadmap Upcoming work (see [SPEC.md](SPEC.md) for full details): -- **Phase 2**: Deployment store (bbolt), sync engine, `akt deploy` workflow, provider gateway commands (`akt provider status/lease-status/lease-logs/lease-shell/send-manifest`), Console API client (`auth-method: console-api`), store export/import -- **Phase 3**: Full TUI with live resource views (deployments, leases, providers, governance, validators), `akt monitor` hub (network, provider, oracle/BME dashboards), command palette, themes +- **TUI**: populate resource views (deployments, leases, providers, governance, validators) with live data from the store and sync engine, then re-enable the shell +- **UX trial follow-up (2026-07)**: decide from feedback whether the duplicated identity/filter flags are restored. The default `command-gating` mode is settled as `dim`; `hide` and `off` remain available. - **Phase 4**: Plugin system, additional TUI resource views (wasm, ibc, escrow), performance optimization ## License diff --git a/SPEC.md b/SPEC.md index b4c31829..106f4ff9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -151,7 +151,8 @@ contexts: auth-method: console-api # use Console managed wallet instead of keyring console-api-url: "" # empty = default (https://console-api.akash.network) # keyring, default-account, and provider-defaults are not used with console-api auth - # API key is supplied via AKT_CONSOLE_API_KEY env var or --console-api-key flag + # API key: --console-api-key flag > AKT_CONSOLE_API_KEY env var > per-context + # credential file (contexts//console-api-key, see §7.1); never stored here # TUI settings tui: @@ -205,7 +206,7 @@ Contexts compose a network, keyring, and context-specific settings. The state st | Field | Type | Required | Default | Description | | ----------------------------- | ------ | -------- | -------------------------------------- | ---------------------------------------------------------------------------- | | `name` | string | yes | -- | Unique context identifier | -| `network` | string | yes | -- | Name of network definition to use | +| `network` | string | see note | -- | Name of network definition to use. Required for `keyring` auth; optional for `console-api` auth (a network-less context operates through the Console API alone and chain commands are capability-gated until a network is attached, §2.10). | | `auth-method` | string | no | `"keyring"` | Authentication method: `keyring` or `console-api` | | `console-api-url` | string | no | `"https://console-api.akash.network"` | Console API base URL (only with `console-api` auth) | | `keyring` | string | no | `"default"` | Keyring name for signing keys (only with `keyring` auth) | @@ -215,6 +216,8 @@ Contexts compose a network, keyring, and context-specific settings. The state st | `fees` | string | no | `""` | Fixed fees (only with `keyring` auth) | | `provider-defaults.auth-type` | string | no | `"jwt"` | Provider gateway auth: `jwt` or `mtls` (only with `keyring` auth) | +**Console API key**: the per-context Console API key is deliberately **not** a config.yaml field. It is stored as a separate credential file at `/contexts//console-api-key` with `0600` permissions and is managed via `akt context create/edit --console-api-key`. See §7.1 for the full resolution order and handling rules. + ### 1.5 Keyring Schema Keyrings are shared wallet storage. Adding a key to a keyring makes it immediately available to all contexts that reference that keyring. @@ -295,7 +298,7 @@ All environment variables use the `AKT_` prefix. When set, they override the cor | `AKT_FEES` | `contexts[*].fees` | `5000uakt` | | `AKT_BROADCAST_MODE` | `defaults.broadcast-mode` | `sync` | | `AKT_OUTPUT` | `defaults.output` | `json` | -| `AKT_CONSOLE_API_KEY` | Console API key (required for `console-api` auth method) | `akt_abc123...` | +| `AKT_CONSOLE_API_KEY` | Console API key (overrides the per-context stored credential; see §7.1) | `akt_abc123...` | ### 1.10 Built-in Network Templates @@ -380,7 +383,9 @@ Implementation note: `tx` and `query` commands are clean-copied from `akash-netw When `akt` is invoked with no subcommand, the following flow determines what happens: -1. **No config exists** (first run): The bootstrap wizard runs (§1.11, `internal/bootstrap/wizard.go`). It prompts the user to select networks, select a keyring backend (`os`, `file`, or `test`; default: `os`), and configure an initial context. After bootstrap completes, the root command continues to step 2. +1. **No config exists** (first run): The bootstrap wizard runs (§1.11, `internal/bootstrap/wizard.go`). It prompts the user to select networks, select a keyring backend (`os`, `file`, or `test`; default: `os`), and configure an initial context. It then offers optional Akash Console onboarding: the user may enter a Console API key (validated best-effort against `/v1/user/me`, stored as the initial context's per-context credential per §7.1) and choose whether deployments for that context should be routed through Console (`auth-method: console-api`). Both prompts default to "no" and are skipped entirely in non-interactive runs. The wizard runs only when stdin is a terminal: in headless environments it declines to bootstrap (no network fetch, no config written) and prints guidance to create a config via `akt context network create` / `akt context create`; the root command then continues to step 2 without a config. After bootstrap completes, the root command continues to step 2. +> **TUI shell status (2026-07): DISABLED pending UX feedback.** Bare `akt` prints the help text and `--interactive`/`-i` reports that the TUI is disabled. The launch path remains compiled behind `AKT_EXPERIMENTAL_TUI=1` for feedback sessions, and `akt monitor` (§2.6) is unaffected. Steps 2–5 below describe the behavior that resumes when the TUI is re-enabled. + 2. **Config exists, `defaults.interactive` is `true` (default), and a TTY is attached**: The interactive TUI application launches (§8). 3. **Config exists, `defaults.interactive` is `false`**: Print the help text (equivalent to `akt --help`). The user has opted out of TUI mode and must use explicit subcommands. 4. **Config exists, no TTY attached** (e.g., `akt | cat`): Print the help text. The TUI requires a terminal. @@ -421,20 +426,20 @@ akt │ │ └── multi-send │ ├── deployment │ │ ├── create -│ │ ├── update -│ │ ├── close +│ │ ├── update # dseq required positionally (--dseq disabled 2026-07); fails fast when missing +│ │ ├── close # dseq required positionally (--dseq disabled 2026-07); fails fast when missing │ │ └── group -│ │ ├── close -│ │ ├── pause -│ │ └── start +│ │ ├── close [dseq] [gseq] +│ │ ├── pause [dseq] [gseq] +│ │ └── start [dseq] [gseq] │ ├── market │ │ ├── bid │ │ │ ├── create │ │ │ └── close │ │ └── lease -│ │ ├── create -│ │ ├── withdraw -│ │ └── close +│ │ ├── create [dseq] [provider] +│ │ ├── withdraw [dseq] [provider] +│ │ └── close [dseq] [provider] │ ├── provider │ │ ├── create │ │ └── update @@ -523,20 +528,20 @@ akt │ │ ├── total │ │ ├── denom-metadata │ │ └── send-enabled [denom1...] -│ ├── deployment [filter] # [owner/]dseq; no arg → list; --state flag +│ ├── deployment [filter] [state] # [owner/]dseq [state]; no arg → list; --state flag — **disabled pending feedback** (positional only, 2026-07) │ │ ├── group [filter] # [owner/]dseq[/gseq] │ │ └── params │ ├── market -│ │ ├── order [filter] # [owner/]dseq[/gseq/oseq]; --state flag -│ │ ├── bid [filter] # [owner/]dseq[/gseq/oseq[/prov]]; --by; --state -│ │ ├── lease [filter] # [owner/]dseq[/gseq/oseq[/prov]]; --by; --state +│ │ ├── order [filter] [state] # [owner/]dseq[/gseq/oseq] [state]; --state flag — **disabled pending feedback** (positional only, 2026-07) +│ │ ├── bid [filter] [state] # [owner/]dseq[/gseq/oseq[/prov]] [state]; --by; --state — **disabled pending feedback** (positional only, 2026-07) +│ │ ├── lease [filter] [state] # [owner/]dseq[/gseq/oseq[/prov]] [state]; --by; --state — **disabled pending feedback** (positional only, 2026-07) │ │ └── params │ ├── provider [address] # List or get (address → single) -│ ├── cert [owner] # Owner or default account; --state flag +│ ├── cert [owner] [state] # Owner or default account, plus [state]; --owner/--state flags — **disabled pending feedback** (positional only, 2026-07) │ ├── audit [owner] # Owner or default account; --auditor flag │ ├── escrow [filter] # [owner[/dseq]]; --state flag │ │ ├── payment [filter] # [owner[/dseq]] -│ │ └── blocks-remaining +│ │ └── blocks-remaining [filter] # [owner/]dseq │ ├── staking │ │ ├── validator │ │ ├── validators @@ -606,22 +611,36 @@ akt │ ├── ibc-transfer # IBC transfer (passthrough from cosmos-sdk, subcommands not expanded) │ ├── upgrade │ ├── block [height] -│ ├── blocks +│ ├── blocks [query] # CometBFT query expr; --query override — **disabled pending feedback** (positional only, 2026-07) │ ├── block-results [height] │ ├── tx -│ ├── txs +│ ├── txs [events] # event list expr; --events override — **disabled pending feedback** (positional only, 2026-07) │ └── module-name-to-address ├── deploy # Workflow: full deployment lifecycle ├── update [dseq] # Workflow: update deployment + send manifest ├── close [dseq] # Workflow: close deployment +├── console # Akash Console managed-wallet API (§2.9) +│ ├── login [key] # Validate + store per-context API key credential +│ ├── logout # Remove stored credential +│ ├── whoami # Authenticated user info +│ ├── deployment # list | get | create | update | close | deposit | settings +│ ├── bid list # Bids for a deployment's open orders +│ ├── lease create [provider] # Accept a bid (uses cached manifest) +│ ├── wallet # list | balance | settings | cost +│ ├── usage [from] [to] # Daily spend history +│ ├── provider # list | get | regions | auditors (public, no key) +│ ├── gpu # GPU availability/pricing (public, no key) +│ ├── template # list | get | sdl (public, no key) +│ ├── apikey # list | create | delete +│ └── jwt create # Mint provider-scoped JWT ├── provider # Provider gateway commands │ ├── status [provider-addr] -│ ├── lease-status -│ ├── lease-logs -│ ├── lease-events +│ ├── lease-status [dseq] +│ ├── lease-logs [dseq] +│ ├── lease-events [dseq] │ ├── lease-shell │ ├── send-manifest -│ ├── get-manifest +│ ├── get-manifest [dseq] │ ├── migrate-hostnames │ └── migrate-endpoints ├── store # Local store management @@ -640,7 +659,7 @@ akt │ ├── oracle [rpc-endpoint] # Oracle/BME dashboard (alias) │ └── bme [rpc-endpoint] # Oracle/BME dashboard (alias) ├── mcp # MCP server for AI assistant integration (stdio) -├── version # Version information +├── version [--long] # Version information (--long: full build info) └── completion # Shell completion scripts ``` @@ -655,6 +674,7 @@ Create a new named context. A context references a network and keyring by name. | `--network` | string | `""` | Network name to use (required; must exist) | | `--auth-method` | string | `"keyring"` | Authentication method: `keyring` or `console-api` | | `--console-api-url` | string | `""` | Console API base URL (empty = default; only with `console-api`) | +| `--console-api-key` | string | `""` | Console API key stored as a per-context credential (§7.1; never written to config.yaml) | | `--keyring` | string | `"default"` | Keyring name (only with `keyring` auth) | | `--default-account` | string | `""` | Default account name (only with `keyring` auth) | | `--gas` | string | `"auto"` | Gas limit override (only with `keyring` auth) | @@ -673,7 +693,8 @@ akt context create monitoring --network mainnet akt context create staging --network testnet --keyring test-keyring --default-account testaccount # Create context using Console API managed wallet -# API key is supplied via AKT_CONSOLE_API_KEY env var at runtime +# Store the API key as the context credential with --console-api-key, +# or later via `akt console login` (see §7.1) akt context create console --network mainnet --auth-method console-api --set-current ``` @@ -733,6 +754,9 @@ Edit context-level settings. For network-level changes (endpoints, gas-prices), | `--default-account` | string | `""` | Change default account | | `--gas` | string | `""` | Change gas setting | | `--fees` | string | `""` | Change fees setting | +| `--auth-method` | string | `""` | Change authentication method: `keyring` or `console-api` | +| `--console-api-url` | string | `""` | Change Console API base URL (empty = default) | +| `--console-api-key` | string | `""` | Set the per-context Console API key (empty string removes it; §7.1) | | `--fork-network` | bool | `false` | Force fork when editing network fields | ```bash @@ -767,7 +791,7 @@ View the action log for the current context. | ----------- | ------ | --------- | ------------------------------------------------------------------- | | `--context` | string | current | Context to view log for | | `--limit` | int | `50` | Number of entries to show | -| `--type` | string | `""` | Filter by action type: `tx`, `query`, `workflow`, `error` | +| `--type` | string | `""` | Filter by action type: `tx`, `workflow`, `provider`, `context`, `console`, `error` (see §5.6) | | `--since` | string | `""` | Show entries since timestamp or duration (e.g., `1h`, `2024-01-01`) | | `--output` | string | `pretty` | Output format: `pretty` (table), `json` (raw JSONL entries, one per line) | @@ -777,7 +801,7 @@ $ akt context log --limit 5 2026-03-23 10:15:32 tx deployment create (dseq: 12345) success 2026-03-23 10:15:45 tx market lease create (dseq: 12345) success 2026-03-23 10:15:50 workflow send-manifest -> akash1prov1... success - 2026-03-23 10:20:01 query deployment deployments --dseq 12345 success + 2026-03-23 10:20:01 context edit (default-account: bob) success 2026-03-23 10:25:00 tx deployment close (dseq: 12345) success ``` @@ -865,6 +889,8 @@ akt context keys show test1 -a Workflow commands (`akt deploy`, `akt update`, `akt close`) are driven by a **declarative workflow engine**. Instead of hardcoded command logic, each workflow is a YAML definition that the engine interprets step by step. Users can override built-in workflows or create custom ones. +**Transports**: actions are defined once — as workflow definitions — and translated per transport by `internal/transport`. Each transport carries the same abstract steps onto its backing rail: the **chain** transport (keyring auth) signs and broadcasts transactions locally plus provider-gateway calls, while the **console** transport (console-api auth) maps the same steps onto Console API REST calls (§7.4–§7.5). Because the command surface (positionals and flags) is generated from the workflow definition and the transport is chosen per context at execution time, `akt deploy/update/close` accept identical arguments on both rails, and adding a new action never requires per-rail redesign. Cross-rail argument syntax (notably `--deposit`, §7.4) is normalized in the transport layer. + #### 2.3.1 Workflow Definition Location Workflow definitions are resolved in order: @@ -874,6 +900,8 @@ Workflow definitions are resolved in order: Top-level commands (`akt deploy`, `akt update`, `akt close`) are thin wrappers that load and run the corresponding workflow. CLI flags are **auto-generated** from the workflow's declared `params` section. +**Dynamic surfacing**: Workflow commands are generated at CLI startup from the set of workflows returned by the loader. A top-level command exists **if and only if** a workflow with that name resolves. The embedded built-ins (`deploy`, `update`, `close`) always resolve and therefore always appear; user-defined workflows in the global or per-context directories add further top-level commands, and removing a user workflow removes its command. Help output reflects only the resolved set. + #### 2.3.2 Workflow Definition Format ```yaml @@ -889,7 +917,7 @@ params: deposit: type: string default: "auto" - description: Initial deposit amount (auto = chain minimum or SDL-specified) + description: "Initial deposit: 5usd or $5 (USD, console-api contexts), 5000000uakt (coin, keyring contexts), auto = chain minimum (keyring)" bid-timeout: type: duration default: "5m" @@ -1085,7 +1113,7 @@ On error: | `workflow` | string | Workflow name (e.g., `deploy`, `update`, `close`) | | `id` | string | Unique workflow run ID (generated at start, same for all steps) | | `step` | string | Step name from the workflow definition | -| `result` | string | `completed` or `error` | +| `result` | string | `completed`, `error`, or `skipped` (step skipped by its on-error policy) | | `errors` | []string | Array of error messages (empty when `result` is `completed`) | | `txs` | []object | Array of raw transaction results (empty for non-tx steps) | @@ -1095,7 +1123,7 @@ On error: | ---------- | ------ | --------------------------------------------- | | `hash` | string | Transaction hash | | `height` | int64 | Block height where the tx was included | -| `gas_used` | int64 | Gas consumed | +| `gas_used` | int64 | Gas consumed (omitted when the executor does not report gas) | | `code` | uint32 | Response code (0 = success) | | `raw_log` | string | Raw log output (present on error, omitted otherwise) | @@ -1137,26 +1165,6 @@ steps: dseq: "{{ .Params.dseq }}" on-error: abort - - name: send-manifests - type: foreach - query: market.leases - params: - owner: "{{ .Account }}" - dseq: "{{ .Params.dseq }}" - state: active - as: lease - step: - type: provider - action: send-manifest - params: - provider: "{{ .Item.lease.id.provider }}" - dseq: "{{ .Params.dseq }}" - sdl: "{{ index .Params \"sdl-file\" }}" - retry: - max: 3 - delay: "5s" - on-error: continue - - name: display-result type: output template: | @@ -1164,6 +1172,8 @@ steps: DSEQ: {{ .Params.dseq }} ``` +Manifest re-send to providers with active leases (a `foreach` over `market.leases` running `provider`/`send-manifest` sub-steps) is planned for when the engine gains the `foreach` step type; until then, keyring users re-send manifests with `akt provider send-manifest` after an update, and console-api contexts get manifest handling from the Console API automatically. + **Close workflow definition:** ```yaml @@ -1207,7 +1217,7 @@ The flagship workflow command. Orchestrates the full deployment lifecycle: | Flag | Type | Default | Description | | ------------------ | -------- | --------------- | ----------------------------------------------------------- | | `--from` | string | context default | Account to deploy from | -| `--deposit` | string | auto-detect | Initial deposit amount | +| `--deposit` | string | `auto` | Initial deposit, unified syntax on both rails (see §7.4): `5usd`/`$5`, `5000000uakt`, or `auto` | | `--bid-timeout` | duration | `5m` | Maximum time to wait for bids | | `--min-bids` | int | `1` | Minimum bids before selection | | `--bid-select` | string | `"interactive"` | Bid selection: `interactive`, `cheapest`, `provider=` | @@ -1358,26 +1368,26 @@ Query provider status. If `provider-addr` is omitted, uses the provider from the | `--provider` | string | `""` | Provider address | | `--auth-type` | string | context default | Auth type: `jwt`, `mtls` | -#### `akt provider lease-status` +#### `akt provider lease-status [dseq]` -Query lease deployment status from the provider gateway. +Query lease deployment status from the provider gateway. The positional `dseq` supplies the deployment sequence. The `--dseq` flag is **disabled pending feedback** (positional only, 2026-07). | Flag | Type | Default | Description | | ------------- | ------ | --------------- | ------------------- | -| `--dseq` | uint64 | required | Deployment sequence | +| `--dseq` | uint64 | required unless positional `dseq` given | Deployment sequence — **disabled pending feedback** (positional only, 2026-07) | | `--gseq` | uint32 | `1` | Group sequence | | `--oseq` | uint32 | `1` | Order sequence | | `--provider` | string | required | Provider address | | `--from` | string | context default | Owner account | | `--auth-type` | string | context default | Auth type | -#### `akt provider lease-logs` +#### `akt provider lease-logs [dseq]` -Stream container logs from a lease. +Stream container logs from a lease. The positional `dseq` supplies the deployment sequence. The `--dseq` flag is **disabled pending feedback** (positional only, 2026-07). | Flag | Type | Default | Description | | ------------- | ------ | --------------- | ------------------------- | -| `--dseq` | uint64 | required | Deployment sequence | +| `--dseq` | uint64 | required unless positional `dseq` given | Deployment sequence — **disabled pending feedback** (positional only, 2026-07) | | `--gseq` | uint32 | `1` | Group sequence | | `--oseq` | uint32 | `1` | Order sequence | | `--provider` | string | required | Provider address | @@ -1387,9 +1397,9 @@ Stream container logs from a lease. | `--tail` | int64 | `-1` | Lines from end (-1 = all) | | `--auth-type` | string | context default | Auth type | -#### `akt provider lease-events` +#### `akt provider lease-events [dseq]` -Stream Kubernetes events from a lease. +Stream Kubernetes events from a lease. The positional `dseq` behaves as in `lease-logs` (`--dseq` — **disabled pending feedback**, positional only, 2026-07). Same flags as `lease-logs` (minus `--service`, `--tail`), plus `--follow`. @@ -1426,9 +1436,9 @@ Send an SDL manifest to provider(s) for an existing lease. | `--provider` | string | `""` | Specific provider (default: all providers with active leases) | | `--auth-type` | string | context default | Auth type | -#### `akt provider get-manifest` +#### `akt provider get-manifest [dseq]` -Retrieve the current manifest from a provider. +Retrieve the current manifest from a provider. The positional `dseq` supplies the deployment sequence. The `--dseq` flag is **disabled pending feedback** (positional only, 2026-07). #### `akt provider migrate-hostnames` @@ -1711,6 +1721,213 @@ With write tools: } ``` +### 2.9 Console Commands + +The `akt console` group drives the Akash Console managed-wallet API (§7): deployments are created, funded, and closed by the Console's server-side wallet — no local keyring or gas handling is involved. Authenticated commands resolve the API key per §7.1 (`--console-api-key` flag > `AKT_CONSOLE_API_KEY` > per-context stored credential) and fail with a pointer to `akt console login` when none is found. The base URL resolves per §7.2 (`--console-api-url` flag > context `console-api-url` > default). Public catalog commands (`provider`, `gpu`, `template`) work without a key and without a configured context. + +**Group persistent flags:** + +| Flag | Type | Default | Description | +| ------------------- | ------ | ------- | -------------------------------------------------------------------- | +| `--console-api-url` | string | `""` | Console API base URL (overrides the context setting) | +| `--console-api-key` | string | `""` | Console API key (overrides env var and stored credential, session only) | + +**Authentication:** + +| Command | Description | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `akt console login [key]` | Validate a key against `GET /v1/user/me` and store it as the active context's credential (§7.1). The key is taken from the positional argument or a hidden stdin prompt. Requires an active context. Prints the username; never prints the key. | +| `akt console logout` | Remove the active context's stored credential. | +| `akt console whoami` | Show the authenticated user (username, email, verified). | + +**Deployments** (positional `dseq` per §3.8.2): + +| Command | Flags | Description | +| --------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `akt console deployment list` | `--skip` (0), `--limit` (20) | List deployments with pagination. | +| `akt console deployment get ` | | Deployment with leases and escrow account. | +| `akt console deployment create [deposit-usd]` | `--deposit ` (alternative to positional; min 0.5) — **disabled pending feedback** (positional only, 2026-07) | Create a deployment; prints `dseq` + tx hash. The deposit uses the unified cross-rail syntax (§7.4): `5`, `5usd`, or `$5` (min $0.50); coin forms like `5000000uakt` fail with the cross-rail error. The returned manifest is cached at `contexts//manifests/.json` for `lease create`. | +| `akt console deployment update ` | | Update the deployment's SDL. | +| `akt console deployment close ` | | Close a deployment. Idempotent: an already-closed deployment prints a note and exits 0. | +| `akt console deployment deposit [amount-usd]` | `--amount ` (alternative to positional; > 0) — **disabled pending feedback** (positional only, 2026-07) | Add funds to the deployment's escrow. The amount uses the unified cross-rail syntax (§7.4): `10`, `10usd`, or `$10`; coin forms fail with the cross-rail error. | +| `akt console deployment settings [true\|false]` | `--auto-top-up true\|false` (alternative) — **disabled pending feedback** (positional only, 2026-07) | Show settings when no value is given; set auto-top-up when a positional or flag value is present. | +| `akt console bid list ` | | List bids for the deployment's open orders. | +| `akt console lease create [provider]` | `--gseq` (1), `--oseq` (1), `--provider` (alternative to positional) — **disabled pending feedback** (positional only, 2026-07), `--manifest ` | Accept a bid; the manifest defaults to the one cached by `deployment create`. | + +**Wallet and usage:** + +| Command | Flags | Description | +| ----------------------------- | ---------------------------- | ---------------------------------------------------------------- | +| `akt console wallet list` | | List managed wallets. `creditAmount` is dollar-scale per the `/v1/wallets` contract (shown as `$X.XX`, no µ scaling), with the wallet's `denom` when the API reports one. | +| `akt console wallet balance` | | Available / in-deployment / total balance in USD. | +| `akt console wallet settings [true\|false]` | `--auto-reload true\|false` (alternative) — **disabled pending feedback** (positional only, 2026-07) | Show settings when no value is given; set auto-reload otherwise. | +| `akt console wallet cost` | | Estimated weekly cost in USD. | +| `akt console usage [from] [to]` | `--from`, `--to` (YYYY-MM-DD, alternatives) — **disabled pending feedback** (positional only, 2026-07) | Daily spend history for the managed wallet. `totalSpent` is the spend within the requested range (sum of the daily values, order-independent); `lifetimeSpent` is the API's cumulative figure as of the range end, omitted when the range is empty. Omitted dates use the API defaults (last 30 days). | + +**Public catalog** (no API key required): + +| Command | Flags | Description | +| ----------------------------------- | ---------------- | -------------------------------------------------------- | +| `akt console provider list` | `--limit` (20, 0 = all) | List providers (limit applied client-side). | +| `akt console provider get
` | | One provider's full catalog record. | +| `akt console provider regions` | | Regions providers advertise. | +| `akt console provider auditors` | | Known auditors. | +| `akt console gpu` | | Network-wide GPU availability and price catalog. | +| `akt console template list` | | Template catalog. | +| `akt console template get ` | | One template. | +| `akt console template sdl ` | | Print the template's raw SDL to stdout (for piping). | + +**API keys and JWTs:** + +| Command | Flags | Description | +| -------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------- | +| `akt console apikey list` | | List API keys (secrets are never shown). | +| `akt console apikey create [expires-at]` | `--name`, `--expires-at` (RFC 3339, alternatives) — **disabled pending feedback** (positional only, 2026-07) | Create a key; the secret is printed exactly once with a warning. | +| `akt console apikey delete ` | | Delete a key; a missing key (404) is a no-op. | +| `akt console jwt create` | `--ttl` (300), `--scope` (csv, default `status,logs,events,shell,send-manifest,get-manifest`) | Mint a short-lived provider-scoped JWT. | + +**Provider gateway (live lease operations):** + +Managed (Console-API) contexts reach provider gateways directly, without a wallet or local key: each command resolves the deployment's first active lease via the Console API, looks up the provider's `hostUri`, and mints a scoped JWT via `POST /v1/create-jwt-token` that the gateway accepts as `Authorization: Bearer`. One-shot calls use a 300 s token; streaming/interactive modes (`--follow`, `--watch`, `shell`) use 3600 s. Without an active lease the commands fail listing the states of the leases that do exist. + +| Command | Flags | Description | +| -------------------------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `akt console logs [service]` | `--follow`, `--tail N`, `--service` (alternative) — **disabled pending feedback** (positional only, 2026-07) | Stream container logs from the lease's provider (JWT scope `logs`). | +| `akt console events ` | `--follow` | Stream Kubernetes events from the lease's provider (JWT scope `events`). | +| `akt console status ` | `--watch`, `--interval` (5s) | Live lease status from the provider gateway (JWT scope `status`); with `--watch`, snapshots are re-printed each interval until interrupted. `deployment get` remains the Console-API view. | +| `akt console shell [-- command...]` | | Interactive shell in a lease container, default `/bin/sh`; exec is the same operation with an explicit command (JWT scope `shell`). TTY auto-detected. | +| `akt console screen ` | | Client-side bid screening: derive resources from the SDL and list the providers able to run it (public endpoint, no key needed). | + +Per the positional-primary convention (§3.8), every console command takes its primary value(s) positionally; the equivalent flags remain as overrides and a positional value wins when both are given. (2026-07: the flag twins marked *disabled pending feedback* above are commented out in code for the positional-only UX trial — the positional form is the only way while the trial runs; the original flag definitions are preserved in `FEEDBACK(2026-07)` comments for restoration.) Output is indented JSON; USD values are rendered as `$X.XX`. State-changing calls are recorded in the context's action log as `type=console` entries (§5.6). No command ever prints a Console API key, except the one-time secret from `apikey create`. + +--- + +### 2.10 Capability Gating + +The active context's configuration determines a **feature set**: which transports are usable and therefore which command groups can work. + +| Capability | Derived from | Gates | +|---|---|---| +| `chain-query` | network has ≥1 RPC endpoint | `query`, `monitor` | +| `chain-tx` | network has ≥1 RPC endpoint | `tx` | +| `provider` | network has ≥1 RPC endpoint | `provider` | +| `console` | Console API key resolvable (§7.1) | Console-backed command groups | + +Commands declare requirements via a cobra annotation (`akt.requires`, package `internal/capability`); alternatives are separated by `|` (e.g. workflow commands require `chain-tx|console`). A command whose requirement the context cannot satisfy fails fast with the missing capability and its remedy instead of erroring mid-transport. + +Presentation is configurable while UX feedback is collected (`defaults.command-gating`): + +| Mode | Behavior | +|---|---| +| `dim` (default) | Unavailable commands stay listed, marked `[unavailable]` in help, and fail fast with an explanation. | +| `hide` | Unavailable commands are removed from help listings (direct invocation still fails fast with the explanation). | +| `off` | No gating; commands fail wherever the missing transport is first touched. | + +Example: a network-less `console-api` context (API key only) lists and runs only Console commands; `tx`, `query`, `provider`, and `monitor` are dimmed or hidden and explain that an RPC endpoint must be added. + +--- + +### 2.11 SDL Commands + +Transport-independent SDL authoring, ported from console-axi (`src/sdl/templates`, `src/sdl/lint.ts`). All `akt sdl` subcommands run entirely locally: no context, key, or RPC endpoint is required, and the group declares no capability requirements. + +#### `akt sdl scaffolds` + +List the built-in SDL scaffolds (alias: `akt sdl templates`, matching the reference CLI's historical name). + +| Scaffold | Shape | +| --------------- | -------------------------------------------------------------------------------------------- | +| `web` | Single web service with one HTTP port exposed to the internet (nginx:1.27, 0.5 CPU / 512Mi) | +| `gpu` | GPU workload (ML/inference) with an nvidia model requirement (pytorch image, a100, 4 CPU / 16Gi) | +| `multi-service` | App + postgres:16 database with a persistent volume (`beta2`) and service-to-service networking | +| `ip-lease` | Service with a dedicated public IP — SDL v2.1 `endpoints` + `expose ... to ip` | + +#### `akt sdl init ` + +Generate SDL YAML on stdout, pipeable into `akt sdl validate -` or redirected to a file for `akt deploy`. The output is self-checked against the validator before printing. Flags are generation parameters with per-scaffold defaults — not positional-argument twins — so the zero-flag invocation always produces a deployable SDL. An int flag left unset keeps its per-scaffold default; an explicitly set value is range-checked up front, so out-of-range input (including an explicit `0`) is a usage error (exit 2), never an internal generation error. Pricing defaults to a 10000 uact/block ceiling (100000 for `gpu`) so bids arrive. + +| Flag | Type | Description | +| ------------- | ----------- | --------------------------------------------------------- | +| `--name` | string | Service name (default per scaffold: `web` / `app`) | +| `--image` | string | Container image; must be tagged, e.g. `nginx:1.27` | +| `--port` | int | Container port, 1-65535 (default 80; 8080 for `gpu`) | +| `--as` | int | External port, 1-65535 (default 80) | +| `--cpu` | string | CPU units, e.g. `0.5` or `500m` | +| `--memory` | string | Memory size, e.g. `512Mi`, `2Gi` | +| `--storage` | string | Storage size, e.g. `1Gi` (sizes the persistent volume for `multi-service`) | +| `--count` | int | Replica count, minimum 1 (default 1) | +| `--price` | int | Max price per block in uact, minimum 1 | +| `--env` | stringArray | Environment variable `KEY=value` (repeatable) | +| `--gpu` | int | GPU units, minimum 1 (`gpu` scaffold, default 1) | +| `--gpu-model` | string | NVIDIA GPU model (`gpu` scaffold, default `a100`) | + +#### `akt sdl validate ` + +Validate an SDL offline (`-` reads stdin). Parsing and schema/relational validation use `pkg.akt.dev/go/sdl` — the same parser behind `akt deploy` and the chain tx commands — followed by lint rules ported from the reference: + +- **Unpinned image** (error): every service image must carry an explicit tag or `@sha256:` digest; untagged images and `:latest` are rejected as non-reproducible. +- **Pricing denom**: `uact` passes; `uakt` produces a **warning**, not an error — a deliberate deviation from the reference, which hard-rejects `uakt` because it only serves the managed Console API. akt serves both rails: `uakt` is valid on-chain, but console-api (managed) contexts price in `uact`. Any other denom is an error, matching the reference. + +Exit `0` when valid, printing a summary (`valid: N service(s), M group(s), K warning(s)`) plus any warnings; exit `1` when invalid, listing every parse/lint error. + +```bash +# Generate, self-check, and validate +akt sdl init web --image nginx:1.27 > deploy.yaml +akt sdl validate deploy.yaml + +# Pipe without touching disk +akt sdl init gpu --gpu-model h100 | akt sdl validate - +``` + +--- + +--- + +### 2.12 Version and Completion Commands + +#### `akt version` + +Print the binary's version. Build metadata is injected at link time by the +Makefile (`main.version`, `main.commit`, `main.date`); a binary built without +those flags reports `dev`. + +| Flag | Type | Default | Description | +| -------- | ---- | ------- | ------------------------------- | +| `--long` | bool | `false` | Print full build information | + +```bash +$ akt version +akt 0.0.1-143-g909f173 (commit: 909f1735b99d83a9ab52a0e6bee32ca7e7402672, built: 2026-07-27T04:20:51Z) + +$ akt version --long +version: 0.0.1-143-g909f173 +commit: 909f1735b99d83a9ab52a0e6bee32ca7e7402672 +built: 2026-07-27T04:20:51Z +go: go1.26.1 +platform: darwin/arm64 +build tags: osusergo,netgo,ledger,muslc,gcc +``` + +The long form is the form to include in bug reports: the build tags and +platform determine which keyring backends and cgo-dependent features are +compiled in. + +#### `akt completion ` + +Generate a shell completion script for `bash`, `zsh`, `fish`, or `powershell`. +Completion is dynamic for context and network names. + +```bash +# Load for the current session +source <(akt completion bash) + +# Install permanently (zsh) +akt completion zsh > "${fpath[1]}/_akt" +``` + +Both commands run without a configured context (§2.10): they require no +transport. + --- ## 3. Flag Specification @@ -1734,7 +1951,7 @@ Two flags control confirmation and safety bypass behavior across the CLI. They s | Flag | Short | Semantics | Example | |---|---|---|---| -| `--yes` | `-y` | Skip interactive confirmation prompts. The operation proceeds as if the user answered "yes" to all prompts. The operation itself is unchanged. | `akt tx deployment close --dseq 12345 --yes` | +| `--yes` | `-y` | Skip interactive confirmation prompts. The operation proceeds as if the user answered "yes" to all prompts. The operation itself is unchanged. | `akt tx deployment close 12345 --yes` | | `--force` | | Override a safety guard that would otherwise prevent the operation. The operation may behave differently or bypass a check. | `akt context network delete mainnet --force` (deletes even if contexts reference it) | `--yes` / `-y` is **not a global flag**. It is added individually to commands that have confirmation prompts: all `tx` commands (via `AddTxFlagsToCmd()`, §3.2), workflow commands (`deploy`, `update`, `close`), and destructive context/store management commands (`context delete`, `store import --replace`). `--force` is used sparingly on specific commands where a structural safety check exists (e.g., deleting a network that is referenced by contexts). Commands should never use `--force` as a synonym for `--yes`. @@ -1808,36 +2025,38 @@ Added to list-type query commands via `AddPaginationFlagsToCmd()`. Used by `tx` commands and `provider` gateway commands for resource identification. These compose hierarchically: LeaseID includes BidID includes OrderID includes GroupID includes DeploymentID. -> **Note:** For `query` commands, resource identity filtering is done via the positional filter argument instead of these flags. See [section 3.8](#38-resource-filter-argument) for the filter syntax. The flags below continue to be used by `tx` commands (e.g., `akt tx deployment close --dseq 12345`) and `provider` commands (e.g., `akt provider lease-logs --dseq 12345 --provider akash1...`) where the filter argument does not apply. +> **Note:** For `query` commands, resource identity filtering is done via the positional filter argument instead of these flags. See [section 3.8](#38-resource-filter-argument) for the filter syntax. The flags below continue to be used by `tx` and `provider` commands where no positional twin exists (e.g. `tx deployment create`, `tx market bid create/close`, `provider send-manifest`, `provider lease-shell`, `provider migrate-*`). +> +> **2026-07 positional-only trial:** on commands where a positional twin exists — `tx deployment close/update`, `tx deployment group close/pause/start`, `tx market lease create/withdraw/close`, `provider lease-status/lease-logs/lease-events/get-manifest` — the duplicated identity flags are **disabled pending feedback** (positional only, 2026-07); see the per-flag notes below. | Flag | Type | Default | Description | | ------------ | ------ | ------------------------- | -------------------------- | | `--owner` | string | context `default-account` | Deployment owner address | -| `--dseq` | uint64 | `0` | Deployment sequence number | -| `--gseq` | uint32 | `1` | Group sequence number | +| `--dseq` | uint64 | `0` | Deployment sequence number — **disabled pending feedback** (positional only, 2026-07) on `tx deployment close/update`, `tx deployment group *`, `tx market lease *`, `provider lease-status/lease-logs/lease-events/get-manifest` | +| `--gseq` | uint32 | `1` | Group sequence number — **disabled pending feedback** (positional only, 2026-07) on `tx deployment group *` | | `--oseq` | uint32 | `1` | Order sequence number | -| `--provider` | string | `""` | Provider address | +| `--provider` | string | `""` | Provider address — **disabled pending feedback** (positional only, 2026-07) on `tx market lease *` | ### 3.6 Deployment Query Flags -Used by `query deployment`. Resource identity (owner, dseq) is supplied via the positional filter argument (see [section 3.8](#38-resource-filter-argument)). When enough components are provided to identify a single deployment, returns detail format; otherwise returns a filtered list. +Used by `query deployment`. Resource identity (owner, dseq) is supplied via the positional filter argument (see [section 3.8](#38-resource-filter-argument)); the duplicated `--owner`/`--dseq` filter flags are **disabled pending feedback** (positional only, 2026-07). When enough components are provided to identify a single deployment, returns detail format; otherwise returns a filtered list. | Flag | Type | Default | Description | | --------- | ------ | ------- | ----------------------------------------------------- | -| `--state` | string | `""` | Filter by state: `active`, `closed`, or empty for all | +| `--state` | string | `""` | Filter by state: `active`, `closed`, or empty for all — **disabled pending feedback** (positional only, 2026-07; use the positional state keyword, including the second positional form `akt query deployment akash1x/12345 active`) | ### 3.7 Market Query Flags -Used by `query market order`, `query market bid`, and `query market lease`. Resource identity is supplied via the positional filter argument (see [section 3.8](#38-resource-filter-argument)). When enough components are provided to uniquely identify a single resource, returns detail format; otherwise returns a filtered list. +Used by `query market order`, `query market bid`, and `query market lease`. Resource identity is supplied via the positional filter argument (see [section 3.8](#38-resource-filter-argument)); the duplicated `--owner`/`--dseq`/`--gseq`/`--oseq`/`--provider` filter flags are **disabled pending feedback** (positional only, 2026-07). When enough components are provided to uniquely identify a single resource, returns detail format; otherwise returns a filtered list. `--by` remains enabled (it is a mode switch with no positional twin). | Flag | Type | Default | Description | | --------- | ------ | --------- | ----------------------------------------- | | `--by` | string | `"owner"` | Filter perspective: `owner` or `provider`. Controls how the filter argument is parsed. See [section 3.8](#38-resource-filter-argument). Only applies to `bid` and `lease` queries. | -| `--state` | string | `""` | Filter by state | +| `--state` | string | `""` | Filter by state — **disabled pending feedback** (positional only, 2026-07; use the positional state keyword, including the second positional form `akt query market lease 12345 active`) | ### 3.8 Resource Filter Argument -Akash `query` commands for deployment, market, certificate, audit, and escrow resources accept an optional positional **filter argument** that replaces flag-based identity filtering (the `--owner`, `--dseq`, `--gseq`, `--oseq`, `--provider` flags documented in section 3.5). This supports the [argument-driven filtering](DESIGN.md#11-goals) design goal. +Akash `query` commands for deployment, market, certificate, audit, and escrow resources accept an optional positional **filter argument** that replaces flag-based identity filtering (the `--owner`, `--dseq`, `--gseq`, `--oseq`, `--provider` flags documented in section 3.5). This supports the [argument-driven filtering](DESIGN.md#11-goals) design goal. The deployment, order, bid, lease, and cert queries additionally accept an optional **second positional argument** — a state keyword — so identity+state combinations stay expressible without `--state` (e.g. `akt query deployment akash1x/12345 active`, `akt query market lease 123 active`). #### 3.8.1 General Format @@ -1865,13 +2084,24 @@ The first component is classified by its format: | ----------------------------- | ----------------------------- | ------------------ | | Bech32 address (`akash1...`) | Leading address (owner or provider per `--by` mode) | `akash1abc...def` | | Unsigned integer | `dseq` — the leading address defaults to the context's `default-account` (in `--by owner` mode) | `12345` | +| State keyword (per-resource vocabulary) | State filter — equivalent to `--state ` | `active` | Subsequent `/`-separated components are parsed positionally: after the leading address comes `dseq` (uint64), then `gseq` (uint32), then `oseq` (uint32), then the trailing address (provider or owner, opposite of the leading address). +State keywords are only recognized as the sole/first component of the filter argument; they do not combine with identity paths inside a single argument. Since 2026-07 the identity+state combination is expressed with the optional **second positional argument** (`akt query deployment akash1abc/12345 active`); two state keywords (a bare-keyword first argument plus a second argument) are an error, and the `--state` flag is **disabled pending feedback** (positional only, 2026-07). On a fully-specified identity the second positional state **verifies** the fetched record rather than filtering (see §3.8.3). Each resource has its own state vocabulary, derived from the on-chain state enums: + +| Resource | State keywords | +| -------------- | ------------------------------------- | +| `deployment` | `active`, `closed` | +| `market order` | `open`, `active`, `closed` | +| `market bid` | `open`, `active`, `lost`, `closed` | +| `market lease` | `active`, `insufficient_funds`, `closed` | + #### 3.8.3 Get-vs-List Heuristic - If enough components are specified to **uniquely identify** a single resource, the command returns a single-item **detail** response. - Otherwise, the command returns a **filtered list** response. +- The optional positional **[state]** argument follows the same split: with a partial identity it filters the list; with a complete identity it **verifies** the fetched record — when the record is in a different state the command fails with an error naming both states (e.g. `deployment akash1x/12345 is closed, not active`) instead of silently printing the record. Dropping the state argument prints the record regardless of state. | Command | Unique identity requires | | ---------------------- | ------------------------------------- | @@ -1902,10 +2132,11 @@ Subsequent `/`-separated components are parsed positionally: after the leading a | `query market bid` | `[owner/]dseq[/gseq/oseq[/provider]]` | yes | | `query market lease` | `[owner/]dseq[/gseq/oseq[/provider]]` | yes | | `query provider` | `[address]` | no | -| `query cert` | `[owner]` | no | +| `query cert` | `[owner] [state]` | no | | `query audit` | `[owner]` | no | | `query escrow` | `[owner[/dseq]]` | no | | `query escrow payment` | `[owner[/dseq]]` | no | +| `query escrow blocks-remaining` | `[owner/]dseq` | no | #### 3.8.6 Examples @@ -1916,8 +2147,11 @@ akt query deployment # List all deployments for defaul akt query deployment 12345 # Get deployment dseq 12345 (owner from context) akt query deployment akash1abc... # List all deployments for that owner akt query deployment akash1abc.../12345 # Get specific deployment -akt query deployment --state active # List active deployments for default account -akt query deployment 12345 --state active # Get dseq 12345 (--state ignored on single get) +akt query deployment --state active # DISABLED pending feedback (2026-07): use `akt query deployment active` +akt query deployment active # List active deployments (positional state keyword) +akt query deployment 12345 active # Get + state verification: errors if 12345 is not active (§3.8.3) +akt query deployment akash1abc.../12345 active # Same, with explicit owner +akt query deployment 12345 --state active # DISABLED pending feedback (2026-07): use `akt query deployment 12345 active` ``` **Market lease queries — owner perspective** (default): @@ -1927,7 +2161,10 @@ akt query market lease # List all leases for default acc akt query market lease 12345 # List leases for dseq 12345 (owner from context) akt query market lease 12345/1/1 # List leases for order 12345/1/1 akt query market lease akash1abc.../12345/1/1/akash1prov... # Get specific lease -akt query market lease --state active # List active leases for default account +akt query market lease --state active # DISABLED pending feedback (2026-07): use `akt query market lease active` +akt query market lease active # List active leases (positional state keyword) +akt query market lease 12345 active # Partial identity + state: filters the lease list (state verifies only on full-identity gets, §3.8.3) +akt query market bid open # List open bids (positional state keyword) ``` **Market lease queries — provider perspective** (`--by provider`): @@ -2373,6 +2610,7 @@ Each context has its own action log at: | `workflow` | A multi-step workflow (e.g., `akt deploy`) | Workflow name, step sequence, each step's type and result | | `provider` | A provider gateway operation | Operation (send-manifest, lease-logs, etc.), provider address, result | | `context` | A context management operation | Operation (switch, edit, etc.), old/new values | +| `console` | A state-changing Console API operation | Operation (create-deployment, close-deployment, etc.), dseq, result | | `error` | A failed operation | Original action type, error message, context | ### 5.3 Action Entry Format @@ -2458,12 +2696,13 @@ The action log records entries for the following command categories: | Command category | Logged | Entry type | When | |---|---|---|---| | `tx *` | Always | `tx` | After broadcast (success or failure). On success: includes tx hash, height, gas used. On failure: includes error message and result code. | -| `query *` | Always | `query` | After query completes. Includes query path, duration. | +| `query *` | Never by default | `query` | Read-only queries are not state changes and are not recorded by default (see verbose row below). | | Workflow commands (`deploy`, `update`, `close`) | Always | `workflow` | One entry per workflow step. Each entry includes the step name, result, and workflow run ID. | -| `provider *` | Always | `provider` | After provider gateway operation completes. | +| `provider *` (state-changing: `send-manifest`, `migrate-hostnames`, `migrate-endpoints`, `lease-shell`) | Always | `provider` | After the provider gateway operation completes (success or failure). Read-only provider queries (`status`, `lease-status`, `lease-logs`, `lease-events`, `get-manifest`) are not recorded. | | `context *` | Always | `context` | After context management operation (switch, edit, create, delete). | +| Console API state changes (create/update/close deployment, create lease, deposit) | Always | `console` | After the Console API call completes (success or failure). Read-only Console queries are not recorded. | | All commands | On failure | `error` | When any command fails. Includes original action type and error message. | -| `query` (read-only, no side effects) | When `-v` is set | `query` | Verbose mode logs all queries for debugging. In default mode, only queries from interactive commands (not internal queries by the sync engine) are logged. | +| `query` (read-only, no side effects) | When `-v` is set (future) | `query` | Verbose-mode query logging for debugging is planned but not yet implemented. Internal queries (e.g. by the sync engine) are never logged. | The action logger is opened in the root command's `PersistentPreRunE` and closed in `PersistentPostRunE`. Commands retrieve it via `cliutil.ActionLogFromContext(cmd.Context())`. @@ -2581,9 +2820,20 @@ All Console API requests require the `x-api-key` header. The API key is resolved 1. `--console-api-key` flag (highest priority, session only) 2. `AKT_CONSOLE_API_KEY` environment variable -3. If neither is set, the command fails with an error instructing the user to set `AKT_CONSOLE_API_KEY`. +3. The per-context stored credential (see below) +4. If none is available, the command fails with an error explaining how to configure a key. + +**Per-context credential storage.** A Console API key can be stored on a context so that switching contexts switches Console identity with no manual credential juggling. The key is stored at: + +``` +/contexts//console-api-key +``` -The API key is **never** persisted in config.yaml or the keyring. +- The file is created with `0600` permissions and contains only the key. +- The key is **never** written to `config.yaml` and is **never** printed by any command or recorded in logs; `akt context show` reports only whether a key is configured. +- Because the credential lives in the context's data directory, `akt context rename` moves it and `akt context delete` removes it (unless `--keep-data`). +- The credential is written via `akt context create --console-api-key ...` or `akt context edit --console-api-key ...`; passing an empty string via `akt context edit --console-api-key ""` removes it. +- Different contexts hold independent keys; Console calls always use the active context's key (subject to the flag/env overrides above). API keys are created at [console.akash.network](https://console.akash.network) > Settings > API Keys. @@ -2599,12 +2849,14 @@ API keys are created at [console.akash.network](https://console.akash.network) > All requests include `Content-Type: application/json` and `x-api-key` headers. +The endpoints below cover the deployment lifecycle used by command and workflow routing (§7.4-§7.5). The client's full surface — user info, wallets, usage, provider/GPU/template catalogs, API keys, and provider-scoped JWTs — is documented per command in §2.9 and contract-tested against the vendored OpenAPI spec (`internal/console/testdata/openapi.json`). + #### `POST /v1/deployments` -- Create Deployment | Field | Type | Required | Description | | --------------- | ------ | -------- | ------------------------------------- | | `data.sdl` | string | yes | SDL content as string | -| `data.deposit` | number | yes | Deposit in USD (minimum $5) | +| `data.deposit` | number | yes | Deposit in USD (minimum $0.50) | Returns `{ data: { dseq: string, manifest: string } }`. @@ -2679,7 +2931,14 @@ When a workflow runs in a context with `auth-method: console-api`, the workflow | `provider` | Provider gateway call (JWT/mTLS) | Not supported — Console API contexts do not interact with provider gateways directly. The Console API handles manifest submission internally during lease creation. | | `foreach` | Iterate and execute nested step | Same, with nested step routing rules applied | -**Deposit handling**: When `auth-method: console-api`, the `--deposit` flag is interpreted as USD (not uakt). The workflow engine passes the value directly to the Console API's `data.deposit` field. +**Deposit handling**: `--deposit` accepts one unified syntax on both rails, parsed in one place (`internal/transport.ParseDeposit`) and translated per transport. The `usd` unit is case-insensitive and always wins over coin parsing. + +| Form | Examples | `keyring` (chain rail) | `console-api` (console rail) | +|---|---|---|---| +| USD | `5usd`, `$5`, `5.50usd` | Error: `USD deposits require a console-api context; specify a coin amount like 5000000uakt` | Sent as USD in the Console API's `data.deposit` field (Console minimum: 0.50 USD) | +| Coin | `5000000uakt`, `5akt` | Attached to the deployment as the coin deposit | Error: `console deposits are in USD; use e.g. 5usd` | +| Bare number | `5`, `5.50` | Error (coins require a denomination — the historical chain behavior, with cross-rail guidance) | Interpreted as USD, same as `5usd` | +| `auto` / empty | `auto` | Chain-minimum deployment deposit, queried on chain | Error: an explicit USD deposit is required | **Manifest handling**: The Console API's `POST /v1/deployments` returns a `manifest` field in the response. The workflow engine stores this value and passes it to `POST /v1/leases` when creating leases, instead of calling the provider's `send-manifest` endpoint directly. @@ -2692,7 +2951,7 @@ When a context uses `console-api` auth, the following commands are routed throug | `akt tx deployment create ` | `POST /v1/deployments` | `--deposit` flag in USD | | `akt tx deployment update ` | `PUT /v1/deployments/{dseq}` | | | `akt tx deployment close` | `DELETE /v1/deployments/{dseq}` | | -| `akt query market bid --dseq` | `GET /v1/bids?dseq=` | | +| `akt query market bid ` | `GET /v1/bids?dseq=` | | | `akt tx market lease create` | `POST /v1/leases` | Requires manifest from deployment create | | `akt tx escrow deposit` | `POST /v1/deposit-deployment` | `--deposit` flag in USD | | `akt query deployment` | `GET /v1/deployments` | Paginated via `--skip`/`--limit` | @@ -2708,11 +2967,11 @@ Use a context with auth-method: keyring for this operation. | HTTP Status | Handling | | ----------- | ----------------------------------------------------------------- | -| 401 | Invalid or expired API key. Prompt user to check `AKT_CONSOLE_API_KEY`. | +| 401 | Invalid or expired API key. Point the user at the key resolution chain (§7.1) and `akt console login`. | | 402 | Insufficient funds in Console account. | | 404 | Deployment not found (dseq does not exist or not owned by user). | -| 429 | Rate limited. Retry with backoff. | -| 5xx | Console API server error. Retry with backoff (max 3 attempts). | +| 429 | Rate limited. Retry with backoff (safe for every method: the request was rejected before processing). | +| 5xx | Console API server error. Retry with backoff (max 3 attempts) for idempotent methods (GET/HEAD/PUT/DELETE) only. POST is never replayed on 5xx: the request may have been processed despite the error (e.g. a gateway 502 after a completed write), and replaying it could duplicate a deployment or a USD deposit. | ### 7.7 Differences from Keyring Auth @@ -2728,6 +2987,54 @@ Use a context with auth-method: keyring for this operation. --- +### 7.8 Console Compatibility Matrix + +Status of `akt` coverage for every Akash Console capability. "Covered" means the capability is reachable from `akt` end to end; deferred entries carry an explicit rationale. Reference points: the Console API surface (§7.1–§7.7), the `console-axi` reference CLI, and the Console web app. + +#### Covered + +| Console capability | akt equivalent | Notes | +|---|---|---| +| Authenticate with API key | `akt console login/logout/whoami`; per-context credential (`akt context edit --console-api-key`) | Resolution: flag > `AKT_CONSOLE_API_KEY` > per-context file (§7.1). Switching context switches Console identity. | +| Create deployment (managed wallet) | `akt console deployment create [deposit-usd]`; `akt deploy` in a `console-api` context | Deposit in USD (minimum $0.50), unified deposit syntax per §7.4. The manifest is cached per context for the follow-up lease. | +| List / inspect deployments | `akt console deployment list/get` | Pagination via `--skip`/`--limit`. | +| Update / close deployment | `akt console deployment update/close`; `akt update`, `akt close` | Close is idempotent: an already-closed deployment reports success. | +| Escrow deposit | `akt console deployment deposit [amount-usd]` | | +| Auto top-up settings | `akt console deployment settings [true\|false]` | `/v2/deployment-settings`, PATCH with POST fallback. | +| View bids / create lease | `akt console bid list `, `akt console lease create [provider]` | The `akt deploy` workflow automates the bid wait and selection. | +| Live lease status | `akt console status ` (`--watch`) | Reads the provider gateway directly using a Console-minted scoped JWT. | +| Container logs / cluster events | `akt console logs [service]`, `akt console events ` (`--follow`) | Same streaming paths as `akt provider lease-logs/lease-events`, authenticated by the Console JWT — no websocket relay needed. | +| Exec / interactive shell | `akt console shell [-- command]` | Exec is the same command with an explicit command argument. | +| Bid screening | `akt console screen ` | Public endpoint; resources are derived from the SDL. | +| Wallet balances & managed wallets | `akt console wallet balance/list` | Balances are µACT rendered as USD (1 ACT = 1 USD); wallet credits are dollar-scale. | +| Wallet auto-reload | `akt console wallet settings [true\|false]` | The only headless funding path, matching the reference CLI. | +| Cost estimate & usage history | `akt console wallet cost`, `akt console usage [from] [to]` | Usage totals the requested range; the lifetime figure is reported separately. | +| Provider marketplace browse | `akt console provider list/get/regions/auditors` | Public endpoints; no key required. | +| GPU availability & pricing | `akt console gpu` | Public. | +| Template catalog | `akt console template list/get/sdl` | `template sdl` writes raw SDL to stdout; redirect it to a file for `akt deploy` (which takes a path, not stdin). | +| SDL authoring | `akt sdl scaffolds/init/validate` (§2.11) | Local scaffolding, generation, and lint; no context, key, or RPC required. | +| API key management | `akt console apikey list/create/delete` | Create shows the secret exactly once. | +| Provider-scoped JWT minting | `akt console jwt create` | Also the mechanism behind the live lease operations above. | +| Action audit trail | Automatic: state-changing Console calls are recorded in the per-context action log (`akt context log`) | Beyond Console parity — Console has no client-side audit trail. | + +#### Deferred or not portable (with rationale) + +| Console capability | Status | Rationale | +|---|---|---| +| Adding funds by card / 3DS payment | Not portable | Stripe checkout with 3DS is inherently interactive and web-only; the reference CLI defers to auto-reload as well. Headless path: `akt console wallet settings true` plus per-deployment auto top-up. | +| Account signup / email verification / team management | Not portable | Web-only Console account flows with no public API endpoints. | +| Certificate management for managed wallets | Not applicable | The managed wallet signs server-side, so no client certificate exists (the reference CLI has no cert commands either). | +| Console's `provider-proxy` websocket relay | Superseded | akt reaches provider gateways directly with a Console-minted JWT, which covers the same operations without reimplementing the relay's in-band auth protocol. | + +#### Behavioral differences vs the reference CLI (intentional) + +- Output follows akt conventions (`-o json|yaml|pretty`) rather than TOON. +- Credentials and the manifest cache are per-context (§7.1) rather than a single global config file, so several Console accounts can be used side by side. +- The composite `deploy` command is the existing `akt deploy` workflow with auth-aware routing (§7.4), not a separate code path. +- Commands take their primary values positionally (§3.8); the reference CLI is flag-based. + +--- + ## 8. TUI Specification The TUI incorporates the real-time monitoring functionality of [`aktop`](https://github.com/cloud-j-luna/aktop) -- a community-built terminal UI for Akash consensus and provider monitoring. The consensus view, validator voting view, provider fleet monitor, and governance parameters view are derived from `aktop` and integrated as first-class views in the `akt` TUI. @@ -4612,7 +4919,7 @@ Cobra provides this feature via `Command.SuggestionsMinimumDistance` and `Comman | 1.4 | Context manager | Context CRUD, switching, composition (network + keyring + store + log), fork/edit-parent for networks | All `akt context *` commands work, fork/edit-parent works, context propagation works | | 1.5 | Context live-reload | Config file watching, propagation of changes to running session | Config changes reflected in session; TUI overrides flags/env; CLI respects override chain | | 1.6 | Keyring integration | Shared multi-keyring support, keys visible to all contexts using the keyring | Keys can be created, listed, and used for signing; adding key to shared keyring visible in all contexts | -| 1.7 | Action log | Append-only JSONL action log per context, log reading/filtering | All tx/query actions logged, `akt context log` shows entries, log rotation works | +| 1.7 | Action log | Append-only JSONL action log per context, log reading/filtering | All mutating actions (tx, workflow, provider, context, console) logged per §5.6 — read-only queries are not recorded by default; `akt context log` shows entries newest-first; log rotation works | | 1.8 | Chain client | Full and light client with multi-endpoint failover | Successful tx broadcast and query with automatic failover when primary endpoint is down | | 1.9 | Transaction commands | All `tx` module commands (bank, deployment, market, provider, cert, audit, staking, distribution, gov, authz, feegrant, escrow, wasm, oracle, bme, slashing, vesting, upgrade, crisis, IBC) | Each command matches the behavioral output of the current `akash` binary | | 1.10 | Query commands | All `query` module commands | Each command matches the behavioral output of the current `akash` binary | @@ -4644,7 +4951,7 @@ Cobra provides this feature via `Command.SuggestionsMinimumDistance` and `Comman | 2.8 | Store export/import | YAML and JSON export, import with merge/replace | Round-trip: export then import produces identical store state | | 2.9 | Store status command | Display store info, sync state, record counts | Accurate reporting of store contents | | 2.10 | Events command | Live blockchain event streaming | `akt events` shows real-time events | -| 2.11 | Console API client | `auth-method: console-api` support, API key via env var, deployment CRUD via Console managed wallet API | Create, update, close deployments; list bids; create leases via Console API with `AKT_CONSOLE_API_KEY` | +| 2.11 | Console API client | `auth-method: console-api` support, API key resolved flag > env > per-context stored credential (§7.1), deployment CRUD via Console managed wallet API, `akt console` command group (§2.9) | Create, update, close deployments; list bids; create leases via Console API with the API key resolved per §7.1 | | 2.12 | MCP server | `akt mcp` command with stdio JSON-RPC transport, 21 read-only tools, 4 write tools gated behind `--enable-writes` | Read-only tools query chain state; write tools send transactions. Config resolved from active context. | ### Phase 3: TUI Mode diff --git a/e2e/console_live_test.go b/e2e/console_live_test.go new file mode 100644 index 00000000..af25149e --- /dev/null +++ b/e2e/console_live_test.go @@ -0,0 +1,185 @@ +package e2e + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// envConsoleLiveKey opts the live Console API smoke suite in. When unset the +// suite is skipped, so normal CI runs never touch the network. +const envConsoleLiveKey = "AKT_E2E_CONSOLE_API_KEY" + +// TestConsoleLive smoke-tests READ-ONLY `akt console` commands against the +// production Console API. It complements the offline OpenAPI contract tests +// in internal/console: those pin the client to the vendored spec, this one +// catches spec drift on the live API. +// +// Opt-in: set AKT_E2E_CONSOLE_API_KEY to a valid Console API key. The suite +// stores the key on a scratch context and runs only read-only commands — it +// never creates, mutates, deposits to, or closes anything. +func TestConsoleLive(t *testing.T) { + key := os.Getenv(envConsoleLiveKey) + if key == "" { + t.Skipf("skipping live Console API smoke tests: %s is not set", envConsoleLiveKey) + } + + home := setupContextHome(t) + + // Store the key as the context's Console credential, the same way a user + // would: akt context edit --console-api-key . + if _, stderr, exit := runAkt(t, home, "context", "edit", "prod", "--console-api-key", key); exit != 0 { + t.Fatalf("failed to store Console API key on context (exit %d): %s", exit, stderr) + } + + tests := []struct { + name string + args []string + markers []string // substrings expected in stdout + }{ + {"whoami", []string{"console", "whoami"}, []string{"username"}}, + {"wallet balance", []string{"console", "wallet", "balance"}, []string{"available", "total"}}, + // No flags on purpose: this is the exact request shape that returned + // HTTP 400 when the client sent empty startDate/endDate. + {"usage", []string{"console", "usage"}, []string{"totalSpent", "history"}}, + {"provider regions", []string{"console", "provider", "regions"}, nil}, + {"gpu", []string{"console", "gpu"}, []string{"availability", "models"}}, + {"template list", []string{"console", "template", "list"}, nil}, + // The remaining read paths that need no arguments. Every console + // subcommand not listed here either mutates (deployment create / + // close / deposit / update, lease create, apikey create / delete) + // or needs a live dseq (status, logs, events, shell, screen, bid + // list) — neither belongs in a suite that must be safe to point at + // a real funded account. + {"deployment list", []string{"console", "deployment", "list"}, nil}, + {"provider list", []string{"console", "provider", "list"}, nil}, + {"provider auditors", []string{"console", "provider", "auditors"}, nil}, + {"wallet list", []string{"console", "wallet", "list"}, nil}, + // Lists key metadata only; the secret is returned once, at create. + {"apikey list", []string{"console", "apikey", "list"}, nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cmdline := "akt " + strings.Join(tc.args, " ") + + stdout, stderr, exit := runAkt(t, home, tc.args...) + if exit != 0 { + t.Fatalf("%s failed (exit %d)\nstdout: %s\nstderr: %s", cmdline, exit, stdout, stderr) + } + + trimmed := strings.TrimSpace(stdout) + if trimmed == "" { + t.Fatalf("%s produced no output", cmdline) + } + if !json.Valid([]byte(trimmed)) { + t.Fatalf("%s output is not valid JSON:\n%s", cmdline, stdout) + } + + for _, marker := range tc.markers { + if !strings.Contains(stdout, marker) { + t.Errorf("%s output missing %q:\n%s", cmdline, marker, stdout) + } + } + }) + } +} + +// activeConsoleDSeq returns the dseq of a Console deployment that is active +// and has an active lease, or "" when the account has none. The gateway read +// paths resolve their provider from the active lease, so a deployment without +// one cannot exercise them. +func activeConsoleDSeq(t *testing.T, home string) string { + t.Helper() + + stdout, stderr, exit := runAkt(t, home, "console", "deployment", "list") + if exit != 0 { + t.Fatalf("console deployment list failed (exit %d): %s", exit, stderr) + } + + var listing struct { + Deployments []struct { + Deployment struct { + State string `json:"state"` + ID struct { + DSeq string `json:"dseq"` + } `json:"id"` + } `json:"deployment"` + Leases []struct { + State string `json:"state"` + } `json:"leases"` + } `json:"deployments"` + } + if err := json.Unmarshal([]byte(stdout), &listing); err != nil { + t.Fatalf("could not parse console deployment list: %v", err) + } + + for _, d := range listing.Deployments { + if d.Deployment.State != "active" { + continue + } + for _, l := range d.Leases { + if l.State == "" || l.State == "active" { + return d.Deployment.ID.DSeq + } + } + } + + return "" +} + +// TestConsoleLiveDeploymentReads covers the read paths that need a live dseq, +// which the argument-free matrix cannot reach: lease status, the bid list, +// and the provider-gateway log fetch. All are read-only. +// +// These run against whatever the account already has; they never create a +// deployment, because doing so spends real funds. +func TestConsoleLiveDeploymentReads(t *testing.T) { + key := os.Getenv(envConsoleLiveKey) + if key == "" { + t.Skipf("skipping live Console API smoke tests: %s is not set", envConsoleLiveKey) + } + + home := setupContextHome(t) + if _, stderr, exit := runAkt(t, home, "context", "edit", "prod", "--console-api-key", key); exit != 0 { + t.Fatalf("failed to store Console API key on context (exit %d): %s", exit, stderr) + } + + dseq := activeConsoleDSeq(t, home) + if dseq == "" { + t.Skip("account has no active deployment with an active lease") + } + + t.Run("status", func(t *testing.T) { + stdout, stderr, exit := runAkt(t, home, "console", "status", dseq) + if exit != 0 { + t.Fatalf("console status %s failed (exit %d): %s", dseq, exit, stderr) + } + if !json.Valid([]byte(strings.TrimSpace(stdout))) { + t.Fatalf("console status output is not valid JSON:\n%s", stdout) + } + }) + + t.Run("bid list", func(t *testing.T) { + stdout, stderr, exit := runAkt(t, home, "console", "bid", "list", dseq) + if exit != 0 { + t.Fatalf("console bid list %s failed (exit %d): %s", dseq, exit, stderr) + } + if !json.Valid([]byte(strings.TrimSpace(stdout))) { + t.Fatalf("console bid list output is not valid JSON:\n%s", stdout) + } + }) + + // A one-shot log fetch ends when the provider closes the connection, + // which arrives as an EOF. That used to be reported as an error, so the + // command exited non-zero after printing every line — this asserts the + // exit status, not just the output. + t.Run("logs exit status", func(t *testing.T) { + stdout, stderr, exit := runAkt(t, home, "console", "logs", dseq) + if exit != 0 { + t.Fatalf("console logs %s exited %d after streaming\nstdout: %s\nstderr: %s", + dseq, exit, stdout, stderr) + } + }) +} diff --git a/e2e/helpers_test.go b/e2e/helpers_test.go index 4da25031..c46d86cd 100644 --- a/e2e/helpers_test.go +++ b/e2e/helpers_test.go @@ -2,6 +2,7 @@ package e2e import ( "bytes" + "errors" "os" "os/exec" "path/filepath" @@ -35,7 +36,8 @@ func runAkt(t *testing.T, home string, args ...string) (string, string, int) { err := cmd.Run() exitCode := 0 if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { exitCode = exitErr.ExitCode() } else { t.Fatalf("failed to run akt: %v", err) @@ -76,7 +78,8 @@ func runAktNoHome(t *testing.T, args ...string) (string, string, int) { err := cmd.Run() exitCode := 0 if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { exitCode = exitErr.ExitCode() } else { t.Fatalf("failed to run akt: %v", err) diff --git a/e2e/localnet_test.go b/e2e/localnet_test.go new file mode 100644 index 00000000..eeb2426e --- /dev/null +++ b/e2e/localnet_test.go @@ -0,0 +1,554 @@ +package e2e + +// Gated localnet e2e tests: these run against a real single-validator Akash +// node and are skipped unless one of the following is set: +// +// AKT_E2E_RPC=http://host:26657 use an existing node (optionally set +// AKT_E2E_CHAIN_ID, and AKT_E2E_MNEMONIC to +// a funded account's mnemonic to enable the +// bank/tx subtests) +// AKT_E2E_LOCALNET=1 bootstrap a throwaway single-validator +// node in docker (image override via +// AKT_E2E_NODE_IMAGE) +// +// The docker bootstrap follows the standard cosmos-sdk single validator +// pattern using the akash binary inside ghcr.io/akash-network/node: +// genesis init -> keys add -> genesis add-account -> genesis gentx -> +// genesis collect -> start. Note the akash CLI names these subcommands +// "add-account" and "collect" (not "add-genesis-account"/"collect-gentxs"). + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +const ( + defaultNodeImage = "ghcr.io/akash-network/node:2.1.1" + defaultLocalChainID = "localakash" + // Both denominations are funded. A deployment's deposit must match the + // denom its SDL prices in, and the scaffolds `akt sdl init` emits price + // in uact — an account holding only uakt cannot deploy them, which is + // what the deployment lifecycle subtest exercises. + validatorGenesisFunds = "100000000000000uakt,100000000000000uact" // 100M each +) + +// localnet describes the chain endpoint the gated tests talk to. +type localnet struct { + RPC string // http://127.0.0.1: + ChainID string + Mnemonic string // funded account mnemonic ("" if unknown — bank/tx subtests skip) +} + +// bootstrapScript initializes and starts a single-validator akash node. +// It runs inside the node container; the validator key (including its +// mnemonic) is written to /data/validator-key.json for later retrieval via +// docker exec. +const bootstrapScript = `set -e +export HOME=/data +AKASH="akash --home /data" +$AKASH genesis init localnode --chain-id ` + defaultLocalChainID + ` >/dev/null 2>&1 +$AKASH keys add validator --keyring-backend test --output json > /data/validator-key.json 2>&1 +ADDR=$($AKASH keys show validator -a --keyring-backend test) +$AKASH genesis add-account "$ADDR" ` + validatorGenesisFunds + ` --keyring-backend test +$AKASH genesis gentx validator 1000000000000uakt --chain-id ` + defaultLocalChainID + ` --keyring-backend test --min-self-delegation 1 >/dev/null 2>&1 +$AKASH genesis collect >/dev/null 2>&1 +echo BOOTSTRAP_DONE +exec $AKASH start --rpc.laddr tcp://0.0.0.0:26657 --grpc.address 0.0.0.0:9090 --minimum-gas-prices 0uakt +` + +// localnetRPC resolves the chain endpoint for gated tests: an external node +// via AKT_E2E_RPC, a docker-bootstrapped node via AKT_E2E_LOCALNET=1, or +// skip. +func localnetRPC(t *testing.T) *localnet { + t.Helper() + + if rpc := os.Getenv("AKT_E2E_RPC"); rpc != "" { + chainID := os.Getenv("AKT_E2E_CHAIN_ID") + if chainID == "" { + chainID = defaultLocalChainID + } + return &localnet{ + RPC: rpc, + ChainID: chainID, + Mnemonic: os.Getenv("AKT_E2E_MNEMONIC"), + } + } + + if os.Getenv("AKT_E2E_LOCALNET") != "1" { + t.Skip("no localnet: set AKT_E2E_RPC or AKT_E2E_LOCALNET=1") + } + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("AKT_E2E_LOCALNET=1 but docker is not installed") + } + if err := exec.Command("docker", "info").Run(); err != nil { + t.Skip("AKT_E2E_LOCALNET=1 but the docker daemon is not reachable") + } + + return startDockerLocalnet(t) +} + +// startDockerLocalnet boots a single-validator akash node container, waits +// for it to produce blocks, and returns its endpoint plus the funded +// validator mnemonic. The container is removed on test cleanup. +func startDockerLocalnet(t *testing.T) *localnet { + t.Helper() + + image := os.Getenv("AKT_E2E_NODE_IMAGE") + if image == "" { + image = defaultNodeImage + } + + name := fmt.Sprintf("akt-e2e-localnet-%d", os.Getpid()) + + // Pre-pull so the run step (and readiness timeout) isn't dominated by + // image download time. Failure is non-fatal: the image may already be + // present locally. + pull := exec.Command("docker", "pull", image) + pull.Stdout = io.Discard + pull.Stderr = io.Discard + _ = pull.Run() + + scriptPath := filepath.Join(t.TempDir(), "bootstrap.sh") + if err := os.WriteFile(scriptPath, []byte(bootstrapScript), 0o755); err != nil { + t.Fatalf("failed to write bootstrap script: %v", err) + } + + // Bind 26657 to an ephemeral host port to avoid clashing with anything + // already listening on the default port. + run := exec.Command("docker", "run", "-d", + "--name", name, + "-p", "127.0.0.1::26657", + "-v", scriptPath+":/bootstrap.sh:ro", + "--entrypoint", "/bin/sh", + image, "/bootstrap.sh") + if out, err := run.CombinedOutput(); err != nil { + t.Fatalf("docker run failed: %v\n%s", err, out) + } + + t.Cleanup(func() { + _ = exec.Command("docker", "rm", "-f", name).Run() + }) + + // Resolve the ephemeral host port for 26657/tcp. + portOut, err := exec.Command("docker", "port", name, "26657/tcp").Output() + if err != nil { + t.Fatalf("docker port failed: %v", err) + } + // Output like "0.0.0.0:55000" or "127.0.0.1:55000" (possibly multiple lines). + hostPort := "" + for _, line := range strings.Split(strings.TrimSpace(string(portOut)), "\n") { + if idx := strings.LastIndex(line, ":"); idx >= 0 { + hostPort = strings.TrimSpace(line[idx+1:]) + break + } + } + if hostPort == "" { + t.Fatalf("could not determine mapped RPC port from %q", portOut) + } + + rpc := "http://127.0.0.1:" + hostPort + waitForChain(t, name, rpc) + + // Retrieve the validator mnemonic written during bootstrap. + keyOut, err := exec.Command("docker", "exec", name, "cat", "/data/validator-key.json").Output() + if err != nil { + t.Fatalf("failed to read validator key from container: %v", err) + } + var key struct { + Address string `json:"address"` + Mnemonic string `json:"mnemonic"` + } + if err := json.Unmarshal(keyOut, &key); err != nil { + t.Fatalf("failed to parse validator key JSON: %v\n%s", err, keyOut) + } + if key.Mnemonic == "" { + t.Fatalf("validator key JSON has no mnemonic:\n%s", keyOut) + } + + return &localnet{RPC: rpc, ChainID: defaultLocalChainID, Mnemonic: key.Mnemonic} +} + +var heightRe = regexp.MustCompile(`"latest_block_height"\s*:\s*"(\d+)"`) + +// waitForChain polls the node's /status endpoint until it reports at least +// one produced block, failing with the container logs on timeout. +func waitForChain(t *testing.T, containerName, rpc string) { + t.Helper() + + deadline := time.Now().Add(120 * time.Second) + client := &http.Client{Timeout: 2 * time.Second} + + for time.Now().Before(deadline) { + resp, err := client.Get(rpc + "/status") + if err == nil { + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr == nil && resp.StatusCode == http.StatusOK { + if m := heightRe.FindSubmatch(body); m != nil && string(m[1]) != "0" { + return + } + } + } + time.Sleep(time.Second) + } + + logs, _ := exec.Command("docker", "logs", containerName).CombinedOutput() + t.Fatalf("localnet did not produce a block within timeout; container logs:\n%s", logs) +} + +// setupLocalnetHome creates an akt home wired to the localnet: a network +// pointing at the node's RPC, an active context, and (when the mnemonic is +// known) the funded validator key imported into the test keyring. +func setupLocalnetHome(t *testing.T, net *localnet) string { + t.Helper() + + home := t.TempDir() + initHome(t, home) + + mustRunAkt(t, home, "context", "network", "create", "localnet", + "--chain-id", net.ChainID, "--rpc", net.RPC) + mustRunAkt(t, home, "context", "create", "localnet", "--network", "localnet", "--set-current") + + if net.Mnemonic != "" { + src := filepath.Join(t.TempDir(), "mnemonic.txt") + if err := os.WriteFile(src, []byte(net.Mnemonic+"\n"), 0o600); err != nil { + t.Fatalf("failed to write mnemonic file: %v", err) + } + mustRunAkt(t, home, "context", "keys", "add", "validator", "--source", src) + } + + return home +} + +// nonZeroDigitRe detects a non-zero balance in pretty output. +var nonZeroDigitRe = regexp.MustCompile(`[1-9]`) + +func TestLocalnet(t *testing.T) { + net := localnetRPC(t) + home := setupLocalnetHome(t, net) + + t.Run("QueryBlock", func(t *testing.T) { + // No --node: the block passthrough commands must resolve the RPC + // endpoint from the active context like module queries do. + stdout := mustRunAkt(t, home, "query", "block") + if !strings.Contains(stdout, net.ChainID) { + t.Fatalf("expected query block output to contain chain-id %q, got:\n%s", net.ChainID, stdout) + } + }) + + t.Run("QueryStakingValidators", func(t *testing.T) { + stdout := stripANSI(mustRunAkt(t, home, "query", "staking", "validators")) + if !strings.Contains(strings.ToLower(stdout), "bonded") { + t.Fatalf("expected a bonded validator in output, got:\n%s", stdout) + } + }) + + t.Run("BankBalances", func(t *testing.T) { + if net.Mnemonic == "" { + t.Skip("no funded mnemonic (set AKT_E2E_MNEMONIC when using AKT_E2E_RPC)") + } + + addr := strings.TrimSpace(stripANSI(mustRunAkt(t, home, "context", "keys", "show", "validator", "-a"))) + stdout := stripANSI(mustRunAkt(t, home, "query", "bank", "balances", addr)) + if !nonZeroDigitRe.MatchString(stdout) { + t.Fatalf("expected genesis account %s to have a non-zero balance, got:\n%s", addr, stdout) + } + }) + + t.Run("TxBankSendRecordsActionLog", func(t *testing.T) { + if net.Mnemonic == "" { + t.Skip("no funded mnemonic (set AKT_E2E_MNEMONIC when using AKT_E2E_RPC)") + } + + mustRunAkt(t, home, "context", "keys", "add", "recipient", "--no-backup") + recipient := strings.TrimSpace(stripANSI(mustRunAkt(t, home, "context", "keys", "show", "recipient", "-a"))) + + // Default gas (auto): exercises gas simulation end to end. + stdout, stderr, exitCode := runAkt(t, home, + "tx", "bank", "send", "validator", recipient, "1000000uakt", + "--fees", "5000uakt", "--yes") + if exitCode != 0 { + t.Fatalf("tx bank send failed (exit %d)\nstdout: %s\nstderr: %s", exitCode, stdout, stderr) + } + + // Ground truth: poll the recipient balance until the transfer lands. + deadline := time.Now().Add(45 * time.Second) + funded := false + var lastBalance string + for time.Now().Before(deadline) { + lastBalance = stripANSI(mustRunAkt(t, home, "query", "bank", "balances", recipient)) + if nonZeroDigitRe.MatchString(lastBalance) { + funded = true + break + } + time.Sleep(2 * time.Second) + } + if !funded { + t.Fatalf("recipient %s never received funds; last balance output:\n%s\nsend output:\n%s%s", + recipient, lastBalance, stdout, stderr) + } + + // AKT-211 acceptance: the broadcast is recorded in the context + // action log as a tx entry. + logOut := stripANSI(mustRunAkt(t, home, "context", "log", "--type", "tx")) + if !strings.Contains(logOut, "bank.MsgSend") { + t.Fatalf("expected action log to contain a bank.MsgSend tx entry, got:\n%s", logOut) + } + if !strings.Contains(logOut, "success") { + t.Fatalf("expected a successful tx entry in the action log, got:\n%s", logOut) + } + }) +} + +// pollUntil retries fn until it returns true or the deadline passes. Chain +// state is only visible a block or two after a tx is accepted, so assertions +// that read back what a tx wrote must poll rather than read once. +func pollUntil(timeout time.Duration, fn func() bool) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if fn() { + return true + } + time.Sleep(2 * time.Second) + } + return false +} + +// TestLocalnetQueries exercises every module's read path against a live +// chain. The offline suite proves these commands parse their arguments; +// only a real node proves the query round-trips and renders — a query that +// builds a malformed request or mishandles an empty result set passes +// offline and fails here. +func TestLocalnetQueries(t *testing.T) { + net := localnetRPC(t) + home := setupLocalnetHome(t, net) + + // Queries needing no account. Chain-wide reads and every module's + // params, which is the one read path every module is guaranteed to + // have on a chain with no activity on it yet. + // Every entry must be a runnable query, not a command group. A group + // prints its help and exits 0 without reaching the chain, so it passes + // this matrix no matter what the node does. `query params` is the + // x/params group — its only subcommand, subspace, needs arguments — and + // it is deliberately absent for that reason. The check for a new entry: + // with the context pointed at a dead endpoint it must exit non-zero. + queries := [][]string{ + {"query", "block"}, + {"query", "auth", "params"}, + {"query", "bank", "total"}, + {"query", "staking", "validators"}, + {"query", "staking", "params"}, + {"query", "staking", "pool"}, + {"query", "distribution", "params"}, + {"query", "slashing", "params"}, + {"query", "mint", "params"}, + {"query", "gov", "params"}, + // Akash modules — the reason this CLI exists, and previously + // untested against a chain at any point in the suite. + {"query", "deployment", "params"}, + {"query", "market", "params"}, + {"query", "provider", "list"}, + {"query", "cert", "list"}, + {"query", "audit", "list"}, + {"query", "bme", "params"}, + {"query", "oracle", "params"}, + } + + if net.Mnemonic != "" { + addr := validatorAddr(t, home) + // Account-scoped reads. These take the address positionally: the + // `list` subcommand form does not exist, so a wrong invocation + // here fails with a filter parse error rather than an empty table. + queries = append(queries, + []string{"query", "bank", "balances", addr}, + []string{"query", "auth", "account", addr}, + []string{"query", "deployment", addr}, + []string{"query", "market", "order", addr}, + []string{"query", "market", "bid", addr}, + []string{"query", "market", "lease", addr}, + ) + } + + for _, q := range queries { + t.Run(strings.Join(q[1:], " "), func(t *testing.T) { + stdout, stderr, exit := runAkt(t, home, q...) + if exit != 0 { + t.Fatalf("akt %s exited %d\nstdout: %s\nstderr: %s", + strings.Join(q, " "), exit, stdout, stderr) + } + if strings.TrimSpace(stripANSI(stdout)) == "" { + t.Fatalf("akt %s produced no output", strings.Join(q, " ")) + } + }) + } +} + +// validatorAddr returns the funded validator's bech32 address. +func validatorAddr(t *testing.T, home string) string { + t.Helper() + return strings.TrimSpace(stripANSI(mustRunAkt(t, home, "context", "keys", "show", "validator", "-a"))) +} + +// sdlPricingDenomRe extracts the denom an SDL prices in. A deployment's +// deposit must use the same denom, so the lifecycle test reads it from the +// generated SDL rather than hardcoding one and drifting from the scaffold. +var sdlPricingDenomRe = regexp.MustCompile(`(?m)^\s*denom:\s*(\S+)`) + +// TestLocalnetDeploymentLifecycle covers the path this CLI exists to serve, +// end to end on a real chain: publish a client certificate, create a +// deployment from a scaffold, observe the order the market opens for it, +// then close it and see the state change. Everything up to bid/lease is +// reachable without a provider; see the note at the close of this test for +// what is not. +func TestLocalnetDeploymentLifecycle(t *testing.T) { + net := localnetRPC(t) + if net.Mnemonic == "" { + t.Skip("no funded mnemonic (set AKT_E2E_MNEMONIC when using AKT_E2E_RPC)") + } + + home := setupLocalnetHome(t, net) + addr := validatorAddr(t, home) + + // A client certificate is a hard prerequisite: without one on disk, + // `tx deployment create` fails before it ever builds the message. + t.Run("CertGenerateAndPublish", func(t *testing.T) { + mustRunAkt(t, home, "tx", "cert", "generate", "client", "--from", "validator", "--fees", "5000uakt", "--yes") + mustRunAkt(t, home, "tx", "cert", "publish", "client", "--from", "validator", "--fees", "5000uakt", "--yes") + + if !pollUntil(45*time.Second, func() bool { + return strings.Contains(stripANSI(mustRunAkt(t, home, "query", "cert", "list")), "valid") + }) { + t.Fatal("published certificate never became valid on chain") + } + }) + + // Dogfood the scaffold: the SDL under test is the one `akt sdl init` + // hands a new user, so a scaffold that does not deploy fails here. + sdl := filepath.Join(t.TempDir(), "deploy.yaml") + sdlBody := mustRunAkt(t, home, "sdl", "init", "web") + if err := os.WriteFile(sdl, []byte(sdlBody), 0o600); err != nil { + t.Fatalf("failed to write generated SDL: %v", err) + } + + m := sdlPricingDenomRe.FindStringSubmatch(sdlBody) + if m == nil { + t.Fatalf("no pricing denom in generated SDL:\n%s", sdlBody) + } + deposit := "5000000" + m[1] + + var dseq string + + t.Run("DeploymentCreate", func(t *testing.T) { + stdout, stderr, exit := runAkt(t, home, "tx", "deployment", "create", sdl, + "--from", "validator", "--deposit", deposit, "--fees", "5000uakt", "--yes") + if exit != 0 { + t.Fatalf("tx deployment create failed (exit %d)\nstdout: %s\nstderr: %s", exit, stdout, stderr) + } + + if !pollUntil(45*time.Second, func() bool { + out := stripANSI(mustRunAkt(t, home, "query", "deployment", addr)) + return strings.Contains(out, "active") + }) { + t.Fatal("deployment never became active") + } + + dseq = deploymentDSeq(t, home, addr) + if dseq == "" { + t.Fatal("could not determine dseq of the created deployment") + } + }) + + t.Run("MarketOpensAnOrder", func(t *testing.T) { + if dseq == "" { + t.Skip("no deployment was created") + } + // Creating a deployment must open an order for its group. Without + // a provider nothing will bid, but the order proves the market + // module saw the deployment. + if !pollUntil(45*time.Second, func() bool { + return strings.Contains(stripANSI(mustRunAkt(t, home, "query", "market", "order", addr)), "open") + }) { + t.Fatal("market never opened an order for the deployment") + } + }) + + t.Run("QueryGroupAndEscrow", func(t *testing.T) { + if dseq == "" { + t.Skip("no deployment was created") + } + group := stripANSI(mustRunAkt(t, home, "query", "deployment", "group", addr+"/"+dseq+"/1")) + if !strings.Contains(group, dseq) { + t.Fatalf("group query did not report dseq %s:\n%s", dseq, group) + } + // Escrow takes a scope-prefixed xid, not a bare address. + mustRunAkt(t, home, "query", "escrow", "accounts", "open", "deployment/"+addr+"/"+dseq) + }) + + t.Run("DeploymentClosePositionalState", func(t *testing.T) { + if dseq == "" { + t.Skip("no deployment was created") + } + mustRunAkt(t, home, "tx", "deployment", "close", dseq, + "--from", "validator", "--fees", "5000uakt", "--yes") + + // The positional state filter is a headline change of this CLI and + // had no live coverage: `query deployment closed` must + // filter server-side, not just parse. + if !pollUntil(45*time.Second, func() bool { + return strings.Contains(stripANSI(mustRunAkt(t, home, "query", "deployment", addr, "closed")), "closed") + }) { + t.Fatal("deployment never reached the closed state") + } + }) + + t.Run("ActionLogRecordsEveryMutation", func(t *testing.T) { + logOut := stripANSI(mustRunAkt(t, home, "context", "log", "--type", "tx")) + for _, want := range []string{"cert", "deployment"} { + if !strings.Contains(logOut, want) { + t.Errorf("action log missing a %q tx entry:\n%s", want, logOut) + } + } + }) + + // Not covered here, and not coverable on a bare localnet: bid, lease, + // manifest send, and every `akt provider` gateway command. All of them + // need a provider bidding on the order, which a single-validator node + // has none of. They are exercised against the live Console rail in + // TestConsoleLive and by hand against mainnet. +} + +// deploymentDSeq returns the dseq of the owner's most recent deployment by +// parsing JSON output, so the test never scrapes a rendered table. +func deploymentDSeq(t *testing.T, home, addr string) string { + t.Helper() + + out := mustRunAkt(t, home, "query", "deployment", addr, "--output", "json") + + var parsed struct { + Deployments []struct { + Deployment struct { + ID struct { + DSeq string `json:"dseq"` + } `json:"id"` + } `json:"deployment"` + } `json:"deployments"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("could not parse deployment query JSON: %v\n%s", err, out) + } + if len(parsed.Deployments) == 0 { + return "" + } + + return parsed.Deployments[len(parsed.Deployments)-1].Deployment.ID.DSeq +} diff --git a/e2e/offline_test.go b/e2e/offline_test.go new file mode 100644 index 00000000..efef184c --- /dev/null +++ b/e2e/offline_test.go @@ -0,0 +1,753 @@ +package e2e + +// Offline e2e tests: everything in this file runs against the prebuilt akt +// binary without any chain connectivity. Tests that need a running node live +// in localnet_test.go and are gated behind AKT_E2E_RPC / AKT_E2E_LOCALNET. + +import ( + "bytes" + "errors" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Deterministic BIP39 mnemonic used for recovery tests. The derived address +// is stable for coin-type 118, account 0, index 0. +const ( + testMnemonic = "crouch ignore mail actor month page year cushion execute home pluck among bone helmet witness company regret image atom produce marriage surround top exhibit" + testMnemonicAddr = "akash19sk2chd930sa5lxeg3wdjmdgk0e9tnn7qhw3rk" +) + +// unreachableNode is an endpoint that fails immediately with connection +// refused, so query/tx commands fail fast at the connection stage without +// ever touching a real network. +const unreachableNode = "tcp://127.0.0.1:1" + +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// stripANSI removes ANSI color/style escape sequences so tests can assert on +// plain text regardless of terminal styling. +func stripANSI(s string) string { + return ansiRe.ReplaceAllString(s, "") +} + +// runAktStdin runs akt with the given home and arguments, feeding input on +// stdin (for passphrase prompts that read a line from stdin). +func runAktStdin(t *testing.T, home, stdin string, args ...string) (string, string, int) { + t.Helper() + bin := aktBinary(t) + fullArgs := append([]string{"--home", home}, args...) + cmd := exec.Command(bin, fullArgs...) + cmd.Stdin = strings.NewReader(stdin) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + exitCode := 0 + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() + } else { + t.Fatalf("failed to run akt: %v", err) + } + } + return stdout.String(), stderr.String(), exitCode +} + +// mustRunAkt runs akt and fails the test on non-zero exit. +func mustRunAkt(t *testing.T, home string, args ...string) string { + t.Helper() + stdout, stderr, exitCode := runAkt(t, home, args...) + if exitCode != 0 { + t.Fatalf("akt %s failed (exit %d)\nstdout: %s\nstderr: %s", + strings.Join(args, " "), exitCode, stdout, stderr) + } + return stdout +} + +// --- context keys lifecycle (test keyring backend) --- + +func TestKeysLifecycle(t *testing.T) { + home := t.TempDir() + initHome(t, home) // config keyring "default" uses backend: test — no OS prompts + + // add: generates a new key and prints the mnemonic backup. + stdout := mustRunAkt(t, home, "context", "keys", "add", "alice") + if !strings.Contains(stdout, "name: alice") { + t.Fatalf("expected add output to contain 'name: alice', got:\n%s", stdout) + } + if !strings.Contains(stdout, "address: akash1") { + t.Fatalf("expected add output to contain an akash1 address, got:\n%s", stdout) + } + if !strings.Contains(stdout, "mnemonic") { + t.Fatalf("expected add output to mention the mnemonic backup, got:\n%s", stdout) + } + + // add --no-backup: mnemonic must NOT be printed. + stdout = mustRunAkt(t, home, "context", "keys", "add", "bob", "--no-backup") + if strings.Contains(stdout, "mnemonic") { + t.Fatalf("expected --no-backup output to omit the mnemonic, got:\n%s", stdout) + } + + // list: both keys present. + stdout = stripANSI(mustRunAkt(t, home, "context", "keys", "list")) + for _, name := range []string{"alice", "bob"} { + if !strings.Contains(stdout, name) { + t.Fatalf("expected keys list to contain %q, got:\n%s", name, stdout) + } + } + + // show -a: prints only the bech32 address. + stdout = mustRunAkt(t, home, "context", "keys", "show", "alice", "-a") + addr := strings.TrimSpace(stripANSI(stdout)) + if !strings.HasPrefix(addr, "akash1") { + t.Fatalf("expected show -a to print a bare akash1 address, got: %q", addr) + } + + // show (full): includes name, type, address, pubkey. + stdout = stripANSI(mustRunAkt(t, home, "context", "keys", "show", "alice")) + for _, field := range []string{"Name:", "Type:", "Address:", "PubKey:"} { + if !strings.Contains(stdout, field) { + t.Fatalf("expected keys show to contain %q, got:\n%s", field, stdout) + } + } + + // show by address: lookup works with the bech32 address too. + stdout = stripANSI(mustRunAkt(t, home, "context", "keys", "show", addr)) + if !strings.Contains(stdout, "alice") { + t.Fatalf("expected keys show
to resolve to alice, got:\n%s", stdout) + } + + // rename. + mustRunAkt(t, home, "context", "keys", "rename", "alice", "alice-main") + stdout = stripANSI(mustRunAkt(t, home, "context", "keys", "list")) + if !strings.Contains(stdout, "alice-main") { + t.Fatalf("expected keys list to contain 'alice-main' after rename, got:\n%s", stdout) + } + + // export: passphrase is read from stdin (non-TTY friendly). + stdout, stderr, exitCode := runAktStdin(t, home, "passphrase123\n", "context", "keys", "export", "alice-main") + if exitCode != 0 { + t.Fatalf("keys export failed (exit %d)\nstdout: %s\nstderr: %s", exitCode, stdout, stderr) + } + begin := strings.Index(stdout, "-----BEGIN TENDERMINT PRIVATE KEY-----") + if begin < 0 { + t.Fatalf("expected export output to contain armored key, got:\n%s", stdout) + } + armor := stdout[begin:] + + // import: round-trip the armored key under a new name. + armorFile := filepath.Join(t.TempDir(), "alice.armor") + if err := os.WriteFile(armorFile, []byte(armor), 0o600); err != nil { + t.Fatalf("failed to write armor file: %v", err) + } + stdout, stderr, exitCode = runAktStdin(t, home, "passphrase123\n", "context", "keys", "import", "alice-restored", armorFile) + if exitCode != 0 { + t.Fatalf("keys import failed (exit %d)\nstdout: %s\nstderr: %s", exitCode, stdout, stderr) + } + + // The restored key must have the same address as the original. + restored := strings.TrimSpace(stripANSI(mustRunAkt(t, home, "context", "keys", "show", "alice-restored", "-a"))) + if restored != addr { + t.Fatalf("expected restored key address %q to equal original %q", restored, addr) + } + + // delete with --yes (non-interactive). + mustRunAkt(t, home, "context", "keys", "delete", "bob", "--yes") + stdout = stripANSI(mustRunAkt(t, home, "context", "keys", "list")) + if strings.Contains(stdout, "bob") { + t.Fatalf("expected keys list to NOT contain 'bob' after delete, got:\n%s", stdout) + } +} + +func TestKeysAddRecoverFromSource(t *testing.T) { + home := t.TempDir() + initHome(t, home) + + // Recover from a known mnemonic via --source (non-interactive --recover). + src := filepath.Join(t.TempDir(), "mnemonic.txt") + if err := os.WriteFile(src, []byte(testMnemonic+"\n"), 0o600); err != nil { + t.Fatalf("failed to write mnemonic file: %v", err) + } + + stdout := mustRunAkt(t, home, "context", "keys", "add", "carol", "--source", src) + if !strings.Contains(stdout, testMnemonicAddr) { + t.Fatalf("expected deterministic address %s for recovered key, got:\n%s", testMnemonicAddr, stdout) + } + // Recovery via --source must not echo the mnemonic back. + if strings.Contains(stdout, "crouch ignore mail") { + t.Fatalf("expected recovery output to omit the mnemonic, got:\n%s", stdout) + } +} + +func TestKeysMnemonic(t *testing.T) { + home := t.TempDir() + initHome(t, home) + + stdout := mustRunAkt(t, home, "context", "keys", "mnemonic") + words := strings.Fields(strings.TrimSpace(stripANSI(stdout))) + if len(words) != 24 { + t.Fatalf("expected a 24-word mnemonic, got %d words:\n%s", len(words), stdout) + } +} + +func TestKeysParse(t *testing.T) { + home := t.TempDir() + initHome(t, home) + + // bech32 -> hex (+ re-encodings under common prefixes). + stdout := stripANSI(mustRunAkt(t, home, "context", "keys", "parse", testMnemonicAddr)) + if !strings.Contains(stdout, "Format: bech32") { + t.Fatalf("expected parse output to identify bech32 format, got:\n%s", stdout) + } + if !strings.Contains(stdout, "HRP: akash") { + t.Fatalf("expected parse output to contain HRP akash, got:\n%s", stdout) + } + if !strings.Contains(stdout, "cosmos1") { + t.Fatalf("expected parse output to re-encode under cosmos prefix, got:\n%s", stdout) + } + + // Garbage input fails. + _, stderr, exitCode := runAkt(t, home, "context", "keys", "parse", "zzz-not-an-address") + if exitCode == 0 { + t.Fatal("expected non-zero exit for unparseable input") + } + if !strings.Contains(stderr, "cannot parse") { + t.Fatalf("expected parse error message, got:\n%s", stderr) + } +} + +// --- context edit / log / rename / network edit + delete --- + +func TestContextEditDefaultAccount(t *testing.T) { + home := setupContextHome(t) + + mustRunAkt(t, home, "context", "edit", "prod", "--default-account", "carol") + + stdout := stripANSI(mustRunAkt(t, home, "context", "show")) + if !strings.Contains(stdout, "Default Account") || !strings.Contains(stdout, "carol") { + t.Fatalf("expected context show to display default account 'carol', got:\n%s", stdout) + } +} + +func TestContextLogRecordsContextActions(t *testing.T) { + home := setupContextHome(t) // create (+ switch via --set-current) + + // Another recorded action so ordering is observable. + mustRunAkt(t, home, "context", "edit", "prod", "--default-account", "dave") + + stdout := stripANSI(mustRunAkt(t, home, "context", "log")) + + if !strings.Contains(stdout, "context") { + t.Fatalf("expected action log to contain 'context' type rows, got:\n%s", stdout) + } + for _, action := range []string{"create", "switch", "edit"} { + if !strings.Contains(stdout, action) { + t.Fatalf("expected action log to contain %q entry, got:\n%s", action, stdout) + } + } + + // Newest-first ordering: the edit (last action) must appear before the + // create/switch entries that happened earlier. + editIdx := strings.Index(stdout, "edit") + createIdx := strings.Index(stdout, "create") + switchIdx := strings.Index(stdout, "switch") + if editIdx > createIdx || editIdx > switchIdx { + t.Fatalf("expected newest-first ordering (edit before switch/create), got:\n%s", stdout) + } +} + +func TestContextRenameRecordsAction(t *testing.T) { + home := setupContextHome(t) + + mustRunAkt(t, home, "context", "rename", "prod", "production") + + // The context dir (including the action log) moves with the rename and + // current-context follows it. + stdout := stripANSI(mustRunAkt(t, home, "context", "log")) + if !strings.Contains(stdout, "rename") { + t.Fatalf("expected action log to contain the rename entry, got:\n%s", stdout) + } + + stdout = stripANSI(mustRunAkt(t, home, "context", "show")) + if !strings.Contains(stdout, "production") { + t.Fatalf("expected context show to display renamed context, got:\n%s", stdout) + } +} + +func TestContextNetworkEditAndDelete(t *testing.T) { + home := t.TempDir() + initHome(t, home) + + // Custom network (no template). + mustRunAkt(t, home, "context", "network", "create", "local", + "--chain-id", "localnet-1", "--rpc", "http://127.0.0.1:26657") + + // edit: change gas prices and chain-id. + mustRunAkt(t, home, "context", "network", "edit", "local", + "--gas-prices", "0.05uakt", "--chain-id", "localnet-2") + + stdout := stripANSI(mustRunAkt(t, home, "context", "network", "show", "local")) + if !strings.Contains(stdout, "0.05uakt") { + t.Fatalf("expected network show to reflect edited gas prices, got:\n%s", stdout) + } + if !strings.Contains(stdout, "localnet-2") { + t.Fatalf("expected network show to reflect edited chain-id, got:\n%s", stdout) + } + + // delete fails while a context references the network. + mustRunAkt(t, home, "context", "create", "dev", "--network", "local") + _, stderr, exitCode := runAkt(t, home, "context", "network", "delete", "local") + if exitCode == 0 { + t.Fatalf("expected network delete to fail while referenced by a context, stderr:\n%s", stderr) + } + + // After removing the referencing context, delete succeeds. + mustRunAkt(t, home, "context", "delete", "dev", "--yes") + mustRunAkt(t, home, "context", "network", "delete", "local") + + stdout = stripANSI(mustRunAkt(t, home, "context", "network", "list")) + if strings.Contains(stdout, "local") { + t.Fatalf("expected network list to NOT contain 'local' after delete, got:\n%s", stdout) + } +} + +// --- console-api auth method (SPEC §7.1) --- + +func TestContextConsoleAPIAuth(t *testing.T) { + home := t.TempDir() + initHome(t, home) + + const apiKey = "test-key-secret-123" + + mustRunAkt(t, home, "context", "network", "create", "mainnet", "--template", "mainnet") + mustRunAkt(t, home, "context", "create", "api", + "--network", "mainnet", + "--auth-method", "console-api", + "--console-api-key", apiKey, + "--set-current") + + stdout := stripANSI(mustRunAkt(t, home, "context", "show")) + + if !strings.Contains(stdout, "console-api") { + t.Fatalf("expected context show to display auth method console-api, got:\n%s", stdout) + } + if !strings.Contains(stdout, "API Key") || !strings.Contains(stdout, "configured") { + t.Fatalf("expected context show to report the API key as configured, got:\n%s", stdout) + } + // The credential itself must never be printed (SPEC §7.1). + if strings.Contains(stdout, apiKey) { + t.Fatalf("context show leaked the console API key:\n%s", stdout) + } + + // Credential file exists at contexts//console-api-key with 0600 perms. + credPath := filepath.Join(home, "contexts", "api", "console-api-key") + info, err := os.Stat(credPath) + if err != nil { + t.Fatalf("expected credential file at %s: %v", credPath, err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("expected credential file perms 0600, got %o", perm) + } + data, err := os.ReadFile(credPath) + if err != nil { + t.Fatalf("failed to read credential file: %v", err) + } + if strings.TrimSpace(string(data)) != apiKey { + t.Fatalf("credential file content mismatch: got %q", string(data)) + } + + // The key must not land in config.yaml. + cfg, err := os.ReadFile(filepath.Join(home, "config.yaml")) + if err != nil { + t.Fatalf("failed to read config.yaml: %v", err) + } + if strings.Contains(string(cfg), apiKey) { + t.Fatal("console API key leaked into config.yaml") + } +} + +// --- positional args smoke tests (AKT-650) --- +// +// Query/tx commands establish the RPC connection in PersistentPreRunE, which +// runs AFTER cobra argument validation but BEFORE the in-RunE filter parsing +// (DepFiltersFromArg). Offline we can therefore prove two things: +// 1. cobra-level Args accept the positional form (a rejection would surface +// as an args error before any connection attempt), and +// 2. the failure is at the connection stage, NOT the old +// "not a valid address or dseq number" arg-parse rejection. +// Full end-to-end verification that the state keyword filters results +// requires a live node — covered by the gated localnet suite and unit tests +// in internal/cli/chain/flags. + +func TestQueryDeploymentPositionalArgsOffline(t *testing.T) { + home := setupContextHome(t) + + cases := []struct { + name string + args []string + }{ + {"state keyword", []string{"query", "deployment", "active", "--node", unreachableNode}}, + {"owner address", []string{"query", "deployment", testMnemonicAddr, "--node", unreachableNode}}, + {"owner slash dseq", []string{"query", "deployment", testMnemonicAddr + "/12345", "--node", unreachableNode}}, + // Second positional: a state keyword combined with the identity + // filter (2026-07 positional-only trial; --state is disabled). + {"identity plus state", []string{"query", "deployment", testMnemonicAddr + "/12345", "active", "--node", unreachableNode}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, exitCode := runAkt(t, home, tc.args...) + combined := stdout + stderr + + if exitCode == 0 { + t.Fatalf("expected non-zero exit against unreachable node, got 0:\n%s", combined) + } + if strings.Contains(combined, "not a valid address or dseq number") { + t.Fatalf("positional arg was rejected at parse stage (AKT-650 regression):\n%s", combined) + } + if strings.Contains(combined, "unknown command") || strings.Contains(combined, "accepts at most") { + t.Fatalf("positional arg was rejected by cobra args validation:\n%s", combined) + } + // Failure must be at the connection stage. + if !strings.Contains(combined, "127.0.0.1") { + t.Fatalf("expected a connection-stage error mentioning the node address, got:\n%s", combined) + } + }) + } +} + +func TestQueryMarketPositionalStateArgsOffline(t *testing.T) { + // The market order/bid/lease twins accept the same optional second + // positional [state]; offline we prove cobra and the parse stage accept + // the identity+state form and the failure is at the connection stage. + home := setupContextHome(t) + + cases := []struct { + name string + args []string + }{ + {"order identity plus state", []string{"query", "market", "order", testMnemonicAddr + "/12345/1/1", "open", "--node", unreachableNode}}, + {"bid identity plus state", []string{"query", "market", "bid", testMnemonicAddr + "/12345/1/1", "open", "--node", unreachableNode}}, + {"lease identity plus state", []string{"query", "market", "lease", testMnemonicAddr + "/12345/1/1", "active", "--node", unreachableNode}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, exitCode := runAkt(t, home, tc.args...) + combined := stdout + stderr + + if exitCode == 0 { + t.Fatalf("expected non-zero exit against unreachable node, got 0:\n%s", combined) + } + if strings.Contains(combined, "not a valid") { + t.Fatalf("positional arg was rejected at parse stage:\n%s", combined) + } + if strings.Contains(combined, "unknown command") || strings.Contains(combined, "accepts at most") { + t.Fatalf("positional arg was rejected by cobra args validation:\n%s", combined) + } + if !strings.Contains(combined, "127.0.0.1") { + t.Fatalf("expected a connection-stage error mentioning the node address, got:\n%s", combined) + } + }) + } +} + +func TestTxDeploymentClosePositionalArgsOffline(t *testing.T) { + // tx deployment close requires a node for account/sequence + // resolution even with --generate-only, so offline we can only assert + // the positional dseq passes cobra validation and the failure is at the + // connection stage. The full path is exercised on the localnet. + home := setupContextHome(t) + + stdout, stderr, exitCode := runAkt(t, home, + "tx", "deployment", "close", "12345", "--generate-only", "--node", unreachableNode) + combined := stdout + stderr + + if exitCode == 0 { + t.Fatalf("expected non-zero exit against unreachable node, got 0:\n%s", combined) + } + if strings.Contains(combined, "not a valid") || strings.Contains(combined, "unknown command") { + t.Fatalf("positional dseq was rejected before connection stage:\n%s", combined) + } + if !strings.Contains(combined, "127.0.0.1") { + t.Fatalf("expected a connection-stage error, got:\n%s", combined) + } +} + +// --- dseq fail-fast guards (2026-07 positional-only trial) --- +// +// With --dseq disabled, the positional dseq is the only source for +// `tx deployment close` and `tx deployment update`. A missing dseq must fail +// during cobra argument validation — before the tx pipeline (connection, +// keyring unlock, broadcast) is entered — with a friendly error pointing at +// the positional form. The --node flag points at an unreachable endpoint so +// any connection attempt would be visible as a 127.0.0.1 error. + +func TestTxDeploymentCloseMissingDSeqFailsFast(t *testing.T) { + home := setupContextHome(t) + + stdout, stderr, exitCode := runAkt(t, home, + "tx", "deployment", "close", "--node", unreachableNode) + combined := stdout + stderr + + if exitCode == 0 { + t.Fatalf("expected non-zero exit for missing dseq, got 0:\n%s", combined) + } + if !strings.Contains(combined, "dseq is required") { + t.Fatalf("expected the dseq guard error, got:\n%s", combined) + } + if strings.Contains(combined, "127.0.0.1") { + t.Fatalf("guard must fire before the connection stage, got:\n%s", combined) + } +} + +func TestTxDeploymentUpdateMissingDSeqFailsFast(t *testing.T) { + home := setupContextHome(t) + + // The SDL file deliberately does not exist: the guard must fire before + // the file is read and before deployment 0 is ever queried. + stdout, stderr, exitCode := runAkt(t, home, + "tx", "deployment", "update", "does-not-exist.yaml", "--node", unreachableNode) + combined := stdout + stderr + + if exitCode == 0 { + t.Fatalf("expected non-zero exit for missing dseq, got 0:\n%s", combined) + } + if !strings.Contains(combined, "dseq is required") { + t.Fatalf("expected the dseq guard error, got:\n%s", combined) + } + if strings.Contains(combined, "127.0.0.1") { + t.Fatalf("guard must fire before the connection stage, got:\n%s", combined) + } + if strings.Contains(combined, "no such file") { + t.Fatalf("guard must fire before the SDL file is read, got:\n%s", combined) + } +} + +// --- store edge cases --- + +func TestStoreImportMissingFile(t *testing.T) { + home := setupContextHome(t) + + _, stderr, exitCode := runAkt(t, home, "store", "import", filepath.Join(t.TempDir(), "does-not-exist.yaml")) + if exitCode == 0 { + t.Fatal("expected non-zero exit for missing import file") + } + if !strings.Contains(stderr, "no such file") { + t.Fatalf("expected 'no such file' error, got:\n%s", stderr) + } +} + +// --- help backstop: every registered command answers --help with exit 0 --- +// +// This is the coverage backstop for "every command has at least one e2e +// test": each command path below gets a --help invocation as a named subtest, +// so a failure identifies the offending command. The list mirrors the +// registration in internal/cli/root.go (context/tx/query/monitor/mcp/ +// provider/store/workflows/version/completion), internal/cli/chain/tx.go and +// internal/cli/chain/query.go. + +func TestAllCommandsHelp(t *testing.T) { + // Hermetic: help must work on a machine with nothing configured, so + // point the CLI at an empty home. AKT_HOME (not --home) because several + // SDK group commands disable flag parsing and would ignore the flag. + t.Setenv("AKT_HOME", t.TempDir()) + + commands := [][]string{ + // root-level + {"version"}, + {"completion"}, + {"completion", "bash"}, + {"completion", "zsh"}, + {"completion", "fish"}, + {"completion", "powershell"}, + {"mcp"}, + + // monitor hub + dashboards + {"monitor"}, + {"monitor", "network"}, + {"monitor", "provider"}, + {"monitor", "oracle"}, + {"monitor", "bme"}, + + // context management + {"context"}, + {"context", "create"}, + {"context", "use"}, + {"context", "list"}, + {"context", "show"}, + {"context", "edit"}, + {"context", "delete"}, + {"context", "rename"}, + {"context", "log"}, + + // context network + {"context", "network"}, + {"context", "network", "create"}, + {"context", "network", "edit"}, + {"context", "network", "delete"}, + {"context", "network", "list"}, + {"context", "network", "show"}, + + // context keys + {"context", "keys"}, + {"context", "keys", "add"}, + {"context", "keys", "delete"}, + {"context", "keys", "list"}, + {"context", "keys", "show"}, + {"context", "keys", "export"}, + {"context", "keys", "import"}, + {"context", "keys", "rename"}, + {"context", "keys", "mnemonic"}, + {"context", "keys", "parse"}, + + // provider (off-chain provider interaction) + {"provider"}, + {"provider", "status"}, + {"provider", "lease-status"}, + {"provider", "lease-logs"}, + {"provider", "lease-events"}, + {"provider", "lease-shell"}, + {"provider", "send-manifest"}, + {"provider", "get-manifest"}, + {"provider", "migrate-hostnames"}, + {"provider", "migrate-endpoints"}, + + // store + {"store"}, + {"store", "status"}, + {"store", "export"}, + {"store", "import"}, + + // built-in workflows + {"deploy"}, + {"update"}, + {"close"}, + + // tx subgroups (internal/cli/chain/tx.go) + {"tx"}, + {"tx", "audit"}, + {"tx", "authz"}, + {"tx", "bank"}, + {"tx", "bank", "send"}, + {"tx", "bme"}, + {"tx", "broadcast"}, + {"tx", "cert"}, + {"tx", "crisis"}, + {"tx", "decode"}, + {"tx", "deployment"}, + {"tx", "deployment", "close"}, + {"tx", "distribution"}, + {"tx", "encode"}, + {"tx", "escrow"}, + {"tx", "evidence"}, + {"tx", "feegrant"}, + {"tx", "gov"}, + {"tx", "ibc"}, + {"tx", "ibc-transfer"}, + {"tx", "market"}, + {"tx", "multi-sign"}, + {"tx", "oracle"}, + {"tx", "provider"}, + {"tx", "sign"}, + {"tx", "sign-batch"}, + {"tx", "slashing"}, + {"tx", "staking"}, + {"tx", "upgrade"}, + {"tx", "validate-signatures"}, + {"tx", "vesting"}, + {"tx", "wasm"}, + + // query subgroups (internal/cli/chain/query.go) + {"query"}, + {"query", "audit"}, + {"query", "auth"}, + {"query", "authz"}, + {"query", "bank"}, + {"query", "bank", "balances"}, + {"query", "block"}, + {"query", "block-results"}, + {"query", "blocks"}, + {"query", "bme"}, + {"query", "cert"}, + {"query", "deployment"}, + {"query", "deployment", "group"}, + {"query", "distribution"}, + {"query", "escrow"}, + {"query", "evidence"}, + {"query", "feegrant"}, + {"query", "gov"}, + {"query", "ibc"}, + {"query", "ibc-transfer"}, + {"query", "market"}, + {"query", "mint"}, + {"query", "module-name-to-address"}, + {"query", "oracle"}, + {"query", "params"}, + {"query", "provider"}, + {"query", "slashing"}, + {"query", "staking"}, + {"query", "tx"}, + {"query", "txs"}, + {"query", "wasm"}, + } + + for _, path := range commands { + name := strings.Join(path, " ") + t.Run(name, func(t *testing.T) { + args := append(append([]string{}, path...), "--help") + stdout, stderr, exitCode := runAktNoHome(t, args...) + if exitCode != 0 { + t.Fatalf("akt %s --help exited %d\nstdout: %s\nstderr: %s", name, exitCode, stdout, stderr) + } + if len(strings.TrimSpace(stdout)) == 0 { + t.Fatalf("akt %s --help produced no output", name) + } + }) + } +} + +// TestConfigFreeCommandsSkipBootstrap covers the commands that produce the +// same output with or without configuration. On an unconfigured machine +// they must not reach the first-run bootstrap: in a terminal it would block +// a scripted smoke test on interactive input, and outside one it printed the +// wizard's "no terminal available" notice to stderr, so a clean run looked +// like a failure. Neither may write a config as a side effect. +func TestConfigFreeCommandsSkipBootstrap(t *testing.T) { + commands := [][]string{ + {"version"}, + {"version", "--long"}, + {"completion", "bash"}, + // A real subcommand, not the bare `akt sdl` group: cobra prints + // help for a command with no RunE without running the persistent + // hooks at all, so the group alone would pass either way. + {"sdl", "scaffolds"}, + } + + for _, path := range commands { + name := strings.Join(path, " ") + t.Run(name, func(t *testing.T) { + // A fresh home with no config.yaml, deliberately not seeded by + // initHome: an already-configured home would never bootstrap + // and the test would pass without exercising anything. + home := t.TempDir() + stdout, stderr, exitCode := runAkt(t, home, path...) + + if exitCode != 0 { + t.Fatalf("akt %s exited %d\nstdout: %s\nstderr: %s", name, exitCode, stdout, stderr) + } + if strings.Contains(stderr, "first-run wizard") { + t.Fatalf("akt %s reached the bootstrap wizard\nstderr: %s", name, stderr) + } + if _, err := os.Stat(filepath.Join(home, "config.yaml")); !os.IsNotExist(err) { + t.Fatalf("akt %s wrote a config on an unconfigured machine", name) + } + }) + } +} diff --git a/e2e/tui_test.go b/e2e/tui_test.go index 57d76a39..214ed6f5 100644 --- a/e2e/tui_test.go +++ b/e2e/tui_test.go @@ -2,7 +2,10 @@ package e2e import ( "context" + "errors" + "os" "os/exec" + "path/filepath" "strings" "testing" "time" @@ -28,7 +31,8 @@ func runAktWithTimeout(t *testing.T, timeout time.Duration, args ...string) (str if ctx.Err() == context.DeadlineExceeded { t.Fatalf("command timed out after %s: akt %s", timeout, strings.Join(args, " ")) } - if exitErr, ok := err.(*exec.ExitError); ok { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { exitCode = exitErr.ExitCode() } else { t.Fatalf("failed to run akt: %v", err) @@ -116,11 +120,18 @@ func TestTUIVersionCommand(t *testing.T) { } func TestTUINoArgNoTTY(t *testing.T) { - // Without a TTY, akt should print help and exit (per SPEC §2.0 step 4). - // It must not hang waiting for TUI input. - _, _, _ = runAktWithTimeout(t, 5*time.Second) - // If we reach here, the command exited within the timeout — success. - // The exit code may be 0 (help) or non-zero depending on config state. + // Without a TTY, akt should print and exit (per SPEC §2.0 step 4). + // It must not hang waiting for TUI input, and with a fresh home it + // must not silently run the first-run bootstrap (which would fetch + // networks over the wire and write a config with fallback answers). + // A temp home makes this hermetic — the CI failure mode was exactly + // bare akt on a machine with no existing config. + home := t.TempDir() + _, stderr, _ := runAktWithTimeout(t, 5*time.Second, "--home", home) + + if _, err := os.Stat(filepath.Join(home, "config.yaml")); !os.IsNotExist(err) { + t.Fatalf("headless first run must not write a config, stderr:\n%s", stderr) + } } func TestTUIContextListEmptyHome(t *testing.T) { diff --git a/go.mod b/go.mod index bcb1ea01..58fcf70b 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/CosmWasm/wasmvm/v3 v3.0.2 github.com/boz/go-lifecycle v0.1.1 github.com/charmbracelet/huh v0.6.0 + github.com/charmbracelet/x/ansi v0.11.6 github.com/charmbracelet/x/exp/golden v0.0.0-20260406091427-a791e22d5143 github.com/cometbft/cometbft v0.38.21 github.com/cosmos/cosmos-db v1.1.3 @@ -28,8 +29,10 @@ require ( github.com/cosmos/ibc-go/v10 v10.5.0 github.com/distribution/reference v0.6.0 github.com/fsnotify/fsnotify v1.9.0 + github.com/getkin/kin-openapi v0.143.0 github.com/go-errors/errors v1.5.1 github.com/hashicorp/go-metrics v0.5.4 + github.com/mark3labs/mcp-go v0.48.0 github.com/mdp/qrterminal/v3 v3.2.1 github.com/pkg/errors v0.9.1 github.com/rs/zerolog v1.34.0 @@ -44,7 +47,7 @@ require ( google.golang.org/grpc v1.75.0 google.golang.org/protobuf v1.36.10 gopkg.in/yaml.v3 v3.0.1 - pkg.akt.dev/go v0.2.6 + pkg.akt.dev/go v0.2.9 pkg.akt.dev/go/sdl v0.1.1 sigs.k8s.io/yaml v1.6.0 ) @@ -107,7 +110,6 @@ require ( github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/lipgloss v0.13.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -155,16 +157,20 @@ require ( github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/gogo/googleapis v1.4.1 // indirect github.com/gogo/protobuf v1.3.3 // indirect + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/orderedcode v0.0.1 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect @@ -203,7 +209,6 @@ require ( github.com/linxGnu/grocksdb v1.9.8 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/manifoldco/promptui v0.9.0 // indirect - github.com/mark3labs/mcp-go v0.48.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -211,6 +216,7 @@ require ( github.com/minio/highwayhash v1.0.3 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mtibben/percent v0.2.1 // indirect @@ -218,6 +224,9 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/oasisprotocol/curve25519-voi v0.0.0-20230904125328-1f23a7beb09a // indirect github.com/oklog/run v1.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -234,7 +243,7 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/cors v1.11.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sasha-s/go-deadlock v0.3.5 // indirect github.com/shamaton/msgpack/v2 v2.2.3 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -247,7 +256,11 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ulikunitz/xz v0.5.14 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/errs v1.4.0 // indirect github.com/zondax/golem v0.27.0 // indirect github.com/zondax/hid v0.9.2 // indirect @@ -284,10 +297,12 @@ require ( gotest.tools/v3 v3.5.2 // indirect k8s.io/api v0.33.3 // indirect k8s.io/apimachinery v0.33.3 // indirect + k8s.io/client-go v0.32.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect nhooyr.io/websocket v1.8.17 // indirect pgregory.net/rapid v1.2.0 // indirect + pkg.akt.dev/specs v0.0.1 // indirect rsc.io/qr v0.2.0 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index d69c6c89..2f77da8e 100644 --- a/go.sum +++ b/go.sum @@ -1313,6 +1313,8 @@ github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hC github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= @@ -1510,6 +1512,8 @@ github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WA github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -1529,6 +1533,8 @@ github.com/edwingeng/deque/v2 v2.1.1 h1:+xjC3TnaeMPLZMi7QQf9jN2K00MZmTwruApqplbL github.com/edwingeng/deque/v2 v2.1.1/go.mod h1:HukI8CQe9KDmZCcURPZRYVYjH79Zy2tIjTF9sN3Bgb0= github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -1584,6 +1590,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/getkin/kin-openapi v0.143.0 h1:mIrOpir9J5x2m1vdree2rhuJ/GYGwbTVBp1kuSCJ62Y= +github.com/getkin/kin-openapi v0.143.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= github.com/getsentry/sentry-go v0.35.0 h1:+FJNlnjJsZMG3g0/rmmP7GiKjQoUF5EXfEtBwtPtkzY= github.com/getsentry/sentry-go v0.35.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -1623,6 +1631,16 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= @@ -1644,6 +1662,8 @@ github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFG github.com/gogo/googleapis v1.4.1-0.20201022092350-68b0159b7869/go.mod h1:5YRNX2z1oM5gXdAkurHa942MDgEJyk02w4OecKY87+c= github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0= github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4= +github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= @@ -1698,6 +1718,8 @@ github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76 github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -1722,6 +1744,8 @@ github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSN github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -1894,6 +1918,8 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC github.com/jmhodges/levigo v1.0.1-0.20191019112844-b572e7f4cdac h1:GcJkaxD5Wy/Ucn+L0USlpbGJy9O6+7r0nBI7ftJ7Uu0= github.com/jmhodges/levigo v1.0.1-0.20191019112844-b572e7f4cdac/go.mod h1:dM7ihgFM8Do6WGIfOXWPgpJ+4bKGR/4ZkYh8HKDdFy4= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -1954,6 +1980,8 @@ github.com/lyft/protoc-gen-star/v2 v2.0.3/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLO github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mark3labs/mcp-go v0.48.0 h1:o+MXuGW/HCeR2ny5LcAcZQn2bo6I2xaZMEHnpRG+dtw= @@ -2000,6 +2028,8 @@ github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/z github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -2021,6 +2051,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/grpc-proxy v0.0.0-20181017164139-0f1106ef9c76/go.mod h1:x5OoJHDHqxHS801UIuhqGl6QdSAEJvtausosHSdazIo= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k= @@ -2032,6 +2064,10 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= github.com/oasisprotocol/curve25519-voi v0.0.0-20230904125328-1f23a7beb09a h1:dlRvE5fWabOchtH7znfiFCcOvmIYgOeAS5ifBXBlh9Q= github.com/oasisprotocol/curve25519-voi v0.0.0-20230904125328-1f23a7beb09a/go.mod h1:hVoHR2EVESiICEMbg137etN/Lx+lSrHPTD39Z/uE+2s= github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= @@ -2164,9 +2200,9 @@ github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sasha-s/go-deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU= github.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= @@ -2254,9 +2290,17 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -3256,6 +3300,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/cheggaaa/pb.v1 v1.0.27/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= @@ -3293,8 +3339,12 @@ k8s.io/api v0.33.3 h1:SRd5t//hhkI1buzxb288fy2xvjubstenEKL9K51KBI8= k8s.io/api v0.33.3/go.mod h1:01Y/iLUjNBM3TAvypct7DIj0M0NIZc+PzAHCIo0CYGE= k8s.io/apimachinery v0.33.3 h1:4ZSrmNa0c/ZpZJhAgRdcsFcZOw1PQU1bALVQ0B3I5LA= k8s.io/apimachinery v0.33.3/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/client-go v0.32.1 h1:otM0AxdhdBIaQh7l1Q0jQpmo7WOFIk5FFa4bg6YMdUU= +k8s.io/client-go v0.32.1/go.mod h1:aTTKZY7MdxUaJ/KiUs8D+GssR9zJZi77ZqtzcGXIiDg= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= @@ -3353,10 +3403,14 @@ nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= -pkg.akt.dev/go v0.2.6 h1:QpZqFNTKu4xNwZcgit6SVJsKNhWkd0IH0Y0Lzgpco9c= -pkg.akt.dev/go v0.2.6/go.mod h1:x9Cku9yibLk4aGLTE/Yy7eEkkda0uOSRWmYwQeFw23o= +pkg.akt.dev/go v0.2.9 h1:2oU971PeW/f9UY2pBL4tmFTrsk4CvSroGlTQ38uKQjY= +pkg.akt.dev/go v0.2.9/go.mod h1:x9Cku9yibLk4aGLTE/Yy7eEkkda0uOSRWmYwQeFw23o= pkg.akt.dev/go/sdl v0.1.1 h1:3CcAqWeKouFlvUSjQMktWLDqftOjn4cBX37TRFT7BRM= pkg.akt.dev/go/sdl v0.1.1/go.mod h1:ADsH8/kh61tWTax8nV0utelOaKWfU3qbG+OT3v9nmeY= +pkg.akt.dev/specs v0.0.1 h1:OP0zil3Fr4kcCuybFqQ8LWgSlSP2Yn7306meWpu6/S4= +pkg.akt.dev/specs v0.0.1/go.mod h1:tiFuJAqzn+lkz662lf9qaEdjdrrDr882r3YMDnWkbp4= +pkg.akt.dev/testdata v0.0.1 h1:yHfqF0Uxf7Rg7WdwSggnyBWMxACtAg5VpBUVFXU+uvM= +pkg.akt.dev/testdata v0.0.1/go.mod h1:j/GzV0/tylRpaORo5C/SwLLzRyhEUrquPDGxTa258Ew= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= diff --git a/internal/actionlog/log.go b/internal/actionlog/log.go index 2b803eca..1968cd19 100644 --- a/internal/actionlog/log.go +++ b/internal/actionlog/log.go @@ -25,6 +25,7 @@ const ( TypeWorkflow ActionType = "workflow" TypeProvider ActionType = "provider" TypeContext ActionType = "context" + TypeConsole ActionType = "console" TypeError ActionType = "error" ) @@ -165,7 +166,10 @@ func (l *Logger) Close() error { func (l *Logger) maybeRotate() error { info, err := l.file.Stat() if err != nil { - return nil // stat failure is not fatal + //nolint:nilerr // a stat failure only means we cannot decide whether to + // rotate; failing the caller here would turn an unreadable log file + // into a failed user action, which is worse than a late rotation. + return nil } if info.Size() < int64(maxLogSize) { @@ -203,12 +207,15 @@ func readLogFile(path string, filter Filter) ([]Entry, error) { if err != nil { return nil, err } - defer f.Close() + defer func() { _ = f.Close() }() var entries []Entry scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + // A single entry may carry large params (SDL contents, raw messages); + // allow lines up to the rotation threshold so Read never chokes on an + // entry that Log accepted. + scanner.Buffer(make([]byte, 0, 64*1024), maxLogSize) for scanner.Scan() { line := scanner.Bytes() diff --git a/internal/actionlog/log_edges_test.go b/internal/actionlog/log_edges_test.go new file mode 100644 index 00000000..7340d7fa --- /dev/null +++ b/internal/actionlog/log_edges_test.go @@ -0,0 +1,261 @@ +package actionlog_test + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "pkg.akt.dev/akt/internal/actionlog" +) + +// TestOpenCreatesParentDirectories covers the MkdirAll branch. The action log +// path is derived from a context name, and the context directory may not exist +// yet the first time something is recorded (e.g. `context create` logging its +// own creation). +func TestOpenCreatesParentDirectories(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "deeper", "actions.log") + + l, err := actionlog.Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer l.Close() + + if err := l.Log(actionlog.Entry{Type: actionlog.TypeTx, Action: "x"}); err != nil { + t.Fatalf("Log: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("log file was not created: %v", err) + } +} + +// TestOpenFailsWhenPathIsUnusable covers the OpenFile error branch. Callers +// (recordContextAction, the console client) swallow the error and degrade to +// no logging; the error must actually be returned so they can. +func TestOpenFailsWhenPathIsUnusable(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "actions.log") + + // A directory where the log file should be. + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + if l, err := actionlog.Open(path); err == nil { + _ = l.Close() + t.Fatal("opening a directory as a log file must fail") + } +} + +// TestLogAfterCloseReportsWriteFailure covers the write-error branch of Log. +// The logger is shared across a command's lifetime; a write after the file is +// closed must surface rather than silently dropping an audit record. +func TestLogAfterCloseReportsWriteFailure(t *testing.T) { + l, err := actionlog.Open(filepath.Join(t.TempDir(), "actions.log")) + if err != nil { + t.Fatalf("Open: %v", err) + } + + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + if err := l.Log(actionlog.Entry{Type: actionlog.TypeTx, Action: "after-close"}); err == nil { + t.Error("logging after Close must return an error") + } else if !strings.Contains(err.Error(), "action log") { + t.Errorf("unexpected error: %v", err) + } +} + +// TestCloseIsIdempotentEnoughForDefer pins that a second Close does not panic. +// Every caller defers Close, and several also close explicitly on the success +// path. +func TestCloseIsIdempotentEnoughForDefer(t *testing.T) { + l, err := actionlog.Open(filepath.Join(t.TempDir(), "actions.log")) + if err != nil { + t.Fatalf("Open: %v", err) + } + + if err := l.Close(); err != nil { + t.Fatalf("first Close: %v", err) + } + // The second close reports the already-closed file; it must not panic. + _ = l.Close() +} + +// TestReadSkipsMalformedLines covers readLogFile's tolerance for corruption. +// The log is append-only JSONL written by concurrent processes; a torn write +// must not make the whole log unreadable, which is what `akt context log` +// depends on. +func TestReadSkipsMalformedLines(t *testing.T) { + path := filepath.Join(t.TempDir(), "actions.log") + + l, err := actionlog.Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + if err := l.Log(actionlog.Entry{Type: actionlog.TypeTx, Action: "good-1"}); err != nil { + t.Fatalf("Log: %v", err) + } + _ = l.Close() + + // Append a torn line, a blank line, and a valid one. + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatalf("reopen: %v", err) + } + if _, err := f.WriteString("{\"type\":\"tx\",\"action\":\"tor\n\n"); err != nil { + t.Fatalf("write torn line: %v", err) + } + _ = f.Close() + + l2, err := actionlog.Open(path) + if err != nil { + t.Fatalf("reopen logger: %v", err) + } + defer l2.Close() + + if err := l2.Log(actionlog.Entry{Type: actionlog.TypeTx, Action: "good-2"}); err != nil { + t.Fatalf("Log: %v", err) + } + + entries, err := l2.Read(actionlog.Filter{}) + if err != nil { + t.Fatalf("Read: %v", err) + } + + var actions []string + for _, e := range entries { + actions = append(actions, e.Action) + } + if len(entries) != 2 { + t.Fatalf("entries = %v, want the two well-formed records", actions) + } + if actions[0] != "good-2" || actions[1] != "good-1" { + t.Errorf("entries = %v, want newest-first [good-2 good-1]", actions) + } +} + +// TestReadOnMissingFileIsNotAnError covers Read's os.IsNotExist tolerance: a +// context whose log has been deleted (or never written) must read as empty. +func TestReadOnMissingFileIsNotAnError(t *testing.T) { + path := filepath.Join(t.TempDir(), "actions.log") + + l, err := actionlog.Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer l.Close() + + if err := os.Remove(path); err != nil { + t.Fatalf("remove: %v", err) + } + + entries, err := l.Read(actionlog.Filter{}) + if err != nil { + t.Fatalf("Read on a removed log must not error: %v", err) + } + if len(entries) != 0 { + t.Errorf("entries = %v, want none", entries) + } +} + +// TestExportWriteErrorPropagates covers Export's writer-error branch. Export +// backs `akt context log --export`; a full disk or a closed pipe must be +// reported rather than producing a silently truncated audit file. +func TestExportWriteErrorPropagates(t *testing.T) { + l, err := actionlog.Open(filepath.Join(t.TempDir(), "actions.log")) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer l.Close() + + for _, a := range []string{"one", "two"} { + if err := l.Log(actionlog.Entry{Type: actionlog.TypeTx, Action: a}); err != nil { + t.Fatalf("Log: %v", err) + } + } + + if err := l.Export(failingWriter{}); err == nil { + t.Fatal("a failing writer must be reported") + } + + // Sanity: the same export succeeds against a working writer, in + // chronological order (the reverse of Read). + var buf bytes.Buffer + if err := l.Export(&buf); err != nil { + t.Fatalf("Export: %v", err) + } + if got := buf.String(); strings.Index(got, "one") > strings.Index(got, "two") { + t.Errorf("export must be chronological, got %q", got) + } +} + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("disk full") } + +// TestFilterCombinesCriteria covers matchesFilter with several criteria at +// once — the shape `akt context log --type tx --since 1h` produces. An OR +// instead of an AND here would show entries the user filtered out. +func TestFilterCombinesCriteria(t *testing.T) { + l, err := actionlog.Open(filepath.Join(t.TempDir(), "actions.log")) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer l.Close() + + entries := []actionlog.Entry{ + {Type: actionlog.TypeTx, Action: "match", DSeq: 42, Account: "alice"}, + {Type: actionlog.TypeTx, Action: "wrong-dseq", DSeq: 7, Account: "alice"}, + {Type: actionlog.TypeTx, Action: "wrong-account", DSeq: 42, Account: "bob"}, + {Type: actionlog.TypeQuery, Action: "wrong-type", DSeq: 42, Account: "alice"}, + } + for _, e := range entries { + if err := l.Log(e); err != nil { + t.Fatalf("Log: %v", err) + } + } + + got, err := l.Read(actionlog.Filter{Type: actionlog.TypeTx, DSeq: 42, Account: "alice"}) + if err != nil { + t.Fatalf("Read: %v", err) + } + if len(got) != 1 || got[0].Action != "match" { + t.Errorf("filtered entries = %+v, want only the fully-matching one", got) + } +} + +// TestLargeParamsSurviveRoundTrip pins the scanner buffer sizing: an entry +// carrying an SDL body is far larger than bufio's 64 KiB default line limit, +// and Read must not choke on a record that Log accepted. +func TestLargeParamsSurviveRoundTrip(t *testing.T) { + l, err := actionlog.Open(filepath.Join(t.TempDir(), "actions.log")) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer l.Close() + + big := strings.Repeat("a", 200*1024) + if err := l.Log(actionlog.Entry{ + Type: actionlog.TypeTx, + Action: "create-deployment", + Params: []byte(`{"sdl":"` + big + `"}`), + }); err != nil { + t.Fatalf("Log: %v", err) + } + + entries, err := l.Read(actionlog.Filter{}) + if err != nil { + t.Fatalf("Read: %v", err) + } + if len(entries) != 1 { + t.Fatalf("entries = %d, want 1 (a large entry must survive Read)", len(entries)) + } + if !strings.Contains(string(entries[0].Params), big) { + t.Error("large params were truncated on read") + } +} diff --git a/internal/actionlog/log_test.go b/internal/actionlog/log_test.go index fb9254b6..4db4097a 100644 --- a/internal/actionlog/log_test.go +++ b/internal/actionlog/log_test.go @@ -1,6 +1,9 @@ package actionlog_test import ( + "encoding/json" + "fmt" + "os" "path/filepath" "strings" "testing" @@ -176,3 +179,48 @@ func TestEmptyRead(t *testing.T) { t.Errorf("expected 0 entries from empty log, got %d", len(result)) } } + +func TestLogRotation(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "actions.log") + + l, err := actionlog.Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = l.Close() }) + + // Each entry carries ~1 MB of params, so the 10 MB threshold trips + // after ~10 entries and rotation shifts the current file to .1. + blob, _ := json.Marshal(map[string]string{"pad": strings.Repeat("x", 1<<20)}) + + const total = 12 + for i := 0; i < total; i++ { + if err := l.Log(actionlog.Entry{ + Type: actionlog.TypeTx, + Action: fmt.Sprintf("action-%d", i), + Params: blob, + }); err != nil { + t.Fatalf("log %d: %v", i, err) + } + } + + if _, err := os.Stat(path + ".1"); err != nil { + t.Fatalf("expected rotated file %s.1 to exist: %v", path, err) + } + + // Read must span rotated files transparently, newest first. + entries, err := l.Read(actionlog.Filter{}) + if err != nil { + t.Fatalf("read: %v", err) + } + if len(entries) != total { + t.Fatalf("expected %d entries across rotated files, got %d", total, len(entries)) + } + if entries[0].Action != fmt.Sprintf("action-%d", total-1) { + t.Errorf("newest entry = %s, want action-%d", entries[0].Action, total-1) + } + if entries[total-1].Action != "action-0" { + t.Errorf("oldest entry = %s, want action-0", entries[total-1].Action) + } +} diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 3ce08d53..64873dc1 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -55,6 +55,16 @@ type metaJSON struct { // Run performs first-run initialization. func Run(cfgRoot string) error { + // The wizard is interactive by design. Without a terminal it must not + // silently fetch networks and write a config with fallback answers + // (SPEC §2.0: no TTY -> print and exit); headless environments create + // their config explicitly via akt context/network commands. + if !term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Fprintln(os.Stderr, "no akt configuration found and no terminal is available for the first-run wizard;") + fmt.Fprintln(os.Stderr, "run akt from a terminal to bootstrap, or create a config with \"akt context network create\" and \"akt context create\"") + return nil + } + fmt.Println("Welcome to akt! No configuration found.") fmt.Println() fmt.Println("Fetching available networks from github.com/akash-network/net ...") @@ -113,10 +123,29 @@ func Run(cfgRoot string) error { }) } + // Optional Console API key onboarding for the initial context. + consoleKey, routeConsole := consoleOnboarding(currentCtx) + if routeConsole { + for i := range cfg.Contexts { + if cfg.Contexts[i].Name == currentCtx { + cfg.Contexts[i].AuthMethod = aktctx.AuthMethodConsoleAPI + } + } + } + if err := aktctx.SaveConfig(cfgRoot, cfg); err != nil { return fmt.Errorf("write config: %w", err) } + // The credential lives outside config.yaml (SPEC §7.1). + if consoleKey != "" { + if err := aktctx.SetConsoleAPIKey(cfgRoot, currentCtx, consoleKey); err != nil { + return fmt.Errorf("store console api key: %w", err) + } + + fmt.Printf("Console API key stored for context %q.\n", currentCtx) + } + fmt.Printf("\nConfig written to %s\n", aktctx.ConfigPath(cfgRoot)) fmt.Printf("Active context: %s\n\n", currentCtx) @@ -150,7 +179,7 @@ func multiSelect(networks []aktctx.Network) []aktctx.Network { if err != nil { return networks } - defer term.Restore(int(os.Stdin.Fd()), oldState) + defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }() render := func() { g := glyphs.G() @@ -214,7 +243,7 @@ func multiSelect(networks []aktctx.Network) []aktctx.Network { // Total rendered lines: header(1) + blank(1) + select-all(1) + blank(1) + networks(n) + blank(1) + hint(1) = n+6 renderLines := n + 6 - clear := func() { + clearLines := func() { for i := 0; i < renderLines; i++ { os.Stdout.WriteString("\033[A\033[2K") } @@ -232,7 +261,7 @@ func multiSelect(networks []aktctx.Network) []aktctx.Network { if nr == 1 { switch buf[0] { case ' ': - clear() + clearLines() if cursor == 0 { allOn := allSelected(checked) for i := range checked { @@ -244,7 +273,7 @@ func multiSelect(networks []aktctx.Network) []aktctx.Network { render() case '\r', '\n': os.Stdout.WriteString("\r\n") - term.Restore(int(os.Stdin.Fd()), oldState) + _ = term.Restore(int(os.Stdin.Fd()), oldState) var result []aktctx.Network for i, net := range networks { @@ -254,21 +283,21 @@ func multiSelect(networks []aktctx.Network) []aktctx.Network { } return result case 'j': - clear() + clearLines() cursor = (cursor + 1) % totalItems render() case 'k': - clear() + clearLines() cursor = (cursor - 1 + totalItems) % totalItems render() case 'q', 3: os.Stdout.WriteString("\r\n") - term.Restore(int(os.Stdin.Fd()), oldState) + _ = term.Restore(int(os.Stdin.Fd()), oldState) fmt.Println("Aborted.") os.Exit(0) } } else if nr == 3 && buf[0] == 27 && buf[1] == 91 { - clear() + clearLines() switch buf[2] { case 65: // Up cursor = (cursor - 1 + totalItems) % totalItems @@ -319,7 +348,7 @@ func selectKeyringBackend() string { if err != nil { return "os" // fallback } - defer term.Restore(int(os.Stdin.Fd()), oldState) + defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }() render := func() { g := glyphs.G() @@ -354,7 +383,7 @@ func selectKeyringBackend() string { // header(1) + blank(1) + options(3) + blank(1) = 6 renderLines := len(options) + 3 - clear := func() { + clearLines := func() { for i := 0; i < renderLines; i++ { os.Stdout.WriteString("\033[A\033[2K") } @@ -374,24 +403,24 @@ func selectKeyringBackend() string { switch buf[0] { case '\r', '\n': os.Stdout.WriteString("\r\n") - term.Restore(int(os.Stdin.Fd()), oldState) + _ = term.Restore(int(os.Stdin.Fd()), oldState) return options[cursor].value case 'j': - clear() + clearLines() cursor = (cursor + 1) % totalItems render() case 'k': - clear() + clearLines() cursor = (cursor - 1 + totalItems) % totalItems render() case 'q', 3: os.Stdout.WriteString("\r\n") - term.Restore(int(os.Stdin.Fd()), oldState) + _ = term.Restore(int(os.Stdin.Fd()), oldState) fmt.Println("Aborted.") os.Exit(0) } } else if nr == 3 && buf[0] == 27 && buf[1] == 91 { - clear() + clearLines() switch buf[2] { case 65: // Up cursor = (cursor - 1 + totalItems) % totalItems diff --git a/internal/bootstrap/console.go b/internal/bootstrap/console.go new file mode 100644 index 00000000..602a1dc9 --- /dev/null +++ b/internal/bootstrap/console.go @@ -0,0 +1,110 @@ +package bootstrap + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "time" + + "golang.org/x/term" + + "pkg.akt.dev/akt/internal/console" +) + +// consoleOnboarding optionally collects an Akash Console API key during the +// first-run wizard (SPEC §7.1). Returns the key ("" = skipped) and whether +// deployments for the initial context should be routed through Console +// (auth-method: console-api). Non-interactive runs skip the step entirely. +func consoleOnboarding(ctxName string) (key string, routeDeployments bool) { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return "", false + } + + fmt.Println(ansiBold + "Akash Console (optional)" + ansiReset) + fmt.Println(ansiDim + "A Console API key enables managed deployments, USD billing, and the" + ansiReset) + fmt.Println(ansiDim + "akt console commands. Keys: console.akash.network > Settings > API Keys." + ansiReset) + fmt.Println() + + if !promptYesNo(fmt.Sprintf("Configure a Console API key for context %q?", ctxName), false) { + fmt.Println(ansiDim + "Skipped. You can add one later with: akt console login" + ansiReset) + fmt.Println() + + return "", false + } + + key = promptSecret("Console API key: ") + if key == "" { + fmt.Println(ansiYellow + "No key entered; skipping Console setup." + ansiReset) + fmt.Println() + + return "", false + } + + validateConsoleKey(key) + + routeDeployments = promptYesNo("Route deployments through Console for this context (managed wallet, USD)?", false) + fmt.Println() + + return key, routeDeployments +} + +// validateConsoleKey checks the key against the Console API. Validation is +// best-effort: first-run may be offline, so a failure only warns. +func validateConsoleKey(key string) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + user, err := console.New("", key).GetUser(ctx) + if err != nil { + fmt.Printf(ansiYellow+"Could not validate the key (%v); storing anyway. Verify later with: akt console whoami"+ansiReset+"\n", err) + return + } + + fmt.Printf(ansiGreen+"Authenticated as %s"+ansiReset+"\n", user.Username) +} + +// promptYesNo asks a yes/no question on stdin. Empty input selects def. +func promptYesNo(question string, def bool) bool { + suffix := " [y/N]: " + if def { + suffix = " [Y/n]: " + } + + fmt.Print(question + suffix) + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return def + } + + return parseYesNo(line, def) +} + +// parseYesNo interprets a yes/no answer, falling back to def on empty or +// unrecognized input. +func parseYesNo(input string, def bool) bool { + switch strings.ToLower(strings.TrimSpace(input)) { + case "y", "yes": + return true + case "n", "no": + return false + default: + return def + } +} + +// promptSecret reads a line without echoing it to the terminal. +func promptSecret(prompt string) string { + fmt.Print(prompt) + + data, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + + if err != nil { + return "" + } + + return strings.TrimSpace(string(data)) +} diff --git a/internal/bootstrap/console_test.go b/internal/bootstrap/console_test.go new file mode 100644 index 00000000..deff333f --- /dev/null +++ b/internal/bootstrap/console_test.go @@ -0,0 +1,55 @@ +package bootstrap + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseYesNo(t *testing.T) { + cases := []struct { + in string + def bool + want bool + }{ + {"y\n", false, true}, + {"Y\n", false, true}, + {"yes\n", false, true}, + {"n\n", true, false}, + {"NO\n", true, false}, + {"\n", false, false}, + {"\n", true, true}, + {"maybe\n", false, false}, + {"maybe\n", true, true}, + {" y \n", false, true}, + } + + for _, c := range cases { + if got := parseYesNo(c.in, c.def); got != c.want { + t.Errorf("parseYesNo(%q, %v) = %v, want %v", c.in, c.def, got, c.want) + } + } +} + +func TestConsoleOnboardingSkipsWithoutTTY(t *testing.T) { + // Test processes have no TTY on stdin, so onboarding must be a no-op. + key, route := consoleOnboarding("prod") + if key != "" || route { + t.Errorf("expected non-interactive skip, got key=%q route=%v", key, route) + } +} + +func TestRunSkipsHeadless(t *testing.T) { + // Test processes have no TTY: Run must decline to bootstrap — no + // network fetch, no config written, nil error so the CLI continues + // to its normal no-config handling. + root := t.TempDir() + + if err := Run(root); err != nil { + t.Fatalf("headless Run must not error, got: %v", err) + } + + if _, err := os.Stat(filepath.Join(root, "config.yaml")); !os.IsNotExist(err) { + t.Fatal("headless Run must not write a config file") + } +} diff --git a/internal/bootstrap/networks_test.go b/internal/bootstrap/networks_test.go new file mode 100644 index 00000000..a21a6610 --- /dev/null +++ b/internal/bootstrap/networks_test.go @@ -0,0 +1,319 @@ +package bootstrap + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + "testing" + + aktctx "pkg.akt.dev/akt/internal/context" +) + +// stubTransport answers requests from a URL-suffix -> response table without +// touching the network. The bootstrap fetchers build their URLs from package +// constants (the real github.com/akash-network/net endpoints), so intercepting +// at the RoundTripper is the only way to exercise them as written. +type stubTransport struct { + // routes maps a URL suffix to the status and body to answer with. + routes map[string]stubResponse + + // seen records every requested URL, in order. + seen []string +} + +type stubResponse struct { + status int + body string +} + +func (s *stubTransport) RoundTrip(req *http.Request) (*http.Response, error) { + s.seen = append(s.seen, req.URL.String()) + + for suffix, resp := range s.routes { + if strings.HasSuffix(req.URL.String(), suffix) { + return &http.Response{ + StatusCode: resp.status, + Body: io.NopCloser(strings.NewReader(resp.body)), + Header: make(http.Header), + Request: req, + }, nil + } + } + + return nil, fmt.Errorf("no stub route for %s", req.URL) +} + +func newStubClient(routes map[string]stubResponse) (*http.Client, *stubTransport) { + tr := &stubTransport{routes: routes} + + return &http.Client{Transport: tr}, tr +} + +// installDefaultTransport swaps http.DefaultTransport for the duration of the +// test so fetchAllNetworks — which builds its own http.Client — can be driven +// offline. Restored via Cleanup; these tests must not run in parallel. +func installDefaultTransport(t *testing.T, routes map[string]stubResponse) *stubTransport { + t.Helper() + + tr := &stubTransport{routes: routes} + prev := http.DefaultTransport + http.DefaultTransport = tr + t.Cleanup(func() { http.DefaultTransport = prev }) + + return tr +} + +const mainnetMeta = `{ + "chain_name": "akashnet", + "chain_id": "akashnet-2", + "pretty_name": "Akash", + "network_type": "mainnet", + "status": "live", + "fees": {"fee_tokens": [{"denom": "uakt", "average_gas_price": 0.0025}]}, + "apis": { + "rpc": [{"address": "https://rpc.example.com:443"}, {"address": ""}], + "rest": [{"address": "https://api.example.com"}], + "grpc": [{"address": "grpc.example.com:9090"}] + } +}` + +// TestMetaToNetworkMapsEndpointsAndGasPrice pins the translation from the +// upstream meta.json into an akt Network. A wrong gas-price format here writes +// an unusable value into every generated context (money path: gas prices are +// what every subsequent tx pays). +func TestMetaToNetworkMapsEndpointsAndGasPrice(t *testing.T) { + client, _ := newStubClient(map[string]stubResponse{ + "/mainnet/meta.json": {http.StatusOK, mainnetMeta}, + }) + + meta, err := fetchMeta(client, "mainnet") + if err != nil { + t.Fatalf("fetchMeta: %v", err) + } + + n := metaToNetwork("mainnet", meta) + + if n.Name != "mainnet" || n.ChainID != "akashnet-2" { + t.Errorf("name/chain-id = %q/%q", n.Name, n.ChainID) + } + // The empty RPC address in the fixture must be dropped, not carried + // through as a "" endpoint that later fails to dial. + if len(n.Endpoints.RPC) != 1 || n.Endpoints.RPC[0] != "https://rpc.example.com:443" { + t.Errorf("rpc endpoints = %v, want the single non-empty address", n.Endpoints.RPC) + } + if len(n.Endpoints.API) != 1 || n.Endpoints.API[0] != "https://api.example.com" { + t.Errorf("api endpoints = %v", n.Endpoints.API) + } + if len(n.Endpoints.GRPC) != 1 || n.Endpoints.GRPC[0] != "grpc.example.com:9090" { + t.Errorf("grpc endpoints = %v", n.Endpoints.GRPC) + } + if n.GasPrices != "0.0025uakt" { + t.Errorf("gas prices = %q, want 0.0025uakt", n.GasPrices) + } + if n.GasAdjustment != "1.5" { + t.Errorf("gas adjustment = %q, want 1.5", n.GasAdjustment) + } +} + +// TestMetaToNetworkOmitsUnusableGasPrice covers the two guards around the fee +// token: a zero price or a missing denom must leave GasPrices empty so the +// context falls back to its own default rather than writing "0" or a bare +// number that the SDK cannot parse. +func TestMetaToNetworkOmitsUnusableGasPrice(t *testing.T) { + cases := map[string]*metaJSON{ + "no fee tokens": {ChainID: "x-1"}, + "zero price": metaWithFee("uakt", 0), + "empty denom": metaWithFee("", 0.025), + } + + for name, meta := range cases { + if got := metaToNetwork("n", meta).GasPrices; got != "" { + t.Errorf("%s: gas prices = %q, want empty", name, got) + } + } +} + +func metaWithFee(denom string, price float64) *metaJSON { + m := &metaJSON{ChainID: "x-1"} + m.Fees.FeeTokens = append(m.Fees.FeeTokens, struct { + Denom string `json:"denom"` + AverageGasPrice float64 `json:"average_gas_price"` + }{denom, price}) + + return m +} + +// TestListRepoDirsKeepsOnlyDirectories covers the filter that separates +// network directories from repo-root files (LICENSE, README...). Without it +// the wizard would offer files as selectable networks. +func TestListRepoDirsKeepsOnlyDirectories(t *testing.T) { + client, _ := newStubClient(map[string]stubResponse{ + "/contents": {http.StatusOK, `[ + {"name":"mainnet","type":"dir"}, + {"name":"testnet-02","type":"dir"}, + {"name":"README.md","type":"file"} + ]`}, + }) + + dirs, err := listRepoDirs(client) + if err != nil { + t.Fatalf("listRepoDirs: %v", err) + } + + if len(dirs) != 2 || dirs[0] != "mainnet" || dirs[1] != "testnet-02" { + t.Errorf("dirs = %v, want [mainnet testnet-02]", dirs) + } +} + +// TestListRepoDirsSurfacesAPIFailures covers the non-200 and malformed-JSON +// branches. GitHub rate-limits unauthenticated API calls with a 403, which is +// exactly what a first-run user behind a shared NAT will hit; the wizard must +// report it rather than proceeding with an empty network list. +func TestListRepoDirsSurfacesAPIFailures(t *testing.T) { + t.Run("rate limited", func(t *testing.T) { + client, _ := newStubClient(map[string]stubResponse{ + "/contents": {http.StatusForbidden, `{"message":"API rate limit exceeded"}`}, + }) + + if _, err := listRepoDirs(client); err == nil { + t.Fatal("a 403 must be surfaced") + } else if !strings.Contains(err.Error(), "403") { + t.Errorf("error should name the status, got %q", err) + } + }) + + t.Run("malformed body", func(t *testing.T) { + client, _ := newStubClient(map[string]stubResponse{ + "/contents": {http.StatusOK, `not json`}, + }) + + if _, err := listRepoDirs(client); err == nil { + t.Fatal("a malformed listing must be surfaced") + } + }) +} + +// TestFetchMetaSurfacesFailures covers the error branches of the per-network +// meta fetch: a directory without a meta.json (404) and a corrupt document. +func TestFetchMetaSurfacesFailures(t *testing.T) { + t.Run("missing", func(t *testing.T) { + client, _ := newStubClient(map[string]stubResponse{ + "/meta.json": {http.StatusNotFound, ""}, + }) + + if _, err := fetchMeta(client, "ghost"); err == nil { + t.Fatal("a 404 meta.json must be an error") + } else if !strings.Contains(err.Error(), "404") { + t.Errorf("error should name the status, got %q", err) + } + }) + + t.Run("malformed", func(t *testing.T) { + client, _ := newStubClient(map[string]stubResponse{ + "/meta.json": {http.StatusOK, `{"chain_id":`}, + }) + + if _, err := fetchMeta(client, "broken"); err == nil { + t.Fatal("a malformed meta.json must be an error") + } + }) +} + +// TestFetchAllNetworksSkipsUnusableEntries is the important one: a single bad +// directory in the upstream repo must not abort first-run setup. Entries whose +// meta.json is missing, or that carry no chain_id, are skipped; the rest still +// reach the user's config. +func TestFetchAllNetworksSkipsUnusableEntries(t *testing.T) { + installDefaultTransport(t, map[string]stubResponse{ + "/contents": {http.StatusOK, `[ + {"name":"mainnet","type":"dir"}, + {"name":"broken","type":"dir"}, + {"name":"nochain","type":"dir"}, + {"name":"LICENSE","type":"file"} + ]`}, + "/mainnet/meta.json": {http.StatusOK, mainnetMeta}, + "/broken/meta.json": {http.StatusInternalServerError, ""}, + "/nochain/meta.json": {http.StatusOK, `{"chain_name":"x","apis":{}}`}, + }) + + networks, err := fetchAllNetworks() + if err != nil { + t.Fatalf("fetchAllNetworks: %v", err) + } + + if len(networks) != 1 { + t.Fatalf("networks = %+v, want only the usable mainnet entry", networks) + } + if networks[0].Name != "mainnet" || networks[0].ChainID != "akashnet-2" { + t.Errorf("network = %+v", networks[0]) + } +} + +// TestFetchAllNetworksPropagatesListingFailure covers the other side: if the +// directory listing itself fails there is nothing to select from, and Run must +// see an error rather than an empty slice it would mistake for "no networks". +func TestFetchAllNetworksPropagatesListingFailure(t *testing.T) { + installDefaultTransport(t, map[string]stubResponse{ + "/contents": {http.StatusServiceUnavailable, ""}, + }) + + if _, err := fetchAllNetworks(); err == nil { + t.Fatal("a failed repo listing must propagate") + } +} + +// TestAllSelected covers the "Select all" checkbox state used by the wizard's +// multi-select: it is checked only when every network is checked. +func TestAllSelected(t *testing.T) { + cases := []struct { + checked []bool + want bool + }{ + {nil, true}, // vacuously true: nothing is unchecked + {[]bool{true, true, true}, true}, + {[]bool{true, false, true}, false}, + {[]bool{false}, false}, + } + + for _, c := range cases { + if got := allSelected(c.checked); got != c.want { + t.Errorf("allSelected(%v) = %v, want %v", c.checked, got, c.want) + } + } +} + +// TestInteractiveSelectorsFallBackWithoutATerminal covers the term.MakeRaw +// failure arms of the two wizard selectors. When stdin is not a terminal (a +// pipe, a CI runner) raw mode cannot be entered, and the selectors must return +// a safe default instead of blocking on a read that will never be answered: +// every network selected, and the recommended "os" keyring backend. +func TestInteractiveSelectorsFallBackWithoutATerminal(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + defer func() { + _ = w.Close() + _ = r.Close() + }() + + prev := os.Stdin + os.Stdin = r + defer func() { os.Stdin = prev }() + + networks := []aktctx.Network{ + {Name: "mainnet", ChainID: "akashnet-2"}, + {Name: "sandbox", ChainID: "sandbox-01"}, + } + + got := multiSelect(networks) + if len(got) != len(networks) { + t.Errorf("multiSelect fallback = %+v, want every network", got) + } + + if backend := selectKeyringBackend(); backend != "os" { + t.Errorf("keyring backend fallback = %q, want os", backend) + } +} diff --git a/internal/capability/capability.go b/internal/capability/capability.go new file mode 100644 index 00000000..ad4ed278 --- /dev/null +++ b/internal/capability/capability.go @@ -0,0 +1,157 @@ +// Package capability derives a feature set from the active context's +// configuration: which transports are usable (chain RPC, Console API) and +// therefore which command groups can work. The CLI uses it to gate the +// command surface so users are never offered commands their configuration +// cannot execute — e.g. a context with only a Console API key and no RPC +// endpoint cannot run chain queries. +// +// Two presentation modes are supported while UX feedback is collected +// (config: defaults.command-gating): +// +// - "dim" (default): unavailable commands stay listed, marked +// "[unavailable: ...]" in help, and fail fast with an explanation. +// - "hide": unavailable commands are removed from help listings entirely +// (still fail fast with the explanation if invoked directly). +// - "off": no gating; commands fail wherever the missing transport is +// first touched (the pre-gating behavior). +package capability + +import ( + "fmt" + "strings" + + aktctx "pkg.akt.dev/akt/internal/context" +) + +// Capability names a single thing a context configuration can do. +type Capability string + +const ( + // ChainQuery requires a network RPC endpoint. + ChainQuery Capability = "chain-query" + // ChainTx requires a network RPC endpoint (transactions additionally + // need a funded key at execution time; key presence is deliberately + // not probed here because opening OS keyrings can prompt). + ChainTx Capability = "chain-tx" + // Console requires a resolvable Console API key. + Console Capability = "console" + // Provider requires chain access (gateway discovery + wallet auth). + Provider Capability = "provider" +) + +// Set is the feature set resolved from a context. +type Set struct { + ChainQuery bool + ChainTx bool + Console bool + Provider bool +} + +// Resolve derives the feature set from a resolved context. A nil context +// (no active context) yields the empty set. +func Resolve(rc *aktctx.Context) Set { + if rc == nil { + return Set{} + } + + hasRPC := len(rc.Network.Endpoints.RPC) > 0 + hasKey := rc.ConsoleAPIKey != "" + + return Set{ + ChainQuery: hasRPC, + ChainTx: hasRPC, + Console: hasKey, + Provider: hasRPC, + } +} + +// Has reports whether the set satisfies a single capability. +func (s Set) Has(c Capability) bool { + switch c { + case ChainQuery: + return s.ChainQuery + case ChainTx: + return s.ChainTx + case Console: + return s.Console + case Provider: + return s.Provider + default: + // Unknown requirements never gate: fail open so a typo in an + // annotation cannot brick a command. + return true + } +} + +// AnnotationKey is the cobra annotation carrying a command's requirement. +// The value is a requirement expression: capabilities separated by "|" are +// alternatives (any one suffices), e.g. "chain-tx|console" for workflow +// commands that run on either rail. +const AnnotationKey = "akt.requires" + +// Satisfies evaluates a requirement expression against the set. +func (s Set) Satisfies(requirement string) bool { + requirement = strings.TrimSpace(requirement) + if requirement == "" { + return true + } + + for _, alt := range strings.Split(requirement, "|") { + if s.Has(Capability(strings.TrimSpace(alt))) { + return true + } + } + + return false +} + +// remedies maps a capability to how the user enables it. +var remedies = map[Capability]string{ + ChainQuery: "add an RPC endpoint to the context's network (akt context network edit --rpc )", + ChainTx: "add an RPC endpoint to the context's network (akt context network edit --rpc )", + Console: "configure a Console API key (akt console login, or akt context edit --console-api-key )", + Provider: "add an RPC endpoint to the context's network (akt context network edit --rpc )", +} + +// Explain describes an unsatisfied requirement and how to fix it. The +// returned string is suitable both for help-line dimming and for the error +// returned when the command is invoked anyway. +func (s Set) Explain(requirement string) string { + var wants []string + for _, alt := range strings.Split(requirement, "|") { + c := Capability(strings.TrimSpace(alt)) + remedy, known := remedies[c] + if !known { + continue + } + wants = append(wants, fmt.Sprintf("%s (%s)", c, remedy)) + } + + if len(wants) == 0 { + return fmt.Sprintf("requires %q", requirement) + } + + return "requires " + strings.Join(wants, ", or ") +} + +// Mode is the gating presentation mode from config. +type Mode string + +const ( + ModeDim Mode = "dim" + ModeHide Mode = "hide" + ModeOff Mode = "off" +) + +// ParseMode normalizes a configured gating mode; unknown values fall back +// to the default (dim) so a config typo never disables the safety net. +func ParseMode(s string) Mode { + switch strings.ToLower(strings.TrimSpace(s)) { + case string(ModeHide): + return ModeHide + case string(ModeOff): + return ModeOff + default: + return ModeDim + } +} diff --git a/internal/capability/capability_test.go b/internal/capability/capability_test.go new file mode 100644 index 00000000..f8368e9e --- /dev/null +++ b/internal/capability/capability_test.go @@ -0,0 +1,96 @@ +package capability_test + +import ( + "strings" + "testing" + + "pkg.akt.dev/akt/internal/capability" + aktctx "pkg.akt.dev/akt/internal/context" +) + +func TestResolve(t *testing.T) { + cases := []struct { + name string + rc *aktctx.Context + want capability.Set + }{ + {"nil context", nil, capability.Set{}}, + { + "rpc only", + &aktctx.Context{Network: aktctx.Network{Endpoints: aktctx.Endpoints{RPC: []string{"https://rpc"}}}}, + capability.Set{ChainQuery: true, ChainTx: true, Provider: true}, + }, + { + "console key only", + &aktctx.Context{ConsoleAPIKey: "sk-x"}, + capability.Set{Console: true}, + }, + { + "both rails", + &aktctx.Context{ + Network: aktctx.Network{Endpoints: aktctx.Endpoints{RPC: []string{"https://rpc"}}}, + ConsoleAPIKey: "sk-x", + }, + capability.Set{ChainQuery: true, ChainTx: true, Provider: true, Console: true}, + }, + } + + for _, c := range cases { + if got := capability.Resolve(c.rc); got != c.want { + t.Errorf("%s: Resolve = %+v, want %+v", c.name, got, c.want) + } + } +} + +func TestSatisfies(t *testing.T) { + consoleOnly := capability.Set{Console: true} + + cases := []struct { + requirement string + want bool + }{ + {"", true}, + {"console", true}, + {"chain-query", false}, + {"chain-tx|console", true}, + {"chain-tx | console", true}, + {"chain-tx", false}, + {"definitely-not-a-capability", true}, // unknown fails open + } + + for _, c := range cases { + if got := consoleOnly.Satisfies(c.requirement); got != c.want { + t.Errorf("Satisfies(%q) = %v, want %v", c.requirement, got, c.want) + } + } +} + +func TestExplainNamesRemedies(t *testing.T) { + s := capability.Set{} + + msg := s.Explain("chain-query") + if !strings.Contains(msg, "RPC endpoint") || !strings.Contains(msg, "akt context network edit") { + t.Errorf("chain-query explanation missing remedy: %q", msg) + } + + msg = s.Explain("chain-tx|console") + if !strings.Contains(msg, "akt console login") || !strings.Contains(msg, ", or ") { + t.Errorf("alternative explanation wrong: %q", msg) + } +} + +func TestParseMode(t *testing.T) { + cases := map[string]capability.Mode{ + "dim": capability.ModeDim, + "HIDE": capability.ModeHide, + "off": capability.ModeOff, + "": capability.ModeDim, + "garbage": capability.ModeDim, + } + + for in, want := range cases { + if got := capability.ParseMode(in); got != want { + t.Errorf("ParseMode(%q) = %s, want %s", in, got, want) + } + } +} diff --git a/internal/capability/gating_test.go b/internal/capability/gating_test.go new file mode 100644 index 00000000..f5459ad7 --- /dev/null +++ b/internal/capability/gating_test.go @@ -0,0 +1,166 @@ +package capability_test + +import ( + "strings" + "testing" + + "pkg.akt.dev/akt/internal/capability" + aktctx "pkg.akt.dev/akt/internal/context" +) + +// TestHasFailsOpenOnUnknownCapability covers the default arm of Set.Has +// directly. The gating layer reads capability names from cobra annotations, so +// a typo'd annotation must never hide a working command — failing open is the +// documented safety choice, and this is the branch that implements it. +func TestHasFailsOpenOnUnknownCapability(t *testing.T) { + empty := capability.Set{} + + if !empty.Has(capability.Capability("chian-query")) { + t.Error("an unknown capability must fail open (never gate)") + } + if !empty.Has("") { + t.Error("an empty capability must fail open") + } + + // Every known capability still gates on the empty set. + for _, c := range []capability.Capability{ + capability.ChainQuery, capability.ChainTx, capability.Console, capability.Provider, + } { + if empty.Has(c) { + t.Errorf("%s must not be satisfied by the empty set", c) + } + } +} + +// TestExplainFallsBackForUnknownRequirements covers Explain's "no known +// capability" branch. An annotation nobody has a remedy for must still produce +// a usable message rather than an empty string appended to an error. +func TestExplainFallsBackForUnknownRequirements(t *testing.T) { + s := capability.Set{} + + msg := s.Explain("teleportation") + if msg == "" { + t.Fatal("Explain must never return an empty message") + } + if !strings.Contains(msg, "teleportation") { + t.Errorf("fallback message should quote the requirement, got %q", msg) + } + + // A mixed expression keeps only the remedies it knows about. + msg = s.Explain("teleportation|console") + if strings.Contains(msg, "teleportation") { + t.Errorf("unknown alternatives must be dropped when a known one exists, got %q", msg) + } + if !strings.Contains(msg, "akt console login") { + t.Errorf("the known alternative's remedy is missing from %q", msg) + } + // Exactly one capability survived, so exactly one " (" clause is + // listed — the remedy text itself contains commas, so count clauses + // rather than searching for a separator. + if got := strings.Count(msg, " ("); got != 2 { + t.Errorf("expected a single capability clause, got %q", msg) + } +} + +// TestResolveIgnoresNetworkWithoutRPC pins the exact signal each capability +// keys off. A context that names a network with no RPC endpoints cannot query +// or transact, and a chain-less console context must not be offered chain +// commands just because it has a network entry. +func TestResolveIgnoresNetworkWithoutRPC(t *testing.T) { + cases := []struct { + name string + rc *aktctx.Context + want capability.Set + }{ + { + "named network, no endpoints", + &aktctx.Context{Network: aktctx.Network{Name: "mainnet"}}, + capability.Set{}, + }, + { + "empty rpc list", + &aktctx.Context{Network: aktctx.Network{Endpoints: aktctx.Endpoints{RPC: []string{}}}}, + capability.Set{}, + }, + { + "rest/grpc only", + &aktctx.Context{Network: aktctx.Network{Endpoints: aktctx.Endpoints{ + API: []string{"https://api"}, + GRPC: []string{"grpc:9090"}, + }}}, + capability.Set{}, + }, + { + "console key present but empty", + &aktctx.Context{ConsoleAPIKey: ""}, + capability.Set{}, + }, + } + + for _, c := range cases { + if got := capability.Resolve(c.rc); got != c.want { + t.Errorf("%s: Resolve = %+v, want %+v", c.name, got, c.want) + } + } +} + +// TestSatisfiesWithAllAlternativesUnsatisfied pins the multi-alternative +// rejection that gates workflow commands ("chain-tx|console"): a context with +// neither rail must be told it has neither. +func TestSatisfiesWithAllAlternativesUnsatisfied(t *testing.T) { + empty := capability.Set{} + + if empty.Satisfies("chain-tx|console") { + t.Error("a context with neither rail must not satisfy chain-tx|console") + } + + msg := empty.Explain("chain-tx|console") + if !strings.Contains(msg, ", or ") { + t.Errorf("both remedies should be offered, got %q", msg) + } + if !strings.Contains(msg, "akt context network edit") || !strings.Contains(msg, "akt console login") { + t.Errorf("explanation missing a remedy: %q", msg) + } +} + +// TestModeStringsAreStable pins the configured gating-mode values. They come +// from user config (defaults.command-gating), so renaming one silently +// downgrades every existing config to the default mode. +func TestModeStringsAreStable(t *testing.T) { + cases := map[capability.Mode]string{ + capability.ModeDim: "dim", + capability.ModeHide: "hide", + capability.ModeOff: "off", + } + + for mode, want := range cases { + if string(mode) != want { + t.Errorf("mode %v = %q, want %q", mode, string(mode), want) + } + if got := capability.ParseMode(" " + strings.ToUpper(want) + " "); got != mode { + t.Errorf("ParseMode with padding/case = %v, want %v", got, mode) + } + } +} + +// TestCapabilityNamesAreStable pins the annotation vocabulary. The values are +// written into cobra annotations across the command tree; renaming one without +// updating every call site silently un-gates those commands (Has fails open). +func TestCapabilityNamesAreStable(t *testing.T) { + cases := map[capability.Capability]string{ + capability.ChainQuery: "chain-query", + capability.ChainTx: "chain-tx", + capability.Console: "console", + capability.Provider: "provider", + } + + for c, want := range cases { + if string(c) != want { + t.Errorf("capability %v = %q, want %q", c, string(c), want) + } + } + + if capability.AnnotationKey != "akt.requires" { + t.Errorf("AnnotationKey = %q, want akt.requires", capability.AnnotationKey) + } +} diff --git a/internal/cli/chain/actionlog.go b/internal/cli/chain/actionlog.go new file mode 100644 index 00000000..81e229f1 --- /dev/null +++ b/internal/cli/chain/actionlog.go @@ -0,0 +1,187 @@ +package cli + +import ( + "context" + "fmt" + "strconv" + "strings" + + sdkclient "github.com/cosmos/cosmos-sdk/client" + sdk "github.com/cosmos/cosmos-sdk/types" + + "pkg.akt.dev/akt/internal/actionlog" + "pkg.akt.dev/akt/internal/cliutil" + + aclient "pkg.akt.dev/go/node/client" + cv1beta3 "pkg.akt.dev/go/node/client/v1beta3" + dv1 "pkg.akt.dev/go/node/deployment/v1" + dv1beta "pkg.akt.dev/go/node/deployment/v1beta4" + mtypes "pkg.akt.dev/go/node/market/v1beta5" +) + +// withActionLog wraps a chain client so every broadcast is recorded in the +// action log carried by ctx (SPEC §5.6) and so a broadcast whose CheckTx +// result carries a non-zero code surfaces as an error (non-zero exit) +// instead of being silently printed as success. The wrapper is applied +// unconditionally; a missing logger only disables recording. +func withActionLog(ctx context.Context, cl aclient.Client) aclient.Client { + return &loggingClient{Client: cl, log: cliutil.ActionLogFromContext(ctx)} +} + +type loggingClient struct { + aclient.Client + log *actionlog.Logger +} + +func (c *loggingClient) Tx() cv1beta3.TxClient { + return &loggingTxClient{ + tx: c.Client.Tx(), + log: c.log, + cctx: c.Client.ClientContext(), + } +} + +type loggingTxClient struct { + tx cv1beta3.TxClient + log *actionlog.Logger + cctx sdkclient.Context +} + +func (t *loggingTxClient) BroadcastMsgs(ctx context.Context, msgs []sdk.Msg, opts ...cv1beta3.BroadcastOption) (interface{}, error) { + resp, err := t.tx.BroadcastMsgs(ctx, msgs, opts...) + t.record(msgs, resp, err) + + return resp, t.failedTxError(resp, err) +} + +func (t *loggingTxClient) BroadcastTx(ctx context.Context, tx sdk.Tx, opts ...cv1beta3.BroadcastOption) (interface{}, error) { + resp, err := t.tx.BroadcastTx(ctx, tx, opts...) + t.record(tx.GetMsgs(), resp, err) + + return resp, t.failedTxError(resp, err) +} + +// failedTxError converts a broadcast whose result carries a non-zero code +// into an error so the CLI exits non-zero on failed transactions. The +// response is still returned to the caller alongside the error. +func (t *loggingTxClient) failedTxError(resp interface{}, err error) error { + if err != nil { + return err + } + + if t.cctx.GenerateOnly || t.cctx.Simulate || t.cctx.Offline { + return nil + } + + if r, ok := resp.(*sdk.TxResponse); ok && r != nil && r.Code != 0 { + return fmt.Errorf("transaction failed: code %d (codespace %s), tx hash %s: %s", + r.Code, r.Codespace, r.TxHash, r.RawLog) + } + + return nil +} + +// record writes a tx entry for a broadcast. Nothing is recorded when the +// command only generates or simulates the transaction (no state change). +func (t *loggingTxClient) record(msgs []sdk.Msg, resp interface{}, err error) { + if t.log == nil || len(msgs) == 0 { + return + } + + if t.cctx.GenerateOnly || t.cctx.Simulate || t.cctx.Offline { + return + } + + entry := actionlog.Entry{ + Type: actionlog.TypeTx, + Action: msgAction(msgs), + Account: t.cctx.FromName, + Status: "success", + } + + applyMsgIDs(&entry, msgs) + + if r, ok := resp.(*sdk.TxResponse); ok && r != nil { + entry.TxHash = r.TxHash + entry.Height = r.Height + entry.GasUsed = r.GasUsed + entry.ResultCode = r.Code + + if r.Code != 0 { + entry.Status = "failed" + entry.Error = r.RawLog + } + } + + if err != nil { + entry.Status = "failed" + entry.Error = err.Error() + } + + _ = t.log.Log(entry) +} + +// msgAction derives a compact action identifier from the broadcast messages, +// e.g. "/akash.deployment.v1beta4.MsgCreateDeployment" becomes +// "deployment.MsgCreateDeployment". Additional messages in a multi-msg tx are +// indicated with a "+N" suffix. +func msgAction(msgs []sdk.Msg) string { + action := shortMsgType(sdk.MsgTypeURL(msgs[0])) + if len(msgs) > 1 { + action += "+" + strconv.Itoa(len(msgs)-1) + } + + return action +} + +func shortMsgType(url string) string { + url = strings.TrimPrefix(url, "/") + parts := strings.Split(url, ".") + + if len(parts) >= 3 { + return parts[1] + "." + parts[len(parts)-1] + } + + return url +} + +// applyMsgIDs extracts Akash resource identifiers from well-known message +// types so log entries can be filtered by dseq/provider. +func applyMsgIDs(entry *actionlog.Entry, msgs []sdk.Msg) { + for _, msg := range msgs { + switch m := msg.(type) { + case *dv1beta.MsgCreateDeployment: + setDeploymentID(entry, m.ID) + case *dv1beta.MsgUpdateDeployment: + setDeploymentID(entry, m.ID) + case *dv1beta.MsgCloseDeployment: + setDeploymentID(entry, m.ID) + case *mtypes.MsgCreateLease: + entry.DSeq = m.BidID.DSeq + entry.GSeq = m.BidID.GSeq + entry.OSeq = m.BidID.OSeq + entry.Provider = m.BidID.Provider + case *mtypes.MsgCloseLease: + entry.DSeq = m.ID.DSeq + entry.GSeq = m.ID.GSeq + entry.OSeq = m.ID.OSeq + entry.Provider = m.ID.Provider + case *mtypes.MsgCloseBid: + entry.DSeq = m.ID.DSeq + entry.GSeq = m.ID.GSeq + entry.OSeq = m.ID.OSeq + entry.Provider = m.ID.Provider + } + + if entry.DSeq != 0 { + return + } + } +} + +func setDeploymentID(entry *actionlog.Entry, id dv1.DeploymentID) { + entry.DSeq = id.DSeq + if entry.Account == "" { + entry.Account = id.Owner + } +} diff --git a/internal/cli/chain/actionlog_test.go b/internal/cli/chain/actionlog_test.go new file mode 100644 index 00000000..804f7c47 --- /dev/null +++ b/internal/cli/chain/actionlog_test.go @@ -0,0 +1,226 @@ +package cli + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + sdkclient "github.com/cosmos/cosmos-sdk/client" + sdk "github.com/cosmos/cosmos-sdk/types" + + "pkg.akt.dev/akt/internal/actionlog" + + cv1beta3 "pkg.akt.dev/go/node/client/v1beta3" + dv1 "pkg.akt.dev/go/node/deployment/v1" + dv1beta "pkg.akt.dev/go/node/deployment/v1beta4" + mv1 "pkg.akt.dev/go/node/market/v1" + mtypes "pkg.akt.dev/go/node/market/v1beta5" +) + +type fakeTxClient struct { + resp interface{} + err error +} + +func (f *fakeTxClient) BroadcastMsgs(_ context.Context, _ []sdk.Msg, _ ...cv1beta3.BroadcastOption) (interface{}, error) { + return f.resp, f.err +} + +func (f *fakeTxClient) BroadcastTx(_ context.Context, _ sdk.Tx, _ ...cv1beta3.BroadcastOption) (interface{}, error) { + return f.resp, f.err +} + +func newTestActionLogger(t *testing.T) *actionlog.Logger { + t.Helper() + + l, err := actionlog.Open(filepath.Join(t.TempDir(), "actions.log")) + if err != nil { + t.Fatalf("open action log: %v", err) + } + t.Cleanup(func() { _ = l.Close() }) + + return l +} + +func TestLoggingTxClientRecordsBroadcast(t *testing.T) { + l := newTestActionLogger(t) + + tx := &loggingTxClient{ + tx: &fakeTxClient{resp: &sdk.TxResponse{ + TxHash: "ABC123", + Height: 42, + GasUsed: 100000, + Code: 0, + }}, + log: l, + cctx: sdkclient.Context{FromName: "alice"}, + } + + msg := &dv1beta.MsgCloseDeployment{ID: dv1.DeploymentID{Owner: "akash1owner", DSeq: 12345}} + if _, err := tx.BroadcastMsgs(context.Background(), []sdk.Msg{msg}); err != nil { + t.Fatalf("BroadcastMsgs: %v", err) + } + + entries, err := l.Read(actionlog.Filter{}) + if err != nil { + t.Fatalf("read: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + + e := entries[0] + if e.Type != actionlog.TypeTx { + t.Errorf("type = %s, want tx", e.Type) + } + if e.Action != "deployment.MsgCloseDeployment" { + t.Errorf("action = %s, want deployment.MsgCloseDeployment", e.Action) + } + if e.TxHash != "ABC123" || e.Height != 42 || e.GasUsed != 100000 { + t.Errorf("tx result fields not recorded: %+v", e) + } + if e.DSeq != 12345 { + t.Errorf("dseq = %d, want 12345", e.DSeq) + } + if e.Account != "alice" { + t.Errorf("account = %s, want alice", e.Account) + } + if e.Status != "success" { + t.Errorf("status = %s, want success", e.Status) + } +} + +func TestLoggingTxClientRecordsFailure(t *testing.T) { + l := newTestActionLogger(t) + + tx := &loggingTxClient{ + tx: &fakeTxClient{err: errors.New("insufficient funds")}, + log: l, + } + + msg := &mtypes.MsgCreateLease{BidID: mv1.BidID{ + Owner: "akash1owner", DSeq: 7, GSeq: 1, OSeq: 1, Provider: "akash1prov", + }} + if _, err := tx.BroadcastMsgs(context.Background(), []sdk.Msg{msg}); err == nil { + t.Fatal("expected error to propagate") + } + + entries, err := l.Read(actionlog.Filter{}) + if err != nil { + t.Fatalf("read: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + + e := entries[0] + if e.Status != "failed" || e.Error != "insufficient funds" { + t.Errorf("failure not recorded: %+v", e) + } + if e.Action != "market.MsgCreateLease" { + t.Errorf("action = %s, want market.MsgCreateLease", e.Action) + } + if e.DSeq != 7 || e.Provider != "akash1prov" { + t.Errorf("bid id fields not recorded: %+v", e) + } +} + +func TestLoggingTxClientSkipsGenerateOnly(t *testing.T) { + l := newTestActionLogger(t) + + tx := &loggingTxClient{ + tx: &fakeTxClient{resp: &sdk.TxResponse{TxHash: "XYZ"}}, + log: l, + cctx: sdkclient.Context{GenerateOnly: true}, + } + + msg := &dv1beta.MsgCloseDeployment{ID: dv1.DeploymentID{DSeq: 1}} + if _, err := tx.BroadcastMsgs(context.Background(), []sdk.Msg{msg}); err != nil { + t.Fatalf("BroadcastMsgs: %v", err) + } + + entries, err := l.Read(actionlog.Filter{}) + if err != nil { + t.Fatalf("read: %v", err) + } + if len(entries) != 0 { + t.Fatalf("generate-only broadcast must not be logged, got %d entries", len(entries)) + } +} + +func TestBroadcastNonZeroCodeReturnsError(t *testing.T) { + l := newTestActionLogger(t) + + tx := &loggingTxClient{ + tx: &fakeTxClient{resp: &sdk.TxResponse{ + TxHash: "FAIL123", + Code: 11, + Codespace: "sdk", + RawLog: "out of gas", + }}, + log: l, + } + + msg := &dv1beta.MsgCloseDeployment{ID: dv1.DeploymentID{DSeq: 1}} + resp, err := tx.BroadcastMsgs(context.Background(), []sdk.Msg{msg}) + if err == nil { + t.Fatal("non-zero code must surface as an error") + } + if resp == nil { + t.Error("response must still be returned alongside the error") + } + for _, want := range []string{"code 11", "FAIL123", "out of gas"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q missing %q", err.Error(), want) + } + } + + entries, readErr := l.Read(actionlog.Filter{}) + if readErr != nil { + t.Fatalf("read: %v", readErr) + } + if len(entries) != 1 || entries[0].Status != "failed" { + t.Errorf("failed broadcast not recorded as failed: %+v", entries) + } +} + +func TestBroadcastNonZeroCodeGenerateOnlyNoError(t *testing.T) { + tx := &loggingTxClient{ + tx: &fakeTxClient{resp: &sdk.TxResponse{Code: 11}}, + cctx: sdkclient.Context{GenerateOnly: true}, + } + + msg := &dv1beta.MsgCloseDeployment{ID: dv1.DeploymentID{DSeq: 1}} + if _, err := tx.BroadcastMsgs(context.Background(), []sdk.Msg{msg}); err != nil { + t.Fatalf("generate-only must not convert codes to errors: %v", err) + } +} + +func TestWithActionLogWrapsWithoutLogger(t *testing.T) { + // The wrapper must apply even without a logger so failed broadcasts + // exit non-zero regardless of action log availability. + tx := &loggingTxClient{ + tx: &fakeTxClient{resp: &sdk.TxResponse{Code: 5, RawLog: "boom"}}, + } + + msg := &dv1beta.MsgCloseDeployment{ID: dv1.DeploymentID{DSeq: 1}} + if _, err := tx.BroadcastMsgs(context.Background(), []sdk.Msg{msg}); err == nil { + t.Fatal("expected error without logger present") + } +} + +func TestShortMsgType(t *testing.T) { + cases := map[string]string{ + "/akash.deployment.v1beta4.MsgCreateDeployment": "deployment.MsgCreateDeployment", + "/cosmos.bank.v1beta1.MsgSend": "bank.MsgSend", + "weird": "weird", + } + + for in, want := range cases { + if got := shortMsgType(in); got != want { + t.Errorf("shortMsgType(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/cli/chain/auth_query.go b/internal/cli/chain/auth_query.go index b9dec3df..8e96e784 100644 --- a/internal/cli/chain/auth_query.go +++ b/internal/cli/chain/auth_query.go @@ -244,7 +244,7 @@ func GetQueryAuthModuleAccountByNameCmd() *cobra.Command { // GetQueryAuthTxsByEventsCmd returns a command to search through transactions by events. func GetQueryAuthTxsByEventsCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "txs", + Use: "txs [events]", Short: "Query for paginated transactions that match a set of events", Long: strings.TrimSpace( fmt.Sprintf(` @@ -254,16 +254,25 @@ to each module's documentation for the full set of events to query for. Each mod documents its respective events under 'xx_events.md'. Example: -$ %s query txs --%s 'message.sender=akash1...&message.action=withdraw_delegator_reward' --page 1 --limit 30 -`, eventFormat, version.AppName, cflags.FlagEvents), +$ %[2]s query txs 'message.sender=akash1...&message.action=withdraw_delegator_reward' --page 1 --limit 30 +`, eventFormat, version.AppName), ), + Args: cobra.MaximumNArgs(1), PersistentPreRunE: QueryPersistentPreRunE, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cl := MustLightClientFromContext(ctx) cctx := cl.ClientContext() - eventsRaw, _ := cmd.Flags().GetString(cflags.FlagEvents) + // FEEDBACK(2026-07): --events disabled for the positional-only UX + // trial; the positional expression is the only source (zero + // fallback). Restore by uncommenting if users ask for the flag + // form back. + // eventsRaw, _ := cmd.Flags().GetString(cflags.FlagEvents) + eventsRaw := cflags.ExprFromArgs(args, "") + if eventsRaw == "" { + return fmt.Errorf("events are required: pass them positionally") + } eventsStr := strings.Trim(eventsRaw, "'") var events []string @@ -307,8 +316,10 @@ $ %s query txs --%s 'message.sender=akash1...&message.action=withdraw_delegator_ cflags.AddQueryFlagsToCmd(cmd) cmd.Flags().Int(cflags.FlagPage, query.DefaultPage, "Query a specific page of paginated results") cmd.Flags().Int(cflags.FlagLimit, query.DefaultLimit, "Query number of transactions results per page returned") - cmd.Flags().String(cflags.FlagEvents, "", fmt.Sprintf("list of transaction events in the form of %s", eventFormat)) - _ = cmd.MarkFlagRequired(cflags.FlagEvents) + // FEEDBACK(2026-07): --events disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // cmd.Flags().String(cflags.FlagEvents, "", fmt.Sprintf("list of transaction events in the form of %s", eventFormat)) return cmd } diff --git a/internal/cli/chain/cert_query.go b/internal/cli/chain/cert_query.go index 23a8d284..e51e2735 100644 --- a/internal/cli/chain/cert_query.go +++ b/internal/cli/chain/cert_query.go @@ -30,13 +30,22 @@ func GetQueryCertCmd() *cobra.Command { return cmd } +// GetQueryCertCertificatesCmd returns the command to list certificates. +// Accepts optional positional [owner] [state] filters (SPEC §3.8): the first +// argument is an owner address (or a bare state keyword), the second a state +// keyword (valid|revoked). func GetQueryCertCertificatesCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "list", + Use: "list [owner] [state]", Short: "Query for all certificates", + Args: cobra.MaximumNArgs(2), SilenceUsage: true, PersistentPreRunE: QueryPersistentPreRunE, - RunE: func(cmd *cobra.Command, _ []string) error { + Example: `akt query cert list +akt query cert list akash1owner... +akt query cert list akash1owner... valid +akt query cert list revoked`, + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cl := MustLightClientFromContext(ctx) @@ -49,18 +58,46 @@ func GetQueryCertCertificatesCmd() *cobra.Command { Pagination: pageReq, } - if value := cmd.Flag("owner").Value.String(); value != "" { - var owner sdk.Address - if owner, err = sdk.AccAddressFromBech32(value); err != nil { - return err + // Positional [owner] [state] filters with smart type detection + // on the first component (SPEC §3.8.2): a bech32 address is the + // owner; a bare state keyword is a state filter. + if len(args) > 0 { + if owner, aerr := sdk.AccAddressFromBech32(args[0]); aerr == nil { + params.Filter.Owner = owner.String() + } else if val, exists := types.State_value[args[0]]; exists && types.State(val) != types.CertificateStateInvalid { + if len(args) > 1 { + return fmt.Errorf("certificate filter: state keyword %q cannot be combined with a second argument %q", args[0], args[1]) + } + params.Filter.State = args[0] + } else { + return fmt.Errorf("certificate filter: %q is not a valid owner address or state (valid|revoked)", args[0]) + } + } + + if len(args) > 1 { + if val, exists := types.State_value[args[1]]; !exists || types.State(val) == types.CertificateStateInvalid { + return fmt.Errorf("certificate filter: %q is not a valid state (valid|revoked)", args[1]) } - params.Filter.Owner = owner.String() + params.Filter.State = args[1] } + // FEEDBACK(2026-07): the --owner flag read is disabled for the + // positional-only UX trial (the positional [owner] argument is + // the only source). Restore by uncommenting if users ask for the + // flag form back. + // if value := cmd.Flag("owner").Value.String(); value != "" { + // var owner sdk.Address + // if owner, err = sdk.AccAddressFromBech32(value); err != nil { + // return err + // } + // + // params.Filter.Owner = owner.String() + // } + if value := cmd.Flag("serial").Value.String(); value != "" { if params.Filter.Owner == "" { - return fmt.Errorf("--serial flag requires --owner to be set") + return fmt.Errorf("--serial flag requires the positional [owner] argument to be set") } val, valid := new(big.Int).SetString(value, 10) if !valid { @@ -70,13 +107,17 @@ func GetQueryCertCertificatesCmd() *cobra.Command { params.Filter.Serial = val.String() } - if value := cmd.Flag("state").Value.String(); value != "" { - if val, exists := types.State_value[value]; !exists || types.State(val) == types.CertificateStateInvalid { - return fmt.Errorf("invalid value of --state flag. expected valid|revoked") - } - - params.Filter.State = value - } + // FEEDBACK(2026-07): the --state flag read is disabled for the + // positional-only UX trial (the positional [state] argument is + // the only source). Restore by uncommenting if users ask for the + // flag form back. + // if value := cmd.Flag("state").Value.String(); value != "" { + // if val, exists := types.State_value[value]; !exists || types.State(val) == types.CertificateStateInvalid { + // return fmt.Errorf("invalid value of --state flag. expected valid|revoked") + // } + // + // params.Filter.State = value + // } res, err := cl.Query().Certs().Certificates(cmd.Context(), params) if err != nil { @@ -91,8 +132,14 @@ func GetQueryCertCertificatesCmd() *cobra.Command { cflags.AddPaginationFlagsToCmd(cmd, "certificates") cmd.Flags().String("serial", "", "filter certificates by serial number") - cmd.Flags().String("owner", "", "filter certificates by owner") - cmd.Flags().String("state", "", "filter certificates by valid|revoked") + // FEEDBACK(2026-07): --owner disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // cmd.Flags().String("owner", "", "filter certificates by owner") + // FEEDBACK(2026-07): --state disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // cmd.Flags().String("state", "", "filter certificates by valid|revoked") return cmd } diff --git a/internal/cli/chain/deployment_query.go b/internal/cli/chain/deployment_query.go index ac0d5421..2479b9e3 100644 --- a/internal/cli/chain/deployment_query.go +++ b/internal/cli/chain/deployment_query.go @@ -15,9 +15,19 @@ import ( // The command itself handles list/get (unified), with group and params as subcommands. func GetQueryDeploymentCmds() *cobra.Command { cmd := &cobra.Command{ - Use: dv1.ModuleName + " [id]", - Short: "Query deployments", - Args: cobra.MaximumNArgs(1), + Use: dv1.ModuleName + " [id] [state]", + Short: "Query deployments", + Long: `Query deployments. + +Without arguments, lists the deployments owned by the context's default +account. The optional [id] argument is [owner/]dseq, a bare owner address, +or a bare state keyword (active|closed) per SPEC §3.8. + +The optional [state] argument narrows the result: with a partial identity it +filters the list; when the identity pins down a single deployment it verifies +the record instead — the command fails if the deployment is in a different +state rather than printing it.`, + Args: cobra.MaximumNArgs(2), SuggestionsMinimumDistance: 2, PersistentPreRunE: QueryPersistentPreRunE, RunE: func(cmd *cobra.Command, args []string) error { @@ -31,15 +41,33 @@ func GetQueryDeploymentCmds() *cobra.Command { defaultOwner := cl.ClientContext().GetFromAddress().String() - if len(args) == 1 { + if len(args) > 0 { af, err := cflags.DepFiltersFromArg(args[0], defaultOwner) if err != nil { return err } - dfilters.Owner = af.Owner + if af.Owner != "" { + dfilters.Owner = af.Owner + } if af.DSeq != 0 { dfilters.DSeq = af.DSeq } + // A bare state keyword may only appear once (SPEC §3.8.2). + if af.State != "" { + if len(args) > 1 { + return fmt.Errorf("deployment filter: state keyword %q cannot be combined with a second argument %q", args[0], args[1]) + } + dfilters.State = af.State + } + } + + // Optional second positional: a state keyword narrowing the + // identity filter (SPEC §3.8), e.g. + // `akt query deployment akash1owner/12345 active`. + if len(args) > 1 { + if dfilters.State, err = cflags.DeploymentStateFromArg(args[1]); err != nil { + return err + } } // Default owner fallback when no arg and no --owner flag. @@ -58,6 +86,13 @@ func GetQueryDeploymentCmds() *cobra.Command { return err } + // SPEC §3.8.3: on the get path the positional [state] is a + // verification, not a filter — never silently ignore it. + if err := requireStateMatch(dv1.ModuleName, fmt.Sprintf("%s/%d", id.Owner, id.DSeq), + dfilters.State, res.Deployment.State.String()); err != nil { + return err + } + return pretty.PrintQueryResult(cmd, cl.ClientContext(), res) } @@ -92,7 +127,7 @@ func GetQueryDeploymentCmds() *cobra.Command { // GetQueryDeploymentGroupCmd returns the command to query deployment groups. // Accepts an optional positional ID in the form owner/dseq[/gseq]. -// When gseq is provided (via arg or --gseq flag), returns a single group. +// When gseq is provided, returns a single group. // When only owner/dseq are given, fetches the deployment and shows all its groups. func GetQueryDeploymentGroupCmd() *cobra.Command { cmd := &cobra.Command{ @@ -122,22 +157,27 @@ func GetQueryDeploymentGroupCmd() *cobra.Command { if fullySpecified { gseq = parsed.GSeq } - } else { - // Read from flags - id, err := cflags.DeploymentIDFromFlags(cmd.Flags()) - if err != nil { - return err - } - owner = id.Owner - dseq = id.DSeq - - if cmd.Flags().Changed(cflags.FlagGSeq) { - gseq, _ = cmd.Flags().GetUint32(cflags.FlagGSeq) - } } + // FEEDBACK(2026-07): the --owner/--dseq/--gseq flag path is + // disabled for the positional-only UX trial; the positional + // [owner/]dseq[/gseq] argument is the only source. Restore by + // uncommenting if users ask for the flag form back. + // } else { + // // Read from flags + // id, err := cflags.DeploymentIDFromFlags(cmd.Flags()) + // if err != nil { + // return err + // } + // owner = id.Owner + // dseq = id.DSeq + // + // if cmd.Flags().Changed(cflags.FlagGSeq) { + // gseq, _ = cmd.Flags().GetUint32(cflags.FlagGSeq) + // } + // } if owner == "" || dseq == 0 { - return fmt.Errorf("owner and dseq are required (provide as owner/dseq[/gseq] or via --owner and --dseq flags)") + return fmt.Errorf("owner and dseq are required (provide as owner/dseq[/gseq])") } // If gseq is specified, query a single group. @@ -165,7 +205,10 @@ func GetQueryDeploymentGroupCmd() *cobra.Command { } cflags.AddQueryFlagsToCmd(cmd) - cflags.AddGroupIDFlags(cmd.Flags()) + // FEEDBACK(2026-07): --owner/--dseq/--gseq disabled for the + // positional-only UX trial (use the positional owner/dseq[/gseq] form + // instead). Restore by uncommenting if users ask for the flag form back. + // cflags.AddGroupIDFlags(cmd.Flags()) return cmd } diff --git a/internal/cli/chain/deployment_tx.go b/internal/cli/chain/deployment_tx.go index 9c673cca..5ecc9bb4 100644 --- a/internal/cli/chain/deployment_tx.go +++ b/internal/cli/chain/deployment_tx.go @@ -21,6 +21,12 @@ import ( var ( errDeploymentUpdate = errors.New("deployment update failed") errDeploymentUpdateGroupsChanged = fmt.Errorf("%w: groups are different than existing deployment, you cannot update groups", errDeploymentUpdate) + + // errDSeqRequired is the fail-fast guard for the positional-only UX + // trial: with --dseq disabled, the positional [dseq] argument is the only + // source, so a missing (or zero) dseq must be rejected before the tx + // pipeline (connection, keyring unlock, broadcast) is ever entered. + errDSeqRequired = errors.New("dseq is required: provide the positional [dseq] argument") ) // GetTxDeploymentCmds returns the transaction commands for this module @@ -133,11 +139,29 @@ func GetTxDeploymentCreateCmd() *cobra.Command { func GetTxDeploymentCloseCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "close", - Short: "Close deployment", - Args: cobra.ExactArgs(0), + Use: "close ", + Short: "Close deployment", + // FEEDBACK(2026-07): --dseq disabled for the positional-only UX + // trial; the positional dseq is the only source, so validate it here + // — before the tx pipeline (connection, keyring, broadcast) starts. + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.MaximumNArgs(1)(cmd, args); err != nil { + return err + } + + dseq, err := cflags.DSeqFromArgs(args, 0) + if err != nil { + return err + } + if dseq == 0 { + return errDSeqRequired + } + + return nil + }, + Example: `akt tx deployment close 12345`, PersistentPreRunE: TxPersistentPreRunE, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cl := MustClientFromContext(ctx) cctx := cl.ClientContext() @@ -147,6 +171,17 @@ func GetTxDeploymentCloseCmd() *cobra.Command { return err } + // FEEDBACK(2026-07): --dseq disabled for the positional-only UX + // trial; the positional dseq is the only source. The Args hook + // already validated it; this guard is defense in depth so a zero + // dseq can never reach the broadcast pipeline. + if id.DSeq, err = cflags.DSeqFromArgs(args, 0); err != nil { + return err + } + if id.DSeq == 0 { + return errDSeqRequired + } + msg := &dv1beta.MsgCloseDeployment{ID: id} resp, err := cl.Tx().BroadcastMsgs(ctx, []sdk.Msg{msg}) @@ -159,15 +194,34 @@ func GetTxDeploymentCloseCmd() *cobra.Command { } cflags.AddTxFlagsToCmd(cmd) - cflags.AddDeploymentIDFlags(cmd.Flags()) + addDeploymentOwnerTxFlags(cmd) return cmd } func GetTxDeploymentUpdateCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "update [sdl-file]", - Short: "update deployment", - Args: cobra.ExactArgs(1), + Use: "update ", + Short: "update deployment", + // FEEDBACK(2026-07): --dseq disabled for the positional-only UX + // trial; the positional dseq is the only source, so validate it here + // — before the tx pipeline (connection, keyring, broadcast) starts + // and before deployment 0 is ever queried. + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.RangeArgs(1, 2)(cmd, args); err != nil { + return err + } + + dseq, err := cflags.DSeqFromArgs(args[1:], 0) + if err != nil { + return err + } + if dseq == 0 { + return errDSeqRequired + } + + return nil + }, + Example: `akt tx deployment update deploy.yaml 12345`, PersistentPreRunE: TxPersistentPreRunE, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -179,6 +233,17 @@ func GetTxDeploymentUpdateCmd() *cobra.Command { return err } + // FEEDBACK(2026-07): --dseq disabled for the positional-only UX + // trial; the positional dseq is the only source. The Args hook + // already validated it; this guard is defense in depth so a zero + // dseq can never reach the query/broadcast pipeline. + if id.DSeq, err = cflags.DSeqFromArgs(args[1:], 0); err != nil { + return err + } + if id.DSeq == 0 { + return errDSeqRequired + } + sdlManifest, err := sdl.ReadFile(args[0]) if err != nil { return err @@ -232,11 +297,26 @@ func GetTxDeploymentUpdateCmd() *cobra.Command { } cflags.AddTxFlagsToCmd(cmd) - cflags.AddDeploymentIDFlags(cmd.Flags()) + addDeploymentOwnerTxFlags(cmd) return cmd } +// addDeploymentOwnerTxFlags registers the deployment identity flags for the +// close/update tx commands: --owner stays (it defaults to the signer and has +// no positional twin). +// FEEDBACK(2026-07): --dseq disabled for the positional-only UX trial (use +// the positional [dseq] argument instead). Restore by replacing this helper's +// body with the original registration: +// cflags.AddDeploymentIDFlags(cmd.Flags()) +func addDeploymentOwnerTxFlags(cmd *cobra.Command) { + cmd.Flags().String(cflags.FlagOwner, "", "Deployment Owner") + // FEEDBACK(2026-07): --dseq disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // cmd.Flags().Uint64(cflags.FlagDSeq, 0, "Deployment Sequence") +} + func GetTxDeploymentGroupCmds() *cobra.Command { cmd := &cobra.Command{ Use: "group", @@ -254,16 +334,18 @@ func GetTxDeploymentGroupCmds() *cobra.Command { func GetTxDeploymentGroupCloseCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "close", - Short: "close a Deployment's specific Group", - Example: "akash tx deployment group-close --owner=[Account Address] --dseq=[uint64] --gseq=[uint32]", - Args: cobra.ExactArgs(0), + Use: "close [dseq] [gseq]", + Short: "close a Deployment's specific Group", + Example: `akt tx deployment group close 12345 1 +akt tx deployment group close 12345 1 --owner=[Account Address]`, + Args: cobra.MaximumNArgs(2), PersistentPreRunE: TxPersistentPreRunE, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cl := MustClientFromContext(ctx) + cctx := cl.ClientContext() - id, err := cflags.GroupIDFromFlags(cmd.Flags()) + id, err := groupIDFromFlagsAndArgs(cmd, args, cctx.GetFromAddress()) if err != nil { return err } @@ -286,24 +368,25 @@ func GetTxDeploymentGroupCloseCmd() *cobra.Command { } cflags.AddTxFlagsToCmd(cmd) - cflags.AddGroupIDFlags(cmd.Flags()) - cflags.MarkReqGroupIDFlags(cmd) + addGroupOwnerTxFlags(cmd) return cmd } func GetDeploymentGroupPauseCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "pause", - Short: "pause a Deployment's specific Group", - Example: "akash tx deployment group pause --owner=[Account Address] --dseq=[uint64] --gseq=[uint32]", - Args: cobra.ExactArgs(0), + Use: "pause [dseq] [gseq]", + Short: "pause a Deployment's specific Group", + Example: `akt tx deployment group pause 12345 1 +akt tx deployment group pause 12345 1 --owner=[Account Address]`, + Args: cobra.MaximumNArgs(2), PersistentPreRunE: TxPersistentPreRunE, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cl := MustClientFromContext(ctx) + cctx := cl.ClientContext() - id, err := cflags.GroupIDFromFlags(cmd.Flags()) + id, err := groupIDFromFlagsAndArgs(cmd, args, cctx.GetFromAddress()) if err != nil { return err } @@ -326,24 +409,25 @@ func GetDeploymentGroupPauseCmd() *cobra.Command { } cflags.AddTxFlagsToCmd(cmd) - cflags.AddGroupIDFlags(cmd.Flags()) - cflags.MarkReqGroupIDFlags(cmd) + addGroupOwnerTxFlags(cmd) return cmd } func GetDeploymentGroupStartCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "start", - Short: "start a Deployment's specific Group", - Example: "akash tx deployment group pause --owner=[Account Address] --dseq=[uint64] --gseq=[uint32]", - Args: cobra.ExactArgs(0), + Use: "start [dseq] [gseq]", + Short: "start a Deployment's specific Group", + Example: `akt tx deployment group start 12345 1 +akt tx deployment group start 12345 1 --owner=[Account Address]`, + Args: cobra.MaximumNArgs(2), PersistentPreRunE: TxPersistentPreRunE, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cl := MustClientFromContext(ctx) + cctx := cl.ClientContext() - id, err := cflags.GroupIDFromFlags(cmd.Flags()) + id, err := groupIDFromFlagsAndArgs(cmd, args, cctx.GetFromAddress()) if err != nil { return err } @@ -366,12 +450,53 @@ func GetDeploymentGroupStartCmd() *cobra.Command { } cflags.AddTxFlagsToCmd(cmd) - cflags.AddGroupIDFlags(cmd.Flags()) - cflags.MarkReqGroupIDFlags(cmd) + addGroupOwnerTxFlags(cmd) return cmd } +// addGroupOwnerTxFlags registers the group identity flags for the group tx +// commands: --owner stays (it defaults to the signer and has no positional +// twin). +// FEEDBACK(2026-07): --dseq/--gseq disabled for the positional-only UX trial +// (use the positional [dseq] [gseq] arguments instead). Restore by replacing +// this helper's body with the original registration: +// cflags.AddGroupIDFlags(cmd.Flags()) +func addGroupOwnerTxFlags(cmd *cobra.Command) { + cmd.Flags().String(cflags.FlagOwner, "", "Deployment Owner") + // FEEDBACK(2026-07): --dseq disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // cmd.Flags().Uint64(cflags.FlagDSeq, 0, "Deployment Sequence") + // FEEDBACK(2026-07): --gseq disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // cmd.Flags().Uint32(cflags.FlagGSeq, 1, "Group Sequence") +} + +// groupIDFromFlagsAndArgs resolves a GroupID from the optional positional +// [dseq] [gseq] arguments. The owner defaults to the signer (the from +// address) and may be overridden with --owner. +func groupIDFromFlagsAndArgs(cmd *cobra.Command, args []string, owner sdk.AccAddress) (dv1.GroupID, error) { + id, err := cflags.GroupIDFromFlags(cmd.Flags(), cflags.WithOwner(owner)) + if err != nil { + return dv1.GroupID{}, err + } + + // FEEDBACK(2026-07): --dseq/--gseq disabled for the positional-only UX + // trial; the positional [dseq] [gseq] arguments are the only source + // (dseq zero fallback; gseq keeps its old flag default of 1). + if id.DSeq, id.GSeq, err = cflags.GroupSeqsFromArgs(args, 0, 1); err != nil { + return dv1.GroupID{}, err + } + + if id.DSeq == 0 { + return dv1.GroupID{}, errDSeqRequired + } + + return id, nil +} + func warnIfGroupVolumesExceeds(cctx sdkclient.Context, dgroups []dv1beta.GroupSpec) { for _, group := range dgroups { for _, resources := range group.GetResourceUnits() { diff --git a/internal/cli/chain/deployment_tx_test.go b/internal/cli/chain/deployment_tx_test.go new file mode 100644 index 00000000..1a89906e --- /dev/null +++ b/internal/cli/chain/deployment_tx_test.go @@ -0,0 +1,58 @@ +package cli + +import ( + "bytes" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +// execTxCmdArgsOnly executes a tx command without wiring any client into the +// command context. Reaching the tx pipeline (connection, keyring, broadcast) +// would therefore panic or fail on client discovery — so a clean guard error +// proves the command failed fast during cobra argument validation. +func execTxCmdArgsOnly(t *testing.T, cmd *cobra.Command, args ...string) error { + t.Helper() + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs(args) + + return cmd.Execute() +} + +// TestTxDeploymentCloseRequiresDSeq pins the fail-fast guard: with --dseq +// disabled for the positional-only trial, `tx deployment close` without a +// positional dseq must return the friendly guard error instead of building +// MsgCloseDeployment{DSeq: 0} and entering the sign/broadcast pipeline. +func TestTxDeploymentCloseRequiresDSeq(t *testing.T) { + err := execTxCmdArgsOnly(t, GetTxDeploymentCloseCmd()) + require.ErrorIs(t, err, errDSeqRequired) +} + +// TestTxDeploymentCloseRejectsZeroDSeq: an explicit positional "0" is just as +// invalid as a missing dseq and must hit the same guard. +func TestTxDeploymentCloseRejectsZeroDSeq(t *testing.T) { + err := execTxCmdArgsOnly(t, GetTxDeploymentCloseCmd(), "0") + require.ErrorIs(t, err, errDSeqRequired) +} + +// TestTxDeploymentUpdateRequiresDSeq pins the fail-fast guard for +// `tx deployment update ` without a dseq, which previously queried +// deployment 0. The SDL file deliberately does not exist: the guard must fire +// before the file is ever read. +func TestTxDeploymentUpdateRequiresDSeq(t *testing.T) { + err := execTxCmdArgsOnly(t, GetTxDeploymentUpdateCmd(), "does-not-exist.yaml") + require.ErrorIs(t, err, errDSeqRequired) +} + +// TestTxDeploymentUpdateRejectsZeroDSeq: an explicit positional "0" must hit +// the same guard as a missing dseq. +func TestTxDeploymentUpdateRejectsZeroDSeq(t *testing.T) { + err := execTxCmdArgsOnly(t, GetTxDeploymentUpdateCmd(), "does-not-exist.yaml", "0") + require.ErrorIs(t, err, errDSeqRequired) +} diff --git a/internal/cli/chain/escrow_query.go b/internal/cli/chain/escrow_query.go index 4e15da85..cc332395 100644 --- a/internal/cli/chain/escrow_query.go +++ b/internal/cli/chain/escrow_query.go @@ -232,19 +232,45 @@ $ %[1]s query %[2]s accounts open deployment/akash1... func GetQueryEscrowBlocksRemainingCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "blocks-remaining", - Short: "Compute the number of blocks remaining for an escrow account", - Args: cobra.ExactArgs(0), + Use: "blocks-remaining [filter]", + Short: "Compute the number of blocks remaining for an escrow account", + Long: `Compute the number of blocks remaining for an escrow account. +The filter argument takes the form [owner/]dseq (SPEC §3.8); the owner +defaults to the context's default account when omitted.`, + Example: `akt query escrow blocks-remaining 12345 +akt query escrow blocks-remaining akash1owner.../12345`, + Args: cobra.MaximumNArgs(1), PersistentPreRunE: QueryPersistentPreRunE, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cl := MustLightClientFromContext(ctx) - id, err := cflags.DeploymentIDFromFlags(cmd.Flags()) + // FEEDBACK(2026-07): --owner/--dseq disabled for the + // positional-only UX trial; start from a zero ID so the + // positional [owner/]dseq filter is the only source. Restore by + // uncommenting if users ask for the flag form back. + // id, err := cflags.DeploymentIDFromFlags(cmd.Flags()) + // if err != nil { + // return err + // } + var id dv1.DeploymentID + + // Positional [owner/]dseq; owner defaults to the context + // default account (SPEC §3.8). + defaultOwner := cl.ClientContext().GetFromAddress().String() + id, err := cflags.DeploymentIDFromArgs(args, id, defaultOwner) if err != nil { return err } + if id.Owner == "" { + return errors.New("owner is required: pass the filter positionally as [owner/]dseq") + } + + if id.DSeq == 0 { + return errors.New("dseq is required: pass the filter positionally as [owner/]dseq") + } + // Fetch leases matching owner & dseq leaseRequest := mvbeta.QueryLeasesRequest{ Filters: mv1.LeaseFilters{ @@ -341,8 +367,10 @@ func GetQueryEscrowBlocksRemainingCmd() *cobra.Command { } cflags.AddQueryFlagsToCmd(cmd) - cflags.AddDeploymentIDFlags(cmd.Flags()) - cflags.MarkReqDeploymentIDFlags(cmd) + // FEEDBACK(2026-07): --owner/--dseq disabled for the positional-only UX + // trial (use the positional [owner/]dseq filter instead). Restore by + // uncommenting if users ask for the flag form back. + // cflags.AddDeploymentIDFlags(cmd.Flags()) return cmd } diff --git a/internal/cli/chain/flags/args.go b/internal/cli/chain/flags/args.go new file mode 100644 index 00000000..9b890131 --- /dev/null +++ b/internal/cli/chain/flags/args.go @@ -0,0 +1,112 @@ +package flags + +import ( + "fmt" + "strconv" + + sdk "github.com/cosmos/cosmos-sdk/types" + + dv1 "pkg.akt.dev/go/node/deployment/v1" +) + +// DSeqFromArgs resolves a deployment sequence from an optional positional +// argument. When args is empty the flag-derived fallback is returned; when a +// positional dseq is present it always wins over the fallback (SPEC §3.8.2: +// positional values are applied after flags). +func DSeqFromArgs(args []string, fallback uint64) (uint64, error) { + if len(args) == 0 { + return fallback, nil + } + + dseq, err := strconv.ParseUint(args[0], 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid dseq %q: %w", args[0], err) + } + + return dseq, nil +} + +// GroupSeqsFromArgs resolves deployment and group sequences from optional +// positional arguments in the form [dseq [gseq]]. Positional values always +// win over the flag-derived fallbacks (SPEC §3.8.2). +func GroupSeqsFromArgs(args []string, dseq uint64, gseq uint32) (uint64, uint32, error) { + dseq, err := DSeqFromArgs(args, dseq) + if err != nil { + return 0, 0, err + } + + if len(args) > 1 { + val, err := strconv.ParseUint(args[1], 10, 32) + if err != nil { + return 0, 0, fmt.Errorf("invalid gseq %q: %w", args[1], err) + } + + gseq = uint32(val) + } + + return dseq, gseq, nil +} + +// LeaseSeqsFromArgs resolves a deployment sequence and provider address from +// optional positional arguments in the form [dseq [provider]]. Positional +// values always win over the flag-derived fallbacks (SPEC §3.8.2). +func LeaseSeqsFromArgs(args []string, dseq uint64, provider string) (uint64, string, error) { + dseq, err := DSeqFromArgs(args, dseq) + if err != nil { + return 0, "", err + } + + if len(args) > 1 { + if _, err := sdk.AccAddressFromBech32(args[1]); err != nil { + return 0, "", fmt.Errorf("invalid provider %q: %w", args[1], err) + } + + provider = args[1] + } + + return dseq, provider, nil +} + +// ExprFromArgs resolves a search expression from an optional positional +// argument. When args is empty the flag-derived fallback is returned; a +// positional expression always wins over the fallback (SPEC §3.8.2). +func ExprFromArgs(args []string, fallback string) string { + if len(args) == 0 { + return fallback + } + + return args[0] +} + +// DeploymentIDFromArgs resolves a deployment ID from an optional positional +// [owner/]dseq filter argument (SPEC §3.8), starting from the flag-derived +// fallback. Positional components win over the fallback and the owner falls +// back to defaultOwner when neither source sets it (SPEC §3.8.4). +func DeploymentIDFromArgs(args []string, fallback dv1.DeploymentID, defaultOwner string) (dv1.DeploymentID, error) { + id := fallback + + if len(args) > 0 { + f, err := DepFiltersFromArg(args[0], defaultOwner) + if err != nil { + return dv1.DeploymentID{}, err + } + + if f.State != "" { + return dv1.DeploymentID{}, fmt.Errorf("deployment ID: expected [owner/]dseq, got state keyword %q", args[0]) + } + + if f.Owner != "" { + id.Owner = f.Owner + } + + if f.DSeq != 0 { + id.DSeq = f.DSeq + } + } + + if id.Owner == "" { + id.Owner = defaultOwner + } + + return id, nil +} diff --git a/internal/cli/chain/flags/args_test.go b/internal/cli/chain/flags/args_test.go new file mode 100644 index 00000000..8a4e7e28 --- /dev/null +++ b/internal/cli/chain/flags/args_test.go @@ -0,0 +1,321 @@ +package flags + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dv1 "pkg.akt.dev/go/node/deployment/v1" +) + +// --------------------------------------------------------------------------- +// DSeqFromArgs +// --------------------------------------------------------------------------- + +func TestDSeqFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + fallback uint64 + want uint64 + wantErr string + }{ + { + name: "no args returns fallback", + args: nil, + fallback: 42, + want: 42, + }, + { + name: "empty slice returns fallback", + args: []string{}, + fallback: 42, + want: 42, + }, + { + name: "positional dseq wins over fallback", + args: []string{"12345"}, + fallback: 42, + want: 12345, + }, + { + name: "positional dseq with zero fallback", + args: []string{"100"}, + fallback: 0, + want: 100, + }, + { + name: "invalid dseq errors", + args: []string{"notanumber"}, + wantErr: "invalid dseq", + }, + { + name: "negative dseq errors", + args: []string{"-1"}, + wantErr: "invalid dseq", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := DSeqFromArgs(tc.args, tc.fallback) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// --------------------------------------------------------------------------- +// GroupSeqsFromArgs +// --------------------------------------------------------------------------- + +func TestGroupSeqsFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + dseq uint64 + gseq uint32 + wantDSeq uint64 + wantGSeq uint32 + wantErr string + }{ + { + name: "no args returns fallbacks", + args: nil, + dseq: 42, + gseq: 1, + wantDSeq: 42, + wantGSeq: 1, + }, + { + name: "positional dseq only — gseq keeps fallback", + args: []string{"12345"}, + dseq: 42, + gseq: 1, + wantDSeq: 12345, + wantGSeq: 1, + }, + { + name: "positional dseq and gseq win over fallbacks", + args: []string{"12345", "2"}, + dseq: 42, + gseq: 1, + wantDSeq: 12345, + wantGSeq: 2, + }, + { + name: "invalid dseq errors", + args: []string{"abc"}, + wantErr: "invalid dseq", + }, + { + name: "invalid gseq errors", + args: []string{"12345", "abc"}, + wantErr: "invalid gseq", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotDSeq, gotGSeq, err := GroupSeqsFromArgs(tc.args, tc.dseq, tc.gseq) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantDSeq, gotDSeq) + assert.Equal(t, tc.wantGSeq, gotGSeq) + }) + } +} + +// --------------------------------------------------------------------------- +// LeaseSeqsFromArgs +// --------------------------------------------------------------------------- + +func TestLeaseSeqsFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + dseq uint64 + provider string + wantDSeq uint64 + wantProvider string + wantErr string + }{ + { + name: "no args returns fallbacks", + args: nil, + dseq: 42, + provider: testProvider, + wantDSeq: 42, + wantProvider: testProvider, + }, + { + name: "positional dseq only — provider keeps fallback", + args: []string{"12345"}, + dseq: 42, + provider: testProvider, + wantDSeq: 12345, + wantProvider: testProvider, + }, + { + name: "positional dseq and provider win over fallbacks", + args: []string{"12345", testProvider}, + dseq: 42, + provider: testOwner, + wantDSeq: 12345, + wantProvider: testProvider, + }, + { + name: "positional values with empty fallbacks", + args: []string{"100", testProvider}, + wantDSeq: 100, + wantProvider: testProvider, + }, + { + name: "invalid dseq errors", + args: []string{"notanumber"}, + wantErr: "invalid dseq", + }, + { + name: "invalid provider errors", + args: []string{"12345", "notanaddress"}, + wantErr: "invalid provider", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotDSeq, gotProvider, err := LeaseSeqsFromArgs(tc.args, tc.dseq, tc.provider) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantDSeq, gotDSeq) + assert.Equal(t, tc.wantProvider, gotProvider) + }) + } +} + +// --------------------------------------------------------------------------- +// ExprFromArgs +// --------------------------------------------------------------------------- + +func TestExprFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + fallback string + want string + }{ + { + name: "no args returns fallback", + args: nil, + fallback: "message.sender='akash1...'", + want: "message.sender='akash1...'", + }, + { + name: "empty slice returns fallback", + args: []string{}, + fallback: "tx.height=5", + want: "tx.height=5", + }, + { + name: "positional expression wins over fallback", + args: []string{"block.height > 7"}, + fallback: "tx.height=5", + want: "block.height > 7", + }, + { + name: "positional expression with empty fallback", + args: []string{"message.action=withdraw"}, + want: "message.action=withdraw", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, ExprFromArgs(tc.args, tc.fallback)) + }) + } +} + +// --------------------------------------------------------------------------- +// DeploymentIDFromArgs +// --------------------------------------------------------------------------- + +func TestDeploymentIDFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + fallback dv1.DeploymentID + defaultOwner string + want dv1.DeploymentID + wantErr string + }{ + { + name: "no args keeps flag fallback", + args: nil, + fallback: dv1.DeploymentID{Owner: testOwner, DSeq: 42}, + defaultOwner: testProvider, + want: dv1.DeploymentID{Owner: testOwner, DSeq: 42}, + }, + { + name: "no args and no flags defaults owner", + args: nil, + defaultOwner: testOwner, + want: dv1.DeploymentID{Owner: testOwner}, + }, + { + name: "positional dseq wins, owner from default account", + args: []string{"12345"}, + fallback: dv1.DeploymentID{DSeq: 42}, + defaultOwner: testOwner, + want: dv1.DeploymentID{Owner: testOwner, DSeq: 12345}, + }, + { + name: "positional owner/dseq wins over flag fallback", + args: []string{testProvider + "/100"}, + fallback: dv1.DeploymentID{Owner: testOwner, DSeq: 42}, + want: dv1.DeploymentID{Owner: testProvider, DSeq: 100}, + }, + { + name: "state keyword is rejected", + args: []string{"active"}, + wantErr: "state keyword", + }, + { + name: "invalid dseq errors", + args: []string{"notanumber"}, + wantErr: "not a valid address or dseq", + }, + { + name: "dseq shorthand without default account errors", + args: []string{"12345"}, + wantErr: "no default account", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := DeploymentIDFromArgs(tc.args, tc.fallback, tc.defaultOwner) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/internal/cli/chain/flags/client.go b/internal/cli/chain/flags/client.go index 4d8a9a73..1508d749 100644 --- a/internal/cli/chain/flags/client.go +++ b/internal/cli/chain/flags/client.go @@ -41,9 +41,16 @@ func ClientOptionsFromFlags(flagSet *pflag.FlagSet) ([]cltypes.ClientOption, err opts = append(opts, cltypes.WithSkipConfirm(skip)) } - if flagSet.Changed(FlagGas) { + // The gas setting must be applied even when the flag is left at its + // default: "auto" means simulate-and-adjust, and skipping the option + // entirely used to broadcast transactions with gasWanted=0, which fail + // CheckTx with an out-of-gas error. + if flagSet.Lookup(FlagGas) != nil { gasStr, _ := flagSet.GetString(FlagGas) - gasSetting, _ := ParseGasSetting(gasStr) + gasSetting, err := ParseGasSetting(gasStr) + if err != nil { + return nil, err + } opts = append(opts, cltypes.WithGas(gasSetting)) } diff --git a/internal/cli/chain/flags/client_test.go b/internal/cli/chain/flags/client_test.go new file mode 100644 index 00000000..7f3ac636 --- /dev/null +++ b/internal/cli/chain/flags/client_test.go @@ -0,0 +1,69 @@ +package flags + +import ( + "testing" + + "github.com/spf13/cobra" + + cltypes "pkg.akt.dev/go/node/client/types" +) + +func applyClientOptions(t *testing.T, opts []cltypes.ClientOption) *cltypes.ClientOptions { + t.Helper() + + applied := &cltypes.ClientOptions{} + for _, opt := range opts { + if err := opt(applied); err != nil { + t.Fatalf("apply option: %v", err) + } + } + + return applied +} + +func TestClientOptionsDefaultGasSimulates(t *testing.T) { + cmd := &cobra.Command{} + AddTxFlagsToCmd(cmd) + + opts, err := ClientOptionsFromFlags(cmd.Flags()) + if err != nil { + t.Fatalf("ClientOptionsFromFlags: %v", err) + } + + applied := applyClientOptions(t, opts) + if !applied.Gas.Simulate { + t.Errorf("default --gas=auto must produce a simulating gas setting, got %+v", applied.Gas) + } +} + +func TestClientOptionsExplicitGas(t *testing.T) { + cmd := &cobra.Command{} + AddTxFlagsToCmd(cmd) + + if err := cmd.Flags().Set(FlagGas, "250000"); err != nil { + t.Fatalf("set gas: %v", err) + } + + opts, err := ClientOptionsFromFlags(cmd.Flags()) + if err != nil { + t.Fatalf("ClientOptionsFromFlags: %v", err) + } + + applied := applyClientOptions(t, opts) + if applied.Gas.Simulate || applied.Gas.Gas != 250000 { + t.Errorf("explicit gas not applied: %+v", applied.Gas) + } +} + +func TestClientOptionsInvalidGasErrors(t *testing.T) { + cmd := &cobra.Command{} + AddTxFlagsToCmd(cmd) + + if err := cmd.Flags().Set(FlagGas, "not-a-number"); err != nil { + t.Fatalf("set gas: %v", err) + } + + if _, err := ClientOptionsFromFlags(cmd.Flags()); err == nil { + t.Error("invalid gas value must be rejected") + } +} diff --git a/internal/cli/chain/flags/deployment.go b/internal/cli/chain/flags/deployment.go index 27da5e44..6dd5a1ef 100644 --- a/internal/cli/chain/flags/deployment.go +++ b/internal/cli/chain/flags/deployment.go @@ -101,8 +101,13 @@ func DeploymentIDFromFlags(flags *pflag.FlagSet, opts ...MarketOption) (dv1.Depl id.Owner = opt.Owner.String() - if id.DSeq, err = flags.GetUint64(FlagDSeq); err != nil { - return id, err + // FEEDBACK(2026-07): several commands disabled --dseq for the + // positional-only UX trial, so tolerate a missing flag and leave DSeq + // zero (the positional argument is then the only source). + if flags.Lookup(FlagDSeq) != nil { + if id.DSeq, err = flags.GetUint64(FlagDSeq); err != nil { + return id, err + } } return id, nil @@ -141,44 +146,65 @@ func GroupIDFromFlags(flags *pflag.FlagSet, opts ...MarketOption) (dv1.GroupID, return id, err } - gseq, err := flags.GetUint32(FlagGSeq) - if err != nil { - return id, err + // FEEDBACK(2026-07): several commands disabled --gseq for the + // positional-only UX trial, so tolerate a missing flag and leave GSeq + // zero (the positional argument is then the only source). + var gseq uint32 + if flags.Lookup(FlagGSeq) != nil { + if gseq, err = flags.GetUint32(FlagGSeq); err != nil { + return id, err + } } return dv1.MakeGroupID(prev, gseq), nil } // AddDeploymentFilterFlags add flags to filter for deployment list func AddDeploymentFilterFlags(flags *pflag.FlagSet) { - flags.String(FlagOwner, "", "deployment owner address to filter") - flags.String(FlagState, "", "deployment state to filter (active,closed)") - flags.Uint64(FlagDSeq, 0, "deployment sequence to filter") + // FEEDBACK(2026-07): --owner disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.String(FlagOwner, "", "deployment owner address to filter") + // FEEDBACK(2026-07): --state disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.String(FlagState, "", "deployment state to filter (active,closed)") + // FEEDBACK(2026-07): --dseq disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.Uint64(FlagDSeq, 0, "deployment sequence to filter") + _ = flags } // DepFiltersFromFlags returns DeploymentFilters with given flags and error if occurred func DepFiltersFromFlags(flags *pflag.FlagSet) (dv1beta.DeploymentFilters, error) { var dfilters dv1beta.DeploymentFilters - owner, err := flags.GetString(FlagOwner) - if err != nil { - return dfilters, err - } - - if owner != "" { - _, err = sdk.AccAddressFromBech32(owner) - if err != nil { - return dfilters, err - } - } - dfilters.Owner = owner - - if dfilters.State, err = flags.GetString(FlagState); err != nil { - return dfilters, err - } - - if dfilters.DSeq, err = flags.GetUint64(FlagDSeq); err != nil { - return dfilters, err - } + // FEEDBACK(2026-07): the --owner/--state/--dseq filter flags are disabled + // for the positional-only UX trial (see AddDeploymentFilterFlags), so this + // returns empty filters and the positional filter argument is the only + // source. Restore by uncommenting if the flags come back. + // owner, err := flags.GetString(FlagOwner) + // if err != nil { + // return dfilters, err + // } + // + // if owner != "" { + // _, err = sdk.AccAddressFromBech32(owner) + // if err != nil { + // return dfilters, err + // } + // } + // + // dfilters.Owner = owner + // + // if dfilters.State, err = flags.GetString(FlagState); err != nil { + // return dfilters, err + // } + // + // if dfilters.DSeq, err = flags.GetUint64(FlagDSeq); err != nil { + // return dfilters, err + // } + _ = flags return dfilters, nil } @@ -203,9 +229,12 @@ func DepFiltersIsID(f dv1beta.DeploymentFilters) bool { // Smart type detection (SPEC §3.8.2): // - If the first component is a bech32 address, it is the owner. // - If the first component is a number, it is the dseq and defaultOwner is used. +// - If the arg is a bare state keyword (active|closed), it is a state +// filter. State keywords do not combine with identity paths inside one +// argument; pass the state as the optional second positional instead. // - When the arg is a bare bech32 address with no "/", it lists all deployments for that owner. // -// Format: [owner/]dseq or owner +// Format: [owner/]dseq or owner or state func DepFiltersFromArg(arg string, defaultOwner string) (dv1beta.DeploymentFilters, error) { parts := strings.Split(arg, "/") var f dv1beta.DeploymentFilters @@ -214,6 +243,16 @@ func DepFiltersFromArg(arg string, defaultOwner string) (dv1beta.DeploymentFilte return f, fmt.Errorf("deployment filter: argument is required") } + // A bare state keyword as the sole argument selects a state filter (SPEC §3.8.2). + if val, exists := dv1.Deployment_State_value[parts[0]]; exists && dv1.Deployment_State(val) != dv1.DeploymentStateInvalid { + if len(parts) > 1 { + return f, fmt.Errorf("deployment filter: state keyword %q cannot be combined with identity path %q; pass the state as a separate second argument instead", parts[0], arg) + } + f.State = parts[0] + + return f, nil + } + // Smart type detection on the first component. if _, err := sdk.AccAddressFromBech32(parts[0]); err == nil { // First component is a bech32 address. @@ -248,6 +287,15 @@ func DepFiltersFromArg(arg string, defaultOwner string) (dv1beta.DeploymentFilte return f, nil } +// DeploymentStateFromArg validates a positional deployment state keyword +// against the deployment state vocabulary (active|closed) — the same +// vocabulary DepFiltersFromArg accepts for the bare-keyword form. It backs +// the optional second positional state argument of `query deployment` +// (SPEC §3.8): `akt query deployment akash1owner/12345 active`. +func DeploymentStateFromArg(arg string) (string, error) { + return stateFromArg("deployment", arg, dv1.Deployment_State_value) +} + // GroupIDFromArg parses a group path into a GroupID with smart type detection. // // Formats: diff --git a/internal/cli/chain/flags/filters_test.go b/internal/cli/chain/flags/filters_test.go index 1d1530fd..d633c905 100644 --- a/internal/cli/chain/flags/filters_test.go +++ b/internal/cli/chain/flags/filters_test.go @@ -30,6 +30,7 @@ func TestDepFiltersFromArg(t *testing.T) { defaultOwner string wantOwner string wantDSeq uint64 + wantState string wantErr string }{ { @@ -87,6 +88,26 @@ func TestDepFiltersFromArg(t *testing.T) { arg: "42/extra", wantErr: "no default account set", }, + { + name: "state keyword active", + arg: "active", + wantState: "active", + }, + { + name: "state keyword closed", + arg: "closed", + wantState: "closed", + }, + { + name: "state keyword with identity path errors", + arg: "active/12345", + wantErr: "cannot be combined", + }, + { + name: "invalid state keyword falls through to identity error", + arg: "invalid", + wantErr: "is not a valid address or dseq number", + }, } for _, tc := range tests { @@ -100,6 +121,7 @@ func TestDepFiltersFromArg(t *testing.T) { require.NoError(t, err) assert.Equal(t, tc.wantOwner, f.Owner) assert.Equal(t, tc.wantDSeq, f.DSeq) + assert.Equal(t, tc.wantState, f.State) }) } } @@ -110,14 +132,14 @@ func TestDepFiltersFromArg(t *testing.T) { func TestGroupIDFromArg(t *testing.T) { tests := []struct { - name string - arg string - defaultOwner string - wantOwner string - wantDSeq uint64 - wantGSeq uint32 - wantFullySpec bool - wantErr string + name string + arg string + defaultOwner string + wantOwner string + wantDSeq uint64 + wantGSeq uint32 + wantFullySpec bool + wantErr string }{ { name: "empty arg errors", @@ -215,6 +237,7 @@ func TestOrderFiltersFromArg(t *testing.T) { wantDSeq uint64 wantGSeq uint32 wantOSeq uint32 + wantState string wantErr string }{ { @@ -273,6 +296,26 @@ func TestOrderFiltersFromArg(t *testing.T) { arg: testOwner + "/100/1/abc", wantErr: "invalid oseq", }, + { + name: "state keyword open", + arg: "open", + wantState: "open", + }, + { + name: "state keyword active", + arg: "active", + wantState: "active", + }, + { + name: "state keyword closed", + arg: "closed", + wantState: "closed", + }, + { + name: "state keyword with identity path errors", + arg: "open/100", + wantErr: "cannot be combined", + }, } for _, tc := range tests { @@ -288,6 +331,7 @@ func TestOrderFiltersFromArg(t *testing.T) { assert.Equal(t, tc.wantDSeq, f.DSeq) assert.Equal(t, tc.wantGSeq, f.GSeq) assert.Equal(t, tc.wantOSeq, f.OSeq) + assert.Equal(t, tc.wantState, f.State) }) } } @@ -307,6 +351,7 @@ func TestBidFiltersFromArg(t *testing.T) { wantDSeq uint64 wantGSeq uint32 wantOSeq uint32 + wantState string wantErr string }{ { @@ -316,12 +361,12 @@ func TestBidFiltersFromArg(t *testing.T) { }, // --- Owner perspective (byProvider=false) --- { - name: "owner/dseq/gseq/oseq/provider — full path", - arg: testOwner + "/100/1/2/" + testProvider, - wantOwner: testOwner, - wantDSeq: 100, - wantGSeq: 1, - wantOSeq: 2, + name: "owner/dseq/gseq/oseq/provider — full path", + arg: testOwner + "/100/1/2/" + testProvider, + wantOwner: testOwner, + wantDSeq: 100, + wantGSeq: 1, + wantOSeq: 2, wantProvider: testProvider, }, { @@ -381,6 +426,28 @@ func TestBidFiltersFromArg(t *testing.T) { byProvider: true, wantErr: "provider address is required", }, + // --- State keywords --- + { + name: "state keyword open", + arg: "open", + wantState: "open", + }, + { + name: "state keyword lost", + arg: "lost", + wantState: "lost", + }, + { + name: "by-provider: state keyword open", + arg: "open", + byProvider: true, + wantState: "open", + }, + { + name: "state keyword with identity path errors", + arg: "open/100", + wantErr: "cannot be combined", + }, } for _, tc := range tests { @@ -397,6 +464,7 @@ func TestBidFiltersFromArg(t *testing.T) { assert.Equal(t, tc.wantDSeq, f.DSeq) assert.Equal(t, tc.wantGSeq, f.GSeq) assert.Equal(t, tc.wantOSeq, f.OSeq) + assert.Equal(t, tc.wantState, f.State) }) } } @@ -422,6 +490,101 @@ func TestLeaseFiltersFromArg(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "argument is required") }) + + t.Run("state keyword active", func(t *testing.T) { + lf, err := LeaseFiltersFromArg("active", "", false) + require.NoError(t, err) + assert.Equal(t, "active", lf.State) + assert.Empty(t, lf.Owner) + }) + + t.Run("state keyword insufficient_funds", func(t *testing.T) { + lf, err := LeaseFiltersFromArg("insufficient_funds", "", false) + require.NoError(t, err) + assert.Equal(t, "insufficient_funds", lf.State) + }) + + t.Run("state keyword closed", func(t *testing.T) { + lf, err := LeaseFiltersFromArg("closed", "", false) + require.NoError(t, err) + assert.Equal(t, "closed", lf.State) + }) + + t.Run("bid-only state keyword rejected", func(t *testing.T) { + _, err := LeaseFiltersFromArg("open", "", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "is not a valid lease state") + }) + + t.Run("state keyword with identity path errors", func(t *testing.T) { + _, err := LeaseFiltersFromArg("active/12345", "", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot be combined") + }) + + t.Run("plain identity still works", func(t *testing.T) { + lf, err := LeaseFiltersFromArg(testOwner+"/100", "", false) + require.NoError(t, err) + assert.Equal(t, testOwner, lf.Owner) + assert.Equal(t, uint64(100), lf.DSeq) + assert.Empty(t, lf.State) + }) +} + +// --------------------------------------------------------------------------- +// StateFromArg helpers — back the optional second positional state argument +// (`akt query deployment akash1x/12345 active`), one per resource vocabulary. +// --------------------------------------------------------------------------- + +func TestStateFromArgHelpers(t *testing.T) { + tests := []struct { + name string + fn func(string) (string, error) + valid []string + invalid []string + }{ + { + name: "deployment", + fn: DeploymentStateFromArg, + valid: []string{"active", "closed"}, + invalid: []string{"open", "lost", "insufficient_funds", "invalid", "bogus", ""}, + }, + { + name: "order", + fn: OrderStateFromArg, + valid: []string{"open", "active", "closed"}, + invalid: []string{"lost", "insufficient_funds", "invalid", "bogus", ""}, + }, + { + name: "bid", + fn: BidStateFromArg, + valid: []string{"open", "active", "lost", "closed"}, + invalid: []string{"insufficient_funds", "invalid", "bogus", ""}, + }, + { + name: "lease", + fn: LeaseStateFromArg, + valid: []string{"active", "insufficient_funds", "closed"}, + invalid: []string{"open", "lost", "invalid", "bogus", ""}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + for _, keyword := range tc.valid { + state, err := tc.fn(keyword) + require.NoError(t, err, "keyword %q must be a valid %s state", keyword, tc.name) + assert.Equal(t, keyword, state) + } + + for _, keyword := range tc.invalid { + _, err := tc.fn(keyword) + require.Error(t, err, "keyword %q must be rejected as a %s state", keyword, tc.name) + assert.Contains(t, err.Error(), "is not a valid state") + assert.Contains(t, err.Error(), tc.name+" filter:") + } + }) + } } // --------------------------------------------------------------------------- diff --git a/internal/cli/chain/flags/market.go b/internal/cli/chain/flags/market.go index 375d8c8a..59a6bc05 100644 --- a/internal/cli/chain/flags/market.go +++ b/internal/cli/chain/flags/market.go @@ -2,6 +2,7 @@ package flags import ( "fmt" + "sort" "strconv" "strings" @@ -117,87 +118,154 @@ func LeaseIDFromFlags(flags *pflag.FlagSet, opts ...MarketOption) (mv1.LeaseID, // AddOrderFilterFlags add flags to filter for order list func AddOrderFilterFlags(flags *pflag.FlagSet) { - flags.String(FlagOwner, "", "order owner address to filter") - flags.String(FlagState, "", "order state to filter (open,matched,closed)") - flags.Uint64(FlagDSeq, 0, "deployment sequence to filter") - flags.Uint32(FlagGSeq, 0, "group sequence to filter") - flags.Uint32(FlagOSeq, 0, "order sequence to filter") + // FEEDBACK(2026-07): --owner disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.String(FlagOwner, "", "order owner address to filter") + // FEEDBACK(2026-07): --state disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.String(FlagState, "", "order state to filter (open,active,closed)") + // FEEDBACK(2026-07): --dseq disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.Uint64(FlagDSeq, 0, "deployment sequence to filter") + // FEEDBACK(2026-07): --gseq disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.Uint32(FlagGSeq, 0, "group sequence to filter") + // FEEDBACK(2026-07): --oseq disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.Uint32(FlagOSeq, 0, "order sequence to filter") + _ = flags } // OrderFiltersFromFlags returns OrderFilters with given flags and error if occurred func OrderFiltersFromFlags(flags *pflag.FlagSet) (mvbeta.OrderFilters, error) { - dfilters, err := DepFiltersFromFlags(flags) - if err != nil { - return mvbeta.OrderFilters{}, err - } - ofilters := mvbeta.OrderFilters{ - Owner: dfilters.Owner, - DSeq: dfilters.DSeq, - State: dfilters.State, - } - - if ofilters.GSeq, err = flags.GetUint32(FlagGSeq); err != nil { - return ofilters, err - } - - if ofilters.OSeq, err = flags.GetUint32(FlagOSeq); err != nil { - return ofilters, err - } - - return ofilters, nil + // FEEDBACK(2026-07): the order filter flags are disabled for the + // positional-only UX trial (see AddOrderFilterFlags), so this returns + // empty filters and the positional filter argument is the only source. + // Restore by uncommenting if the flags come back. + // dfilters, err := DepFiltersFromFlags(flags) + // if err != nil { + // return mvbeta.OrderFilters{}, err + // } + // ofilters := mvbeta.OrderFilters{ + // Owner: dfilters.Owner, + // DSeq: dfilters.DSeq, + // State: dfilters.State, + // } + // + // if ofilters.GSeq, err = flags.GetUint32(FlagGSeq); err != nil { + // return ofilters, err + // } + // + // if ofilters.OSeq, err = flags.GetUint32(FlagOSeq); err != nil { + // return ofilters, err + // } + _ = flags + + return mvbeta.OrderFilters{}, nil } // AddBidFilterFlags add flags to filter for bid list func AddBidFilterFlags(flags *pflag.FlagSet) { - flags.String(FlagOwner, "", "bid owner address to filter") - flags.String(FlagState, "", "bid state to filter (open,matched,lost,closed)") - flags.Uint64(FlagDSeq, 0, "deployment sequence to filter") - flags.Uint32(FlagGSeq, 0, "group sequence to filter") - flags.Uint32(FlagOSeq, 0, "order sequence to filter") - flags.String(FlagProvider, "", "bid provider address to filter") + // FEEDBACK(2026-07): --owner disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.String(FlagOwner, "", "bid owner address to filter") + // FEEDBACK(2026-07): --state disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.String(FlagState, "", "bid state to filter (open,active,lost,closed)") + // FEEDBACK(2026-07): --dseq disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.Uint64(FlagDSeq, 0, "deployment sequence to filter") + // FEEDBACK(2026-07): --gseq disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.Uint32(FlagGSeq, 0, "group sequence to filter") + // FEEDBACK(2026-07): --oseq disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.Uint32(FlagOSeq, 0, "order sequence to filter") + // FEEDBACK(2026-07): --provider disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.String(FlagProvider, "", "bid provider address to filter") + _ = flags } // BidFiltersFromFlags returns BidFilters with given flags and error if occurred func BidFiltersFromFlags(flags *pflag.FlagSet) (mvbeta.BidFilters, error) { - ofilters, err := OrderFiltersFromFlags(flags) - if err != nil { - return mvbeta.BidFilters{}, err - } - bfilters := mvbeta.BidFilters{ - Owner: ofilters.Owner, - DSeq: ofilters.DSeq, - GSeq: ofilters.GSeq, - OSeq: ofilters.OSeq, - State: ofilters.State, - } - - provider, err := flags.GetString(FlagProvider) - if err != nil { - return bfilters, err - } - - if provider != "" { - _, err = sdk.AccAddressFromBech32(provider) - if err != nil { - return bfilters, err - } - } - bfilters.Provider = provider - - return bfilters, nil + // FEEDBACK(2026-07): the bid filter flags are disabled for the + // positional-only UX trial (see AddBidFilterFlags), so this returns + // empty filters and the positional filter argument is the only source. + // Restore by uncommenting if the flags come back. + // ofilters, err := OrderFiltersFromFlags(flags) + // if err != nil { + // return mvbeta.BidFilters{}, err + // } + // bfilters := mvbeta.BidFilters{ + // Owner: ofilters.Owner, + // DSeq: ofilters.DSeq, + // GSeq: ofilters.GSeq, + // OSeq: ofilters.OSeq, + // State: ofilters.State, + // } + // + // provider, err := flags.GetString(FlagProvider) + // if err != nil { + // return bfilters, err + // } + // + // if provider != "" { + // _, err = sdk.AccAddressFromBech32(provider) + // if err != nil { + // return bfilters, err + // } + // } + // bfilters.Provider = provider + _ = flags + + return mvbeta.BidFilters{}, nil } // AddLeaseFilterFlags add flags to filter for a lease list func AddLeaseFilterFlags(flags *pflag.FlagSet) { - flags.String(FlagOwner, "", "lease owner address to filter") - flags.String(FlagState, "", "lease state to filter (active,insufficient_funds,closed)") - flags.Uint64(FlagDSeq, 0, "deployment sequence to filter") - flags.Uint32(FlagGSeq, 0, "group sequence to filter") - flags.Uint32(FlagOSeq, 0, "order sequence to filter") - flags.String(FlagProvider, "", "bid provider address to filter") + // FEEDBACK(2026-07): --owner disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.String(FlagOwner, "", "lease owner address to filter") + // FEEDBACK(2026-07): --state disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.String(FlagState, "", "lease state to filter (active,insufficient_funds,closed)") + // FEEDBACK(2026-07): --dseq disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.Uint64(FlagDSeq, 0, "deployment sequence to filter") + // FEEDBACK(2026-07): --gseq disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.Uint32(FlagGSeq, 0, "group sequence to filter") + // FEEDBACK(2026-07): --oseq disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.Uint32(FlagOSeq, 0, "order sequence to filter") + // FEEDBACK(2026-07): --provider disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // flags.String(FlagProvider, "", "bid provider address to filter") + _ = flags } -// LeaseFiltersFromFlags returns LeaseFilters with given flags and error if occurred +// LeaseFiltersFromFlags returns LeaseFilters with given flags and error if occurred. +// FEEDBACK(2026-07): the lease filter flags are disabled for the positional-only +// UX trial (see AddLeaseFilterFlags), so this returns empty filters via the +// (also gutted) BidFiltersFromFlags. func LeaseFiltersFromFlags(flags *pflag.FlagSet) (mv1.LeaseFilters, error) { bfilters, err := BidFiltersFromFlags(flags) if err != nil { @@ -230,6 +298,9 @@ func LeaseFiltersIsID(f mv1.LeaseFilters) bool { // - [owner/]dseq[/gseq[/oseq]] // - If the first component is a number, it is dseq and defaultOwner is used. // - If the first component is a bech32 address, it is the owner. +// - If the arg is a bare state keyword (open|active|closed), it is a state +// filter. State keywords do not combine with identity paths inside one +// argument; pass the state as the optional second positional instead. func OrderFiltersFromArg(arg string, defaultOwner string) (mvbeta.OrderFilters, error) { parts := strings.Split(arg, "/") var f mvbeta.OrderFilters @@ -238,6 +309,16 @@ func OrderFiltersFromArg(arg string, defaultOwner string) (mvbeta.OrderFilters, return f, fmt.Errorf("order filter: argument is required") } + // A bare state keyword as the sole argument selects a state filter (SPEC §3.8.2). + if val, exists := mvbeta.Order_State_value[parts[0]]; exists && mvbeta.Order_State(val) != mvbeta.OrderStateInvalid { + if len(parts) > 1 { + return f, fmt.Errorf("order filter: state keyword %q cannot be combined with identity path %q; pass the state as a separate second argument instead", parts[0], arg) + } + f.State = parts[0] + + return f, nil + } + idx := 0 // Smart type detection on the first component. @@ -293,6 +374,10 @@ func OrderFiltersFromArg(arg string, defaultOwner string) (mvbeta.OrderFilters, // When byProvider is true, the leading address is the provider and the trailing // address is the owner. Otherwise the leading address is the owner and the // trailing address is the provider. +// +// A bare state keyword (open|active|lost|closed) as the sole argument is a +// state filter. State keywords do not combine with identity paths inside one +// argument; pass the state as the optional second positional instead. func BidFiltersFromArg(arg string, defaultOwner string, byProvider bool) (mvbeta.BidFilters, error) { parts := strings.Split(arg, "/") var f mvbeta.BidFilters @@ -301,6 +386,16 @@ func BidFiltersFromArg(arg string, defaultOwner string, byProvider bool) (mvbeta return f, fmt.Errorf("bid filter: argument is required") } + // A bare state keyword as the sole argument selects a state filter (SPEC §3.8.2). + if val, exists := mvbeta.Bid_State_value[parts[0]]; exists && mvbeta.Bid_State(val) != mvbeta.BidStateInvalid { + if len(parts) > 1 { + return f, fmt.Errorf("bid filter: state keyword %q cannot be combined with identity path %q; pass the state as a separate second argument instead", parts[0], arg) + } + f.State = parts[0] + + return f, nil + } + idx := 0 // Smart type detection on the first component. @@ -373,14 +468,90 @@ func BidFiltersFromArg(arg string, defaultOwner string, byProvider bool) (mvbeta // LeaseFiltersFromArg parses a partial lease path into LeaseFilters. // See BidFiltersFromArg for format details. +// +// A bare state keyword (active|insufficient_funds|closed) as the sole argument +// is a state filter. The lease state vocabulary differs +// from the bid vocabulary, so it is handled here before delegating identity +// parsing to BidFiltersFromArg. func LeaseFiltersFromArg(arg string, defaultOwner string, byProvider bool) (mv1.LeaseFilters, error) { + parts := strings.Split(arg, "/") + + // A bare state keyword as the sole argument selects a state filter (SPEC §3.8.2). + if val, exists := mv1.Lease_State_value[parts[0]]; exists && mv1.Lease_State(val) != mv1.LeaseStateInvalid { + if len(parts) > 1 { + return mv1.LeaseFilters{}, fmt.Errorf("lease filter: state keyword %q cannot be combined with identity path %q; pass the state as a separate second argument instead", parts[0], arg) + } + + return mv1.LeaseFilters{State: parts[0]}, nil + } + bf, err := BidFiltersFromArg(arg, defaultOwner, byProvider) if err != nil { return mv1.LeaseFilters{}, err } + + // BidFiltersFromArg recognizes bid state keywords; reject those that are + // not valid lease states (e.g. "open", "lost"). + if bf.State != "" { + if _, exists := mv1.Lease_State_value[bf.State]; !exists { + return mv1.LeaseFilters{}, fmt.Errorf("lease filter: %q is not a valid lease state (%s)", bf.State, stateKeywords(mv1.Lease_State_value)) + } + } + return mv1.LeaseFilters(bf), nil } +// OrderStateFromArg validates a positional order state keyword against the +// order state vocabulary (open|active|closed). It backs the optional second +// positional state argument of `query market order` (SPEC §3.8). +func OrderStateFromArg(arg string) (string, error) { + return stateFromArg("order", arg, mvbeta.Order_State_value) +} + +// BidStateFromArg validates a positional bid state keyword against the bid +// state vocabulary (open|active|lost|closed). It backs the optional second +// positional state argument of `query market bid` (SPEC §3.8). +func BidStateFromArg(arg string) (string, error) { + return stateFromArg("bid", arg, mvbeta.Bid_State_value) +} + +// LeaseStateFromArg validates a positional lease state keyword against the +// lease state vocabulary (active|insufficient_funds|closed). It backs the +// optional second positional state argument of `query market lease` +// (SPEC §3.8). +func LeaseStateFromArg(arg string) (string, error) { + return stateFromArg("lease", arg, mv1.Lease_State_value) +} + +// stateFromArg validates a positional state keyword against a resource's +// protobuf State_value vocabulary — the same check the *FiltersFromArg +// parsers apply to a bare-keyword first argument. The zero enum value is the +// per-resource "invalid" placeholder and is never a valid keyword. +func stateFromArg(resource, arg string, values map[string]int32) (string, error) { + if val, exists := values[arg]; exists && val != 0 { + return arg, nil + } + + return "", fmt.Errorf("%s filter: %q is not a valid state (%s)", resource, arg, stateKeywords(values)) +} + +// stateKeywords returns the valid state keywords of a protobuf State_value +// enum map joined with "|", ordered by enum value and excluding the zero +// (invalid) placeholder. +func stateKeywords(values map[string]int32) string { + names := make([]string, 0, len(values)) + for name, val := range values { + if val == 0 { + continue + } + names = append(names, name) + } + + sort.Slice(names, func(i, j int) bool { return values[names[i]] < values[names[j]] }) + + return strings.Join(names, "|") +} + // AddBidClosedReasonFlag add the reason flag when the provider initiates lease close func AddBidClosedReasonFlag(flags *pflag.FlagSet) { flags.Int32(FlagClosedReason, int32(mv1.LeaseClosedReasonUnspecified), "Numeric reason for closing the bid (10000=unstable, 10001=decommission, 10002=unspecified, 10003=manifest_timeout)") diff --git a/internal/cli/chain/genesis_init.go b/internal/cli/chain/genesis_init.go index 7f35c550..23eadd5a 100644 --- a/internal/cli/chain/genesis_init.go +++ b/internal/cli/chain/genesis_init.go @@ -188,12 +188,12 @@ func GetGenesisInitCmd(mbm module.BasicManager, defaultNodeHome string) *cobra.C } bankGenState.SendEnabled = []banktypes.SendEnabled{ { - sdkutil.DenomUakt, - true, + Denom: sdkutil.DenomUakt, + Enabled: true, }, { - sdkutil.DenomUact, - false, + Denom: sdkutil.DenomUact, + Enabled: false, }, } bankGenStateBz, err := cdc.MarshalJSON(bankGenState) diff --git a/internal/cli/chain/market_query.go b/internal/cli/chain/market_query.go index 117a99ee..0ca12270 100644 --- a/internal/cli/chain/market_query.go +++ b/internal/cli/chain/market_query.go @@ -1,6 +1,8 @@ package cli import ( + "fmt" + "github.com/spf13/cobra" sdkclient "github.com/cosmos/cosmos-sdk/client" @@ -36,9 +38,18 @@ func GetQueryMarketCmds() *cobra.Command { // When partially specified, returns a filtered list. func GetQueryMarketOrderCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "order [id]", - Short: "Query orders", - Args: cobra.MaximumNArgs(1), + Use: "order [id] [state]", + Short: "Query orders", + Long: `Query orders. + +The optional [id] argument is [owner/]dseq[/gseq[/oseq]], a bare owner +address, or a bare state keyword (open|active|closed) per SPEC §3.8. + +The optional [state] argument narrows the result: with a partial identity it +filters the list; when the identity pins down a single order it verifies the +record instead — the command fails if the order is in a different state +rather than printing it.`, + Args: cobra.MaximumNArgs(2), PersistentPreRunE: QueryPersistentPreRunE, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -51,12 +62,14 @@ func GetQueryMarketOrderCmd() *cobra.Command { defaultOwner := cl.ClientContext().GetFromAddress().String() - if len(args) == 1 { + if len(args) > 0 { af, err := cflags.OrderFiltersFromArg(args[0], defaultOwner) if err != nil { return err } - ofilters.Owner = af.Owner + if af.Owner != "" { + ofilters.Owner = af.Owner + } if af.DSeq != 0 { ofilters.DSeq = af.DSeq } @@ -66,6 +79,22 @@ func GetQueryMarketOrderCmd() *cobra.Command { if af.OSeq != 0 { ofilters.OSeq = af.OSeq } + // A bare state keyword may only appear once (SPEC §3.8.2). + if af.State != "" { + if len(args) > 1 { + return fmt.Errorf("order filter: state keyword %q cannot be combined with a second argument %q", args[0], args[1]) + } + ofilters.State = af.State + } + } + + // Optional second positional: a state keyword narrowing the + // identity filter (SPEC §3.8), e.g. + // `akt query market order 12345 open`. + if len(args) > 1 { + if ofilters.State, err = cflags.OrderStateFromArg(args[1]); err != nil { + return err + } } // Default owner fallback when no arg and no --owner flag. @@ -86,6 +115,13 @@ func GetQueryMarketOrderCmd() *cobra.Command { return err } + // SPEC §3.8.3: on the get path the positional [state] is a + // verification, not a filter — never silently ignore it. + if err := requireStateMatch("order", fmt.Sprintf("%s/%d/%d/%d", id.Owner, id.DSeq, id.GSeq, id.OSeq), + ofilters.State, res.Order.State.String()); err != nil { + return err + } + return pretty.PrintQueryResult(cmd, cl.ClientContext(), &res.Order) } @@ -120,9 +156,19 @@ func GetQueryMarketOrderCmd() *cobra.Command { // When partially specified, returns a filtered list. func GetQueryMarketBidCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "bid [id]", - Short: "Query bids", - Args: cobra.MaximumNArgs(1), + Use: "bid [id] [state]", + Short: "Query bids", + Long: `Query bids. + +The optional [id] argument is [owner/]dseq[/gseq[/oseq[/provider]]] (with +--by provider: [provider/]dseq[/gseq[/oseq[/owner]]]), a bare address, or a +bare state keyword (open|active|lost|closed) per SPEC §3.8. + +The optional [state] argument narrows the result: with a partial identity it +filters the list; when the identity pins down a single bid it verifies the +record instead — the command fails if the bid is in a different state rather +than printing it.`, + Args: cobra.MaximumNArgs(2), PersistentPreRunE: QueryPersistentPreRunE, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -137,7 +183,7 @@ func GetQueryMarketBidCmd() *cobra.Command { byProvider, _ := cmd.Flags().GetString("by") isByProvider := byProvider == "provider" - if len(args) == 1 { + if len(args) > 0 { af, err := cflags.BidFiltersFromArg(args[0], defaultOwner, isByProvider) if err != nil { return err @@ -157,6 +203,22 @@ func GetQueryMarketBidCmd() *cobra.Command { if af.Provider != "" { bfilters.Provider = af.Provider } + // A bare state keyword may only appear once (SPEC §3.8.2). + if af.State != "" { + if len(args) > 1 { + return fmt.Errorf("bid filter: state keyword %q cannot be combined with a second argument %q", args[0], args[1]) + } + bfilters.State = af.State + } + } + + // Optional second positional: a state keyword narrowing the + // identity filter (SPEC §3.8), e.g. + // `akt query market bid 12345 open`. + if len(args) > 1 { + if bfilters.State, err = cflags.BidStateFromArg(args[1]); err != nil { + return err + } } // Default owner fallback when no arg and no --owner flag (owner mode only). @@ -178,6 +240,13 @@ func GetQueryMarketBidCmd() *cobra.Command { return err } + // SPEC §3.8.3: on the get path the positional [state] is a + // verification, not a filter — never silently ignore it. + if err := requireStateMatch("bid", fmt.Sprintf("%s/%d/%d/%d/%s", id.Owner, id.DSeq, id.GSeq, id.OSeq, id.Provider), + bfilters.State, res.Bid.State.String()); err != nil { + return err + } + return pretty.PrintQueryResult(cmd, cl.ClientContext(), res) } @@ -213,9 +282,19 @@ func GetQueryMarketBidCmd() *cobra.Command { // When partially specified, returns a filtered list. func GetQueryMarketLeaseCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "lease [id]", - Short: "Query leases", - Args: cobra.MaximumNArgs(1), + Use: "lease [id] [state]", + Short: "Query leases", + Long: `Query leases. + +The optional [id] argument is [owner/]dseq[/gseq[/oseq[/provider]]] (with +--by provider: [provider/]dseq[/gseq[/oseq[/owner]]]), a bare address, or a +bare state keyword (active|insufficient_funds|closed) per SPEC §3.8. + +The optional [state] argument narrows the result: with a partial identity it +filters the list; when the identity pins down a single lease it verifies the +record instead — the command fails if the lease is in a different state +rather than printing it.`, + Args: cobra.MaximumNArgs(2), PersistentPreRunE: QueryPersistentPreRunE, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -230,7 +309,7 @@ func GetQueryMarketLeaseCmd() *cobra.Command { byProvider, _ := cmd.Flags().GetString("by") isByProvider := byProvider == "provider" - if len(args) == 1 { + if len(args) > 0 { af, err := cflags.LeaseFiltersFromArg(args[0], defaultOwner, isByProvider) if err != nil { return err @@ -250,6 +329,22 @@ func GetQueryMarketLeaseCmd() *cobra.Command { if af.Provider != "" { lfilters.Provider = af.Provider } + // A bare state keyword may only appear once (SPEC §3.8.2). + if af.State != "" { + if len(args) > 1 { + return fmt.Errorf("lease filter: state keyword %q cannot be combined with a second argument %q", args[0], args[1]) + } + lfilters.State = af.State + } + } + + // Optional second positional: a state keyword narrowing the + // identity filter (SPEC §3.8), e.g. + // `akt query market lease 12345 active`. + if len(args) > 1 { + if lfilters.State, err = cflags.LeaseStateFromArg(args[1]); err != nil { + return err + } } // Default owner fallback when no arg and no --owner flag (owner mode only). @@ -271,6 +366,13 @@ func GetQueryMarketLeaseCmd() *cobra.Command { return err } + // SPEC §3.8.3: on the get path the positional [state] is a + // verification, not a filter — never silently ignore it. + if err := requireStateMatch("lease", fmt.Sprintf("%s/%d/%d/%d/%s", id.Owner, id.DSeq, id.GSeq, id.OSeq, id.Provider), + lfilters.State, res.Lease.State.String()); err != nil { + return err + } + return pretty.PrintQueryResult(cmd, cl.ClientContext(), res) } diff --git a/internal/cli/chain/market_tx.go b/internal/cli/chain/market_tx.go index 836b5961..2ee1d25e 100644 --- a/internal/cli/chain/market_tx.go +++ b/internal/cli/chain/market_tx.go @@ -1,6 +1,8 @@ package cli import ( + "errors" + "github.com/spf13/cobra" sdkclient "github.com/cosmos/cosmos-sdk/client" @@ -165,16 +167,17 @@ func GetTxMarketLeaseCmds() *cobra.Command { func GetTxMarketLeaseCreateCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "create", + Use: "create [dseq] [provider]", Short: "Create a market lease", - Args: cobra.ExactArgs(0), + Args: cobra.MaximumNArgs(2), + Example: `akt tx market lease create 12345 akash1provider...`, PersistentPreRunE: TxPersistentPreRunE, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cl := MustClientFromContext(ctx) cctx := cl.ClientContext() - id, err := cflags.LeaseIDFromFlags(cmd.Flags(), cflags.WithOwner(cctx.FromAddress)) + id, err := leaseIDFromFlagsAndArgs(cmd, args, cctx.FromAddress) if err != nil { return err } @@ -197,25 +200,25 @@ func GetTxMarketLeaseCreateCmd() *cobra.Command { } cflags.AddTxFlagsToCmd(cmd) - cflags.AddLeaseIDFlags(cmd.Flags()) - cflags.MarkReqLeaseIDFlags(cmd, cflags.DeploymentIDOptionNoOwner(true)) + addLeaseOwnerTxFlags(cmd) return cmd } func GetTxMarketLeaseWithdrawCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "withdraw", + Use: "withdraw [dseq] [provider]", Short: "Settle and withdraw available funds from market order escrow account", - Args: cobra.ExactArgs(0), + Args: cobra.MaximumNArgs(2), + Example: `akt tx market lease withdraw 12345 akash1provider...`, PersistentPreRunE: TxPersistentPreRunE, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cl := MustClientFromContext(ctx) cctx := cl.ClientContext() - id, err := cflags.LeaseIDFromFlags(cmd.Flags(), cflags.WithOwner(cctx.FromAddress)) + id, err := leaseIDFromFlagsAndArgs(cmd, args, cctx.FromAddress) if err != nil { return err } @@ -238,24 +241,24 @@ func GetTxMarketLeaseWithdrawCmd() *cobra.Command { } cflags.AddTxFlagsToCmd(cmd) - cflags.AddLeaseIDFlags(cmd.Flags()) - cflags.MarkReqLeaseIDFlags(cmd, cflags.DeploymentIDOptionNoOwner(true)) + addLeaseOwnerTxFlags(cmd) return cmd } func GetTxMarketLeaseCloseCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "close", + Use: "close [dseq] [provider]", Short: "Close a market order", - Args: cobra.ExactArgs(0), + Args: cobra.MaximumNArgs(2), + Example: `akt tx market lease close 12345 akash1provider...`, PersistentPreRunE: TxPersistentPreRunE, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cl := MustClientFromContext(ctx) cctx := cl.ClientContext() - id, err := cflags.LeaseIDFromFlags(cmd.Flags(), cflags.WithOwner(cctx.FromAddress)) + id, err := leaseIDFromFlagsAndArgs(cmd, args, cctx.FromAddress) if err != nil { return err } @@ -280,8 +283,69 @@ func GetTxMarketLeaseCloseCmd() *cobra.Command { } cflags.AddTxFlagsToCmd(cmd) - cflags.AddLeaseIDFlags(cmd.Flags()) - cflags.MarkReqLeaseIDFlags(cmd, cflags.DeploymentIDOptionNoOwner(true)) + addLeaseOwnerTxFlags(cmd) return cmd } + +// addLeaseOwnerTxFlags registers the lease identity flags for the lease tx +// commands: --owner (defaults to the signer), --gseq, and --oseq stay — none +// of them has a positional twin. +// FEEDBACK(2026-07): --dseq/--provider disabled for the positional-only UX +// trial (use the positional [dseq] [provider] arguments instead). Restore by +// replacing this helper's body with the original registration: +// cflags.AddLeaseIDFlags(cmd.Flags()) +func addLeaseOwnerTxFlags(cmd *cobra.Command) { + cmd.Flags().String(cflags.FlagOwner, "", "Deployment Owner") + cmd.Flags().Uint32(cflags.FlagGSeq, 1, "Group Sequence") + cmd.Flags().Uint32(cflags.FlagOSeq, 1, "Order Sequence") + // FEEDBACK(2026-07): --dseq disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // cmd.Flags().Uint64(cflags.FlagDSeq, 0, "Deployment Sequence") + // FEEDBACK(2026-07): --provider disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // cmd.Flags().String(cflags.FlagProvider, "", "Provider") +} + +// leaseIDFromFlagsAndArgs resolves a LeaseID from the optional positional +// [dseq] [provider] arguments; gseq/oseq keep their flag defaults and the +// owner defaults to the signer (overridable with --owner). +func leaseIDFromFlagsAndArgs(cmd *cobra.Command, args []string, owner sdk.AccAddress) (mv1.LeaseID, error) { + oid, err := cflags.OrderIDFromFlags(cmd.Flags(), cflags.WithOwner(owner)) + if err != nil { + return mv1.LeaseID{}, err + } + + // FEEDBACK(2026-07): --provider disabled for the positional-only UX + // trial (use the positional form instead). Restore by uncommenting if + // users ask for the flag form back. + // provider, err := cmd.Flags().GetString(cflags.FlagProvider) + // if err != nil { + // return mv1.LeaseID{}, err + // } + provider := "" + + // FEEDBACK(2026-07): --dseq disabled for the positional-only UX trial; + // the positional [dseq] [provider] arguments are the only source (zero + // fallbacks). + if oid.DSeq, provider, err = cflags.LeaseSeqsFromArgs(args, 0, provider); err != nil { + return mv1.LeaseID{}, err + } + + if oid.DSeq == 0 { + return mv1.LeaseID{}, errors.New("dseq is required: pass it positionally") + } + + if provider == "" { + return mv1.LeaseID{}, errors.New("provider is required: pass it positionally") + } + + paddr, err := sdk.AccAddressFromBech32(provider) + if err != nil { + return mv1.LeaseID{}, err + } + + return mv1.MakeBidID(oid, paddr).LeaseID(), nil +} diff --git a/internal/cli/chain/query.go b/internal/cli/chain/query.go index 9fbc2734..74165c5f 100644 --- a/internal/cli/chain/query.go +++ b/internal/cli/chain/query.go @@ -8,6 +8,7 @@ import ( ibctransfer "github.com/cosmos/ibc-go/v10/modules/apps/transfer" ibccore "github.com/cosmos/ibc-go/v10/modules/core" + "pkg.akt.dev/akt/internal/capability" cflags "pkg.akt.dev/akt/internal/cli/chain/flags" aclient "pkg.akt.dev/go/node/client/discovery" ) @@ -45,6 +46,8 @@ func QueryCmd() *cobra.Command { Use: "query", Aliases: []string{"q"}, Short: "Querying subcommands", + // Capability gating: chain queries require a chain RPC endpoint. + Annotations: map[string]string{capability.AnnotationKey: string(capability.ChainQuery)}, } cmd.AddCommand( diff --git a/internal/cli/chain/query_state.go b/internal/cli/chain/query_state.go new file mode 100644 index 00000000..01c1e311 --- /dev/null +++ b/internal/cli/chain/query_state.go @@ -0,0 +1,19 @@ +package cli + +import "fmt" + +// requireStateMatch enforces the optional positional [state] argument on the +// single-record get path (SPEC §3.8.3). When the identity components uniquely +// select one record, the state keyword is a verification rather than a list +// filter: the record is fetched by identity and printed only when its +// on-chain state matches the requested keyword. On a mismatch the command +// fails instead of silently printing a record in a different state. +// requested is empty when no positional state was supplied, in which case +// there is nothing to verify. +func requireStateMatch(resource, id, requested, actual string) error { + if requested == "" || requested == actual { + return nil + } + + return fmt.Errorf("%s %s is %s, not %s; drop the state argument to print it regardless of state", resource, id, actual, requested) +} diff --git a/internal/cli/chain/query_state_test.go b/internal/cli/chain/query_state_test.go new file mode 100644 index 00000000..195a81f6 --- /dev/null +++ b/internal/cli/chain/query_state_test.go @@ -0,0 +1,259 @@ +package cli + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + sdkclient "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + + cv1beta3 "pkg.akt.dev/go/node/client/v1beta3" + dv1 "pkg.akt.dev/go/node/deployment/v1" + dvbeta "pkg.akt.dev/go/node/deployment/v1beta4" + mv1 "pkg.akt.dev/go/node/market/v1" + mvbeta "pkg.akt.dev/go/node/market/v1beta5" +) + +// Valid akash bech32 addresses (same fixtures as the flags package tests). +const ( + stateTestOwner = "akash1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnwduagr" + stateTestProvider = "akash1v3jkvemgd94xkmrddehhqutjwd682anh9zw2p2" +) + +// stubDeploymentQuery serves a canned single deployment and counts get/list +// calls so tests can prove which path the command took. +type stubDeploymentQuery struct { + dvbeta.QueryClient + + res *dvbeta.QueryDeploymentResponse + getCalls int + listCalls int +} + +func (s *stubDeploymentQuery) Deployment(_ context.Context, _ *dvbeta.QueryDeploymentRequest, _ ...grpc.CallOption) (*dvbeta.QueryDeploymentResponse, error) { + s.getCalls++ + return s.res, nil +} + +func (s *stubDeploymentQuery) Deployments(_ context.Context, _ *dvbeta.QueryDeploymentsRequest, _ ...grpc.CallOption) (*dvbeta.QueryDeploymentsResponse, error) { + s.listCalls++ + return &dvbeta.QueryDeploymentsResponse{}, nil +} + +// stubMarketQuery serves canned single order/bid/lease records. +type stubMarketQuery struct { + mvbeta.QueryClient + + order *mvbeta.QueryOrderResponse + bid *mvbeta.QueryBidResponse + lease *mvbeta.QueryLeaseResponse +} + +func (s *stubMarketQuery) Order(_ context.Context, _ *mvbeta.QueryOrderRequest, _ ...grpc.CallOption) (*mvbeta.QueryOrderResponse, error) { + return s.order, nil +} + +func (s *stubMarketQuery) Bid(_ context.Context, _ *mvbeta.QueryBidRequest, _ ...grpc.CallOption) (*mvbeta.QueryBidResponse, error) { + return s.bid, nil +} + +func (s *stubMarketQuery) Lease(_ context.Context, _ *mvbeta.QueryLeaseRequest, _ ...grpc.CallOption) (*mvbeta.QueryLeaseResponse, error) { + return s.lease, nil +} + +// stubQueryClient plugs the module stubs into the aggregated v1beta3 query +// client. Unstubbed modules panic if touched, which is what a test wants. +type stubQueryClient struct { + cv1beta3.QueryClient + + dep dvbeta.QueryClient + mkt mvbeta.QueryClient +} + +func (s *stubQueryClient) Deployment() dvbeta.QueryClient { return s.dep } +func (s *stubQueryClient) Market() mvbeta.QueryClient { return s.mkt } +func (s *stubQueryClient) ClientContext() sdkclient.Context { return sdkclient.Context{} } + +// stubLightClient satisfies aclient.LightClient so query RunE bodies execute +// without any network: queries hit the stubs above and printed output lands +// in the client context's Output buffer. +type stubLightClient struct { + q cv1beta3.QueryClient + cctx sdkclient.Context +} + +func (s *stubLightClient) Query() cv1beta3.QueryClient { return s.q } +func (s *stubLightClient) Node() cv1beta3.NodeClient { return nil } +func (s *stubLightClient) ClientContext() sdkclient.Context { return s.cctx } +func (s *stubLightClient) PrintMessage(interface{}) error { return nil } +func (s *stubLightClient) PrintJSON(interface{}) error { return nil } + +// execQueryCmd runs a query command against the stub client and returns what +// the command printed (the query result output) alongside its error. +func execQueryCmd(t *testing.T, cmd *cobra.Command, q cv1beta3.QueryClient, args ...string) (string, error) { + t.Helper() + + var queryOut bytes.Buffer + cl := &stubLightClient{ + q: q, + cctx: sdkclient.Context{ + Codec: codec.NewProtoCodec(codectypes.NewInterfaceRegistry()), + Output: &queryOut, + }, + } + + var cobraOut bytes.Buffer + cmd.SetOut(&cobraOut) + cmd.SetErr(&cobraOut) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs(append(args, "--output", "json")) + + ctx := context.WithValue(context.Background(), ClientContextKey, &sdkclient.Context{}) + ctx = context.WithValue(ctx, ContextTypeQueryClient, cl) + cmd.SetContext(ctx) + + err := cmd.Execute() + + return queryOut.String(), err +} + +// TestQueryDeploymentPositionalStateMismatchFails pins regression AKT-650#1: +// `query deployment / ` with a mismatching state must not +// silently print the record — the get is performed and the state verified. +func TestQueryDeploymentPositionalStateMismatchFails(t *testing.T) { + dep := &stubDeploymentQuery{res: &dvbeta.QueryDeploymentResponse{ + Deployment: dv1.Deployment{ + ID: dv1.DeploymentID{Owner: stateTestOwner, DSeq: 12345}, + State: dv1.DeploymentClosed, + }, + }} + + out, err := execQueryCmd(t, GetQueryDeploymentCmds(), &stubQueryClient{dep: dep}, stateTestOwner+"/12345", "active") + require.Error(t, err, "mismatching positional state must not silently print the record") + require.Contains(t, err.Error(), "is closed, not active") + require.Equal(t, 1, dep.getCalls, "complete identity must stay on the get path") + require.Zero(t, dep.listCalls) + require.Empty(t, out, "no record may be printed on a state mismatch") +} + +// TestQueryDeploymentPositionalStateMatchPrints: when the record really is in +// the requested state, the get result is printed as before. +func TestQueryDeploymentPositionalStateMatchPrints(t *testing.T) { + dep := &stubDeploymentQuery{res: &dvbeta.QueryDeploymentResponse{ + Deployment: dv1.Deployment{ + ID: dv1.DeploymentID{Owner: stateTestOwner, DSeq: 12345}, + State: dv1.DeploymentActive, + }, + }} + + out, err := execQueryCmd(t, GetQueryDeploymentCmds(), &stubQueryClient{dep: dep}, stateTestOwner+"/12345", "active") + require.NoError(t, err) + require.Equal(t, 1, dep.getCalls) + require.Contains(t, out, "12345") +} + +// TestQueryDeploymentIdentityOnlyUnchanged: the identity-only get path stays +// exactly as it was — no state argument, no verification, record printed. +func TestQueryDeploymentIdentityOnlyUnchanged(t *testing.T) { + dep := &stubDeploymentQuery{res: &dvbeta.QueryDeploymentResponse{ + Deployment: dv1.Deployment{ + ID: dv1.DeploymentID{Owner: stateTestOwner, DSeq: 12345}, + State: dv1.DeploymentClosed, + }, + }} + + out, err := execQueryCmd(t, GetQueryDeploymentCmds(), &stubQueryClient{dep: dep}, stateTestOwner+"/12345") + require.NoError(t, err) + require.Equal(t, 1, dep.getCalls) + require.Zero(t, dep.listCalls) + require.Contains(t, out, "12345") +} + +// TestQueryMarketPositionalStateMismatchFails applies the same pin to the +// order/bid/lease twins: a mismatching positional [state] on a complete +// identity is an error, never a silent print. +func TestQueryMarketPositionalStateMismatchFails(t *testing.T) { + mkt := &stubMarketQuery{ + order: &mvbeta.QueryOrderResponse{Order: mvbeta.Order{ + ID: mv1.OrderID{Owner: stateTestOwner, DSeq: 12345, GSeq: 1, OSeq: 1}, + State: mvbeta.OrderClosed, + }}, + bid: &mvbeta.QueryBidResponse{Bid: mvbeta.Bid{ + ID: mv1.BidID{Owner: stateTestOwner, DSeq: 12345, GSeq: 1, OSeq: 1, Provider: stateTestProvider}, + State: mvbeta.BidClosed, + }}, + lease: &mvbeta.QueryLeaseResponse{Lease: mv1.Lease{ + ID: mv1.LeaseID{Owner: stateTestOwner, DSeq: 12345, GSeq: 1, OSeq: 1, Provider: stateTestProvider}, + State: mv1.LeaseClosed, + }}, + } + + fullBidID := fmt.Sprintf("%s/12345/1/1/%s", stateTestOwner, stateTestProvider) + + cases := []struct { + name string + cmd *cobra.Command + args []string + wantErr string + }{ + {"order", GetQueryMarketOrderCmd(), []string{stateTestOwner + "/12345/1/1", "open"}, "is closed, not open"}, + {"bid", GetQueryMarketBidCmd(), []string{fullBidID, "open"}, "is closed, not open"}, + {"lease", GetQueryMarketLeaseCmd(), []string{fullBidID, "active"}, "is closed, not active"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := execQueryCmd(t, tc.cmd, &stubQueryClient{mkt: mkt}, tc.args...) + require.Error(t, err, "mismatching positional state must not silently print the record") + require.Contains(t, err.Error(), tc.wantErr) + require.Empty(t, out, "no record may be printed on a state mismatch") + }) + } +} + +// TestQueryMarketPositionalStateMatchPrints: matching states on the get path +// still print the record for order/bid/lease. +func TestQueryMarketPositionalStateMatchPrints(t *testing.T) { + mkt := &stubMarketQuery{ + order: &mvbeta.QueryOrderResponse{Order: mvbeta.Order{ + ID: mv1.OrderID{Owner: stateTestOwner, DSeq: 12345, GSeq: 1, OSeq: 1}, + State: mvbeta.OrderOpen, + }}, + bid: &mvbeta.QueryBidResponse{Bid: mvbeta.Bid{ + ID: mv1.BidID{Owner: stateTestOwner, DSeq: 12345, GSeq: 1, OSeq: 1, Provider: stateTestProvider}, + State: mvbeta.BidOpen, + }}, + lease: &mvbeta.QueryLeaseResponse{Lease: mv1.Lease{ + ID: mv1.LeaseID{Owner: stateTestOwner, DSeq: 12345, GSeq: 1, OSeq: 1, Provider: stateTestProvider}, + State: mv1.LeaseActive, + }}, + } + + fullBidID := fmt.Sprintf("%s/12345/1/1/%s", stateTestOwner, stateTestProvider) + + cases := []struct { + name string + cmd *cobra.Command + args []string + }{ + {"order", GetQueryMarketOrderCmd(), []string{stateTestOwner + "/12345/1/1", "open"}}, + {"bid", GetQueryMarketBidCmd(), []string{fullBidID, "open"}}, + {"lease", GetQueryMarketLeaseCmd(), []string{fullBidID, "active"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := execQueryCmd(t, tc.cmd, &stubQueryClient{mkt: mkt}, tc.args...) + require.NoError(t, err) + require.Contains(t, out, "12345") + }) + } +} diff --git a/internal/cli/chain/server_util.go b/internal/cli/chain/server_util.go index 8b9582e5..e88f1598 100644 --- a/internal/cli/chain/server_util.go +++ b/internal/cli/chain/server_util.go @@ -3,6 +3,7 @@ package cli import ( "context" "encoding/json" + "errors" "fmt" "strconv" "strings" @@ -168,7 +169,7 @@ func VersionCmd() *cobra.Command { // QueryBlocksCmd returns a command to search through blocks by events. func QueryBlocksCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "blocks", + Use: "blocks [query]", Short: "Query for paginated blocks that match a set of events", Long: `Search for blocks that match the exact given events where results are paginated. The events query is directly passed to CometBFT's RPC BlockSearch method and must @@ -177,15 +178,24 @@ Please refer to each module's documentation for the full set of events to query for. Each module documents its respective events under 'xx_events.md'. `, Example: fmt.Sprintf( - "$ %s query blocks --query \"message.sender='cosmos1...' AND block.height > 7\" --page 1 --limit 30 --order_by asc", + "$ %[1]s query blocks \"message.sender='cosmos1...' AND block.height > 7\" --page 1 --limit 30 --order_by asc", version.AppName, ), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - cctx, err := client.GetClientQueryContext(cmd) + cctx, err := GetClientQueryContext(cmd) if err != nil { return err } - query, _ := cmd.Flags().GetString(cflags.FlagQuery) + // FEEDBACK(2026-07): --query disabled for the positional-only UX + // trial; the positional expression is the only source (zero + // fallback). Restore by uncommenting if users ask for the flag + // form back. + // query, _ := cmd.Flags().GetString(cflags.FlagQuery) + query := cflags.ExprFromArgs(args, "") + if query == "" { + return errors.New("query expression is required: pass it positionally") + } page, _ := cmd.Flags().GetInt(cflags.FlagPage) limit, _ := cmd.Flags().GetInt(cflags.FlagLimit) orderBy, _ := cmd.Flags().GetString(cflags.FlagOrderBy) @@ -202,9 +212,11 @@ for. Each module documents its respective events under 'xx_events.md'. cflags.AddQueryFlagsToCmd(cmd) cmd.Flags().Int(cflags.FlagPage, query.DefaultPage, "Query a specific page of paginated results") cmd.Flags().Int(cflags.FlagLimit, query.DefaultLimit, "Query number of transactions results per page returned") - cmd.Flags().String(cflags.FlagQuery, "", "The blocks events query per CometBFT's query semantics") + // FEEDBACK(2026-07): --query disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // cmd.Flags().String(cflags.FlagQuery, "", "The blocks events query per CometBFT's query semantics") cmd.Flags().String(cflags.FlagOrderBy, "", "The ordering semantics (asc|dsc)") - _ = cmd.MarkFlagRequired(cflags.FlagQuery) return cmd } @@ -223,7 +235,7 @@ $ %s query block --%s=%s version.AppName, cflags.FlagType, cflags.TypeHash)), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - cctx, err := client.GetClientQueryContext(cmd) + cctx, err := GetClientQueryContext(cmd) if err != nil { return err } @@ -308,7 +320,7 @@ func QueryBlockResultsCmd() *cobra.Command { Long: "Query for a specific committed block's results using the CometBFT RPC `block_results` method", Args: cobra.RangeArgs(0, 1), RunE: func(cmd *cobra.Command, args []string) error { - cctx, err := client.GetClientQueryContext(cmd) + cctx, err := GetClientQueryContext(cmd) if err != nil { return err } diff --git a/internal/cli/chain/testutil/auth.go b/internal/cli/chain/testutil/auth.go index cb92726b..334e8391 100644 --- a/internal/cli/chain/testutil/auth.go +++ b/internal/cli/chain/testutil/auth.go @@ -8,7 +8,7 @@ import ( "github.com/cosmos/cosmos-sdk/crypto/keyring" "github.com/cosmos/cosmos-sdk/testutil" - "pkg.akt.dev/akt/internal/cli/chain" + cli "pkg.akt.dev/akt/internal/cli/chain" cflags "pkg.akt.dev/akt/internal/cli/chain/flags" ) diff --git a/internal/cli/chain/testutil/authz.go b/internal/cli/chain/testutil/authz.go index 93ce83fa..13434513 100644 --- a/internal/cli/chain/testutil/authz.go +++ b/internal/cli/chain/testutil/authz.go @@ -6,7 +6,7 @@ import ( "github.com/cosmos/cosmos-sdk/client" "github.com/cosmos/cosmos-sdk/testutil" - "pkg.akt.dev/akt/internal/cli/chain" + cli "pkg.akt.dev/akt/internal/cli/chain" ) func ExecCreateGrant(ctx context.Context, cctx client.Context, args ...string) (testutil.BufferWriter, error) { diff --git a/internal/cli/chain/testutil/bme.go b/internal/cli/chain/testutil/bme.go index 1f9c954e..10e5e775 100644 --- a/internal/cli/chain/testutil/bme.go +++ b/internal/cli/chain/testutil/bme.go @@ -6,7 +6,7 @@ import ( "github.com/cosmos/cosmos-sdk/client" sdktest "github.com/cosmos/cosmos-sdk/testutil" - "pkg.akt.dev/akt/internal/cli/chain" + cli "pkg.akt.dev/akt/internal/cli/chain" ) // ExecBMEBurnMint is used for testing BME burn-mint tx diff --git a/internal/cli/chain/testutil/certs.go b/internal/cli/chain/testutil/certs.go index 9d22fb97..12c03117 100644 --- a/internal/cli/chain/testutil/certs.go +++ b/internal/cli/chain/testutil/certs.go @@ -6,7 +6,7 @@ import ( "github.com/cosmos/cosmos-sdk/client" sdktest "github.com/cosmos/cosmos-sdk/testutil" - "pkg.akt.dev/akt/internal/cli/chain" + cli "pkg.akt.dev/akt/internal/cli/chain" ) // TxGenerateServerExec is used for testing create server certificate tx diff --git a/internal/cli/chain/testutil/cmd.go b/internal/cli/chain/testutil/cmd.go index 9476ff15..8194a822 100644 --- a/internal/cli/chain/testutil/cmd.go +++ b/internal/cli/chain/testutil/cmd.go @@ -11,7 +11,7 @@ import ( "github.com/cosmos/cosmos-sdk/server" "github.com/cosmos/cosmos-sdk/testutil" - "pkg.akt.dev/akt/internal/cli/chain" + cli "pkg.akt.dev/akt/internal/cli/chain" ) func execSetContext(ctx context.Context, cctx client.Context, cmd *cobra.Command, args ...string) (testutil.BufferWriter, error) { diff --git a/internal/cli/chain/testutil/deployment.go b/internal/cli/chain/testutil/deployment.go index 424b831c..02e972f9 100644 --- a/internal/cli/chain/testutil/deployment.go +++ b/internal/cli/chain/testutil/deployment.go @@ -6,7 +6,7 @@ import ( "github.com/cosmos/cosmos-sdk/client" sdktest "github.com/cosmos/cosmos-sdk/testutil" - "pkg.akt.dev/akt/internal/cli/chain" + cli "pkg.akt.dev/akt/internal/cli/chain" ) // ExecDeploymentCreate is used for testing create deployment tx @@ -20,13 +20,13 @@ func ExecDeploymentUpdate(ctx context.Context, cctx client.Context, args ...stri } // ExecDeploymentClose is used for testing close deployment tx -// requires --dseq, --fees +// requires a positional dseq and --fees func ExecDeploymentClose(ctx context.Context, cctx client.Context, args ...string) (sdktest.BufferWriter, error) { return ExecTestCLICmd(ctx, cctx, cli.GetTxDeploymentCloseCmd(), args...) } // ExecDeploymentGroupClose is used for testing close deployment group tx -// requires --dseq, --fees +// requires positional dseq/gseq and --fees func ExecDeploymentGroupClose(ctx context.Context, cctx client.Context, args ...string) (sdktest.BufferWriter, error) { return ExecTestCLICmd(ctx, cctx, cli.GetTxDeploymentGroupCloseCmd(), args...) } diff --git a/internal/cli/chain/testutil/escrow.go b/internal/cli/chain/testutil/escrow.go index fd95ba3d..c4c2ec6e 100644 --- a/internal/cli/chain/testutil/escrow.go +++ b/internal/cli/chain/testutil/escrow.go @@ -6,7 +6,7 @@ import ( "github.com/cosmos/cosmos-sdk/client" sdktest "github.com/cosmos/cosmos-sdk/testutil" - "pkg.akt.dev/akt/internal/cli/chain" + cli "pkg.akt.dev/akt/internal/cli/chain" ) // ExecEscrowDeposit is used for testing deposits into escrow account diff --git a/internal/cli/chain/testutil/market.go b/internal/cli/chain/testutil/market.go index 70fe3286..5638b643 100644 --- a/internal/cli/chain/testutil/market.go +++ b/internal/cli/chain/testutil/market.go @@ -6,7 +6,7 @@ import ( "github.com/cosmos/cosmos-sdk/client" sdktest "github.com/cosmos/cosmos-sdk/testutil" - "pkg.akt.dev/akt/internal/cli/chain" + cli "pkg.akt.dev/akt/internal/cli/chain" ) // ExecCreateBid is used for testing create bid tx diff --git a/internal/cli/chain/testutil/oracle.go b/internal/cli/chain/testutil/oracle.go index 19dbd049..26590826 100644 --- a/internal/cli/chain/testutil/oracle.go +++ b/internal/cli/chain/testutil/oracle.go @@ -6,7 +6,7 @@ import ( "github.com/cosmos/cosmos-sdk/client" sdktest "github.com/cosmos/cosmos-sdk/testutil" - "pkg.akt.dev/akt/internal/cli/chain" + cli "pkg.akt.dev/akt/internal/cli/chain" ) // ExecOracleFeedPrice is used for testing oracle feed price tx diff --git a/internal/cli/chain/testutil/provider.go b/internal/cli/chain/testutil/provider.go index c7a445c5..88fab5ab 100644 --- a/internal/cli/chain/testutil/provider.go +++ b/internal/cli/chain/testutil/provider.go @@ -6,7 +6,7 @@ import ( "github.com/cosmos/cosmos-sdk/client" sdktest "github.com/cosmos/cosmos-sdk/testutil" - "pkg.akt.dev/akt/internal/cli/chain" + cli "pkg.akt.dev/akt/internal/cli/chain" ) // ExecTxCreateProvider is used for testing create provider tx diff --git a/internal/cli/chain/tx.go b/internal/cli/chain/tx.go index ec027ea7..5f87fa67 100644 --- a/internal/cli/chain/tx.go +++ b/internal/cli/chain/tx.go @@ -9,6 +9,7 @@ import ( ibctransfer "github.com/cosmos/ibc-go/v10/modules/apps/transfer" ibccore "github.com/cosmos/ibc-go/v10/modules/core" + "pkg.akt.dev/akt/internal/capability" cflags "pkg.akt.dev/akt/internal/cli/chain/flags" aclient "pkg.akt.dev/go/node/client/discovery" ) @@ -46,7 +47,10 @@ func TxPersistentPreRunE(cmd *cobra.Command, _ []string) error { return err } - ctx = context.WithValue(ctx, ContextTypeClient, cl) + // Record every broadcast in the per-context action log (SPEC §5.6). + wrapped := withActionLog(ctx, cl) + + ctx = context.WithValue(ctx, ContextTypeClient, wrapped) cmd.SetContext(ctx) } @@ -58,6 +62,8 @@ func TxCmd() *cobra.Command { cmd := &cobra.Command{ Use: "tx", Short: "Transactions subcommands", + // Capability gating: broadcasting requires a chain RPC endpoint. + Annotations: map[string]string{capability.AnnotationKey: string(capability.ChainTx)}, } cmd.AddCommand( diff --git a/internal/cli/console/apikey.go b/internal/cli/console/apikey.go new file mode 100644 index 00000000..76dbcfe5 --- /dev/null +++ b/internal/cli/console/apikey.go @@ -0,0 +1,188 @@ +package console + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + aktctx "pkg.akt.dev/akt/internal/context" +) + +func apikeyCmds(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "apikey", + Short: "Manage Console API keys", + } + + cmd.AddCommand( + apikeyListCmd(mgrFn), + apikeyCreateCmd(mgrFn), + apikeyDeleteCmd(mgrFn), + ) + + return cmd +} + +func apikeyListCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List API keys (secrets are never shown)", + Args: cobra.NoArgs, + Example: ` akt console apikey list`, + RunE: func(cmd *cobra.Command, _ []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + keys, err := cl.ListAPIKeys(cmd.Context()) + if err != nil { + return fmt.Errorf("list API keys: %w", err) + } + + return printJSON(cmd, keys) + }, + } +} + +func apikeyCreateCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "create [expires-at]", + Short: "Create an API key (the secret is shown ONCE)", + Args: cobra.MaximumNArgs(2), + Example: ` # Name (and optional RFC 3339 expiry) as positional arguments + akt console apikey create ci 2027-01-01T00:00:00Z`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + // FEEDBACK(2026-07): --name/--expires-at disabled for the + // positional-only UX trial; the positional [expires-at] + // arguments are the only source. Restore by uncommenting if + // users ask for the flag form back. + // name, _ := cmd.Flags().GetString("name") + // expiresAt, _ := cmd.Flags().GetString("expires-at") + name, expiresAt := "", "" + if len(args) > 0 { + name = args[0] + } + if len(args) > 1 { + expiresAt = args[1] + } + if name == "" { + return fmt.Errorf("name is required: pass it as the first argument") + } + + created, err := cl.CreateAPIKey(cmd.Context(), name, expiresAt) + if err != nil { + return fmt.Errorf("create API key: %w", err) + } + + // The secret is shown exactly once, by design. + return printJSON(cmd, struct { + ID string `json:"id"` + Name string `json:"name"` + APIKey string `json:"apiKey"` + Warning string `json:"warning"` + }{created.ID, created.Name, created.APIKey, "Store this key now. It will not be shown again."}) + }, + } + + // FEEDBACK(2026-07): --name disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // cmd.Flags().String("name", "", "Human-readable key name; alternative to the positional argument") + // FEEDBACK(2026-07): --expires-at disabled for the positional-only UX + // trial (use the positional form instead). Restore by uncommenting if + // users ask for the flag form back. + // cmd.Flags().String("expires-at", "", "Expiry as an RFC 3339 timestamp (empty = no expiry)") + + return cmd +} + +func apikeyDeleteCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Delete an API key by ID (already-absent is a no-op)", + Args: cobra.ExactArgs(1), + Example: ` akt console apikey delete 0b8052e2-...`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + // DeleteAPIKey treats 404 as a no-op. + if err := cl.DeleteAPIKey(cmd.Context(), args[0]); err != nil { + return fmt.Errorf("delete API key %s: %w", args[0], err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "API key %s deleted.\n", args[0]) + return nil + }, + } +} + +// defaultJWTScope mirrors the Console reference CLI's default lease scopes. +var defaultJWTScope = []string{"status", "logs", "events", "shell", "send-manifest", "get-manifest"} + +func jwtCmds(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "jwt", + Short: "Mint provider-scoped JWTs", + } + + create := &cobra.Command{ + Use: "create", + Short: "Create a short-lived JWT for direct provider access", + Args: cobra.NoArgs, + Example: ` akt console jwt create --ttl 600 --scope status,logs`, + RunE: func(cmd *cobra.Command, _ []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + ttl, _ := cmd.Flags().GetInt("ttl") + if ttl <= 0 { + return fmt.Errorf("--ttl must be a positive number of seconds, got %d", ttl) + } + + scopeCSV, _ := cmd.Flags().GetString("scope") + + scope := defaultJWTScope + if scopeCSV != "" { + scope = nil + for _, s := range strings.Split(scopeCSV, ",") { + if s = strings.TrimSpace(s); s != "" { + scope = append(scope, s) + } + } + } + if len(scope) == 0 { + return fmt.Errorf("--scope must contain at least one scope") + } + + token, err := cl.CreateJWTToken(cmd.Context(), ttl, scope) + if err != nil { + return fmt.Errorf("create JWT token: %w", err) + } + + return printJSON(cmd, struct { + Token string `json:"token"` + TTL int `json:"ttl"` + Scope []string `json:"scope"` + }{token, ttl, scope}) + }, + } + + create.Flags().Int("ttl", 300, "Token lifetime in seconds") + create.Flags().String("scope", "", "Comma-separated scopes (default: "+strings.Join(defaultJWTScope, ",")+")") + + cmd.AddCommand(create) + + return cmd +} diff --git a/internal/cli/console/commands.go b/internal/cli/console/commands.go new file mode 100644 index 00000000..1364a09c --- /dev/null +++ b/internal/cli/console/commands.go @@ -0,0 +1,291 @@ +// Package console implements the `akt console` CLI commands for the Akash +// Console managed-wallet API (SPEC §2.9, §7). Deployments created here are +// signed server-side by the Console managed wallet; no local keyring is used. +package console + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "pkg.akt.dev/akt/internal/capability" + "pkg.akt.dev/akt/internal/cliutil" + "pkg.akt.dev/akt/internal/console" + aktctx "pkg.akt.dev/akt/internal/context" +) + +func Commands(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "console", + Short: "Akash Console managed-wallet operations", + Long: "Interact with the Akash Console API: create and manage deployments through " + + "the Console managed wallet, inspect bids and leases, and browse the public " + + "provider/GPU/template catalog. Authenticated commands need a Console API key " + + "(run `akt console login`); catalog commands work without one.", + } + + cmd.PersistentFlags().String("console-api-url", "", "Console API base URL (overrides the context setting)") + cmd.PersistentFlags().String("console-api-key", "", "Console API key (overrides AKT_CONSOLE_API_KEY and the stored credential)") + + cmd.AddCommand( + // Credential management and public marketplace/catalog commands + // work without a stored key, so they carry no capability gate. + loginCmd(mgrFn), + logoutCmd(mgrFn), + providerCmds(mgrFn), + gpuCmd(mgrFn), + templateCmds(mgrFn), + screenCmd(mgrFn), + + // Everything below needs a resolvable Console API key (SPEC §2.10). + requireConsole(whoamiCmd(mgrFn)), + requireConsole(deploymentCmds(mgrFn)), + requireConsole(bidCmds(mgrFn)), + requireConsole(leaseCmds(mgrFn)), + requireConsole(walletCmds(mgrFn)), + requireConsole(usageCmd(mgrFn)), + requireConsole(apikeyCmds(mgrFn)), + requireConsole(jwtCmds(mgrFn)), + requireConsole(logsCmd(mgrFn)), + requireConsole(eventsCmd(mgrFn)), + requireConsole(statusCmd(mgrFn)), + requireConsole(shellCmd(mgrFn)), + ) + + return cmd +} + +// requireConsole tags a command as needing the console capability so the +// gating layer (internal/capability, SPEC §2.10) can dim or hide it when the +// active context has no resolvable Console API key. +func requireConsole(cmd *cobra.Command) *cobra.Command { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[capability.AnnotationKey] = string(capability.Console) + + return cmd +} + +// --- authentication --------------------------------------------------------- + +func loginCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + return &cobra.Command{ + Use: "login [key]", + Short: "Validate a Console API key and store it for the active context", + Long: "Validate a Console API key against the Console API and store it as the active " + + "context's credential (SPEC §7.1). The key can be passed as an argument or entered " + + "at a hidden prompt. Keys are created at console.akash.network > Settings > API Keys.", + Args: cobra.MaximumNArgs(1), + Example: ` # Prompt for the key (input hidden) + akt console login + + # Pass the key directly + akt console login sk-console-...`, + RunE: func(cmd *cobra.Command, args []string) error { + rc, err := mgrFn().Resolve("") + if err != nil { + return fmt.Errorf("login requires an active context to store the key: %w", err) + } + + var key string + if len(args) > 0 { + key = args[0] + } else { + key, err = promptForKey(cmd) + if err != nil { + return err + } + } + + if key == "" { + return fmt.Errorf("no API key provided") + } + + baseURL, _ := cmd.Flags().GetString("console-api-url") + if baseURL == "" { + baseURL = rc.ConsoleAPIURL + } + + // Validate against the live user endpoint before persisting. + probe := console.New(baseURL, key) + user, err := probe.GetUser(cmd.Context()) + if err != nil { + return fmt.Errorf("validate Console API key: %w", err) + } + + if err := aktctx.SetConsoleAPIKey(rc.Root, rc.Name, key); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Logged in as %s (context %q)\n", user.Username, rc.Name) + return nil + }, + } +} + +func logoutCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Remove the stored Console API key for the active context", + Args: cobra.NoArgs, + Example: ` akt console logout`, + RunE: func(cmd *cobra.Command, _ []string) error { + rc, err := mgrFn().Resolve("") + if err != nil { + return err + } + + if err := aktctx.SetConsoleAPIKey(rc.Root, rc.Name, ""); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Removed Console API key for context %q\n", rc.Name) + return nil + }, + } +} + +func whoamiCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + return &cobra.Command{ + Use: "whoami", + Short: "Show the authenticated Console user", + Args: cobra.NoArgs, + Example: ` akt console whoami`, + RunE: func(cmd *cobra.Command, _ []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + user, err := cl.GetUser(cmd.Context()) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + return printJSON(cmd, struct { + Username string `json:"username"` + Email string `json:"email"` + EmailVerified bool `json:"emailVerified"` + }{user.Username, user.Email, user.EmailVerified}) + }, + } +} + +// promptForKey reads a Console API key from stdin: with echo off when stdin is +// a terminal, as a plain line otherwise. +func promptForKey(cmd *cobra.Command) (string, error) { + fd := int(os.Stdin.Fd()) + if term.IsTerminal(fd) { + fmt.Fprint(cmd.ErrOrStderr(), "Console API key: ") + + data, err := term.ReadPassword(fd) + fmt.Fprintln(cmd.ErrOrStderr()) + if err != nil { + return "", fmt.Errorf("read API key: %w", err) + } + + return strings.TrimSpace(string(data)), nil + } + + line, err := bufio.NewReader(cmd.InOrStdin()).ReadString('\n') + if err != nil && line == "" { + return "", fmt.Errorf("read API key from stdin: %w", err) + } + + return strings.TrimSpace(line), nil +} + +// --- shared helpers --------------------------------------------------------- + +// clientFromCmd builds a Console API client from the command's flags and the +// resolved context. Resolution (SPEC §7.1/§7.2): --console-api-key flag > +// context (env > stored credential); --console-api-url flag > context URL. +// A missing context is tolerated so public catalog endpoints work keyless; +// requireKey enforces a key for authenticated endpoints. The returned context +// is nil when no context could be resolved. +func clientFromCmd(cmd *cobra.Command, mgrFn func() *aktctx.Manager, requireKey bool) (*console.Client, *aktctx.Context, error) { + var rc *aktctx.Context + if m := mgrFn(); m != nil { + // Honor the global --context override: credentials are read from and + // written to the context the user named, and Console operations bill + // that context's managed wallet. + override := "" + if f := cmd.Flags().Lookup("context"); f != nil { + override = f.Value.String() + } + + rc, _ = m.Resolve(m.ActiveContext(override)) // missing context is OK for public endpoints + } + + key, _ := cmd.Flags().GetString("console-api-key") + baseURL, _ := cmd.Flags().GetString("console-api-url") + + if rc != nil { + if key == "" { + key = rc.ConsoleAPIKey // env var > stored credential + } + if baseURL == "" { + baseURL = rc.ConsoleAPIURL + } + } else if key == "" { + key = os.Getenv(aktctx.EnvConsoleAPIKey) + } + + if requireKey && key == "" { + if rc == nil { + return nil, nil, fmt.Errorf("no Console API key configured and no active context: set %s or --console-api-key, or create a context and run `akt console login`", aktctx.EnvConsoleAPIKey) + } + + return nil, nil, fmt.Errorf("no Console API key configured for context %q: run `akt console login`, set %s, or `akt context edit %s --console-api-key `", + rc.Name, aktctx.EnvConsoleAPIKey, rc.Name) + } + + cl := console.New(baseURL, key).WithActionLog(cliutil.ActionLogFromContext(cmd.Context())) + + return cl, rc, nil +} + +// printJSON marshals v to indented JSON and writes it to the command's output. +func printJSON(cmd *cobra.Command, v interface{}) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("marshal output: %w", err) + } + + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return nil +} + +// printRawJSON re-indents a raw JSON document and writes it to the command's +// output. +func printRawJSON(cmd *cobra.Command, raw json.RawMessage) error { + var v interface{} + if err := json.Unmarshal(raw, &v); err != nil { + return fmt.Errorf("decode output: %w", err) + } + + return printJSON(cmd, v) +} + +// formatUSD renders a USD amount as $X.XX. +func formatUSD(v float64) string { + return fmt.Sprintf("$%.2f", v) +} + +// parseBoolValue parses a strict true|false string flag value. +func parseBoolValue(value, flag string) (bool, error) { + switch value { + case "true": + return true, nil + case "false": + return false, nil + default: + return false, fmt.Errorf("%s must be true or false, got %q", flag, value) + } +} diff --git a/internal/cli/console/commands_test.go b/internal/cli/console/commands_test.go new file mode 100644 index 00000000..969f899b --- /dev/null +++ b/internal/cli/console/commands_test.go @@ -0,0 +1,371 @@ +package console + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + aktctx "pkg.akt.dev/akt/internal/context" +) + +// newTestManager builds a Manager on a temp home with a "mainnet" network +// (from template), a "prod" context, and "prod" as the current context. +// DefaultConfig already seeds the "default" keyring. +func newTestManager(t *testing.T) *aktctx.Manager { + t.Helper() + + m, err := aktctx.NewManager(t.TempDir()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + if err := m.CreateNetworkFromTemplate("mainnet", "mainnet"); err != nil { + t.Fatalf("CreateNetworkFromTemplate: %v", err) + } + + if err := m.CreateContext(aktctx.Context{ + Name: "prod", + Network: aktctx.Network{Name: "mainnet"}, + }); err != nil { + t.Fatalf("CreateContext: %v", err) + } + + if err := m.UseContext("prod"); err != nil { + t.Fatalf("UseContext: %v", err) + } + + return m +} + +// execConsole runs `akt console ` against the given manager, +// pointing the client at srvURL when non-empty, and returns combined output. +func execConsole(t *testing.T, m *aktctx.Manager, srvURL string, args ...string) (string, error) { + t.Helper() + + // Neutralize any ambient key so tests only see what they configure. + t.Setenv(aktctx.EnvConsoleAPIKey, "") + + cmd := Commands(func() *aktctx.Manager { return m }) + + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + + if srvURL != "" { + args = append(args, "--console-api-url", srvURL) + } + cmd.SetArgs(args) + + err := cmd.Execute() + + return buf.String(), err +} + +func writeJSON(t *testing.T, w http.ResponseWriter, body string) { + t.Helper() + + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte(body)); err != nil { + t.Errorf("write response: %v", err) + } +} + +const userBody = `{"data":{"id":"uuid-1","userId":"user-1","username":"max","email":"max@example.com","emailVerified":true}}` + +func TestLoginValidatesAndStoresKey(t *testing.T) { + m := newTestManager(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/user/me" { + t.Errorf("unexpected path %s", r.URL.Path) + } + if got := r.Header.Get("x-api-key"); got != "sekrit" { + t.Errorf("x-api-key = %q, want sekrit", got) + } + writeJSON(t, w, userBody) + })) + defer srv.Close() + + out, err := execConsole(t, m, srv.URL, "login", "sekrit") + if err != nil { + t.Fatalf("login: %v", err) + } + + if !strings.Contains(out, "max") { + t.Errorf("login output should print the username, got %q", out) + } + if strings.Contains(out, "sekrit") { + t.Errorf("login output must never contain the API key, got %q", out) + } + + data, err := os.ReadFile(aktctx.ConsoleAPIKeyPath(m.Root(), "prod")) + if err != nil { + t.Fatalf("read credential file: %v", err) + } + if string(data) != "sekrit\n" { + t.Errorf("credential file content = %q, want %q", string(data), "sekrit\n") + } +} + +func TestLoginRejectsInvalidKey(t *testing.T) { + m := newTestManager(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + _, err := execConsole(t, m, srv.URL, "login", "bad-key") + if err == nil { + t.Fatal("login with a rejected key should fail") + } + + if _, statErr := os.Stat(aktctx.ConsoleAPIKeyPath(m.Root(), "prod")); !os.IsNotExist(statErr) { + t.Error("a rejected key must not be stored") + } +} + +func TestWhoamiPrintsUsername(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, userBody) + })) + defer srv.Close() + + out, err := execConsole(t, m, srv.URL, "whoami") + if err != nil { + t.Fatalf("whoami: %v", err) + } + + if !strings.Contains(out, `"username": "max"`) || !strings.Contains(out, "max@example.com") { + t.Errorf("whoami output missing user fields, got %q", out) + } +} + +func TestDeploymentListSendsAPIKey(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + var gotPath, gotQuery, gotKey string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + gotKey = r.Header.Get("x-api-key") + writeJSON(t, w, `{"data":{"deployments":[],"pagination":{"total":0,"skip":0,"limit":20,"hasMore":false}}}`) + })) + defer srv.Close() + + out, err := execConsole(t, m, srv.URL, "deployment", "list") + if err != nil { + t.Fatalf("deployment list: %v", err) + } + + if gotPath != "/v1/deployments" { + t.Errorf("path = %q, want /v1/deployments", gotPath) + } + if gotQuery != "skip=0&limit=20" { + t.Errorf("query = %q, want skip=0&limit=20", gotQuery) + } + if gotKey != "sekrit" { + t.Errorf("x-api-key = %q, want sekrit", gotKey) + } + if !strings.Contains(out, `"deployments"`) { + t.Errorf("output should include the deployment list, got %q", out) + } +} + +func TestDeploymentCloseAlreadyClosedExitsClean(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + // The Console API answers 404 for an already-closed deployment. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || r.URL.Path != "/v1/deployments/42" { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + out, err := execConsole(t, m, srv.URL, "deployment", "close", "42") + if err != nil { + t.Fatalf("close of an already-closed deployment must succeed, got %v", err) + } + + if !strings.Contains(out, "already closed") { + t.Errorf("output should report the no-op, got %q", out) + } +} + +func TestMissingKeyErrorMessage(t *testing.T) { + m := newTestManager(t) + + _, err := execConsole(t, m, "", "deployment", "list") + if err == nil { + t.Fatal("a key-requiring command without a key must fail") + } + + msg := err.Error() + for _, want := range []string{ + `no Console API key configured for context "prod"`, + "akt console login", + aktctx.EnvConsoleAPIKey, + "akt context edit prod --console-api-key", + } { + if !strings.Contains(msg, want) { + t.Errorf("error %q should mention %q", msg, want) + } + } +} + +func TestTemplateSDLPrintsRawSDL(t *testing.T) { + m := newTestManager(t) + + const rawSDL = "version: \"2.0\"\nservices:\n web:\n image: nginx:1.27\n" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/templates/tpl-1" { + t.Errorf("unexpected path %s", r.URL.Path) + } + body, _ := json.Marshal(map[string]any{ + "data": map[string]any{"id": "tpl-1", "name": "web", "deploy": rawSDL}, + }) + writeJSON(t, w, string(body)) + })) + defer srv.Close() + + // Public endpoint: no key configured anywhere. + out, err := execConsole(t, m, srv.URL, "template", "sdl", "tpl-1") + if err != nil { + t.Fatalf("template sdl: %v", err) + } + + if out != rawSDL { + t.Errorf("template sdl output = %q, want the raw SDL %q", out, rawSDL) + } +} + +func TestJWTCreatePrintsToken(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/create-jwt-token" { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + + var body struct { + Data struct { + TTL int `json:"ttl"` + Leases struct { + Access string `json:"access"` + Scope []string `json:"scope"` + } `json:"leases"` + } `json:"data"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request: %v", err) + } + if body.Data.TTL != 300 { + t.Errorf("default ttl = %d, want 300", body.Data.TTL) + } + if len(body.Data.Leases.Scope) != len(defaultJWTScope) { + t.Errorf("default scope = %v, want %v", body.Data.Leases.Scope, defaultJWTScope) + } + + writeJSON(t, w, `{"data":{"token":"tok-abc123"}}`) + })) + defer srv.Close() + + out, err := execConsole(t, m, srv.URL, "jwt", "create") + if err != nil { + t.Fatalf("jwt create: %v", err) + } + + if !strings.Contains(out, "tok-abc123") { + t.Errorf("jwt create output should contain the token, got %q", out) + } +} + +func TestUsagePositionalDates(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + var usageQuery url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/user/me": + writeJSON(t, w, `{"data":{"id":"u1","username":"joe"}}`) + case "/v1/wallets": + writeJSON(t, w, `{"data":[{"address":"akash1wallet","creditAmount":1000000}]}`) + case "/v1/usage/history": + usageQuery = r.URL.Query() + writeJSON(t, w, `[]`) + default: + t.Errorf("unexpected request %s", r.URL.Path) + } + })) + defer srv.Close() + + if _, err := execConsole(t, m, srv.URL, "usage", "2026-01-01", "2026-01-31"); err != nil { + t.Fatalf("usage with positional dates: %v", err) + } + + if usageQuery.Get("startDate") != "2026-01-01" || usageQuery.Get("endDate") != "2026-01-31" { + t.Errorf("positional dates not forwarded: %v", usageQuery) + } + + // No dates at all: params must be omitted so the API applies defaults. + usageQuery = nil + if _, err := execConsole(t, m, srv.URL, "usage"); err != nil { + t.Fatalf("usage without dates: %v", err) + } + if _, present := usageQuery["startDate"]; present { + t.Errorf("empty startDate must be omitted, got %v", usageQuery) + } +} + +func TestDepositPositionalAmount(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + var body string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + body = string(b) + writeJSON(t, w, `{"data":{}}`) + })) + defer srv.Close() + + if _, err := execConsole(t, m, srv.URL, "deployment", "deposit", "12345", "10"); err != nil { + t.Fatalf("deposit with positional amount: %v", err) + } + if !strings.Contains(body, `"deposit":10`) || !strings.Contains(body, `"dseq":"12345"`) { + t.Errorf("positional amount not sent: %s", body) + } + + // Invalid positional amount must fail before any request. + if _, err := execConsole(t, m, srv.URL, "deployment", "deposit", "12345", "ten"); err == nil { + t.Error("invalid positional amount must be rejected") + } +} diff --git a/internal/cli/console/deployment.go b/internal/cli/console/deployment.go new file mode 100644 index 00000000..5938d8cb --- /dev/null +++ b/internal/cli/console/deployment.go @@ -0,0 +1,460 @@ +package console + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" + + "pkg.akt.dev/akt/internal/console" + aktctx "pkg.akt.dev/akt/internal/context" + "pkg.akt.dev/akt/internal/transport" +) + +// parseConsoleUSD parses a positional deposit/amount argument using the +// unified cross-rail deposit syntax (transport.ParseDeposit, SPEC §7.4): +// bare numbers, "5usd", and "$5" are USD on the console rail; coin forms +// ("5000000uakt") fail with the transport package's cross-rail error before +// any request is made. +func parseConsoleUSD(arg string) (float64, error) { + dep, err := transport.ParseDeposit(arg) + if err != nil { + return 0, err + } + + if _, err := dep.RailValue(transport.KindConsole); err != nil { + return 0, err + } + + // ""/"auto" defers to the rail default, but the console rail has none: + // callers' minimum/positivity checks reject the zero value with a + // message pointing at the positional argument. + return dep.USD, nil +} + +func deploymentCmds(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "deployment", + Short: "Manage Console deployments", + Long: "Create, inspect, update, fund, and close deployments through the Console managed wallet.", + } + + cmd.AddCommand( + deploymentListCmd(mgrFn), + deploymentGetCmd(mgrFn), + deploymentCreateCmd(mgrFn), + deploymentUpdateCmd(mgrFn), + deploymentCloseCmd(mgrFn), + deploymentDepositCmd(mgrFn), + deploymentSettingsCmd(mgrFn), + ) + + return cmd +} + +func deploymentListCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List deployments", + Args: cobra.NoArgs, + Example: ` akt console deployment list --limit 10`, + RunE: func(cmd *cobra.Command, _ []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + skip, _ := cmd.Flags().GetInt("skip") + limit, _ := cmd.Flags().GetInt("limit") + + list, err := cl.ListDeployments(cmd.Context(), skip, limit) + if err != nil { + return fmt.Errorf("list deployments: %w", err) + } + + return printJSON(cmd, list) + }, + } + + cmd.Flags().Int("skip", 0, "Pagination offset") + cmd.Flags().Int("limit", 20, "Page size") + + return cmd +} + +func deploymentGetCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Show a deployment with its leases and escrow account", + Args: cobra.ExactArgs(1), + Example: ` akt console deployment get 12345`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + detail, err := cl.GetDeployment(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("get deployment %s: %w", args[0], err) + } + + return printJSON(cmd, detail) + }, + } +} + +func deploymentCreateCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "create [deposit-usd]", + Short: "Create a deployment (managed wallet signs server-side)", + Long: "Create a deployment from an SDL file with a USD deposit. The returned manifest " + + "is cached per-context so `akt console lease create` can send it without re-passing it.", + Args: cobra.RangeArgs(1, 2), + Example: ` # Deposit as positional argument + akt console deployment create deploy.yaml 5`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, rc, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + // FEEDBACK(2026-07): --deposit disabled for the positional-only + // UX trial; the positional [deposit-usd] argument is the only + // source (zero fallback). Restore by uncommenting if users ask + // for the flag form back. + // deposit, _ := cmd.Flags().GetFloat64("deposit") + deposit := 0.0 + if len(args) > 1 { + deposit, err = parseConsoleUSD(args[1]) + if err != nil { + return err + } + } + // NOTE: internal/workflow/adapters/console.go carries a private + if deposit < transport.MinConsoleDepositUSD { + return fmt.Errorf("deposit must be at least %s (got %s): pass it as the [deposit-usd] argument", formatUSD(transport.MinConsoleDepositUSD), formatUSD(deposit)) + } + + sdl, err := os.ReadFile(args[0]) + if err != nil { + return fmt.Errorf("read SDL file: %w", err) + } + + result, err := cl.CreateDeployment(cmd.Context(), string(sdl), deposit) + if err != nil { + return fmt.Errorf("create deployment: %w", err) + } + + // Cache the manifest so `lease create` can default to it. + note := "" + switch { + case rc == nil: + note = "manifest not cached (no active context); pass --manifest to `lease create`" + case result.Manifest != "": + if err := console.SaveManifest(rc.Root, rc.Name, result.DSeq.String(), result.Manifest); err != nil { + note = fmt.Sprintf("manifest not cached: %v", err) + } + } + + txHash := "" + if result.SignTx != nil { + txHash = result.SignTx.TransactionHash + } + + return printJSON(cmd, struct { + DSeq string `json:"dseq"` + TxHash string `json:"txHash,omitempty"` + State string `json:"state"` + Note string `json:"note,omitempty"` + }{result.DSeq.String(), txHash, "open", note}) + }, + } + + // FEEDBACK(2026-07): --deposit disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // cmd.Flags().Float64("deposit", 0, "Deposit amount in USD (minimum 0.5); alternative to the positional argument") + + return cmd +} + +func deploymentUpdateCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + return &cobra.Command{ + Use: "update ", + Short: "Update a deployment's SDL", + Args: cobra.ExactArgs(2), + Example: ` akt console deployment update 12345 deploy.yaml`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + sdl, err := os.ReadFile(args[1]) + if err != nil { + return fmt.Errorf("read SDL file: %w", err) + } + + detail, err := cl.UpdateDeployment(cmd.Context(), args[0], string(sdl)) + if err != nil { + return fmt.Errorf("update deployment %s: %w", args[0], err) + } + + return printJSON(cmd, detail) + }, + } +} + +func deploymentCloseCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + return &cobra.Command{ + Use: "close ", + Short: "Close a deployment (idempotent: already-closed is a no-op)", + Args: cobra.ExactArgs(1), + Example: ` akt console deployment close 12345`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + err = cl.CloseDeployment(cmd.Context(), args[0]) + switch { + case err == nil: + fmt.Fprintf(cmd.OutOrStdout(), "Deployment %s closed.\n", args[0]) + return nil + + case errors.Is(err, console.ErrAlreadyClosed): + fmt.Fprintf(cmd.OutOrStdout(), "Deployment %s already closed.\n", args[0]) + return nil + + default: + return fmt.Errorf("close deployment %s: %w", args[0], err) + } + }, + } +} + +func deploymentDepositCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "deposit [amount-usd]", + Short: "Add funds to a deployment's escrow", + Args: cobra.RangeArgs(1, 2), + Example: ` # Amount as positional argument + akt console deployment deposit 12345 10`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + // FEEDBACK(2026-07): --amount disabled for the positional-only + // UX trial; the positional [amount-usd] argument is the only + // source (zero fallback). Restore by uncommenting if users ask + // for the flag form back. + // amount, _ := cmd.Flags().GetFloat64("amount") + amount := 0.0 + if len(args) > 1 { + amount, err = parseConsoleUSD(args[1]) + if err != nil { + return err + } + } + if amount <= 0 { + return fmt.Errorf("amount must be a positive USD amount (got %s): pass it as the [amount-usd] argument", formatUSD(amount)) + } + + if err := cl.Deposit(cmd.Context(), args[0], amount); err != nil { + return fmt.Errorf("deposit to deployment %s: %w", args[0], err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Deposited %s to deployment %s.\n", formatUSD(amount), args[0]) + return nil + }, + } + + // FEEDBACK(2026-07): --amount disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // cmd.Flags().Float64("amount", 0, "Amount to add in USD; alternative to the positional argument") + + return cmd +} + +func deploymentSettingsCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "settings [true|false]", + Short: "View or change a deployment's auto-top-up setting", + Args: cobra.RangeArgs(1, 2), + Example: ` # Show current settings + akt console deployment settings 12345 + + # Enable auto-top-up + akt console deployment settings 12345 true`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + // FEEDBACK(2026-07): --auto-top-up disabled for the + // positional-only UX trial; the positional [true|false] argument + // is the only source. Restore by uncommenting if users ask for + // the flag form back. + // if len(args) > 1 || cmd.Flags().Changed("auto-top-up") { + // value, _ := cmd.Flags().GetString("auto-top-up") + // if len(args) > 1 { + // value = args[1] + // } + if len(args) > 1 { + value := args[1] + + enabled, err := parseBoolValue(value, "auto-top-up") + if err != nil { + return err + } + + settings, err := cl.SetDeploymentAutoTopUp(cmd.Context(), args[0], enabled) + if err != nil { + return fmt.Errorf("update deployment settings for %s: %w", args[0], err) + } + + return printJSON(cmd, settings) + } + + settings, err := cl.GetDeploymentSettings(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("get deployment settings for %s: %w", args[0], err) + } + + return printJSON(cmd, settings) + }, + } + + // FEEDBACK(2026-07): --auto-top-up disabled for the positional-only UX + // trial (use the positional form instead). Restore by uncommenting if + // users ask for the flag form back. + // cmd.Flags().String("auto-top-up", "", "Enable or disable auto-top-up (true|false)") + + return cmd +} + +// --- bids and leases -------------------------------------------------------- + +func bidCmds(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "bid", + Short: "Inspect provider bids", + } + + cmd.AddCommand(&cobra.Command{ + Use: "list ", + Short: "List bids for a deployment's open orders", + Args: cobra.ExactArgs(1), + Example: ` akt console bid list 12345`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + bids, err := cl.FetchBids(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("list bids for deployment %s: %w", args[0], err) + } + + if len(bids) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No bids yet (providers may still be bidding). Re-run in a few seconds.") + return nil + } + + return printJSON(cmd, bids) + }, + }) + + return cmd +} + +func leaseCmds(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "lease", + Short: "Create leases from accepted bids", + } + + create := &cobra.Command{ + Use: "create [provider]", + Short: "Accept a bid by creating a lease and sending the manifest", + Long: "Accept a bid by creating a lease and sending the deployment manifest to the " + + "winning provider. The manifest defaults to the one cached by `deployment create`.", + Args: cobra.RangeArgs(1, 2), + Example: ` # Provider as positional argument (gseq/oseq default to 1) + akt console lease create 12345 akash1provider...`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, rc, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + dseq := args[0] + gseq, _ := cmd.Flags().GetUint32("gseq") + oseq, _ := cmd.Flags().GetUint32("oseq") + // FEEDBACK(2026-07): --provider disabled for the positional-only + // UX trial; the positional [provider] argument is the only + // source. Restore by uncommenting if users ask for the flag form + // back. + // provider, _ := cmd.Flags().GetString("provider") + provider := "" + if len(args) > 1 { + provider = args[1] + } + if provider == "" { + return fmt.Errorf("provider is required: pass it as the [provider] argument") + } + manifestFile, _ := cmd.Flags().GetString("manifest") + + var manifest string + switch { + case manifestFile != "": + data, err := os.ReadFile(manifestFile) + if err != nil { + return fmt.Errorf("read manifest file: %w", err) + } + manifest = string(data) + + case rc != nil: + manifest, err = console.LoadManifest(rc.Root, rc.Name, dseq) + if err != nil { + return fmt.Errorf("no cached manifest for deployment %s: pass --manifest , or recreate with `akt console deployment create` (%w)", dseq, err) + } + + default: + return fmt.Errorf("no cached manifest available without an active context: pass --manifest ") + } + + detail, err := cl.CreateLease(cmd.Context(), manifest, []console.LeaseRequest{{ + DSeq: dseq, + GSeq: gseq, + OSeq: oseq, + Provider: provider, + }}) + if err != nil { + return fmt.Errorf("create lease for deployment %s: %w", dseq, err) + } + + return printJSON(cmd, detail) + }, + } + + create.Flags().Uint32("gseq", 1, "Group sequence number") + create.Flags().Uint32("oseq", 1, "Order sequence number") + // FEEDBACK(2026-07): --provider disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // create.Flags().String("provider", "", "Provider address; alternative to the positional argument") + create.Flags().String("manifest", "", "Manifest file (defaults to the one cached by `deployment create`)") + + cmd.AddCommand(create) + + return cmd +} diff --git a/internal/cli/console/deployment_test.go b/internal/cli/console/deployment_test.go new file mode 100644 index 00000000..072767b1 --- /dev/null +++ b/internal/cli/console/deployment_test.go @@ -0,0 +1,152 @@ +package console + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + aktctx "pkg.akt.dev/akt/internal/context" +) + +// TestDeploymentCreateUnifiedDepositSyntax pins the cross-rail deposit +// contract (transport.ParseDeposit, SPEC §7.4) on `deployment create`: bare +// numbers, "5usd", and "$5" are all USD on the console rail; coin forms fail +// with the transport package's cross-rail error before any request is sent; +// and the shared $0.50 minimum (transport.MinConsoleDepositUSD) is enforced. +func TestDeploymentCreateUnifiedDepositSyntax(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + sdlPath := filepath.Join(t.TempDir(), "deploy.yaml") + if err := os.WriteFile(sdlPath, []byte("version: \"2.0\"\n"), 0o600); err != nil { + t.Fatalf("write SDL file: %v", err) + } + + var body string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/deployments" { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + b, _ := io.ReadAll(r.Body) + body = string(b) + writeJSON(t, w, `{"data":{"dseq":"321","manifest":""}}`) + })) + defer srv.Close() + + for _, tc := range []struct { + arg string + want string + }{ + {"5", `"deposit":5`}, + {"5usd", `"deposit":5`}, + {"$5", `"deposit":5`}, + {"2.50usd", `"deposit":2.5`}, + } { + body = "" + if _, err := execConsole(t, m, srv.URL, "deployment", "create", sdlPath, tc.arg); err != nil { + t.Fatalf("create with deposit %q: %v", tc.arg, err) + } + if !strings.Contains(body, tc.want) { + t.Errorf("deposit %q: request body = %s, want %s", tc.arg, body, tc.want) + } + } + + // Coin forms are chain-rail syntax: rejected client-side with the + // transport package's cross-rail message, no request sent. + body = "" + _, err := execConsole(t, m, srv.URL, "deployment", "create", sdlPath, "5000000uakt") + if err == nil || !strings.Contains(err.Error(), "console deposits are in USD") { + t.Errorf("coin deposit must fail with the cross-rail error, got %v", err) + } + if body != "" { + t.Errorf("no request must be sent for a rejected deposit, got body %s", body) + } + + // Below the shared minimum (transport.MinConsoleDepositUSD). + _, err = execConsole(t, m, srv.URL, "deployment", "create", sdlPath, "0.25") + if err == nil || !strings.Contains(err.Error(), "$0.50") { + t.Errorf("sub-minimum deposit must fail mentioning the $0.50 floor, got %v", err) + } +} + +// TestDeploymentDepositUnifiedSyntax pins the same unified syntax on +// `deployment deposit`: USD forms are accepted, coin forms rejected with the +// cross-rail error, and garbage rejected before any request. +func TestDeploymentDepositUnifiedSyntax(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + var body string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + body = string(b) + writeJSON(t, w, `{"data":{}}`) + })) + defer srv.Close() + + for _, tc := range []struct { + arg string + want string + }{ + {"10usd", `"deposit":10`}, + {"$2.50", `"deposit":2.5`}, + } { + body = "" + if _, err := execConsole(t, m, srv.URL, "deployment", "deposit", "12345", tc.arg); err != nil { + t.Fatalf("deposit %q: %v", tc.arg, err) + } + if !strings.Contains(body, tc.want) || !strings.Contains(body, `"dseq":"12345"`) { + t.Errorf("deposit %q: request body = %s, want %s", tc.arg, body, tc.want) + } + } + + body = "" + _, err := execConsole(t, m, srv.URL, "deployment", "deposit", "12345", "1akt") + if err == nil || !strings.Contains(err.Error(), "console deposits are in USD") { + t.Errorf("coin amount must fail with the cross-rail error, got %v", err) + } + if body != "" { + t.Errorf("no request must be sent for a rejected amount, got body %s", body) + } + + if _, err := execConsole(t, m, srv.URL, "deployment", "deposit", "12345", "ten"); err == nil { + t.Error("non-numeric amount must be rejected") + } +} + +// TestDeploymentListZeroFlagValues pins that legitimately-zero flag values +// are not mis-reported as errors: --skip 0 is forwarded and --limit 0 falls +// back to the server default page size (the API requires limit >= 1). +func TestDeploymentListZeroFlagValues(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + var gotQuery url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + writeJSON(t, w, `{"data":{"deployments":[],"pagination":{"total":0,"skip":0,"limit":20,"hasMore":false}}}`) + })) + defer srv.Close() + + if _, err := execConsole(t, m, srv.URL, "deployment", "list", "--skip", "0", "--limit", "0"); err != nil { + t.Fatalf("deployment list with zero flag values must succeed, got %v", err) + } + + if gotQuery.Get("skip") != "0" { + t.Errorf("skip=0 must be forwarded, got query %v", gotQuery) + } + if _, present := gotQuery["limit"]; present { + t.Errorf("limit=0 must be omitted so the server default applies, got query %v", gotQuery) + } +} diff --git a/internal/cli/console/gateway.go b/internal/cli/console/gateway.go new file mode 100644 index 00000000..cc8e7655 --- /dev/null +++ b/internal/cli/console/gateway.go @@ -0,0 +1,591 @@ +package console + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/spf13/cobra" + "golang.org/x/term" + + mtypes "pkg.akt.dev/go/node/market/v1" + attrv1 "pkg.akt.dev/go/node/types/attributes/v1" + rest "pkg.akt.dev/go/provider/client" + "pkg.akt.dev/go/sdl" + + // Registers the akash bech32 prefixes with the cosmos-sdk global config + // (sealed in sdkutil's init), so provider addresses parse correctly even + // when this package is used without the chain command tree. + _ "pkg.akt.dev/go/sdkutil" + + "pkg.akt.dev/akt/internal/console" + aktctx "pkg.akt.dev/akt/internal/context" +) + +// JWT lifetimes for Console-minted provider gateway tokens, in seconds. +// One-shot calls get a short token; streams (follow/watch/shell) get an hour. +const ( + gatewayJWTTTLOneShot = 300 + gatewayJWTTTLStream = 3600 +) + +// --- gateway resolution ------------------------------------------------------- + +// gatewayForDeployment resolves a Console-managed deployment to an +// authenticated provider gateway client, without any wallet or local key: +// +// 1. GetDeployment(dseq) and pick the first active lease, +// 2. GetProvider(lease.provider) for the gateway hostUri, +// 3. CreateJWTToken(ttl, scope) to mint a Console-signed scoped JWT that +// provider gateways accept as `Authorization: Bearer`, +// 4. build a pkg.akt.dev provider REST client against the hostUri. +// +// ttl is in seconds (gatewayJWTTTLOneShot / gatewayJWTTTLStream); scope lists +// the gateway permissions the token grants (e.g. "status", "logs"). +func gatewayForDeployment(cmd *cobra.Command, cl *console.Client, dseq string, ttl int, scope []string) (rest.Client, mtypes.LeaseID, error) { + ctx := cmd.Context() + + detail, err := cl.GetDeployment(ctx, dseq) + if err != nil { + return nil, mtypes.LeaseID{}, fmt.Errorf("get deployment %s: %w", dseq, err) + } + + lease, err := activeLease(detail, dseq) + if err != nil { + return nil, mtypes.LeaseID{}, err + } + + lid, err := leaseIDFromConsole(detail, lease) + if err != nil { + return nil, mtypes.LeaseID{}, err + } + + prov, err := cl.GetProvider(ctx, lease.ID.Provider) + if err != nil { + return nil, mtypes.LeaseID{}, fmt.Errorf("get provider %s: %w", lease.ID.Provider, err) + } + if prov.HostURI == "" { + return nil, mtypes.LeaseID{}, fmt.Errorf("provider %s has no gateway hostUri in the Console catalog", lease.ID.Provider) + } + + token, err := cl.CreateJWTToken(ctx, ttl, scope) + if err != nil { + return nil, mtypes.LeaseID{}, fmt.Errorf("mint provider JWT (scope %s): %w", strings.Join(scope, ","), err) + } + + addr, err := sdk.AccAddressFromBech32(lease.ID.Provider) + if err != nil { + return nil, mtypes.LeaseID{}, fmt.Errorf("invalid provider address %q: %w", lease.ID.Provider, err) + } + + gw, err := rest.NewClient(ctx, addr, + rest.WithProviderURL(prov.HostURI), + rest.WithAuthToken(token), + ) + if err != nil { + return nil, mtypes.LeaseID{}, fmt.Errorf("create provider gateway client for %s: %w", prov.HostURI, err) + } + + return gw, lid, nil +} + +// streamCloseErr converts a closed stream's reason into an error, or nil when +// the close is simply the end of the output. +// +// A provider ends a one-shot (non-follow) stream by closing the connection +// once it has sent everything rather than by sending a close frame, which the +// websocket layer surfaces as "unexpected EOF". Reporting that as a failure +// exits non-zero after every line has already been printed, so +// `akt console logs && next` never runs next despite the fetch having +// completely succeeded. +// +// In follow mode the user asked for an open-ended stream, so an EOF really did +// cut it short and is still reported. +func streamCloseErr(kind, reason string, follow bool) error { + if reason == "" { + return nil + } + + if !follow && strings.Contains(strings.ToLower(reason), "eof") { + return nil + } + + return fmt.Errorf("%s stream closed: %s", kind, reason) +} + +// activeLease returns the deployment's first active lease, or an error that +// names the states of the leases that do exist. +func activeLease(detail *console.DeploymentDetail, dseq string) (*console.Lease, error) { + if len(detail.Leases) == 0 { + return nil, fmt.Errorf("deployment %s has no leases yet: create one with `akt console lease create %s `", dseq, dseq) + } + + states := make([]string, 0, len(detail.Leases)) + for i := range detail.Leases { + if detail.Leases[i].State == "active" { + return &detail.Leases[i], nil + } + states = append(states, detail.Leases[i].State) + } + + return nil, fmt.Errorf("deployment %s has no active lease (lease states: %s)", dseq, strings.Join(states, ", ")) +} + +// leaseIDFromConsole converts a Console lease record into a chain LeaseID for +// the provider gateway client. The owner falls back to the deployment owner +// when the lease record omits it. +func leaseIDFromConsole(detail *console.DeploymentDetail, lease *console.Lease) (mtypes.LeaseID, error) { + dseq, err := strconv.ParseUint(lease.ID.DSeq.String(), 10, 64) + if err != nil { + return mtypes.LeaseID{}, fmt.Errorf("invalid lease dseq %q: %w", lease.ID.DSeq.String(), err) + } + + owner := lease.ID.Owner + if owner == "" { + owner = detail.Deployment.ID.Owner + } + + return mtypes.LeaseID{ + Owner: owner, + DSeq: dseq, + GSeq: lease.ID.GSeq, + OSeq: lease.ID.OSeq, + Provider: lease.ID.Provider, + }, nil +} + +// --- commands ----------------------------------------------------------------- + +func logsCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "logs [service]", + Short: "Stream container logs from the lease's provider", + Long: "Stream container logs for a Console-managed deployment directly from the provider " + + "gateway. The provider is resolved from the deployment's active lease and access is " + + "authorized by a short-lived Console-minted JWT — no wallet or local key is involved.", + Args: cobra.RangeArgs(1, 2), + Example: ` # All services + akt console logs 12345 + + # One service, following output + akt console logs 12345 web --follow + + # Last 100 lines + akt console logs 12345 --tail 100`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + follow, _ := cmd.Flags().GetBool("follow") + tail, _ := cmd.Flags().GetInt64("tail") + // FEEDBACK(2026-07): --service disabled for the positional-only + // UX trial; the positional [service] argument is the only + // source. Restore by uncommenting if users ask for the flag form + // back. + // service, _ := cmd.Flags().GetString("service") + service := "" + if len(args) > 1 { + service = args[1] + } + + ttl := gatewayJWTTTLOneShot + if follow { + ttl = gatewayJWTTTLStream + } + + gw, lid, err := gatewayForDeployment(cmd, cl, args[0], ttl, []string{"logs"}) + if err != nil { + return err + } + + logs, err := gw.LeaseLogs(ctx, lid, service, follow, tail) + if err != nil { + return fmt.Errorf("stream lease logs: %w", err) + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case msg, ok := <-logs.Stream: + if !ok { + return nil + } + fmt.Fprintf(cmd.OutOrStdout(), "[%s] %s\n", msg.Name, msg.Message) + case reason, ok := <-logs.OnClose: + if !ok { + return nil + } + return streamCloseErr("log", reason, follow) + } + } + }, + } + + cmd.Flags().BoolP("follow", "f", false, "Follow log output") + cmd.Flags().Int64("tail", -1, "Number of lines to show from the end of the logs") + // FEEDBACK(2026-07): --service disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // cmd.Flags().String("service", "", "Filter logs by service name; alternative to the positional argument") + + return cmd +} + +func eventsCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "events ", + Short: "Stream Kubernetes events from the lease's provider", + Long: "Stream Kubernetes events for a Console-managed deployment directly from the provider " + + "gateway, authorized by a short-lived Console-minted JWT (no wallet involved).", + Args: cobra.ExactArgs(1), + Example: ` # Recent events + akt console events 12345 + + # Follow events + akt console events 12345 --follow`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + follow, _ := cmd.Flags().GetBool("follow") + + ttl := gatewayJWTTTLOneShot + if follow { + ttl = gatewayJWTTTLStream + } + + gw, lid, err := gatewayForDeployment(cmd, cl, args[0], ttl, []string{"events"}) + if err != nil { + return err + } + + events, err := gw.LeaseEvents(ctx, lid, "", follow) + if err != nil { + return fmt.Errorf("stream lease events: %w", err) + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case evt, ok := <-events.Stream: + if !ok { + return nil + } + fmt.Fprintf(cmd.OutOrStdout(), "%s [%s/%s] %s: %s\n", + evt.Type, evt.Object.Kind, evt.Object.Name, evt.Reason, evt.Note) + case reason, ok := <-events.OnClose: + if !ok { + return nil + } + return streamCloseErr("event", reason, follow) + } + } + }, + } + + cmd.Flags().BoolP("follow", "f", false, "Follow event output") + + return cmd +} + +func statusCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "status ", + Short: "Live lease status from the provider gateway", + Long: "Query the live status of a Console-managed deployment's lease straight from the " + + "provider gateway (services, forwarded ports, IPs), authorized by a short-lived " + + "Console-minted JWT. `akt console deployment get` remains the Console-API view of the " + + "same deployment. With --watch the status is re-polled until interrupted (or the " + + "one-hour token expires).", + Args: cobra.ExactArgs(1), + Example: ` # One snapshot + akt console status 12345 + + # Poll every 5s until Ctrl-C + akt console status 12345 --watch + + # Poll every 30s + akt console status 12345 --watch --interval 30s`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + watch, _ := cmd.Flags().GetBool("watch") + interval, _ := cmd.Flags().GetDuration("interval") + if interval <= 0 { + interval = 5 * time.Second + } + + ttl := gatewayJWTTTLOneShot + if watch { + ttl = gatewayJWTTTLStream + } + + gw, lid, err := gatewayForDeployment(cmd, cl, args[0], ttl, []string{"status"}) + if err != nil { + return err + } + + status, err := gw.LeaseStatus(ctx, lid) + if err != nil { + return fmt.Errorf("query lease status: %w", err) + } + if err := printJSON(cmd, status); err != nil { + return err + } + + if !watch { + return nil + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(interval): + } + + status, err := gw.LeaseStatus(ctx, lid) + if err != nil { + return fmt.Errorf("query lease status: %w", err) + } + if err := printJSON(cmd, status); err != nil { + return err + } + } + }, + } + + cmd.Flags().Bool("watch", false, "Keep polling and printing status snapshots until interrupted") + cmd.Flags().Duration("interval", 5*time.Second, "Polling interval used with --watch") + + return cmd +} + +func shellCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + return &cobra.Command{ + Use: "shell [-- command...]", + Short: "Open a shell or run a command in a lease container", + Long: "Open an interactive shell (default command /bin/sh) or run an explicit command in a " + + "container of a Console-managed deployment, connecting to the provider gateway with a " + + "short-lived Console-minted JWT — no wallet involved. exec is the same operation as " + + "shell with an explicit command: `akt console shell -- `. A TTY is " + + "allocated automatically when stdin is a terminal.", + Args: cobra.MinimumNArgs(2), + Example: ` # Interactive shell (defaults to /bin/sh) + akt console shell 12345 web + + # Run a single command (a.k.a. exec) + akt console shell 12345 web -- ls -la`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + dseq, service := args[0], args[1] + + command := args[2:] + if len(command) == 0 { + command = []string{"/bin/sh"} + } + + gw, lid, err := gatewayForDeployment(cmd, cl, dseq, gatewayJWTTTLStream, []string{"shell"}) + if err != nil { + return err + } + + tty := term.IsTerminal(int(os.Stdin.Fd())) + + return gw.LeaseShell(cmd.Context(), lid, service, 0, command, + os.Stdin, os.Stdout, os.Stderr, tty, nil) + }, + } +} + +// --- bid screening ------------------------------------------------------------ + +func screenCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + return &cobra.Command{ + Use: "screen ", + Short: "List providers able to run an SDL (bid screening)", + Long: "Ask the Console API which providers can satisfy an SDL's resource requirements " + + "before creating a deployment. Resources are derived from the SDL client-side; the " + + "endpoint is public, so no API key is needed.", + Args: cobra.ExactArgs(1), + Example: ` akt console screen deploy.yaml`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, false) + if err != nil { + return err + } + + resources, err := screeningResourcesFromSDL(args[0]) + if err != nil { + return err + } + + providers, err := cl.ScreenBids(cmd.Context(), &console.BidScreeningRequest{ + Resources: resources, + Timezone: screeningTimezone(), + }) + if err != nil { + return fmt.Errorf("screen providers: %w", err) + } + + if len(providers) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No providers matched the SDL's resource requirements.") + return nil + } + + return printJSON(cmd, providers) + }, + } +} + +// screeningTimezone returns the local IANA zone name for bid screening. The +// Console API rejects UTC-family zones, so UTC/Local (and abbreviations that +// are not real zone names) fall back to America/Chicago, mirroring the +// Console reference CLI. +func screeningTimezone() string { + name := time.Local.String() + if name == "" || name == "Local" || + strings.HasPrefix(name, "UTC") || strings.HasPrefix(name, "Etc/") || + !strings.Contains(name, "/") { + return "America/Chicago" + } + + return name +} + +// screeningResourcesFromSDL derives the /v1/bid-screening resources array from +// an SDL file's deployment groups. +// +// The wire shape is built by hand instead of marshaling the chain structs: +// those serialize memory/storage quantities under "size" and prices as decimal +// strings, both of which the bid-screening schema rejects (it wants "quantity" +// objects and integer-string amounts). Deliberate simplification: endpoint +// details are dropped (sent as an empty list) — the screening endpoint matches +// on cpu/memory/gpu/storage quantities and attributes, which are all carried +// over. +func screeningResourcesFromSDL(path string) (json.RawMessage, error) { + doc, err := sdl.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read SDL file: %w", err) + } + + groups, err := doc.DeploymentGroups() + if err != nil { + return nil, fmt.Errorf("derive deployment groups from SDL: %w", err) + } + + type val struct { + Val string `json:"val"` + } + type units struct { + Units val `json:"units"` + Attributes []console.Attribute `json:"attributes,omitempty"` + } + type quantity struct { + Quantity val `json:"quantity"` + Attributes []console.Attribute `json:"attributes,omitempty"` + } + type volume struct { + Name string `json:"name"` + Quantity val `json:"quantity"` + Attributes []console.Attribute `json:"attributes,omitempty"` + } + type resource struct { + ID uint32 `json:"id"` + CPU units `json:"cpu"` + Memory quantity `json:"memory"` + GPU units `json:"gpu"` + Storage []volume `json:"storage"` + Endpoints []any `json:"endpoints"` + } + type price struct { + Denom string `json:"denom"` + Amount string `json:"amount"` + } + type unit struct { + Resource resource `json:"resource"` + Count uint32 `json:"count"` + Price price `json:"price"` + } + + var out []unit + for _, g := range groups { + for _, ru := range g.Resources { + r := resource{ + ID: ru.ID, + CPU: units{Units: val{"0"}}, + Memory: quantity{Quantity: val{"0"}}, + GPU: units{Units: val{"0"}}, + Storage: []volume{}, + Endpoints: []any{}, + } + + if ru.CPU != nil { + r.CPU = units{val{ru.CPU.Units.Val.String()}, screeningAttrs(ru.CPU.Attributes)} + } + if ru.Memory != nil { + r.Memory = quantity{val{ru.Memory.Quantity.Val.String()}, screeningAttrs(ru.Memory.Attributes)} + } + if ru.GPU != nil { + r.GPU = units{val{ru.GPU.Units.Val.String()}, screeningAttrs(ru.GPU.Attributes)} + } + for _, v := range ru.Storage { + r.Storage = append(r.Storage, volume{v.Name, val{v.Quantity.Val.String()}, screeningAttrs(v.Attributes)}) + } + + out = append(out, unit{ + Resource: r, + Count: ru.Count, + Price: price{ + Denom: ru.Price.Denom, + // The schema wants an integer string; SDL prices are + // decimal on chain, so round up. + Amount: ru.Price.Amount.Ceil().TruncateInt().String(), + }, + }) + } + } + + if len(out) == 0 { + return nil, fmt.Errorf("SDL %s produced no resource groups", path) + } + + return json.Marshal(out) +} + +// screeningAttrs converts chain attributes to the Console wire type. +func screeningAttrs(attrs attrv1.Attributes) []console.Attribute { + if len(attrs) == 0 { + return nil + } + + out := make([]console.Attribute, 0, len(attrs)) + for _, a := range attrs { + out = append(out, console.Attribute{Key: a.Key, Value: a.Value}) + } + + return out +} diff --git a/internal/cli/console/gateway_test.go b/internal/cli/console/gateway_test.go new file mode 100644 index 00000000..ce17fb7d --- /dev/null +++ b/internal/cli/console/gateway_test.go @@ -0,0 +1,394 @@ +package console + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + aktctx "pkg.akt.dev/akt/internal/context" +) + +// testProviderAddr is a checksummed akash bech32 address (the sdkutil import +// in gateway.go registers the prefix) used as the lease's provider. +const testProviderAddr = "akash1skjwj5whet0lpe65qaq4rpq03hjxlwd97juzxv" + +// jwtCapture records the /v1/create-jwt-token requests a test observed. +type jwtCapture struct { + Count int + TTL int + Scope []string +} + +// newGatewayConsoleServer mocks the Console API endpoints the gateway helper +// hits for dseq 777: deployment detail (with the given leases JSON array), +// provider detail (pointing hostUri at gatewayURL), and JWT minting. +func newGatewayConsoleServer(t *testing.T, gatewayURL, leasesJSON string, jwt *jwtCapture) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/deployments/777": + writeJSON(t, w, `{"data":{"deployment":{"id":{"owner":"akash1owner","dseq":"777"},"state":"active"},"leases":`+leasesJSON+`}}`) + + case "/v1/providers/" + testProviderAddr: + writeJSON(t, w, `{"owner":"`+testProviderAddr+`","hostUri":"`+gatewayURL+`","isOnline":true}`) + + case "/v1/create-jwt-token": + var body struct { + Data struct { + TTL int `json:"ttl"` + Leases struct { + Access string `json:"access"` + Scope []string `json:"scope"` + } `json:"leases"` + } `json:"data"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode create-jwt-token request: %v", err) + } + jwt.Count++ + jwt.TTL = body.Data.TTL + jwt.Scope = body.Data.Leases.Scope + writeJSON(t, w, `{"data":{"token":"tok-live"}}`) + + default: + t.Errorf("unexpected console request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) +} + +// activeLeaseJSON is a deployment with one closed and one active lease; the +// active one uses gseq 2 so tests can prove the active lease was selected. +const activeLeaseJSON = `[ + {"id":{"owner":"akash1owner","dseq":"777","gseq":1,"oseq":1,"provider":"` + testProviderAddr + `"},"state":"closed"}, + {"id":{"owner":"akash1owner","dseq":"777","gseq":2,"oseq":1,"provider":"` + testProviderAddr + `"},"state":"active"} +]` + +func TestStatusEndToEndViaGateway(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + var gwPath, gwAuth string + gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gwPath = r.URL.Path + gwAuth = r.Header.Get("Authorization") + writeJSON(t, w, `{"services":{"web":{"name":"web","available":1,"total":1}},"forwarded_ports":{},"ips":null}`) + })) + defer gateway.Close() + + var jwt jwtCapture + consoleSrv := newGatewayConsoleServer(t, gateway.URL, activeLeaseJSON, &jwt) + defer consoleSrv.Close() + + out, err := execConsole(t, m, consoleSrv.URL, "status", "777") + if err != nil { + t.Fatalf("status: %v", err) + } + + // The active lease (gseq 2) must be targeted at the mocked hostUri with + // the Console-minted JWT as Bearer auth. + if gwPath != "/lease/777/2/1/status" { + t.Errorf("gateway path = %q, want /lease/777/2/1/status", gwPath) + } + if gwAuth != "Bearer tok-live" { + t.Errorf("gateway Authorization = %q, want Bearer tok-live", gwAuth) + } + if jwt.Count != 1 || jwt.TTL != 300 { + t.Errorf("jwt mints = %d ttl = %d, want 1 mint with ttl 300", jwt.Count, jwt.TTL) + } + if len(jwt.Scope) != 1 || jwt.Scope[0] != "status" { + t.Errorf("jwt scope = %v, want [status]", jwt.Scope) + } + if !strings.Contains(out, `"web"`) { + t.Errorf("status output should include the service snapshot, got %q", out) + } +} + +func TestStatusWatchPollsUntilError(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + // First poll succeeds, the second fails: the watch loop must print the + // snapshot and surface the poll error. + calls := 0 + gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls++ + if calls > 1 { + w.WriteHeader(http.StatusInternalServerError) + return + } + writeJSON(t, w, `{"services":{"web":{"name":"web","available":1,"total":1}},"forwarded_ports":{},"ips":null}`) + })) + defer gateway.Close() + + var jwt jwtCapture + consoleSrv := newGatewayConsoleServer(t, gateway.URL, activeLeaseJSON, &jwt) + defer consoleSrv.Close() + + out, err := execConsole(t, m, consoleSrv.URL, "status", "777", "--watch", "--interval", "10ms") + if err == nil { + t.Fatal("watch must surface the failing poll") + } + if !strings.Contains(err.Error(), "query lease status") { + t.Errorf("error = %v, want a lease status poll failure", err) + } + if calls != 2 { + t.Errorf("gateway calls = %d, want 2 (snapshot + failing poll)", calls) + } + if jwt.TTL != 3600 { + t.Errorf("watch jwt ttl = %d, want 3600", jwt.TTL) + } + if !strings.Contains(out, `"web"`) { + t.Errorf("watch should print the first snapshot, got %q", out) + } +} + +// TestLogsResolvesGatewayAndScopes covers the resolution path for `logs`: the +// Console lookups happen and a logs-scoped JWT is minted with a TTL matching +// the follow mode. The websocket streaming loop itself is shared library code +// and needs a TLS gateway, so it is not exercised here; the mocked hostUri is +// plain http, which the library rejects at dial time with a scheme error — +// proof the command got as far as opening the stream. +func TestLogsResolvesGatewayAndScopes(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + var jwt jwtCapture + consoleSrv := newGatewayConsoleServer(t, "http://gateway.invalid", activeLeaseJSON, &jwt) + defer consoleSrv.Close() + + // One-shot: short token. + _, err := execConsole(t, m, consoleSrv.URL, "logs", "777", "web") + if err == nil || !strings.Contains(err.Error(), "invalid uri scheme") { + t.Fatalf("logs against an http hostUri should fail at the websocket dial, got %v", err) + } + if jwt.TTL != 300 || len(jwt.Scope) != 1 || jwt.Scope[0] != "logs" { + t.Errorf("one-shot logs jwt = ttl %d scope %v, want ttl 300 scope [logs]", jwt.TTL, jwt.Scope) + } + + // Follow: long-lived token. + _, err = execConsole(t, m, consoleSrv.URL, "logs", "777", "web", "--follow") + if err == nil || !strings.Contains(err.Error(), "invalid uri scheme") { + t.Fatalf("logs --follow against an http hostUri should fail at the websocket dial, got %v", err) + } + if jwt.TTL != 3600 { + t.Errorf("follow logs jwt ttl = %d, want 3600", jwt.TTL) + } +} + +func TestGatewayNoActiveLeaseError(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + var jwt jwtCapture + leases := `[ + {"id":{"owner":"akash1owner","dseq":"777","gseq":1,"oseq":1,"provider":"` + testProviderAddr + `"},"state":"closed"}, + {"id":{"owner":"akash1owner","dseq":"777","gseq":2,"oseq":1,"provider":"` + testProviderAddr + `"},"state":"insufficient_funds"} + ]` + consoleSrv := newGatewayConsoleServer(t, "http://gateway.invalid", leases, &jwt) + defer consoleSrv.Close() + + _, err := execConsole(t, m, consoleSrv.URL, "status", "777") + if err == nil { + t.Fatal("status without an active lease must fail") + } + for _, want := range []string{"no active lease", "closed", "insufficient_funds"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q should mention %q", err.Error(), want) + } + } + if jwt.Count != 0 { + t.Errorf("no JWT should be minted without an active lease, got %d mints", jwt.Count) + } + + // No leases at all gets a pointer to lease creation. + consoleSrv2 := newGatewayConsoleServer(t, "http://gateway.invalid", `[]`, &jwt) + defer consoleSrv2.Close() + + _, err = execConsole(t, m, consoleSrv2.URL, "status", "777") + if err == nil || !strings.Contains(err.Error(), "has no leases yet") { + t.Errorf("error = %v, want a no-leases message", err) + } +} + +const screenSDL = `--- +version: "2.0" +services: + web: + image: nginx + expose: + - port: 80 + to: + - global: true +profiles: + compute: + web: + resources: + cpu: + units: "100m" + memory: + size: "128Mi" + storage: + size: "1Gi" + placement: + westcoast: + pricing: + web: + denom: uakt + amount: 50 +deployment: + web: + westcoast: + profile: web + count: 1 +` + +func TestScreenRequestShape(t *testing.T) { + m := newTestManager(t) + + var body []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/bid-screening" { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + body, _ = io.ReadAll(r.Body) + writeJSON(t, w, `{"providers":[{"owner":"akash1prov","isAudited":true,"organization":"acme"}]}`) + })) + defer srv.Close() + + sdlPath := filepath.Join(t.TempDir(), "deploy.yaml") + if err := os.WriteFile(sdlPath, []byte(screenSDL), 0o600); err != nil { + t.Fatalf("write SDL: %v", err) + } + + // Public endpoint: no API key configured anywhere. + out, err := execConsole(t, m, srv.URL, "screen", sdlPath) + if err != nil { + t.Fatalf("screen: %v", err) + } + if !strings.Contains(out, "akash1prov") || !strings.Contains(out, "acme") { + t.Errorf("screen output should list the matched provider, got %q", out) + } + + var req struct { + Resources []struct { + Resource struct { + CPU struct { + Units struct { + Val string `json:"val"` + } `json:"units"` + } `json:"cpu"` + Memory struct { + Quantity struct { + Val string `json:"val"` + } `json:"quantity"` + } `json:"memory"` + GPU struct { + Units struct { + Val string `json:"val"` + } `json:"units"` + } `json:"gpu"` + Storage []struct { + Name string `json:"name"` + Quantity struct { + Val string `json:"val"` + } `json:"quantity"` + } `json:"storage"` + Endpoints []any `json:"endpoints"` + } `json:"resource"` + Count int `json:"count"` + Price struct { + Denom string `json:"denom"` + Amount string `json:"amount"` + } `json:"price"` + } `json:"resources"` + Timezone string `json:"timezone"` + } + if err := json.Unmarshal(body, &req); err != nil { + t.Fatalf("decode screening request: %v", err) + } + + if len(req.Resources) != 1 { + t.Fatalf("resources len = %d, want 1: %s", len(req.Resources), body) + } + + r := req.Resources[0] + if r.Resource.CPU.Units.Val != "100" { + t.Errorf("cpu units = %q, want 100 (100m)", r.Resource.CPU.Units.Val) + } + if r.Resource.Memory.Quantity.Val != "134217728" { + t.Errorf("memory quantity = %q, want 134217728 (128Mi)", r.Resource.Memory.Quantity.Val) + } + if r.Resource.GPU.Units.Val != "0" { + t.Errorf("gpu units = %q, want 0", r.Resource.GPU.Units.Val) + } + if len(r.Resource.Storage) != 1 || r.Resource.Storage[0].Quantity.Val != "1073741824" { + t.Errorf("storage = %+v, want one 1Gi volume", r.Resource.Storage) + } + if r.Resource.Endpoints == nil { + t.Error("endpoints must be an array, not null") + } + if r.Count != 1 { + t.Errorf("count = %d, want 1", r.Count) + } + if r.Price.Denom != "uakt" || r.Price.Amount != "50" { + t.Errorf("price = %+v, want uakt/50 (integer string)", r.Price) + } + + // The Console API rejects UTC-family zones; the fallback (or the real + // local zone) must always be a slash-form IANA name. + tz := req.Timezone + if tz == "" || tz == "Local" || !strings.Contains(tz, "/") || + strings.HasPrefix(tz, "UTC") || strings.HasPrefix(tz, "Etc/") { + t.Errorf("timezone = %q, want a non-UTC IANA zone name", tz) + } +} + +func TestStreamCloseErr(t *testing.T) { + tests := []struct { + name string + reason string + follow bool + wantErr bool + }{ + // A clean close carries no reason and always succeeds. + {"clean close", "", false, false}, + {"clean close following", "", true, false}, + // The regression: a one-shot fetch ends when the provider closes the + // connection, which surfaces as an EOF after every line was printed. + {"one-shot eof", "unexpected EOF", false, false}, + {"one-shot plain eof", "EOF", false, false}, + {"one-shot eof mixed case", "Unexpected Eof", false, false}, + // Following asks for an open-ended stream, so an EOF cut it short. + {"following eof", "unexpected EOF", true, true}, + // Genuine failures are reported either way. + {"one-shot abnormal", "close 1006 (abnormal closure)", false, true}, + {"following abnormal", "connection reset by peer", true, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := streamCloseErr("log", tc.reason, tc.follow) + if tc.wantErr && err == nil { + t.Fatalf("reason %q follow=%v: expected an error, got nil", tc.reason, tc.follow) + } + if !tc.wantErr && err != nil { + t.Fatalf("reason %q follow=%v: expected nil, got %v", tc.reason, tc.follow, err) + } + }) + } +} diff --git a/internal/cli/console/marketplace.go b/internal/cli/console/marketplace.go new file mode 100644 index 00000000..c813fc16 --- /dev/null +++ b/internal/cli/console/marketplace.go @@ -0,0 +1,218 @@ +package console + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + aktctx "pkg.akt.dev/akt/internal/context" +) + +// Public catalog commands. None of these require a Console API key; a key is +// still sent when one is configured. + +func providerCmds(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "provider", + Short: "Browse the Console provider catalog (no API key required)", + } + + list := &cobra.Command{ + Use: "list", + Short: "List providers", + Args: cobra.NoArgs, + Example: ` akt console provider list --limit 10`, + RunE: func(cmd *cobra.Command, _ []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, false) + if err != nil { + return err + } + + providers, err := cl.ListProviders(cmd.Context(), "", nil) + if err != nil { + return fmt.Errorf("list providers: %w", err) + } + + // The endpoint has no server-side paging; --limit trims locally. + limit, _ := cmd.Flags().GetInt("limit") + if limit > 0 && len(providers) > limit { + providers = providers[:limit] + } + + return printJSON(cmd, providers) + }, + } + list.Flags().Int("limit", 20, "Maximum providers to show (0 = all)") + + get := &cobra.Command{ + Use: "get
", + Short: "Show one provider's full catalog record", + Args: cobra.ExactArgs(1), + Example: ` akt console provider get akash1...`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, false) + if err != nil { + return err + } + + detail, err := cl.GetProvider(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("get provider %s: %w", args[0], err) + } + + // Raw carries the complete document (stats etc.), not just the + // typed summary fields. + return printRawJSON(cmd, detail.Raw) + }, + } + + regions := &cobra.Command{ + Use: "regions", + Short: "List regions providers advertise", + Args: cobra.NoArgs, + Example: ` akt console provider regions`, + RunE: func(cmd *cobra.Command, _ []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, false) + if err != nil { + return err + } + + out, err := cl.ListProviderRegions(cmd.Context()) + if err != nil { + return fmt.Errorf("list provider regions: %w", err) + } + + return printJSON(cmd, out) + }, + } + + auditors := &cobra.Command{ + Use: "auditors", + Short: "List known auditors", + Args: cobra.NoArgs, + Example: ` akt console provider auditors`, + RunE: func(cmd *cobra.Command, _ []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, false) + if err != nil { + return err + } + + out, err := cl.ListAuditors(cmd.Context()) + if err != nil { + return fmt.Errorf("list auditors: %w", err) + } + + return printJSON(cmd, out) + }, + } + + cmd.AddCommand(list, get, regions, auditors) + + return cmd +} + +func gpuCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + return &cobra.Command{ + Use: "gpu", + Short: "Network-wide GPU availability and prices (no API key required)", + Args: cobra.NoArgs, + Example: ` akt console gpu`, + RunE: func(cmd *cobra.Command, _ []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, false) + if err != nil { + return err + } + + prices, err := cl.GetGPUPrices(cmd.Context()) + if err != nil { + return fmt.Errorf("get GPU prices: %w", err) + } + + return printJSON(cmd, prices) + }, + } +} + +func templateCmds(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "template", + Short: "Browse deployment templates (no API key required)", + } + + list := &cobra.Command{ + Use: "list", + Short: "List the template catalog", + Args: cobra.NoArgs, + Example: ` akt console template list`, + RunE: func(cmd *cobra.Command, _ []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, false) + if err != nil { + return err + } + + raw, err := cl.ListTemplates(cmd.Context()) + if err != nil { + return fmt.Errorf("list templates: %w", err) + } + + return printRawJSON(cmd, raw) + }, + } + + get := &cobra.Command{ + Use: "get ", + Short: "Show one template", + Args: cobra.ExactArgs(1), + Example: ` akt console template get hello-world`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, false) + if err != nil { + return err + } + + tmpl, err := cl.GetTemplate(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("get template %s: %w", args[0], err) + } + + return printJSON(cmd, tmpl) + }, + } + + sdl := &cobra.Command{ + Use: "sdl ", + Short: "Print a template's raw SDL to stdout (for piping)", + Args: cobra.ExactArgs(1), + Example: ` # Write a template's SDL to a file, then deploy it + akt console template sdl hello-world > deploy.yaml + akt console deployment create deploy.yaml 5`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, false) + if err != nil { + return err + } + + tmpl, err := cl.GetTemplate(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("get template %s: %w", args[0], err) + } + + if tmpl.Deploy == "" { + return fmt.Errorf("template %s has no deploy SDL", args[0]) + } + + out := tmpl.Deploy + if !strings.HasSuffix(out, "\n") { + out += "\n" + } + fmt.Fprint(cmd.OutOrStdout(), out) + + return nil + }, + } + + cmd.AddCommand(list, get, sdl) + + return cmd +} diff --git a/internal/cli/console/money_test.go b/internal/cli/console/money_test.go new file mode 100644 index 00000000..0066f2b6 --- /dev/null +++ b/internal/cli/console/money_test.go @@ -0,0 +1,719 @@ +package console + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "pkg.akt.dev/akt/internal/capability" + "pkg.akt.dev/akt/internal/console" + aktctx "pkg.akt.dev/akt/internal/context" +) + +// newAuthedManager returns a manager whose "prod" context already carries a +// Console API key, for the authenticated command surface. +func newAuthedManager(t *testing.T) *aktctx.Manager { + t.Helper() + + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + return m +} + +// TestWalletBalanceScalesMicroACT is a money-display test. /v1/balances is +// reported in µACT integer units; every field must be divided by 1e6 before +// it reaches the user. A missed division on any one of the three fields +// misreports the user's balance by six orders of magnitude — and +// DeploymentsUSD (escrowed funds) is the field with no other caller. +func TestWalletBalanceScalesMicroACT(t *testing.T) { + m := newAuthedManager(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/balances" { + t.Errorf("unexpected path %s", r.URL.Path) + } + // 12.34 available, 7.50 escrowed, 19.84 total. + writeJSON(t, w, `{"data":{"balance":12340000,"deployments":7500000,"total":19840000}}`) + })) + defer srv.Close() + + out, err := execConsole(t, m, srv.URL, "wallet", "balance") + if err != nil { + t.Fatalf("wallet balance: %v", err) + } + + var got struct { + Available string `json:"available"` + InDeployments string `json:"inDeployments"` + Total string `json:"total"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("output is not the documented JSON shape (%q): %v", out, err) + } + + if got.Available != "$12.34" { + t.Errorf("available = %q, want $12.34", got.Available) + } + if got.InDeployments != "$7.50" { + t.Errorf("inDeployments = %q, want $7.50 (escrow must be scaled too)", got.InDeployments) + } + if got.Total != "$19.84" { + t.Errorf("total = %q, want $19.84", got.Total) + } +} + +// TestWalletCostFormatsUSD covers the weekly-cost path: the API returns a bare +// number and the CLI must render it as a USD string, not a raw float. +func TestWalletCostFormatsUSD(t *testing.T) { + m := newAuthedManager(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/weekly-cost" { + t.Errorf("unexpected path %s", r.URL.Path) + } + writeJSON(t, w, `{"data":{"weeklyCost":3.5}}`) + })) + defer srv.Close() + + out, err := execConsole(t, m, srv.URL, "wallet", "cost") + if err != nil { + t.Fatalf("wallet cost: %v", err) + } + + if !strings.Contains(out, `"weeklyCost": "$3.50"`) { + t.Errorf("weekly cost should render as $3.50, got %q", out) + } +} + +// TestWalletSettingsWireShape covers both branches of `wallet settings`: the +// read (GET) and the write (PUT with the data-enveloped body). Auto-reload +// tops the managed wallet up with real money, so the enable/disable flag must +// reach the API exactly as typed. +func TestWalletSettingsWireShape(t *testing.T) { + m := newAuthedManager(t) + + var gotMethod, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + + if r.URL.Path != "/v1/wallet-settings" { + t.Errorf("unexpected path %s", r.URL.Path) + } + writeJSON(t, w, `{"data":{"autoReloadEnabled":true}}`) + })) + defer srv.Close() + + // Read. + if _, err := execConsole(t, m, srv.URL, "wallet", "settings"); err != nil { + t.Fatalf("wallet settings (read): %v", err) + } + if gotMethod != http.MethodGet { + t.Errorf("read used %s, want GET", gotMethod) + } + + // Write: true. + if _, err := execConsole(t, m, srv.URL, "wallet", "settings", "true"); err != nil { + t.Fatalf("wallet settings true: %v", err) + } + if gotMethod != http.MethodPut { + t.Errorf("write used %s, want PUT", gotMethod) + } + if !strings.Contains(gotBody, `"autoReloadEnabled":true`) { + t.Errorf("PUT body = %s, want autoReloadEnabled:true", gotBody) + } + + // Write: false must send false, not omit the field. + if _, err := execConsole(t, m, srv.URL, "wallet", "settings", "false"); err != nil { + t.Fatalf("wallet settings false: %v", err) + } + if !strings.Contains(gotBody, `"autoReloadEnabled":false`) { + t.Errorf("PUT body = %s, want autoReloadEnabled:false", gotBody) + } +} + +// TestSettingsRejectNonBooleanValue covers parseBoolValue on both settings +// commands. A permissive parse ("yes", "1", "TRUE") would silently disagree +// with the strict true|false the help text documents — and on the wallet +// command that decides whether real money is auto-charged. +func TestSettingsRejectNonBooleanValue(t *testing.T) { + m := newAuthedManager(t) + + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + writeJSON(t, w, `{"data":{}}`) + })) + defer srv.Close() + + for _, bad := range []string{"yes", "1", "TRUE", "on"} { + if _, err := execConsole(t, m, srv.URL, "wallet", "settings", bad); err == nil { + t.Errorf("wallet settings %q must be rejected", bad) + } else if !strings.Contains(err.Error(), "auto-reload must be true or false") { + t.Errorf("wallet settings %q: unexpected error %v", bad, err) + } + + if _, err := execConsole(t, m, srv.URL, "deployment", "settings", "42", bad); err == nil { + t.Errorf("deployment settings %q must be rejected", bad) + } else if !strings.Contains(err.Error(), "auto-top-up must be true or false") { + t.Errorf("deployment settings %q: unexpected error %v", bad, err) + } + } + + if requests != 0 { + t.Errorf("a rejected boolean must not reach the API, got %d requests", requests) + } +} + +// TestDeploymentSettingsWireShape covers the read and write branches of +// `deployment settings`, including the PATCH body the API expects. Auto-top-up +// draws from the managed wallet, so the dseq and flag must both be right. +func TestDeploymentSettingsWireShape(t *testing.T) { + m := newAuthedManager(t) + + var gotMethod, gotPath, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + + writeJSON(t, w, `{"data":{"dseq":"12345","autoTopUpEnabled":true}}`) + })) + defer srv.Close() + + if _, err := execConsole(t, m, srv.URL, "deployment", "settings", "12345"); err != nil { + t.Fatalf("deployment settings (read): %v", err) + } + if gotMethod != http.MethodGet || gotPath != "/v2/deployment-settings/12345" { + t.Errorf("read = %s %s, want GET /v2/deployment-settings/12345", gotMethod, gotPath) + } + + if _, err := execConsole(t, m, srv.URL, "deployment", "settings", "12345", "true"); err != nil { + t.Fatalf("deployment settings (write): %v", err) + } + if gotMethod != http.MethodPatch || gotPath != "/v2/deployment-settings/12345" { + t.Errorf("write = %s %s, want PATCH /v2/deployment-settings/12345", gotMethod, gotPath) + } + if !strings.Contains(gotBody, `"autoTopUpEnabled":true`) { + t.Errorf("PATCH body = %s", gotBody) + } +} + +// TestDeploymentSettingsCreatesWhenAbsent covers the PATCH->POST fallback: a +// deployment with no settings row yet answers 404 to the PATCH, and the client +// must create the row instead of surfacing "not found" to the user. +func TestDeploymentSettingsCreatesWhenAbsent(t *testing.T) { + m := newAuthedManager(t) + + var methods []string + var postBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods = append(methods, r.Method) + + if r.Method == http.MethodPatch { + w.WriteHeader(http.StatusNotFound) + return + } + + b, _ := io.ReadAll(r.Body) + postBody = string(b) + writeJSON(t, w, `{"data":{"dseq":"12345","autoTopUpEnabled":true}}`) + })) + defer srv.Close() + + if _, err := execConsole(t, m, srv.URL, "deployment", "settings", "12345", "true"); err != nil { + t.Fatalf("settings on a deployment with no settings row: %v", err) + } + + if len(methods) != 2 || methods[0] != http.MethodPatch || methods[1] != http.MethodPost { + t.Fatalf("methods = %v, want [PATCH POST]", methods) + } + // The POST body must carry the dseq; the PATCH carried it in the path. + if !strings.Contains(postBody, `"dseq":"12345"`) || !strings.Contains(postBody, `"autoTopUpEnabled":true`) { + t.Errorf("POST body = %s, want dseq and flag", postBody) + } +} + +// TestDepositRejectsNonPositiveAmount covers the guard between the deposit +// parser (which permits zero) and the API: a zero or omitted amount must fail +// locally with a message pointing at the positional argument, rather than +// sending a no-op charge. +func TestDepositRejectsNonPositiveAmount(t *testing.T) { + m := newAuthedManager(t) + + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + writeJSON(t, w, `{"data":{}}`) + })) + defer srv.Close() + + for _, arg := range [][]string{ + {"deployment", "deposit", "12345"}, // no amount at all + {"deployment", "deposit", "12345", "0"}, // explicit zero + } { + _, err := execConsole(t, m, srv.URL, arg...) + if err == nil { + t.Errorf("%v must be rejected", arg) + continue + } + if !strings.Contains(err.Error(), "positive USD amount") { + t.Errorf("%v: unexpected error %v", arg, err) + } + } + + if requests != 0 { + t.Errorf("a rejected amount must not reach the API, got %d requests", requests) + } +} + +// TestCreateRejectsNegativeDeposit covers the negative branch of the shared +// deposit parser through the CLI. "-5" must never be sent as a charge. +func TestCreateRejectsNegativeDeposit(t *testing.T) { + m := newAuthedManager(t) + + sdlPath := filepath.Join(t.TempDir(), "deploy.yaml") + if err := os.WriteFile(sdlPath, []byte("version: \"2.0\"\n"), 0o600); err != nil { + t.Fatalf("write SDL: %v", err) + } + + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + writeJSON(t, w, `{"data":{"dseq":"1"}}`) + })) + defer srv.Close() + + if _, err := execConsole(t, m, srv.URL, "deployment", "create", sdlPath, "$-5"); err == nil { + t.Fatal("a negative deposit must be rejected") + } + if requests != 0 { + t.Errorf("a negative deposit must not reach the API, got %d requests", requests) + } +} + +// TestCreateRejectsMissingSDLBeforeCharging pins the ordering: the SDL file is +// read before the deployment request goes out, so a typo'd path cannot leave +// the user with a funded deployment carrying no workload. +func TestCreateRejectsMissingSDLBeforeCharging(t *testing.T) { + m := newAuthedManager(t) + + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + writeJSON(t, w, `{"data":{"dseq":"1"}}`) + })) + defer srv.Close() + + _, err := execConsole(t, m, srv.URL, "deployment", "create", filepath.Join(t.TempDir(), "nope.yaml"), "5") + if err == nil { + t.Fatal("a missing SDL file must fail") + } + if !strings.Contains(err.Error(), "read SDL file") { + t.Errorf("unexpected error: %v", err) + } + if requests != 0 { + t.Errorf("no deployment must be created for an unreadable SDL, got %d requests", requests) + } +} + +// TestCreateCachesManifestForLeaseCreate covers the manifest hand-off that +// makes `lease create` work without re-passing the manifest: the manifest +// returned by `deployment create` is written to the per-context cache with +// owner-only permissions (it can carry private registry credentials). +func TestCreateCachesManifestForLeaseCreate(t *testing.T) { + m := newAuthedManager(t) + + sdlPath := filepath.Join(t.TempDir(), "deploy.yaml") + if err := os.WriteFile(sdlPath, []byte("version: \"2.0\"\n"), 0o600); err != nil { + t.Fatalf("write SDL: %v", err) + } + + const manifest = `[{"name":"dcloud","services":[]}]` + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + body, _ := json.Marshal(map[string]any{ + "data": map[string]any{ + "dseq": "9911", + "manifest": manifest, + "signTx": map[string]any{"transactionHash": "ABC123"}, + }, + }) + writeJSON(t, w, string(body)) + })) + defer srv.Close() + + out, err := execConsole(t, m, srv.URL, "deployment", "create", sdlPath, "5") + if err != nil { + t.Fatalf("deployment create: %v", err) + } + + if !strings.Contains(out, `"dseq": "9911"`) || !strings.Contains(out, `"txHash": "ABC123"`) { + t.Errorf("create output should report dseq and tx hash, got %q", out) + } + if strings.Contains(out, `"note"`) { + t.Errorf("a successful cache write must not emit a note, got %q", out) + } + + cached, err := console.LoadManifest(m.Root(), "prod", "9911") + if err != nil { + t.Fatalf("manifest was not cached: %v", err) + } + if cached != manifest { + t.Errorf("cached manifest = %q, want %q", cached, manifest) + } + + path, err := console.ManifestPath(m.Root(), "prod", "9911") + if err != nil { + t.Fatalf("ManifestPath: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat cached manifest: %v", err) + } + if perm := info.Mode().Perm(); perm&0o077 != 0 { + t.Errorf("cached manifest mode = %o, want no group/other access", perm) + } +} + +// TestLeaseCreateUsesCachedManifest closes the loop: `lease create` must find +// the manifest cached by `deployment create` and send it with the lease. +func TestLeaseCreateUsesCachedManifest(t *testing.T) { + m := newAuthedManager(t) + + const manifest = `[{"name":"dcloud"}]` + if err := console.SaveManifest(m.Root(), "prod", "9911", manifest); err != nil { + t.Fatalf("SaveManifest: %v", err) + } + + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + writeJSON(t, w, `{"data":{"deployment":{"id":{"owner":"akash1x","dseq":"9911"}},"leases":[]}}`) + })) + defer srv.Close() + + if _, err := execConsole(t, m, srv.URL, "lease", "create", "9911", "akash1provider"); err != nil { + t.Fatalf("lease create: %v", err) + } + + if !strings.Contains(gotBody, `dcloud`) { + t.Errorf("cached manifest not sent with the lease: %s", gotBody) + } + if !strings.Contains(gotBody, `"provider":"akash1provider"`) { + t.Errorf("provider not sent: %s", gotBody) + } + // gseq/oseq default to 1; a zero would address a nonexistent order. + if !strings.Contains(gotBody, `"gseq":1`) || !strings.Contains(gotBody, `"oseq":1`) { + t.Errorf("gseq/oseq defaults not sent: %s", gotBody) + } +} + +// TestLeaseCreateRequiresProviderAndManifest covers the two guards that stop a +// lease from being created against the wrong provider or with no manifest +// (which would leave the lease paid-for but unserviced). +func TestLeaseCreateRequiresProviderAndManifest(t *testing.T) { + m := newAuthedManager(t) + + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + writeJSON(t, w, `{"data":{}}`) + })) + defer srv.Close() + + if _, err := execConsole(t, m, srv.URL, "lease", "create", "9911"); err == nil { + t.Error("lease create without a provider must fail") + } else if !strings.Contains(err.Error(), "provider is required") { + t.Errorf("unexpected error: %v", err) + } + + // Provider given, but nothing cached for this dseq. + if _, err := execConsole(t, m, srv.URL, "lease", "create", "4242", "akash1provider"); err == nil { + t.Error("lease create without a manifest must fail") + } else if !strings.Contains(err.Error(), "no cached manifest") { + t.Errorf("unexpected error: %v", err) + } + + if requests != 0 { + t.Errorf("neither failure may reach the API, got %d requests", requests) + } +} + +// TestBidListReportsEmptyResultPlainly covers the empty-bids branch. Providers +// bid asynchronously, so an empty list right after create is normal; printing +// "[]" would read as a failure. +func TestBidListReportsEmptyResultPlainly(t *testing.T) { + m := newAuthedManager(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("dseq") != "12345" { + t.Errorf("dseq not forwarded: %v", r.URL.Query()) + } + writeJSON(t, w, `{"data":[]}`) + })) + defer srv.Close() + + out, err := execConsole(t, m, srv.URL, "bid", "list", "12345") + if err != nil { + t.Fatalf("bid list: %v", err) + } + if !strings.Contains(out, "No bids yet") { + t.Errorf("empty bid list should be explained, got %q", out) + } +} + +// TestLogoutRemovesStoredCredential covers the credential-removal path. A +// logout that leaves the key on disk is a real leak: the file outlives the +// user's expectation that they signed out. +func TestLogoutRemovesStoredCredential(t *testing.T) { + m := newAuthedManager(t) + + path := aktctx.ConsoleAPIKeyPath(m.Root(), "prod") + if _, err := os.Stat(path); err != nil { + t.Fatalf("precondition: credential should exist: %v", err) + } + + out, err := execConsole(t, m, "", "logout") + if err != nil { + t.Fatalf("logout: %v", err) + } + if !strings.Contains(out, "prod") { + t.Errorf("logout should name the context, got %q", out) + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("credential file still present after logout (stat err = %v)", err) + } + + // Logout must be idempotent: a second run has nothing to remove. + if _, err := execConsole(t, m, "", "logout"); err != nil { + t.Errorf("second logout should be a no-op, got %v", err) + } +} + +// TestAPIKeyCreateShowsSecretOnceWithWarning covers the one place the CLI +// prints a Console secret. The secret and the "shown once" warning must both +// be present — a silent drop leaves the user with an unusable key. +func TestAPIKeyCreateShowsSecretOnceWithWarning(t *testing.T) { + m := newAuthedManager(t) + + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/api-keys" { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + writeJSON(t, w, `{"data":{"id":"key-1","name":"ci","apiKey":"sk-brand-new"}}`) + })) + defer srv.Close() + + out, err := execConsole(t, m, srv.URL, "apikey", "create", "ci", "2027-01-01T00:00:00Z") + if err != nil { + t.Fatalf("apikey create: %v", err) + } + + if !strings.Contains(gotBody, `"name":"ci"`) { + t.Errorf("name not sent: %s", gotBody) + } + if !strings.Contains(out, "sk-brand-new") { + t.Errorf("the created secret must be printed once, got %q", out) + } + if !strings.Contains(out, "not be shown again") { + t.Errorf("output should warn the secret is shown once, got %q", out) + } + + // A missing name must be caught locally. + if _, err := execConsole(t, m, srv.URL, "apikey", "create"); err == nil { + t.Error("apikey create without a name must fail") + } +} + +// TestAPIKeyDeleteIsIdempotent covers the delete no-op: the client maps 404 to +// success, so re-running a delete script must not fail. +func TestAPIKeyDeleteIsIdempotent(t *testing.T) { + m := newAuthedManager(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("unexpected method %s", r.Method) + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + out, err := execConsole(t, m, srv.URL, "apikey", "delete", "key-1") + if err != nil { + t.Fatalf("deleting an absent key must be a no-op, got %v", err) + } + if !strings.Contains(out, "key-1 deleted") { + t.Errorf("unexpected output %q", out) + } +} + +// TestJWTCreateRejectsInvalidTTLAndScope covers the two local guards on the +// JWT minting path. A non-positive TTL or an empty scope produces a token the +// provider rejects, so both must fail before the request. +func TestJWTCreateRejectsInvalidTTLAndScope(t *testing.T) { + m := newAuthedManager(t) + + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + writeJSON(t, w, `{"data":{"token":"t"}}`) + })) + defer srv.Close() + + if _, err := execConsole(t, m, srv.URL, "jwt", "create", "--ttl", "0"); err == nil { + t.Error("--ttl 0 must be rejected") + } + if _, err := execConsole(t, m, srv.URL, "jwt", "create", "--scope", " , "); err == nil { + t.Error("an all-blank --scope must be rejected") + } + if requests != 0 { + t.Errorf("invalid JWT parameters must not reach the API, got %d requests", requests) + } +} + +// TestPublicCatalogWorksWithoutAKey covers the keyless surface. The catalog +// commands pass requireKey=false, and breaking that would lock out users who +// have not logged in at all. +func TestPublicCatalogWorksWithoutAKey(t *testing.T) { + m := newTestManager(t) // no credential stored + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, present := r.Header["X-Api-Key"]; present { + t.Error("no key is configured, so none must be sent") + } + + switch r.URL.Path { + case "/v1/gpu-prices": + writeJSON(t, w, `{"availability":{"total":10,"available":4},"models":[{"vendor":"nvidia","model":"h100","price":{"min":1,"max":3,"avg":2}}]}`) + case "/v1/providers": + writeJSON(t, w, `[{"owner":"akash1p","isOnline":true},{"owner":"akash1q"}]`) + default: + t.Errorf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + out, err := execConsole(t, m, srv.URL, "gpu") + if err != nil { + t.Fatalf("gpu: %v", err) + } + if !strings.Contains(out, "h100") { + t.Errorf("gpu output missing the model, got %q", out) + } + + // --limit trims client-side (the endpoint has no server-side paging). + out, err = execConsole(t, m, srv.URL, "provider", "list", "--limit", "1") + if err != nil { + t.Fatalf("provider list: %v", err) + } + if strings.Count(out, `"owner"`) != 1 { + t.Errorf("--limit 1 should trim to one provider, got %q", out) + } +} + +// TestProviderGetPrintsRawDocument covers printRawJSON: `provider get` must +// surface fields beyond the typed summary (stats, uptime buckets), which only +// the raw document carries. +func TestProviderGetPrintsRawDocument(t *testing.T) { + m := newTestManager(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/providers/akash1p" { + t.Errorf("unexpected path %s", r.URL.Path) + } + writeJSON(t, w, `{"owner":"akash1p","unmodeledStat":{"leaseCount":7}}`) + })) + defer srv.Close() + + out, err := execConsole(t, m, srv.URL, "provider", "get", "akash1p") + if err != nil { + t.Fatalf("provider get: %v", err) + } + + if !strings.Contains(out, "unmodeledStat") || !strings.Contains(out, "leaseCount") { + t.Errorf("raw document fields must survive to the output, got %q", out) + } +} + +// TestFlagKeyOverridesStoredCredential covers the documented resolution order +// (SPEC §7.1/§7.2): --console-api-key beats the stored per-context credential. +// Operators rely on it to run one-off commands as a different identity. +func TestFlagKeyOverridesStoredCredential(t *testing.T) { + m := newAuthedManager(t) // stores "sekrit" + + var gotKey string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("x-api-key") + writeJSON(t, w, userBody) + })) + defer srv.Close() + + if _, err := execConsole(t, m, srv.URL, "whoami", "--console-api-key", "flag-key"); err != nil { + t.Fatalf("whoami: %v", err) + } + if gotKey != "flag-key" { + t.Errorf("x-api-key = %q, want the flag value", gotKey) + } +} + +// TestAuthenticatedCommandsCarryConsoleCapability pins the gating annotations. +// The capability layer uses them to dim or hide commands when no Console key +// is resolvable; a command that loses its annotation silently reappears and +// then fails deep inside an HTTP call instead of up front. +func TestAuthenticatedCommandsCarryConsoleCapability(t *testing.T) { + root := Commands(func() *aktctx.Manager { return nil }) + + gated := map[string]bool{ + "whoami": true, "deployment": true, "bid": true, "lease": true, + "wallet": true, "usage": true, "apikey": true, "jwt": true, + "logs": true, "events": true, "status": true, "shell": true, + } + keyless := map[string]bool{ + "login": true, "logout": true, "provider": true, + "gpu": true, "template": true, "screen": true, + } + + seen := map[string]bool{} + for _, sub := range root.Commands() { + name := sub.Name() + seen[name] = true + got := sub.Annotations[capability.AnnotationKey] + + switch { + case gated[name]: + if got != string(capability.Console) { + t.Errorf("%q annotation = %q, want %q", name, got, capability.Console) + } + case keyless[name]: + if got != "" { + t.Errorf("%q must stay ungated (works without a key), got %q", name, got) + } + } + } + + for name := range gated { + if !seen[name] { + t.Errorf("gated command %q is missing from the console group", name) + } + } + for name := range keyless { + if !seen[name] { + t.Errorf("keyless command %q is missing from the console group", name) + } + } +} diff --git a/internal/cli/console/wallet.go b/internal/cli/console/wallet.go new file mode 100644 index 00000000..a6ffddd1 --- /dev/null +++ b/internal/cli/console/wallet.go @@ -0,0 +1,286 @@ +package console + +import ( + "fmt" + + "github.com/spf13/cobra" + + aktctx "pkg.akt.dev/akt/internal/context" +) + +func walletCmds(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "wallet", + Short: "Managed wallet balance, settings, and cost", + } + + cmd.AddCommand( + walletListCmd(mgrFn), + walletBalanceCmd(mgrFn), + walletSettingsCmd(mgrFn), + walletCostCmd(mgrFn), + ) + + return cmd +} + +func walletListCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List managed wallets", + Args: cobra.NoArgs, + Example: ` akt console wallet list`, + RunE: func(cmd *cobra.Command, _ []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + user, err := cl.GetUser(cmd.Context()) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + wallets, err := cl.ListWallets(cmd.Context(), user.ID) + if err != nil { + return fmt.Errorf("list wallets: %w", err) + } + + type walletRow struct { + Address string `json:"address"` + Balance string `json:"balance"` + Denom string `json:"denom,omitempty"` + Trialing bool `json:"trialing"` + } + + rows := make([]walletRow, 0, len(wallets)) + for _, w := range wallets { + rows = append(rows, walletRow{ + Address: w.Address, + // creditAmount is dollar-scale per the /v1/wallets + // contract (reported alongside a denom, e.g. "usdc") — + // unlike /v1/balances, which is µACT. No 1e6 scaling. + Balance: formatUSD(w.CreditAmount), + Denom: w.Denom, + Trialing: w.IsTrialing, + }) + } + + return printJSON(cmd, rows) + }, + } +} + +func walletBalanceCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + return &cobra.Command{ + Use: "balance", + Short: "Available / in-deployment / total balance in USD", + Args: cobra.NoArgs, + Example: ` akt console wallet balance`, + RunE: func(cmd *cobra.Command, _ []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + b, err := cl.GetBalances(cmd.Context()) + if err != nil { + return fmt.Errorf("get balances: %w", err) + } + + return printJSON(cmd, struct { + Available string `json:"available"` + InDeployments string `json:"inDeployments"` + Total string `json:"total"` + }{formatUSD(b.BalanceUSD()), formatUSD(b.DeploymentsUSD()), formatUSD(b.TotalUSD())}) + }, + } +} + +func walletSettingsCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "settings [true|false]", + Short: "View or change wallet settings", + Args: cobra.MaximumNArgs(1), + Example: ` # Show current settings + akt console wallet settings + + # Enable automatic top-up + akt console wallet settings true`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + // FEEDBACK(2026-07): --auto-reload disabled for the + // positional-only UX trial; the positional [true|false] argument + // is the only source. Restore by uncommenting if users ask for + // the flag form back. + // if len(args) > 0 || cmd.Flags().Changed("auto-reload") { + // value, _ := cmd.Flags().GetString("auto-reload") + // if len(args) > 0 { + // value = args[0] + // } + if len(args) > 0 { + value := args[0] + + enabled, err := parseBoolValue(value, "auto-reload") + if err != nil { + return err + } + + settings, err := cl.UpdateWalletSettings(cmd.Context(), enabled) + if err != nil { + return fmt.Errorf("update wallet settings: %w", err) + } + + return printJSON(cmd, settings) + } + + settings, err := cl.GetWalletSettings(cmd.Context()) + if err != nil { + return fmt.Errorf("get wallet settings: %w", err) + } + + return printJSON(cmd, settings) + }, + } + + // FEEDBACK(2026-07): --auto-reload disabled for the positional-only UX + // trial (use the positional form instead). Restore by uncommenting if + // users ask for the flag form back. + // cmd.Flags().String("auto-reload", "", "Enable or disable automatic top-up (true|false)") + + return cmd +} + +func walletCostCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + return &cobra.Command{ + Use: "cost", + Short: "Estimated weekly cost in USD", + Args: cobra.NoArgs, + Example: ` akt console wallet cost`, + RunE: func(cmd *cobra.Command, _ []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + cost, err := cl.GetWeeklyCost(cmd.Context()) + if err != nil { + return fmt.Errorf("get weekly cost: %w", err) + } + + return printJSON(cmd, struct { + WeeklyCost string `json:"weeklyCost"` + }{formatUSD(cost)}) + }, + } +} + +func usageCmd(mgrFn func() *aktctx.Manager) *cobra.Command { + cmd := &cobra.Command{ + Use: "usage [from] [to]", + Short: "Historical spend and active-deployment counts", + Args: cobra.MaximumNArgs(2), + Example: ` # Last 30 days (API default) + akt console usage + + # Explicit range (positional dates) + akt console usage 2026-01-01 2026-01-31`, + RunE: func(cmd *cobra.Command, args []string) error { + cl, _, err := clientFromCmd(cmd, mgrFn, true) + if err != nil { + return err + } + + // FEEDBACK(2026-07): --from/--to disabled for the positional-only + // UX trial; the positional [from] [to] dates are the only source. + // Restore by uncommenting if users ask for the flag form back. + // from, _ := cmd.Flags().GetString("from") + // to, _ := cmd.Flags().GetString("to") + from, to := "", "" + if len(args) > 0 { + from = args[0] + } + if len(args) > 1 { + to = args[1] + } + + // Usage history is keyed by wallet address: resolve the user's + // first managed wallet with an on-chain address. + user, err := cl.GetUser(cmd.Context()) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + wallets, err := cl.ListWallets(cmd.Context(), user.ID) + if err != nil { + return fmt.Errorf("list wallets: %w", err) + } + + address := "" + for _, w := range wallets { + if w.Address != "" { + address = w.Address + break + } + } + if address == "" { + return fmt.Errorf("no managed wallet with an on-chain address was found") + } + + points, err := cl.GetUsageHistory(cmd.Context(), address, from, to) + if err != nil { + return fmt.Errorf("get usage history: %w", err) + } + + type usageRow struct { + Date string `json:"date"` + Deployments int `json:"deployments"` + Spent string `json:"spent"` + } + + // totalSpent is the spend within the requested range: the sum + // of the per-day values shown in the rows. TotalUsdcSpent is + // the API's lifetime figure ("cumulative spent up to this + // date" per the vendored contract), so the range maximum — not + // whichever element happens to come last — is the lifetime + // spend as of the range end, independent of point ordering. + totalSpent := 0.0 + lifetimeSpent := 0.0 + rows := make([]usageRow, 0, len(points)) + for _, p := range points { + totalSpent += p.DailyUsdcSpent + if p.TotalUsdcSpent > lifetimeSpent { + lifetimeSpent = p.TotalUsdcSpent + } + rows = append(rows, usageRow{p.Date, p.ActiveDeployments, formatUSD(p.DailyUsdcSpent)}) + } + + lifetime := "" + if len(points) > 0 { + lifetime = formatUSD(lifetimeSpent) + } + + return printJSON(cmd, struct { + TotalSpent string `json:"totalSpent"` + LifetimeSpent string `json:"lifetimeSpent,omitempty"` + Days int `json:"days"` + History []usageRow `json:"history"` + }{formatUSD(totalSpent), lifetime, len(rows), rows}) + }, + } + + // FEEDBACK(2026-07): --from disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // cmd.Flags().String("from", "", "Start date (YYYY-MM-DD)") + // FEEDBACK(2026-07): --to disabled for the positional-only UX trial + // (use the positional form instead). Restore by uncommenting if users + // ask for the flag form back. + // cmd.Flags().String("to", "", "End date (YYYY-MM-DD)") + + return cmd +} diff --git a/internal/cli/console/wallet_test.go b/internal/cli/console/wallet_test.go new file mode 100644 index 00000000..33f00015 --- /dev/null +++ b/internal/cli/console/wallet_test.go @@ -0,0 +1,138 @@ +package console + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + aktctx "pkg.akt.dev/akt/internal/context" +) + +// TestWalletListShowsDollarScaleCredits pins the /v1/wallets scaling: +// creditAmount is dollar-scale (reported with a denom, e.g. "usdc"), unlike +// /v1/balances which is µACT. A 25.5 credit must render as $25.50, not the +// $0.00 produced by dividing by 1e6. +func TestWalletListShowsDollarScaleCredits(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/user/me": + writeJSON(t, w, `{"data":{"id":"u1","username":"joe"}}`) + case "/v1/wallets": + // Mirrors the fixture in internal/console/wallet_test.go. + writeJSON(t, w, `{"data":[{"address":"akash1abc","creditAmount":25.5,"isTrialing":true,"denom":"usdc"}]}`) + default: + t.Errorf("unexpected request %s", r.URL.Path) + } + })) + defer srv.Close() + + out, err := execConsole(t, m, srv.URL, "wallet", "list") + if err != nil { + t.Fatalf("wallet list: %v", err) + } + + if !strings.Contains(out, `"balance": "$25.50"`) { + t.Errorf("wallet list must render creditAmount 25.5 as $25.50, got %q", out) + } + if strings.Contains(out, "$0.00") { + t.Errorf("creditAmount must not be scaled as µ-denominated, got %q", out) + } + if !strings.Contains(out, `"denom": "usdc"`) { + t.Errorf("wallet list should report the API's denom, got %q", out) + } +} + +// usageTestServer serves the three endpoints the usage command touches, with +// the history body swappable between calls. +func usageTestServer(t *testing.T, historyBody *string) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/user/me": + writeJSON(t, w, `{"data":{"id":"u1","username":"joe"}}`) + case "/v1/wallets": + writeJSON(t, w, `{"data":[{"address":"akash1wallet","creditAmount":25.5,"denom":"usdc"}]}`) + case "/v1/usage/history": + writeJSON(t, w, *historyBody) + default: + t.Errorf("unexpected request %s", r.URL.Path) + } + })) +} + +// TestUsageTotalSpentSumsRequestedRange pins the headline totalSpent to the +// sum of the per-day values within the requested range — not the cumulative +// lifetime figure carried by whichever point happens to come last — and +// proves the result is independent of the API's point ordering. The lifetime +// figure is reported separately as the range maximum of totalUsdcSpent. +func TestUsageTotalSpentSumsRequestedRange(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + const ( + ascending = `[ + {"date":"2026-07-01","activeDeployments":2,"dailyUsdcSpent":1.25,"totalUsdcSpent":10.0}, + {"date":"2026-07-02","activeDeployments":3,"dailyUsdcSpent":2.5,"totalUsdcSpent":12.5} + ]` + descending = `[ + {"date":"2026-07-02","activeDeployments":3,"dailyUsdcSpent":2.5,"totalUsdcSpent":12.5}, + {"date":"2026-07-01","activeDeployments":2,"dailyUsdcSpent":1.25,"totalUsdcSpent":10.0} + ]` + ) + + body := ascending + srv := usageTestServer(t, &body) + defer srv.Close() + + for name, points := range map[string]string{"ascending": ascending, "descending": descending} { + body = points + + out, err := execConsole(t, m, srv.URL, "usage", "2026-07-01", "2026-07-02") + if err != nil { + t.Fatalf("usage (%s points): %v", name, err) + } + + // 1.25 + 2.5 within the range, regardless of point order. + if !strings.Contains(out, `"totalSpent": "$3.75"`) { + t.Errorf("%s points: totalSpent must be the range sum $3.75, got %q", name, out) + } + // Lifetime cumulative spend as of the range end, order-independent. + if !strings.Contains(out, `"lifetimeSpent": "$12.50"`) { + t.Errorf("%s points: lifetimeSpent must be $12.50, got %q", name, out) + } + } +} + +// TestUsageEmptyHistoryOmitsLifetime pins the empty-range shape: a $0.00 +// range total and no lifetime figure (there is no point to read it from). +func TestUsageEmptyHistoryOmitsLifetime(t *testing.T) { + m := newTestManager(t) + if err := aktctx.SetConsoleAPIKey(m.Root(), "prod", "sekrit"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + body := `[]` + srv := usageTestServer(t, &body) + defer srv.Close() + + out, err := execConsole(t, m, srv.URL, "usage") + if err != nil { + t.Fatalf("usage: %v", err) + } + + if !strings.Contains(out, `"totalSpent": "$0.00"`) { + t.Errorf("empty history should total $0.00, got %q", out) + } + if strings.Contains(out, "lifetimeSpent") { + t.Errorf("empty history must omit lifetimeSpent, got %q", out) + } +} diff --git a/internal/cli/context/actionlog.go b/internal/cli/context/actionlog.go new file mode 100644 index 00000000..d3c1a6ea --- /dev/null +++ b/internal/cli/context/actionlog.go @@ -0,0 +1,37 @@ +package context + +import ( + "encoding/json" + + "pkg.akt.dev/akt/internal/actionlog" + aktctx "pkg.akt.dev/akt/internal/context" +) + +// recordContextAction appends a context-management entry (SPEC §5.6) to the +// action log of the context named target. Context commands often run without +// an open logger for the affected context (e.g. create, or switching away +// from the current one), so the target log is opened directly. Logging is +// best-effort: a failure never blocks the command itself. +func recordContextAction(root, target, action string, details map[string]string) { + if root == "" || target == "" { + return + } + + l, err := actionlog.Open(aktctx.ActionLogPath(root, target)) + if err != nil { + return + } + defer func() { _ = l.Close() }() + + var params json.RawMessage + if len(details) > 0 { + params, _ = json.Marshal(details) + } + + _ = l.Log(actionlog.Entry{ + Type: actionlog.TypeContext, + Action: action, + Status: "success", + Params: params, + }) +} diff --git a/internal/cli/context/actionlog_test.go b/internal/cli/context/actionlog_test.go new file mode 100644 index 00000000..873d4577 --- /dev/null +++ b/internal/cli/context/actionlog_test.go @@ -0,0 +1,146 @@ +package context + +import ( + "testing" + + "pkg.akt.dev/akt/internal/actionlog" + aktctx "pkg.akt.dev/akt/internal/context" +) + +func newTestManager(t *testing.T) *aktctx.Manager { + t.Helper() + + m, err := aktctx.NewManager(t.TempDir()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + if err := m.CreateNetworkFromTemplate("mainnet", "mainnet"); err != nil { + t.Fatalf("CreateNetworkFromTemplate: %v", err) + } + + // DefaultConfig already seeds the "default" keyring. + return m +} + +func readLog(t *testing.T, root, ctxName string) []actionlog.Entry { + t.Helper() + + l, err := actionlog.Open(aktctx.ActionLogPath(root, ctxName)) + if err != nil { + t.Fatalf("open action log for %s: %v", ctxName, err) + } + defer l.Close() + + entries, err := l.Read(actionlog.Filter{}) + if err != nil { + t.Fatalf("read action log for %s: %v", ctxName, err) + } + + return entries +} + +func runCmd(t *testing.T, cmd interface { + SetArgs([]string) + Execute() error +}, args ...string) { + t.Helper() + + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute %v: %v", args, err) + } +} + +func TestCreateAndSwitchRecordedInActionLog(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + runCmd(t, createCmd(mgrFn), "prod", "--network", "mainnet", "--set-current") + + entries := readLog(t, m.Root(), "prod") + if len(entries) != 2 { + t.Fatalf("expected 2 entries (create + switch), got %d: %+v", len(entries), entries) + } + + // Newest first: switch, then create. + if entries[0].Type != actionlog.TypeContext || entries[0].Action != "switch" { + t.Errorf("entry[0] = %s/%s, want context/switch", entries[0].Type, entries[0].Action) + } + if entries[1].Action != "create" { + t.Errorf("entry[1] action = %s, want create", entries[1].Action) + } +} + +func TestUseRecordedInActionLog(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + runCmd(t, createCmd(mgrFn), "prod", "--network", "mainnet") + runCmd(t, useCmd(mgrFn), "prod") + + entries := readLog(t, m.Root(), "prod") + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries[0].Action != "switch" { + t.Errorf("newest action = %s, want switch", entries[0].Action) + } +} + +func TestEditRecordedInActionLog(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + runCmd(t, createCmd(mgrFn), "prod", "--network", "mainnet") + runCmd(t, editCmd(mgrFn), "prod", "--default-account", "alice") + + entries := readLog(t, m.Root(), "prod") + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries[0].Action != "edit" { + t.Errorf("newest action = %s, want edit", entries[0].Action) + } + if string(entries[0].Params) == "" { + t.Error("edit entry should record the changed fields in params") + } +} + +func TestRenameRecordedInActionLog(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + runCmd(t, createCmd(mgrFn), "prod", "--network", "mainnet") + runCmd(t, renameCmd(mgrFn), "prod", "prod2") + + // The log moves with the renamed context directory. + entries := readLog(t, m.Root(), "prod2") + if len(entries) != 2 { + t.Fatalf("expected 2 entries (create carried over + rename), got %d", len(entries)) + } + if entries[0].Action != "rename" { + t.Errorf("newest action = %s, want rename", entries[0].Action) + } +} + +func TestDeleteRecordedInCurrentContextLog(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + runCmd(t, createCmd(mgrFn), "prod", "--network", "mainnet", "--set-current") + runCmd(t, createCmd(mgrFn), "staging", "--network", "mainnet") + runCmd(t, deleteCmd(mgrFn), "staging", "--yes") + + entries := readLog(t, m.Root(), "prod") + + var found bool + for _, e := range entries { + if e.Action == "delete" { + found = true + } + } + if !found { + t.Errorf("expected a delete entry in the current context's log, got %+v", entries) + } +} diff --git a/internal/cli/context/commands.go b/internal/cli/context/commands.go index 32f4a145..5cf7dc84 100644 --- a/internal/cli/context/commands.go +++ b/internal/cli/context/commands.go @@ -62,12 +62,24 @@ func createCmd(mgr func() *aktctx.Manager) *cobra.Command { defaultAccount, _ := cmd.Flags().GetString("default-account") gas, _ := cmd.Flags().GetString("gas") fees, _ := cmd.Flags().GetString("fees") + authMethod, _ := cmd.Flags().GetString("auth-method") + consoleAPIURL, _ := cmd.Flags().GetString("console-api-url") + consoleAPIKey, _ := cmd.Flags().GetString("console-api-key") setCurrent, _ := cmd.Flags().GetBool("set-current") + // console-api contexts may be created without a network — they + // operate through the Console API alone (chain commands are + // capability-gated until a network is attached). + if network == "" && authMethod != aktctx.AuthMethodConsoleAPI { + return fmt.Errorf("--network is required unless --auth-method %s is used", aktctx.AuthMethodConsoleAPI) + } + ctx := aktctx.Context{ Name: name, Network: aktctx.Network{Name: network}, Keyring: aktctx.Keyring{Name: keyring}, + AuthMethod: authMethod, + ConsoleAPIURL: consoleAPIURL, DefaultAccount: defaultAccount, Gas: gas, Fees: fees, @@ -77,8 +89,28 @@ func createCmd(mgr func() *aktctx.Manager) *cobra.Command { return err } + // The Console API key is a credential, stored per-context + // outside config.yaml (SPEC §7.1) and never logged. + if consoleAPIKey != "" { + if err := aktctx.SetConsoleAPIKey(m.Root(), name, consoleAPIKey); err != nil { + return err + } + } + + recordContextAction(m.Root(), name, "create", map[string]string{ + "network": network, + "keyring": keyring, + }) + if setCurrent { - return m.UseContext(name) + previous := m.CurrentContext() + if err := m.UseContext(name); err != nil { + return err + } + recordContextAction(m.Root(), name, "switch", map[string]string{ + "from": previous, + "to": name, + }) } return nil @@ -90,8 +122,12 @@ func createCmd(mgr func() *aktctx.Manager) *cobra.Command { cmd.Flags().String("default-account", "", "Default account name") cmd.Flags().String("gas", "auto", "Gas limit override") cmd.Flags().String("fees", "", "Fixed fees override") + cmd.Flags().String("auth-method", "", "Authentication method: keyring (default) or console-api") + cmd.Flags().String("console-api-url", "", "Console API base URL (empty = default; only with console-api auth)") + cmd.Flags().String("console-api-key", "", "Console API key stored as a per-context credential (never written to config.yaml)") cmd.Flags().Bool("set-current", false, "Set as current context after creation") - _ = cmd.MarkFlagRequired("network") + // --network is validated in RunE rather than MarkFlagRequired: console-api + // contexts are allowed to omit it (network-less, Console-only operation). _ = cmd.RegisterFlagCompletionFunc("network", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { m := mgr() @@ -117,16 +153,28 @@ func useCmd(mgr func() *aktctx.Manager) *cobra.Command { ValidArgsFunction: completeContextNames(mgr), Example: ` akt context use staging`, RunE: func(cmd *cobra.Command, args []string) error { - return mgr().UseContext(args[0]) + m := mgr() + previous := m.CurrentContext() + + if err := m.UseContext(args[0]); err != nil { + return err + } + + recordContextAction(m.Root(), args[0], "switch", map[string]string{ + "from": previous, + "to": args[0], + }) + + return nil }, } } func listCmd(mgr func() *aktctx.Manager) *cobra.Command { return &cobra.Command{ - Use: "list", - Short: "List all contexts", - Args: cobra.NoArgs, + Use: "list", + Short: "List all contexts", + Args: cobra.NoArgs, Example: ` akt context list`, RunE: func(cmd *cobra.Command, _ []string) error { m := mgr() @@ -188,9 +236,9 @@ func listCmd(mgr func() *aktctx.Manager) *cobra.Command { func currentCmd(mgr func() *aktctx.Manager) *cobra.Command { return &cobra.Command{ - Use: "show", - Short: "Show the active context with full details", - Args: cobra.NoArgs, + Use: "show", + Short: "Show the active context with full details", + Args: cobra.NoArgs, Example: ` akt context show`, RunE: func(cmd *cobra.Command, _ []string) error { m := mgr() @@ -212,7 +260,7 @@ func editCmd(mgr func() *aktctx.Manager) *cobra.Command { Short: "Edit a context", Args: cobra.ExactArgs(1), ValidArgsFunction: completeContextNames(mgr), - Example: ` # Change default account + Example: ` # Change default account akt context edit prod --default-account bob # Switch to a different network @@ -226,31 +274,73 @@ func editCmd(mgr func() *aktctx.Manager) *cobra.Command { return fmt.Errorf("cannot use --fork-network with --network; use akt network create to fork manually") } - return m.UpdateContext(name, func(c *aktctx.Context) error { + changed := map[string]string{} + + if err := m.UpdateContext(name, func(c *aktctx.Context) error { if cmd.Flags().Changed("network") { network, _ := cmd.Flags().GetString("network") + changed["network"] = network c.Network = aktctx.Network{Name: network} } if cmd.Flags().Changed("keyring") { keyring, _ := cmd.Flags().GetString("keyring") + changed["keyring"] = keyring c.Keyring = aktctx.Keyring{Name: keyring} } if cmd.Flags().Changed("default-account") { c.DefaultAccount, _ = cmd.Flags().GetString("default-account") + changed["default-account"] = c.DefaultAccount } if cmd.Flags().Changed("gas") { c.Gas, _ = cmd.Flags().GetString("gas") + changed["gas"] = c.Gas } if cmd.Flags().Changed("fees") { c.Fees, _ = cmd.Flags().GetString("fees") + changed["fees"] = c.Fees + } + + if cmd.Flags().Changed("auth-method") { + method, _ := cmd.Flags().GetString("auth-method") + if method != aktctx.AuthMethodKeyring && method != aktctx.AuthMethodConsoleAPI { + return fmt.Errorf("invalid auth-method %q: must be %q or %q", method, aktctx.AuthMethodKeyring, aktctx.AuthMethodConsoleAPI) + } + c.AuthMethod = method + changed["auth-method"] = method + } + + if cmd.Flags().Changed("console-api-url") { + c.ConsoleAPIURL, _ = cmd.Flags().GetString("console-api-url") + changed["console-api-url"] = c.ConsoleAPIURL } return nil - }) + }); err != nil { + return err + } + + // The credential is stored outside config.yaml (SPEC §7.1); + // only "updated"/"removed" is recorded, never the key itself. + if cmd.Flags().Changed("console-api-key") { + key, _ := cmd.Flags().GetString("console-api-key") + if err := aktctx.SetConsoleAPIKey(m.Root(), name, key); err != nil { + return err + } + + if key == "" { + changed["console-api-key"] = "removed" + } else { + changed["console-api-key"] = "updated" + } + } + + recordContextAction(m.Root(), name, "edit", changed) + + return nil }, } @@ -259,6 +349,9 @@ func editCmd(mgr func() *aktctx.Manager) *cobra.Command { cmd.Flags().String("default-account", "", "Change default account") cmd.Flags().String("gas", "", "Change gas setting") cmd.Flags().String("fees", "", "Change fees setting") + cmd.Flags().String("auth-method", "", "Change authentication method: keyring or console-api") + cmd.Flags().String("console-api-url", "", "Change Console API base URL (empty = default)") + cmd.Flags().String("console-api-key", "", "Set the per-context Console API key (empty string removes it)") cmd.Flags().Bool("fork-network", false, "Fork the context's network before editing") _ = cmd.RegisterFlagCompletionFunc("network", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { @@ -283,7 +376,7 @@ func deleteCmd(mgr func() *aktctx.Manager) *cobra.Command { Short: "Delete a context", Args: cobra.ExactArgs(1), ValidArgsFunction: completeContextNames(mgr), - Example: ` # Delete with confirmation prompt + Example: ` # Delete with confirmation prompt akt context delete staging # Skip confirmation @@ -296,7 +389,7 @@ func deleteCmd(mgr func() *aktctx.Manager) *cobra.Command { fmt.Printf("Delete context %q? This removes the state store and action log. [y/N]: ", name) var answer string - fmt.Scanln(&answer) + _, _ = fmt.Scanln(&answer) if !strings.EqualFold(answer, "y") && !strings.EqualFold(answer, "yes") { fmt.Println("Cancelled.") @@ -306,7 +399,20 @@ func deleteCmd(mgr func() *aktctx.Manager) *cobra.Command { keepData, _ := cmd.Flags().GetBool("keep-data") - return mgr().DeleteContext(name, keepData) + m := mgr() + if err := m.DeleteContext(name, keepData); err != nil { + return err + } + + // The deleted context's log is gone (unless --keep-data), so + // record the deletion in the current context's log if one exists. + if current := m.CurrentContext(); current != "" && current != name { + recordContextAction(m.Root(), current, "delete", map[string]string{ + "context": name, + }) + } + + return nil }, } @@ -324,7 +430,18 @@ func renameCmd(mgr func() *aktctx.Manager) *cobra.Command { ValidArgsFunction: completeContextNames(mgr), Example: ` akt context rename staging testnet-staging`, RunE: func(cmd *cobra.Command, args []string) error { - return mgr().RenameContext(args[0], args[1]) + m := mgr() + + if err := m.RenameContext(args[0], args[1]); err != nil { + return err + } + + recordContextAction(m.Root(), args[1], "rename", map[string]string{ + "from": args[0], + "to": args[1], + }) + + return nil }, } } @@ -355,7 +472,7 @@ func logCmd(mgr func() *aktctx.Manager) *cobra.Command { if err != nil { return err } - defer logger.Close() + defer func() { _ = logger.Close() }() limit, _ := cmd.Flags().GetInt("limit") actionType, _ := cmd.Flags().GetString("type") @@ -421,16 +538,16 @@ func logCmd(mgr func() *aktctx.Manager) *cobra.Command { } cmd.Flags().Int("limit", 50, "Number of entries to show") - cmd.Flags().String("type", "", "Filter by action type: tx, query, workflow, error") + cmd.Flags().String("type", "", "Filter by action type: tx, workflow, provider, context, console, error") cmd.Flags().String("since", "", "Show entries since duration (1h) or date (2006-01-02)") return cmd } -func truncate(s string, max int) string { - if len(s) <= max { +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { return s } - return s[:max-3] + "..." + return s[:maxLen-3] + "..." } diff --git a/internal/cli/context/commands_test.go b/internal/cli/context/commands_test.go new file mode 100644 index 00000000..2506809a --- /dev/null +++ b/internal/cli/context/commands_test.go @@ -0,0 +1,522 @@ +package context + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + "time" + + sdkkeyring "github.com/cosmos/cosmos-sdk/crypto/keyring" + "github.com/spf13/cobra" + + "pkg.akt.dev/akt/internal/actionlog" + aktctx "pkg.akt.dev/akt/internal/context" +) + +// captureStdout runs fn with os.Stdout redirected to a pipe and returns what +// was written. The context list/show/log commands print through +// output.PrintData and fmt.Print, both of which target the process stdout +// rather than cmd.OutOrStdout(). +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + + prev := os.Stdout + os.Stdout = w + + done := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + done <- buf.String() + }() + + fn() + + os.Stdout = prev + _ = w.Close() + out := <-done + _ = r.Close() + + return out +} + +// runOK executes a command and fails the test if it errors. +func runOK(t *testing.T, cmd *cobra.Command, args ...string) { + t.Helper() + + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(args) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute %v: %v", args, err) + } +} + +// runErr executes a command expecting a failure and returns the error. +func runErr(t *testing.T, cmd *cobra.Command, args ...string) error { + t.Helper() + + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(args) + + err := cmd.Execute() + if err == nil { + t.Fatalf("execute %v: expected an error", args) + } + + return err +} + +// TestCreateRequiresNetworkUnlessConsole covers the one place `context create` +// validates in RunE rather than with MarkFlagRequired: a keyring context with +// no network is unusable, but a console-api context legitimately has none. +func TestCreateRequiresNetworkUnlessConsole(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + err := runErr(t, createCmd(mgrFn), "broken") + if !strings.Contains(err.Error(), "--network is required") { + t.Errorf("unexpected error: %v", err) + } + if m.GetContext("broken") != nil { + t.Error("a rejected create must not leave a context behind") + } + + // console-api contexts may omit the network entirely. + runOK(t, createCmd(mgrFn), "managed", "--auth-method", aktctx.AuthMethodConsoleAPI) + + c := m.GetContext("managed") + if c == nil { + t.Fatal("network-less console-api context was not created") + } + if c.AuthMethod != aktctx.AuthMethodConsoleAPI { + t.Errorf("auth method = %q, want console-api", c.AuthMethod) + } +} + +// TestCreateStoresConsoleKeyOutsideConfig pins SPEC §7.1: the key passed to +// `context create --console-api-key` lands in the per-context credential file +// with 0600, and never in config.yaml or the action log. +func TestCreateStoresConsoleKeyOutsideConfig(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + runOK(t, createCmd(mgrFn), + "prod", "--network", "mainnet", + "--auth-method", aktctx.AuthMethodConsoleAPI, + "--console-api-key", "sk-super-secret", + ) + + stored, err := aktctx.StoredConsoleAPIKey(m.Root(), "prod") + if err != nil { + t.Fatalf("StoredConsoleAPIKey: %v", err) + } + if stored != "sk-super-secret" { + t.Errorf("stored key = %q", stored) + } + + info, err := os.Stat(aktctx.ConsoleAPIKeyPath(m.Root(), "prod")) + if err != nil { + t.Fatalf("stat credential: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("credential mode = %o, want 600", perm) + } + + cfg, err := os.ReadFile(aktctx.ConfigPath(m.Root())) + if err != nil { + t.Fatalf("read config: %v", err) + } + if strings.Contains(string(cfg), "sk-super-secret") { + t.Error("the Console API key must never be written to config.yaml") + } + + for _, e := range readLog(t, m.Root(), "prod") { + if strings.Contains(string(e.Params), "sk-super-secret") { + t.Errorf("the Console API key must never reach the action log: %+v", e) + } + } +} + +// TestEditRecordsCredentialChangeWithoutTheKey covers the credential branch of +// `context edit`: setting and clearing the key must be recorded as +// updated/removed, never as the key itself. +func TestEditRecordsCredentialChangeWithoutTheKey(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + runOK(t, createCmd(mgrFn), "prod", "--network", "mainnet") + runOK(t, editCmd(mgrFn), "prod", "--console-api-key", "sk-rotated") + + if got, _ := aktctx.StoredConsoleAPIKey(m.Root(), "prod"); got != "sk-rotated" { + t.Errorf("stored key = %q, want sk-rotated", got) + } + + entries := readLog(t, m.Root(), "prod") + params := string(entries[0].Params) + if !strings.Contains(params, `"console-api-key":"updated"`) { + t.Errorf("edit params = %s, want console-api-key:updated", params) + } + if strings.Contains(params, "sk-rotated") { + t.Error("the key value must never be logged") + } + + // An empty value removes the credential. + runOK(t, editCmd(mgrFn), "prod", "--console-api-key", "") + + if got, _ := aktctx.StoredConsoleAPIKey(m.Root(), "prod"); got != "" { + t.Errorf("key after removal = %q, want empty", got) + } + if params := string(readLog(t, m.Root(), "prod")[0].Params); !strings.Contains(params, `"console-api-key":"removed"`) { + t.Errorf("removal params = %s, want console-api-key:removed", params) + } +} + +// TestEditRejectsInvalidAuthMethod covers the auth-method validation inside +// the UpdateContext callback. An unknown method would leave the context +// choosing neither rail at resolve time. +func TestEditRejectsInvalidAuthMethod(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + runOK(t, createCmd(mgrFn), "prod", "--network", "mainnet") + + err := runErr(t, editCmd(mgrFn), "prod", "--auth-method", "carrier-pigeon") + if !strings.Contains(err.Error(), "invalid auth-method") { + t.Errorf("unexpected error: %v", err) + } + + if got := m.GetContext("prod").AuthMethod; got == "carrier-pigeon" { + t.Error("a rejected auth-method must not be persisted") + } +} + +// TestEditRejectsForkNetworkWithNetwork covers the mutually-exclusive flag +// guard: forking and switching networks in one command is ambiguous, and +// silently picking one would move the context to the wrong network. +func TestEditRejectsForkNetworkWithNetwork(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + runOK(t, createCmd(mgrFn), "prod", "--network", "mainnet") + + err := runErr(t, editCmd(mgrFn), "prod", "--fork-network", "--network", "mainnet") + if !strings.Contains(err.Error(), "cannot use --fork-network with --network") { + t.Errorf("unexpected error: %v", err) + } +} + +// TestEditOnlyTouchesChangedFields pins the Changed()-gated update logic: an +// edit that names one flag must not reset the others to their zero defaults. +// Without the guard, `--default-account bob` would wipe the gas setting. +func TestEditOnlyTouchesChangedFields(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + runOK(t, createCmd(mgrFn), "prod", + "--network", "mainnet", "--gas", "300000", "--fees", "5000uakt", + "--default-account", "alice") + + runOK(t, editCmd(mgrFn), "prod", "--default-account", "bob") + + c := m.GetContext("prod") + if c.DefaultAccount != "bob" { + t.Errorf("default account = %q, want bob", c.DefaultAccount) + } + if c.Gas != "300000" { + t.Errorf("gas = %q, want the untouched 300000", c.Gas) + } + if c.Fees != "5000uakt" { + t.Errorf("fees = %q, want the untouched 5000uakt", c.Fees) + } + if c.Network.Name != "mainnet" { + t.Errorf("network = %q, want the untouched mainnet", c.Network.Name) + } +} + +// TestListMarksCurrentContext covers listCmd, including the current-context +// marker and the resolved chain-id column (which comes from the network, not +// the context). +func TestListMarksCurrentContext(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + runOK(t, createCmd(mgrFn), "prod", "--network", "mainnet", "--default-account", "alice", "--set-current") + runOK(t, createCmd(mgrFn), "staging", "--network", "mainnet") + + out := captureStdout(t, func() { runOK(t, listCmd(mgrFn)) }) + + var prodLine, stagingLine string + for _, line := range strings.Split(out, "\n") { + switch { + case strings.Contains(line, "prod"): + prodLine = line + case strings.Contains(line, "staging"): + stagingLine = line + } + } + + if !strings.HasPrefix(strings.TrimLeft(prodLine, " "), "*") { + t.Errorf("current context must be marked with *, got %q", prodLine) + } + if strings.Contains(stagingLine, "*") { + t.Errorf("non-current context must not be marked, got %q", stagingLine) + } + if !strings.Contains(prodLine, "alice") { + t.Errorf("default account column missing: %q", prodLine) + } + if !strings.Contains(out, "CHAIN-ID") { + t.Errorf("chain-id column header missing from %q", out) + } +} + +// TestListWithNoContextsExplainsHowToCreateOne covers the empty branch: an +// empty table would leave a first-run user with no next step. +func TestListWithNoContextsExplainsHowToCreateOne(t *testing.T) { + m := newTestManager(t) + + out := captureStdout(t, func() { runOK(t, listCmd(func() *aktctx.Manager { return m })) }) + + if !strings.Contains(out, "akt context create") { + t.Errorf("empty list should point at the create command, got %q", out) + } +} + +// TestShowResolvesTheActiveContext covers currentCmd's success and failure +// branches. With no current context the command must error rather than print +// an empty record. +func TestShowResolvesTheActiveContext(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + if err := currentCmd(mgrFn).Execute(); err == nil { + t.Error("show without a current context must fail") + } + + runOK(t, createCmd(mgrFn), "prod", "--network", "mainnet", "--set-current") + + out := captureStdout(t, func() { runOK(t, currentCmd(mgrFn)) }) + if !strings.Contains(out, "prod") || !strings.Contains(out, "mainnet") { + t.Errorf("show output should name the context and its network, got %q", out) + } +} + +// TestLogRequiresACurrentContext covers logCmd's precondition. The log is +// per-context, so with no current context there is nothing to read. +func TestLogRequiresACurrentContext(t *testing.T) { + m := newTestManager(t) + + if err := logCmd(func() *aktctx.Manager { return m }).Execute(); err == nil { + t.Fatal("log without a current context must fail") + } +} + +// TestLogRendersEntriesAndFilters covers the reading path: type filtering, the +// limit, and the empty-result message. +func TestLogRendersEntriesAndFilters(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + runOK(t, createCmd(mgrFn), "prod", "--network", "mainnet", "--set-current") + + // Seed a tx entry alongside the context entries written by create. + l, err := actionlog.Open(aktctx.ActionLogPath(m.Root(), "prod")) + if err != nil { + t.Fatalf("open action log: %v", err) + } + if err := l.Log(actionlog.Entry{Type: actionlog.TypeTx, Action: "create-deployment", Status: "success"}); err != nil { + t.Fatalf("log: %v", err) + } + _ = l.Close() + + out := captureStdout(t, func() { runOK(t, logCmd(mgrFn)) }) + if !strings.Contains(out, "create-deployment") || !strings.Contains(out, "create") { + t.Errorf("log should list both entry kinds, got %q", out) + } + + // --type narrows to one kind. + out = captureStdout(t, func() { runOK(t, logCmd(mgrFn), "--type", "tx") }) + if !strings.Contains(out, "create-deployment") { + t.Errorf("--type tx should keep the tx entry, got %q", out) + } + if strings.Contains(out, "switch") { + t.Errorf("--type tx must drop context entries, got %q", out) + } + + // A type with no entries prints the empty message, not a bare header. + out = captureStdout(t, func() { runOK(t, logCmd(mgrFn), "--type", "workflow") }) + if !strings.Contains(out, "No action log entries") { + t.Errorf("an empty filter result should say so, got %q", out) + } +} + +// TestLogSinceAcceptsDurationsAndDates covers all three --since forms plus the +// rejection path. A silently-ignored bad value would show the user the wrong +// time window. +func TestLogSinceAcceptsDurationsAndDates(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + runOK(t, createCmd(mgrFn), "prod", "--network", "mainnet", "--set-current") + + for _, since := range []string{ + "1h", + time.Now().UTC().Add(-time.Hour).Format(time.RFC3339), + "2000-01-01", + } { + out := captureStdout(t, func() { runOK(t, logCmd(mgrFn), "--since", since) }) + if !strings.Contains(out, "create") { + t.Errorf("--since %q should keep recent entries, got %q", since, out) + } + } + + // A future cutoff filters everything out. + future := time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339) + out := captureStdout(t, func() { runOK(t, logCmd(mgrFn), "--since", future) }) + if !strings.Contains(out, "No action log entries") { + t.Errorf("a future --since should filter everything, got %q", out) + } + + err := runErr(t, logCmd(mgrFn), "--since", "last tuesday") + if !strings.Contains(err.Error(), "invalid --since") { + t.Errorf("unexpected error: %v", err) + } +} + +// TestTruncate covers the log renderer's error-column shortener, including the +// boundary where truncation kicks in. An off-by-one here slices past the end +// of short strings. +func TestTruncate(t *testing.T) { + cases := []struct { + in string + max int + want string + }{ + {"short", 10, "short"}, + {"exactly-10", 10, "exactly-10"}, + {"eleven-char", 10, "eleven-..."}, + {"", 5, ""}, + } + + for _, c := range cases { + if got := truncate(c.in, c.max); got != c.want { + t.Errorf("truncate(%q, %d) = %q, want %q", c.in, c.max, got, c.want) + } + if len(truncate(c.in, c.max)) > max(len(c.in), c.max) { + t.Errorf("truncate(%q, %d) grew the string", c.in, c.max) + } + } +} + +// TestDeleteCancelledByPrompt covers the confirmation branch: a `no` answer +// must leave the context (and its store and action log) intact. +func TestDeleteCancelledByPrompt(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + runOK(t, createCmd(mgrFn), "prod", "--network", "mainnet") + + withStdin(t, "n\n", func() { + out := captureStdout(t, func() { runOK(t, deleteCmd(mgrFn), "prod") }) + if !strings.Contains(out, "Cancelled") { + t.Errorf("a declined delete should say so, got %q", out) + } + }) + + if m.GetContext("prod") == nil { + t.Error("a declined delete must not remove the context") + } + + // A "y" answer goes through. + withStdin(t, "y\n", func() { + captureStdout(t, func() { runOK(t, deleteCmd(mgrFn), "prod") }) + }) + + if m.GetContext("prod") != nil { + t.Error("a confirmed delete must remove the context") + } +} + +// withStdin replaces os.Stdin with a pipe carrying input for the duration of +// fn (deleteCmd reads the confirmation with fmt.Scanln). +func withStdin(t *testing.T, input string, fn func()) { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + + if _, err := w.WriteString(input); err != nil { + t.Fatalf("write stdin: %v", err) + } + _ = w.Close() + + prev := os.Stdin + os.Stdin = r + defer func() { + os.Stdin = prev + _ = r.Close() + }() + + fn() +} + +// TestCompleteContextNames covers the shell-completion helper used by use, +// edit, delete, and rename — including the nil-manager guard that keeps +// completion from panicking before a config exists. +func TestCompleteContextNames(t *testing.T) { + m := newTestManager(t) + mgrFn := func() *aktctx.Manager { return m } + + runOK(t, createCmd(mgrFn), "prod", "--network", "mainnet") + runOK(t, createCmd(mgrFn), "staging", "--network", "mainnet") + + names, directive := completeContextNames(mgrFn)(nil, nil, "") + if len(names) != 2 { + t.Errorf("completions = %v, want both contexts", names) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("directive = %v, want NoFileComp", directive) + } + + names, directive = completeContextNames(func() *aktctx.Manager { return nil })(nil, nil, "") + if names != nil { + t.Errorf("a nil manager must yield no completions, got %v", names) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("directive = %v, want NoFileComp", directive) + } +} + +// TestCommandsTreeIsComplete guards the command surface: `akt context` is the +// documented entry point for contexts, networks, and keys, and a dropped +// subcommand is invisible until a user reaches for it. +func TestCommandsTreeIsComplete(t *testing.T) { + m := newTestManager(t) + + root := Commands( + func() *aktctx.Manager { return m }, + func() (sdkkeyring.Keyring, error) { return nil, nil }, + ) + + have := map[string]bool{} + for _, sub := range root.Commands() { + have[sub.Name()] = true + } + + for _, want := range []string{"create", "use", "list", "show", "edit", "delete", "rename", "log", "network", "keys"} { + if !have[want] { + t.Errorf("subcommand %q missing from the context group", want) + } + } +} diff --git a/internal/cli/context/completion.go b/internal/cli/context/completion.go index b80ff239..03d0d0c4 100644 --- a/internal/cli/context/completion.go +++ b/internal/cli/context/completion.go @@ -25,5 +25,3 @@ func completeContextNames(mgr func() *aktctx.Manager) func(*cobra.Command, []str return names, cobra.ShellCompDirectiveNoFileComp } } - - diff --git a/internal/cli/errors.go b/internal/cli/errors.go index b5fd7dd1..e89192f8 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -19,10 +19,10 @@ const ( type CLIError = cliutil.CLIError var ( - ExitCode = cliutil.ExitCode - ErrUsage = cliutil.ErrUsage - ErrConfig = cliutil.ErrConfig - ErrConnection = cliutil.ErrConnection + ExitCode = cliutil.ExitCode + ErrUsage = cliutil.ErrUsage + ErrConfig = cliutil.ErrConfig + ErrConnection = cliutil.ErrConnection ErrTransaction = cliutil.ErrTransaction - ErrAuth = cliutil.ErrAuth + ErrAuth = cliutil.ErrAuth ) diff --git a/internal/cli/gating.go b/internal/cli/gating.go new file mode 100644 index 00000000..9c065817 --- /dev/null +++ b/internal/cli/gating.go @@ -0,0 +1,110 @@ +package cli + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "pkg.akt.dev/akt/internal/capability" + aktctx "pkg.akt.dev/akt/internal/context" +) + +// applyCapabilityGating walks the command tree and adjusts the presentation +// of commands whose capability requirement (capability.AnnotationKey +// annotation) the active context cannot satisfy: +// +// - ModeDim: the command stays listed but its short help is prefixed with +// "[unavailable]" so users see it exists and why it is off. +// - ModeHide: the command is removed from help listings. +// +// Execution is enforced separately by requirementError so both modes (and +// direct invocation of hidden commands) fail fast with the explanation. +// Mutations are idempotent per process: cobra commands are rebuilt each run. +func applyCapabilityGating(root *cobra.Command, set capability.Set, mode capability.Mode) { + if mode == capability.ModeOff { + return + } + + var walk func(cmd *cobra.Command) + walk = func(cmd *cobra.Command) { + if req := cmd.Annotations[capability.AnnotationKey]; req != "" && !set.Satisfies(req) { + switch mode { + case capability.ModeHide: + cmd.Hidden = true + default: // ModeDim + if !strings.HasPrefix(cmd.Short, "[unavailable] ") { + cmd.Short = "[unavailable] " + cmd.Short + } + } + } + + for _, sub := range cmd.Commands() { + walk(sub) + } + } + + walk(root) +} + +// invocationCapabilities augments a context-derived feature set with the +// capabilities this invocation supplies explicitly. Gating describes what the +// *configuration* can do, so a per-invocation override — an explicit RPC +// endpoint or a Console key passed on the command line — must never be +// rejected by it (SPEC §2.10). +// +// argv is the raw command line because several clean-copied SDK groups +// disable flag parsing; posArgs are the command's parsed positional +// arguments, used for commands that take an endpoint positionally. +func invocationCapabilities(set capability.Set, cmd *cobra.Command, argv, posArgs []string) capability.Set { + grantChain := func() { + set.ChainQuery = true + set.ChainTx = true + set.Provider = true + } + + for _, a := range argv { + if a == "--" { + break + } + + switch { + case a == "--node" || strings.HasPrefix(a, "--node="): + grantChain() + case a == "--console-api-key" || strings.HasPrefix(a, "--console-api-key="): + set.Console = true + } + } + + // The environment carries the same Console credential the client + // resolves at point of use. + if os.Getenv(aktctx.EnvConsoleAPIKey) != "" { + set.Console = true + } + + // `akt monitor [rpc-endpoint]` connects to an endpoint given + // positionally and works with no context at all. + if cmd != nil && strings.HasPrefix(cmd.CommandPath(), "akt monitor") && len(posArgs) > 0 { + grantChain() + } + + return set +} + +// requirementError returns the fail-fast error for cmd when it (or an +// ancestor) carries a capability requirement the set cannot satisfy, or nil +// when the command is runnable with the current configuration. +func requirementError(cmd *cobra.Command, set capability.Set) error { + for c := cmd; c != nil; c = c.Parent() { + req := c.Annotations[capability.AnnotationKey] + if req == "" || set.Satisfies(req) { + continue + } + + return fmt.Errorf("%s is unavailable with the current context configuration: %s", + c.CommandPath(), set.Explain(req)) + } + + return nil +} diff --git a/internal/cli/gating_test.go b/internal/cli/gating_test.go new file mode 100644 index 00000000..166ab5e7 --- /dev/null +++ b/internal/cli/gating_test.go @@ -0,0 +1,188 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + + "pkg.akt.dev/akt/internal/capability" +) + +func gatedTree() (*cobra.Command, *cobra.Command, *cobra.Command) { + root := &cobra.Command{Use: "akt"} + + tx := &cobra.Command{ + Use: "tx", + Short: "Transactions subcommands", + Annotations: map[string]string{capability.AnnotationKey: string(capability.ChainTx)}, + } + txLeaf := &cobra.Command{Use: "send", Short: "send", RunE: func(*cobra.Command, []string) error { return nil }} + tx.AddCommand(txLeaf) + + console := &cobra.Command{ + Use: "console", + Short: "Console commands", + Annotations: map[string]string{capability.AnnotationKey: string(capability.Console)}, + } + + root.AddCommand(tx, console) + + return root, tx, console +} + +func TestGatingDimMode(t *testing.T) { + root, tx, console := gatedTree() + consoleOnly := capability.Set{Console: true} + + applyCapabilityGating(root, consoleOnly, capability.ModeDim) + + if !strings.HasPrefix(tx.Short, "[unavailable] ") { + t.Errorf("tx should be dimmed, got Short=%q", tx.Short) + } + if tx.Hidden { + t.Error("dim mode must not hide commands") + } + if strings.HasPrefix(console.Short, "[unavailable]") { + t.Errorf("console is available and must not be dimmed, got %q", console.Short) + } + + // Idempotent: applying twice must not double the prefix. + applyCapabilityGating(root, consoleOnly, capability.ModeDim) + if strings.Count(tx.Short, "[unavailable]") != 1 { + t.Errorf("dim prefix applied twice: %q", tx.Short) + } +} + +func TestGatingHideMode(t *testing.T) { + root, tx, console := gatedTree() + + applyCapabilityGating(root, capability.Set{Console: true}, capability.ModeHide) + + if !tx.Hidden { + t.Error("tx should be hidden in hide mode") + } + if console.Hidden { + t.Error("console is available and must not be hidden") + } +} + +func TestGatingOffMode(t *testing.T) { + root, tx, _ := gatedTree() + + applyCapabilityGating(root, capability.Set{}, capability.ModeOff) + + if tx.Hidden || strings.HasPrefix(tx.Short, "[unavailable]") { + t.Error("off mode must not touch the tree") + } +} + +func TestRequirementErrorWalksAncestors(t *testing.T) { + _, tx, _ := gatedTree() + leaf := tx.Commands()[0] + + err := requirementError(leaf, capability.Set{Console: true}) + if err == nil { + t.Fatal("leaf under an unsatisfied group must be gated") + } + for _, want := range []string{"akt tx", "unavailable", "RPC endpoint"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q missing %q", err.Error(), want) + } + } + + if err := requirementError(leaf, capability.Set{ChainQuery: true, ChainTx: true}); err != nil { + t.Errorf("satisfied requirement must not error: %v", err) + } +} + +func TestHelpRequestedIgnoresPositionalsAndTerminator(t *testing.T) { + cases := []struct { + name string + args []string + want bool + }{ + {"help flag", []string{"tx", "bank", "--help"}, true}, + {"short help flag", []string{"tx", "-h"}, true}, + {"help with value form", []string{"tx", "--help=true"}, true}, + // A bare "help" as a positional VALUE must not disable enforcement: + // `akt tx deployment close help` used to skip the no-context guard + // and go straight to broadcast. + {"help as positional value", []string{"tx", "deployment", "close", "help"}, false}, + {"flag after terminator", []string{"provider", "lease-shell", "--", "sh", "-h"}, false}, + {"none", []string{"query", "deployment"}, false}, + } + + for _, c := range cases { + if got := helpRequested(c.args); got != c.want { + t.Errorf("%s: helpRequested(%v) = %v, want %v", c.name, c.args, got, c.want) + } + } +} + +func TestIsHelpInvocationMatchesHelpCommand(t *testing.T) { + // cobra resolves `akt help foo` to the help command itself. + helpCmd := &cobra.Command{Use: "help"} + if !isHelpInvocation(helpCmd, []string{"help", "tx"}) { + t.Error("the help command must count as a help invocation") + } + + other := &cobra.Command{Use: "close"} + if isHelpInvocation(other, []string{"tx", "deployment", "close", "help"}) { + t.Error("a positional 'help' value must not count as a help invocation") + } +} + +func TestInvocationCapabilitiesGrantsExplicitOverrides(t *testing.T) { + empty := capability.Set{} + + if got := invocationCapabilities(empty, nil, []string{"query", "deployment", "--node", "https://rpc"}, nil); !got.ChainQuery { + t.Error("--node must grant chain capabilities") + } + if got := invocationCapabilities(empty, nil, []string{"query", "--node=https://rpc"}, nil); !got.ChainTx { + t.Error("--node=value form must grant chain capabilities") + } + if got := invocationCapabilities(empty, nil, []string{"console", "whoami", "--console-api-key", "sk"}, nil); !got.Console { + t.Error("--console-api-key must grant the console capability") + } + + // Overrides after the terminator are command data, not akt flags. + if got := invocationCapabilities(empty, nil, []string{"provider", "lease-shell", "--", "sh", "--node"}, nil); got.ChainQuery { + t.Error("tokens after -- must not grant capabilities") + } + + monitor := &cobra.Command{Use: "monitor"} + root := &cobra.Command{Use: "akt"} + root.AddCommand(monitor) + if got := invocationCapabilities(empty, monitor, []string{"monitor", "https://rpc"}, []string{"https://rpc"}); !got.ChainQuery { + t.Error("a positional monitor endpoint must grant chain capabilities") + } + if got := invocationCapabilities(empty, monitor, []string{"monitor"}, nil); got.ChainQuery { + t.Error("monitor without an endpoint must not grant capabilities") + } +} + +func TestInvocationCapabilitiesGrantsFromEnv(t *testing.T) { + t.Setenv("AKT_CONSOLE_API_KEY", "sk-env") + + if got := invocationCapabilities(capability.Set{}, nil, []string{"console", "whoami"}, nil); !got.Console { + t.Error("AKT_CONSOLE_API_KEY must grant the console capability") + } +} + +func TestRequiresContextExemptsOfflineGroups(t *testing.T) { + root := &cobra.Command{Use: "akt"} + for _, group := range []string{"sdl", "console", "context", "version", "completion", "monitor"} { + g := &cobra.Command{Use: group} + root.AddCommand(g) + if requiresContext(g) { + t.Errorf("%s must not require a configured context", group) + } + } + + tx := &cobra.Command{Use: "tx"} + root.AddCommand(tx) + if !requiresContext(tx) { + t.Error("tx must still require a context") + } +} diff --git a/internal/cli/keys/commands.go b/internal/cli/keys/commands.go index 03bdd6c2..bd205f85 100644 --- a/internal/cli/keys/commands.go +++ b/internal/cli/keys/commands.go @@ -109,7 +109,7 @@ func addCmd(getKeyring func() (sdkkeyring.Keyring, error)) *cobra.Command { return nil } - recover_, _ := cmd.Flags().GetBool("recover") + recoverKey, _ := cmd.Flags().GetBool("recover") source, _ := cmd.Flags().GetString("source") var mnemonic string @@ -121,7 +121,7 @@ func addCmd(getKeyring func() (sdkkeyring.Keyring, error)) *cobra.Command { } mnemonic = strings.TrimSpace(string(data)) - } else if recover_ { + } else if recoverKey { fmt.Print("Enter your mnemonic: ") reader := bufio.NewReader(os.Stdin) @@ -164,7 +164,7 @@ func addCmd(getKeyring func() (sdkkeyring.Keyring, error)) *cobra.Command { fmt.Printf(" address: %s\n", addr.String()) fmt.Printf(" type: %s\n", keyType) - if !noBackup && !recover_ && source == "" { + if !noBackup && !recoverKey && source == "" { fmt.Println("") fmt.Println("**Important** write this mnemonic phrase in a safe place.") fmt.Println("It is the only way to recover your account if you ever forget your password.") @@ -234,9 +234,9 @@ func addMultisig(kr sdkkeyring.Keyring, name, keyNames string, threshold int) er func deleteCmd(getKeyring func() (sdkkeyring.Keyring, error)) *cobra.Command { cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a key from the keyring", - Args: cobra.ExactArgs(1), + Use: "delete ", + Short: "Delete a key from the keyring", + Args: cobra.ExactArgs(1), Example: ` akt context keys delete alice`, RunE: func(cmd *cobra.Command, args []string) error { kr, err := getKeyring() @@ -255,7 +255,7 @@ func deleteCmd(getKeyring func() (sdkkeyring.Keyring, error)) *cobra.Command { fmt.Printf("Delete key %q? [y/N]: ", name) var answer string - fmt.Scanln(&answer) + _, _ = fmt.Scanln(&answer) if !strings.EqualFold(answer, "y") && !strings.EqualFold(answer, "yes") { fmt.Println("Cancelled.") @@ -274,9 +274,9 @@ func deleteCmd(getKeyring func() (sdkkeyring.Keyring, error)) *cobra.Command { func listCmd(getKeyring func() (sdkkeyring.Keyring, error)) *cobra.Command { return &cobra.Command{ - Use: "list", - Short: "List all keys in the current keyring", - Args: cobra.NoArgs, + Use: "list", + Short: "List all keys in the current keyring", + Args: cobra.NoArgs, Example: ` akt context keys list`, RunE: func(cmd *cobra.Command, _ []string) error { kr, err := getKeyring() @@ -394,9 +394,9 @@ func showCmd(getKeyring func() (sdkkeyring.Keyring, error)) *cobra.Command { func exportCmd(getKeyring func() (sdkkeyring.Keyring, error)) *cobra.Command { return &cobra.Command{ - Use: "export ", - Short: "Export a private key (encrypted armor)", - Args: cobra.ExactArgs(1), + Use: "export ", + Short: "Export a private key (encrypted armor)", + Args: cobra.ExactArgs(1), Example: ` akt context keys export alice`, RunE: func(cmd *cobra.Command, args []string) error { kr, err := getKeyring() @@ -430,9 +430,9 @@ func exportCmd(getKeyring func() (sdkkeyring.Keyring, error)) *cobra.Command { func importCmd(getKeyring func() (sdkkeyring.Keyring, error)) *cobra.Command { return &cobra.Command{ - Use: "import ", - Short: "Import a private key from encrypted armor file", - Args: cobra.ExactArgs(2), + Use: "import ", + Short: "Import a private key from encrypted armor file", + Args: cobra.ExactArgs(2), Example: ` akt context keys import alice ./alice-key.armor`, RunE: func(cmd *cobra.Command, args []string) error { kr, err := getKeyring() @@ -467,9 +467,9 @@ func importCmd(getKeyring func() (sdkkeyring.Keyring, error)) *cobra.Command { func renameCmd(getKeyring func() (sdkkeyring.Keyring, error)) *cobra.Command { return &cobra.Command{ - Use: "rename ", - Short: "Rename a key", - Args: cobra.ExactArgs(2), + Use: "rename ", + Short: "Rename a key", + Args: cobra.ExactArgs(2), Example: ` akt context keys rename alice alice-main`, RunE: func(cmd *cobra.Command, args []string) error { kr, err := getKeyring() @@ -490,9 +490,9 @@ func renameCmd(getKeyring func() (sdkkeyring.Keyring, error)) *cobra.Command { func mnemonicCmd() *cobra.Command { return &cobra.Command{ - Use: "mnemonic", - Short: "Generate a new BIP39 mnemonic", - Args: cobra.NoArgs, + Use: "mnemonic", + Short: "Generate a new BIP39 mnemonic", + Args: cobra.NoArgs, Example: ` akt context keys mnemonic`, RunE: func(cmd *cobra.Command, _ []string) error { mnemonic, err := generateMnemonic() diff --git a/internal/cli/network/commands.go b/internal/cli/network/commands.go index f81c2198..ee3decc9 100644 --- a/internal/cli/network/commands.go +++ b/internal/cli/network/commands.go @@ -94,7 +94,7 @@ func editCmd(mgr func() *aktctx.Manager) *cobra.Command { Long: "Changes apply to ALL contexts using this network.", Args: cobra.ExactArgs(1), ValidArgsFunction: completeNetworkNames(mgr), - Example: ` # Add a backup RPC endpoint + Example: ` # Add a backup RPC endpoint akt context network edit mainnet --rpc https://rpc.akashnet.net:443,https://rpc-backup.example.com:443 # Change gas prices @@ -158,9 +158,9 @@ func deleteCmd(mgr func() *aktctx.Manager) *cobra.Command { func listCmd(mgr func() *aktctx.Manager) *cobra.Command { return &cobra.Command{ - Use: "list", - Short: "List all networks", - Args: cobra.NoArgs, + Use: "list", + Short: "List all networks", + Args: cobra.NoArgs, Example: ` akt context network list`, RunE: func(cmd *cobra.Command, _ []string) error { m := mgr() @@ -236,10 +236,10 @@ func showCmd(mgr func() *aktctx.Manager) *cobra.Command { } } -func truncate(s string, max int) string { - if len(s) <= max { +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { return s } - return s[:max-3] + "..." + return s[:maxLen-3] + "..." } diff --git a/internal/cli/provider/actionlog.go b/internal/cli/provider/actionlog.go new file mode 100644 index 00000000..b2d53db0 --- /dev/null +++ b/internal/cli/provider/actionlog.go @@ -0,0 +1,33 @@ +package provider + +import ( + "context" + + "pkg.akt.dev/akt/internal/actionlog" + "pkg.akt.dev/akt/internal/cliutil" +) + +// recordProviderAction writes a type=provider entry (SPEC §5.6) for a +// state-changing provider gateway operation. Logging is best-effort: a +// failure never blocks the command itself. +func recordProviderAction(ctx context.Context, action, provider string, dseq uint64, opErr error) { + l := cliutil.ActionLogFromContext(ctx) + if l == nil { + return + } + + entry := actionlog.Entry{ + Type: actionlog.TypeProvider, + Action: action, + Provider: provider, + DSeq: dseq, + Status: "success", + } + + if opErr != nil { + entry.Status = "failed" + entry.Error = opErr.Error() + } + + _ = l.Log(entry) +} diff --git a/internal/cli/provider/actionlog_test.go b/internal/cli/provider/actionlog_test.go new file mode 100644 index 00000000..3bfd96d1 --- /dev/null +++ b/internal/cli/provider/actionlog_test.go @@ -0,0 +1,48 @@ +package provider + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "pkg.akt.dev/akt/internal/actionlog" + "pkg.akt.dev/akt/internal/cliutil" +) + +func TestRecordProviderAction(t *testing.T) { + l, err := actionlog.Open(filepath.Join(t.TempDir(), "actions.log")) + if err != nil { + t.Fatalf("open action log: %v", err) + } + defer l.Close() + + ctx := cliutil.WithActionLog(context.Background(), l) + + recordProviderAction(ctx, "send-manifest", "akash1prov", 42, nil) + recordProviderAction(ctx, "migrate-hostnames", "akash1prov", 42, errors.New("boom")) + + entries, err := l.Read(actionlog.Filter{}) + if err != nil { + t.Fatalf("read: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + + // Newest first. + if entries[0].Action != "migrate-hostnames" || entries[0].Status != "failed" || entries[0].Error != "boom" { + t.Errorf("failure entry wrong: %+v", entries[0]) + } + if entries[1].Action != "send-manifest" || entries[1].Status != "success" { + t.Errorf("success entry wrong: %+v", entries[1]) + } + if entries[1].Type != actionlog.TypeProvider || entries[1].Provider != "akash1prov" || entries[1].DSeq != 42 { + t.Errorf("provider/dseq fields wrong: %+v", entries[1]) + } +} + +func TestRecordProviderActionNoLogger(t *testing.T) { + // Must be a no-op without a logger in context. + recordProviderAction(context.Background(), "send-manifest", "akash1prov", 1, nil) +} diff --git a/internal/cli/provider/commands.go b/internal/cli/provider/commands.go index 86a6d02a..dfaf349b 100644 --- a/internal/cli/provider/commands.go +++ b/internal/cli/provider/commands.go @@ -15,6 +15,8 @@ import ( rest "pkg.akt.dev/go/provider/client" "pkg.akt.dev/go/sdl" + "pkg.akt.dev/akt/internal/capability" + cflags "pkg.akt.dev/akt/internal/cli/chain/flags" aktprovider "pkg.akt.dev/akt/internal/provider" ) @@ -24,6 +26,8 @@ func Commands() *cobra.Command { Use: "provider", Short: "Provider gateway operations", Long: "Interact with Akash provider gateway APIs: query status, manage leases, send manifests, and more.", + // Capability gating: gateway discovery and wallet auth need chain access. + Annotations: map[string]string{capability.AnnotationKey: string(capability.Provider)}, } cmd.PersistentFlags().String("auth-type", "", "Provider auth type: jwt (default) or mtls") @@ -81,13 +85,13 @@ func statusCmd() *cobra.Command { func leaseStatusCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "lease-status", + Use: "lease-status [dseq]", Short: "Query lease deployment status", Long: "Query the live status of a lease from a provider, including service status, forwarded ports, and IPs.", - Args: cobra.NoArgs, - Example: ` # Query lease status - akt provider lease-status --dseq 12345 --provider akash1...`, - RunE: func(cmd *cobra.Command, _ []string) error { + Args: cobra.MaximumNArgs(1), + Example: ` # Query lease status (positional dseq) + akt provider lease-status 12345 --provider akash1...`, + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() providerAddr, providerURL, err := resolveProvider(cmd, nil) @@ -100,7 +104,7 @@ func leaseStatusCmd() *cobra.Command { return err } - lid, err := leaseIDFromFlags(cmd, providerAddr) + lid, err := leaseIDFromFlags(cmd, args, providerAddr) if err != nil { return err } @@ -121,19 +125,19 @@ func leaseStatusCmd() *cobra.Command { func leaseLogsCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "lease-logs", + Use: "lease-logs [dseq]", Short: "Stream container logs", Long: "Stream container logs from a lease. Supports filtering by service and following output.", - Args: cobra.NoArgs, - Example: ` # Stream logs for all services - akt provider lease-logs --dseq 12345 --provider akash1... + Args: cobra.MaximumNArgs(1), + Example: ` # Stream logs for all services (positional dseq) + akt provider lease-logs 12345 --provider akash1... # Follow logs for a specific service - akt provider lease-logs --dseq 12345 --provider akash1... --service web --follow + akt provider lease-logs 12345 --provider akash1... --service web --follow # Show last 100 lines - akt provider lease-logs --dseq 12345 --provider akash1... --tail 100`, - RunE: func(cmd *cobra.Command, _ []string) error { + akt provider lease-logs 12345 --provider akash1... --tail 100`, + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() providerAddr, providerURL, err := resolveProvider(cmd, nil) @@ -146,7 +150,7 @@ func leaseLogsCmd() *cobra.Command { return err } - lid, err := leaseIDFromFlags(cmd, providerAddr) + lid, err := leaseIDFromFlags(cmd, args, providerAddr) if err != nil { return err } @@ -192,16 +196,16 @@ func leaseLogsCmd() *cobra.Command { func leaseEventsCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "lease-events", + Use: "lease-events [dseq]", Short: "Stream Kubernetes events", Long: "Stream Kubernetes events for a lease from the provider.", - Args: cobra.NoArgs, - Example: ` # Stream events - akt provider lease-events --dseq 12345 --provider akash1... + Args: cobra.MaximumNArgs(1), + Example: ` # Stream events (positional dseq) + akt provider lease-events 12345 --provider akash1... # Follow events - akt provider lease-events --dseq 12345 --provider akash1... --follow`, - RunE: func(cmd *cobra.Command, _ []string) error { + akt provider lease-events 12345 --provider akash1... --follow`, + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() providerAddr, providerURL, err := resolveProvider(cmd, nil) @@ -214,7 +218,7 @@ func leaseEventsCmd() *cobra.Command { return err } - lid, err := leaseIDFromFlags(cmd, providerAddr) + lid, err := leaseIDFromFlags(cmd, args, providerAddr) if err != nil { return err } @@ -279,7 +283,9 @@ func leaseShellCmd() *cobra.Command { return err } - lid, err := leaseIDFromFlags(cmd, providerAddr) + // lease-shell consumes its positional args as the remote command, + // so dseq must come from the --dseq flag here. + lid, err := leaseIDFromFlags(cmd, nil, providerAddr) if err != nil { return err } @@ -291,12 +297,15 @@ func leaseShellCmd() *cobra.Command { tty, _ := cmd.Flags().GetBool("tty") - return cl.LeaseShell(ctx, lid, service, 0, args, + err = cl.LeaseShell(ctx, lid, service, 0, args, os.Stdin, os.Stdout, os.Stderr, tty, nil) + recordProviderAction(ctx, "lease-shell", lid.Provider, lid.DSeq, err) + + return err }, } - addLeaseFlags(cmd) + addLeaseShellFlags(cmd) cmd.Flags().String("service", "", "Service name (required)") cmd.Flags().BoolP("tty", "t", true, "Allocate a TTY") @@ -339,7 +348,9 @@ func sendManifestCmd() *cobra.Command { return fmt.Errorf("build manifest from SDL: %w", err) } - if err := cl.SubmitManifest(ctx, dseq, mani); err != nil { + err = cl.SubmitManifest(ctx, dseq, mani) + recordProviderAction(ctx, "send-manifest", providerAddr.String(), dseq, err) + if err != nil { return fmt.Errorf("submit manifest: %w", err) } @@ -355,13 +366,13 @@ func sendManifestCmd() *cobra.Command { func getManifestCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "get-manifest", + Use: "get-manifest [dseq]", Short: "Retrieve current manifest", Long: "Retrieve the current manifest for a lease from the provider.", - Args: cobra.NoArgs, - Example: ` # Get manifest for a lease - akt provider get-manifest --dseq 12345 --provider akash1...`, - RunE: func(cmd *cobra.Command, _ []string) error { + Args: cobra.MaximumNArgs(1), + Example: ` # Get manifest for a lease (positional dseq) + akt provider get-manifest 12345 --provider akash1...`, + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() providerAddr, providerURL, err := resolveProvider(cmd, nil) @@ -374,7 +385,7 @@ func getManifestCmd() *cobra.Command { return err } - lid, err := leaseIDFromFlags(cmd, providerAddr) + lid, err := leaseIDFromFlags(cmd, args, providerAddr) if err != nil { return err } @@ -425,7 +436,9 @@ func migrateHostnamesCmd() *cobra.Command { return fmt.Errorf("--hostnames is required") } - if err := cl.MigrateHostnames(ctx, hostnames, dseq, gseq); err != nil { + err = cl.MigrateHostnames(ctx, hostnames, dseq, gseq) + recordProviderAction(ctx, "migrate-hostnames", providerAddr.String(), dseq, err) + if err != nil { return fmt.Errorf("migrate hostnames: %w", err) } @@ -473,7 +486,9 @@ func migrateEndpointsCmd() *cobra.Command { return fmt.Errorf("--endpoints is required") } - if err := cl.MigrateEndpoints(ctx, endpoints, dseq, gseq); err != nil { + err = cl.MigrateEndpoints(ctx, endpoints, dseq, gseq) + recordProviderAction(ctx, "migrate-endpoints", providerAddr.String(), dseq, err) + if err != nil { return fmt.Errorf("migrate endpoints: %w", err) } @@ -491,21 +506,45 @@ func migrateEndpointsCmd() *cobra.Command { // --- helpers --- -// addLeaseFlags adds the common lease identification flags to a command. +// addLeaseFlags adds the common lease identification flags to the commands +// that take a positional [dseq] (lease-status, lease-logs, lease-events, +// get-manifest). lease-shell keeps its --dseq via addLeaseShellFlags. func addLeaseFlags(cmd *cobra.Command) { + // FEEDBACK(2026-07): --dseq disabled for the positional-only UX trial + // (use the positional [dseq] argument instead). Restore by uncommenting + // if users ask for the flag form back. + // cmd.Flags().Uint64("dseq", 0, "Deployment sequence number") + cmd.Flags().Uint32("gseq", 1, "Group sequence number") + cmd.Flags().Uint32("oseq", 1, "Order sequence number") +} + +// addLeaseShellFlags adds the lease identification flags for lease-shell, +// which consumes its positional args as the remote command and therefore +// keeps --dseq (no positional twin). +func addLeaseShellFlags(cmd *cobra.Command) { cmd.Flags().Uint64("dseq", 0, "Deployment sequence number") cmd.Flags().Uint32("gseq", 1, "Group sequence number") cmd.Flags().Uint32("oseq", 1, "Order sequence number") } -// leaseIDFromFlags builds a LeaseID from the command flags. -func leaseIDFromFlags(cmd *cobra.Command, provider sdk.AccAddress) (mtypes.LeaseID, error) { +// leaseIDFromFlags builds a LeaseID from the command flags and an optional +// positional dseq argument. Only lease-shell still registers --dseq +// (FEEDBACK 2026-07: the flag is disabled on the positional-[dseq] commands +// for the positional-only UX trial); for those commands the flag lookup +// below yields zero and the positional argument is the sole source. +func leaseIDFromFlags(cmd *cobra.Command, args []string, provider sdk.AccAddress) (mtypes.LeaseID, error) { cctx := sdkclient.GetClientContextFromCmd(cmd) owner := cctx.GetFromAddress() dseq, _ := cmd.Flags().GetUint64("dseq") + + dseq, err := cflags.DSeqFromArgs(args, dseq) + if err != nil { + return mtypes.LeaseID{}, err + } + if dseq == 0 { - return mtypes.LeaseID{}, fmt.Errorf("--dseq is required") + return mtypes.LeaseID{}, fmt.Errorf("dseq is required: provide the positional [dseq] argument (or --dseq for lease-shell)") } gseq, _ := cmd.Flags().GetUint32("gseq") diff --git a/internal/cli/provider/commands_test.go b/internal/cli/provider/commands_test.go new file mode 100644 index 00000000..ce47f261 --- /dev/null +++ b/internal/cli/provider/commands_test.go @@ -0,0 +1,403 @@ +package provider + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/spf13/cobra" + + "pkg.akt.dev/akt/internal/capability" +) + +// newProviderCmd builds a throwaway command carrying the persistent flags the +// `akt provider` group installs, so the helpers under test see the same flag +// set they see in production. +func newProviderCmd(t *testing.T, args ...string) *cobra.Command { + t.Helper() + + cmd := &cobra.Command{Use: "test", RunE: func(*cobra.Command, []string) error { return nil }} + cmd.Flags().String("auth-type", "", "") + cmd.Flags().String("provider", "", "") + cmd.Flags().String("provider-url", "", "") + addLeaseShellFlags(cmd) + + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(args) + + if err := cmd.Execute(); err != nil { + t.Fatalf("parse flags %v: %v", args, err) + } + + return cmd +} + +// A valid bech32 akash account address, used wherever the code path runs +// through sdk.AccAddressFromBech32. +const testProviderAddr = "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5jepelx" + +// mustAddr decodes testProviderAddr, failing the test if the fixture is not a +// valid bech32 address. +func mustAddr(t *testing.T) sdk.AccAddress { + t.Helper() + + addr, err := sdk.AccAddressFromBech32(testProviderAddr) + if err != nil { + t.Fatalf("test fixture is not a valid address: %v", err) + } + + return addr +} + +// TestResolveProviderRequiresAnAddress covers the guard that keeps a gateway +// call from being attempted with no provider at all — without it the bech32 +// decode below would produce a confusing "empty address" error instead. +func TestResolveProviderRequiresAnAddress(t *testing.T) { + cmd := newProviderCmd(t) + + _, _, err := resolveProvider(cmd, nil) + if err == nil { + t.Fatal("a missing provider address must be rejected") + } + if !strings.Contains(err.Error(), "--provider") { + t.Errorf("error should name the flag, got %q", err) + } +} + +// TestResolveProviderRejectsInvalidBech32 covers the address-validation +// branch: a typo'd provider must fail locally rather than being sent to a +// gateway as an opaque string. +func TestResolveProviderRejectsInvalidBech32(t *testing.T) { + cmd := newProviderCmd(t, "--provider", "not-a-bech32-address") + + if _, _, err := resolveProvider(cmd, nil); err == nil { + t.Fatal("an invalid bech32 provider address must be rejected") + } else if !strings.Contains(err.Error(), "invalid provider address") { + t.Errorf("error should name the bad address, got %q", err) + } +} + +// TestResolveProviderRequiresURL pins the current limitation: on-chain HostURI +// lookup is not implemented, so --provider-url is mandatory. If that lookup is +// ever wired in, this test is the reminder to revisit the contract. +func TestResolveProviderRequiresURL(t *testing.T) { + cmd := newProviderCmd(t, "--provider", testProviderAddr) + + if _, _, err := resolveProvider(cmd, nil); err == nil { + t.Fatal("a missing --provider-url must be rejected") + } else if !strings.Contains(err.Error(), "--provider-url") { + t.Errorf("error should name --provider-url, got %q", err) + } +} + +// TestResolveProviderPositionalWinsOverFlag pins the documented precedence: +// `akt provider status ` addresses , not whatever --provider +// happens to carry (from a shell alias, say). Getting this backwards would +// silently query the wrong provider. +func TestResolveProviderPositionalWinsOverFlag(t *testing.T) { + const other = "akash1errud3wyc0pvrs9lh67mewa6hxut0d44pfj6vh" + + cmd := newProviderCmd(t, "--provider", other, "--provider-url", "https://gw.example.com:8443") + + addr, url, err := resolveProvider(cmd, []string{testProviderAddr}) + if err != nil { + t.Fatalf("resolveProvider: %v", err) + } + if addr.String() != testProviderAddr { + t.Errorf("address = %s, want the positional %s", addr, testProviderAddr) + } + if url != "https://gw.example.com:8443" { + t.Errorf("url = %q, want the --provider-url value", url) + } +} + +// TestResolveProviderFlagFallback covers the no-positional branch, which every +// lease-* command uses (they pass nil args and rely on --provider). +func TestResolveProviderFlagFallback(t *testing.T) { + cmd := newProviderCmd(t, "--provider", testProviderAddr, "--provider-url", "https://gw") + + addr, url, err := resolveProvider(cmd, nil) + if err != nil { + t.Fatalf("resolveProvider: %v", err) + } + if addr.String() != testProviderAddr || url != "https://gw" { + t.Errorf("resolveProvider = (%s, %q)", addr, url) + } +} + +// TestLeaseIDFromFlagsPositionalDSeq covers the positional-only UX trial path +// (FEEDBACK 2026-07): the positional [dseq] must land in the LeaseID, and the +// gseq/oseq defaults must be 1 — a zero gseq would address a nonexistent +// group and fail at the provider with an unhelpful error. +func TestLeaseIDFromFlagsPositionalDSeq(t *testing.T) { + cmd := newProviderCmd(t) + + addr := mustAddr(t) + + lid, err := leaseIDFromFlags(cmd, []string{"12345"}, addr) + if err != nil { + t.Fatalf("leaseIDFromFlags: %v", err) + } + + if lid.DSeq != 12345 { + t.Errorf("dseq = %d, want 12345", lid.DSeq) + } + if lid.GSeq != 1 || lid.OSeq != 1 { + t.Errorf("gseq/oseq = %d/%d, want 1/1", lid.GSeq, lid.OSeq) + } + if lid.Provider != testProviderAddr { + t.Errorf("provider = %q, want %q", lid.Provider, testProviderAddr) + } +} + +// TestLeaseIDFromFlagsPositionalWinsOverFlag pins the precedence for +// lease-shell, the one command that still registers --dseq: an explicit +// positional value must win, exactly as DSeqFromArgs documents. +func TestLeaseIDFromFlagsPositionalWinsOverFlag(t *testing.T) { + cmd := newProviderCmd(t, "--dseq", "999", "--gseq", "2", "--oseq", "3") + + addr := mustAddr(t) + + lid, err := leaseIDFromFlags(cmd, []string{"12345"}, addr) + if err != nil { + t.Fatalf("leaseIDFromFlags: %v", err) + } + if lid.DSeq != 12345 { + t.Errorf("dseq = %d, want the positional 12345", lid.DSeq) + } + if lid.GSeq != 2 || lid.OSeq != 3 { + t.Errorf("gseq/oseq = %d/%d, want the flag values 2/3", lid.GSeq, lid.OSeq) + } +} + +// TestLeaseIDFromFlagsUsesDSeqFlagWithoutPositional covers lease-shell's own +// path: it consumes its positional args as the remote command, so --dseq is +// the only source there. +func TestLeaseIDFromFlagsUsesDSeqFlagWithoutPositional(t *testing.T) { + cmd := newProviderCmd(t, "--dseq", "777") + + addr := mustAddr(t) + + lid, err := leaseIDFromFlags(cmd, nil, addr) + if err != nil { + t.Fatalf("leaseIDFromFlags: %v", err) + } + if lid.DSeq != 777 { + t.Errorf("dseq = %d, want 777 from --dseq", lid.DSeq) + } +} + +// TestLeaseIDFromFlagsRejectsMissingDSeq covers the guard that stops a lease +// call from being made against dseq 0, which the provider would answer with a +// generic not-found. +func TestLeaseIDFromFlagsRejectsMissingDSeq(t *testing.T) { + cmd := newProviderCmd(t) + + addr := mustAddr(t) + + if _, err := leaseIDFromFlags(cmd, nil, addr); err == nil { + t.Fatal("dseq 0 must be rejected") + } else if !strings.Contains(err.Error(), "dseq is required") { + t.Errorf("error should say dseq is required, got %q", err) + } +} + +// TestLeaseIDFromFlagsRejectsNonNumericDSeq covers the positional parse error. +func TestLeaseIDFromFlagsRejectsNonNumericDSeq(t *testing.T) { + cmd := newProviderCmd(t) + + addr := mustAddr(t) + + if _, err := leaseIDFromFlags(cmd, []string{"twelve"}, addr); err == nil { + t.Fatal("a non-numeric positional dseq must be rejected") + } else if !strings.Contains(err.Error(), "invalid dseq") { + t.Errorf("error should name the bad dseq, got %q", err) + } +} + +// TestPrintJSONWritesToCommandOutput covers the happy path plus the marshal +// error branch. printJSON is the only output path for every provider query, +// so a silent write to os.Stdout (rather than cmd.OutOrStdout) would break +// every output-capturing caller. +func TestPrintJSON(t *testing.T) { + var buf bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&buf) + + if err := printJSON(cmd, map[string]int{"dseq": 42}); err != nil { + t.Fatalf("printJSON: %v", err) + } + + var got map[string]int + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("output is not valid JSON (%q): %v", buf.String(), err) + } + if got["dseq"] != 42 { + t.Errorf("output = %v, want dseq 42", got) + } + + // Unmarshalable values must surface an error, not a partial write. + if err := printJSON(cmd, make(chan int)); err == nil { + t.Error("an unmarshalable value must produce an error") + } +} + +// TestCommandsGatedOnProviderCapability pins the capability annotation. The +// gating layer reads it to dim/hide the group when the context has no RPC +// endpoint; losing the annotation silently re-exposes commands that cannot +// work (gateway discovery and wallet auth both need chain access). +func TestCommandsGatedOnProviderCapability(t *testing.T) { + cmd := Commands() + + if got := cmd.Annotations[capability.AnnotationKey]; got != string(capability.Provider) { + t.Errorf("provider group annotation = %q, want %q", got, capability.Provider) + } + + for _, name := range []string{"auth-type", "provider", "provider-url"} { + if cmd.PersistentFlags().Lookup(name) == nil { + t.Errorf("persistent flag --%s missing", name) + } + } +} + +// TestCommandsRegistersEveryGatewayOperation guards against a subcommand being +// dropped from the tree during a refactor: each name here is a documented +// `akt provider` operation. +func TestCommandsRegistersEveryGatewayOperation(t *testing.T) { + cmd := Commands() + + have := map[string]bool{} + for _, sub := range cmd.Commands() { + have[sub.Name()] = true + } + + for _, want := range []string{ + "status", "lease-status", "lease-logs", "lease-events", + "lease-shell", "send-manifest", "get-manifest", + "migrate-hostnames", "migrate-endpoints", + } { + if !have[want] { + t.Errorf("subcommand %q missing from the provider group", want) + } + } +} + +// TestPositionalDSeqCommandsDoNotRegisterDSeqFlag pins the positional-only UX +// trial (FEEDBACK 2026-07). If --dseq comes back on these commands without +// leaseIDFromFlags being revisited, the flag would silently win over the +// positional argument for callers that pass both. +func TestPositionalDSeqCommandsDoNotRegisterDSeqFlag(t *testing.T) { + cmd := Commands() + + positional := map[string]bool{ + "lease-status": true, "lease-logs": true, + "lease-events": true, "get-manifest": true, + } + + for _, sub := range cmd.Commands() { + switch { + case positional[sub.Name()]: + if sub.Flags().Lookup("dseq") != nil { + t.Errorf("%s must take dseq positionally, not via --dseq", sub.Name()) + } + if sub.Flags().Lookup("gseq") == nil || sub.Flags().Lookup("oseq") == nil { + t.Errorf("%s should still expose --gseq/--oseq", sub.Name()) + } + + case sub.Name() == "lease-shell": + // lease-shell consumes its positional args as the remote command, + // so it keeps --dseq. + if sub.Flags().Lookup("dseq") == nil { + t.Error("lease-shell must keep --dseq (its positionals are the remote command)") + } + } + } +} + +// TestStatusRejectsMissingProvider drives the real RunE far enough to prove +// the resolveProvider guard runs before any network call is attempted. +func TestStatusRejectsMissingProvider(t *testing.T) { + root := Commands() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"status"}) + + if err := root.Execute(); err == nil { + t.Fatal("status without a provider must fail") + } else if !strings.Contains(err.Error(), "provider address is required") { + t.Errorf("unexpected error: %v", err) + } +} + +// TestStatusRejectsMissingProviderURL proves the second guard fires too, so a +// user who supplies only an address gets the actionable message instead of a +// dial error against an empty URL. +func TestStatusRejectsMissingProviderURL(t *testing.T) { + root := Commands() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"status", testProviderAddr}) + + if err := root.Execute(); err == nil { + t.Fatal("status without --provider-url must fail") + } else if !strings.Contains(err.Error(), "--provider-url is required") { + t.Errorf("unexpected error: %v", err) + } +} + +// TestGatewayClientRejectsUnknownAuthType covers gatewayClientFromCmd's only +// local failure mode. --auth-type is a persistent flag on the whole group, so +// a typo would otherwise be discovered only when the provider rejected the +// unsigned request. +func TestGatewayClientRejectsUnknownAuthType(t *testing.T) { + root := Commands() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{ + "status", testProviderAddr, + "--provider-url", "https://gw.example", + "--auth-type", "bogus", + }) + + if err := root.Execute(); err == nil { + t.Fatal("an unknown --auth-type must be rejected") + } else if !strings.Contains(err.Error(), "unsupported auth type") { + t.Errorf("unexpected error: %v", err) + } +} + +// TestLeaseCommandsRequireProviderBeforeGatewayWork walks every lease-scoped +// subcommand through its first guard. These commands take no positional +// provider, so a missing --provider must be reported by name rather than +// surfacing as a gateway construction failure. +func TestLeaseCommandsRequireProviderBeforeGatewayWork(t *testing.T) { + cases := [][]string{ + {"lease-status", "1"}, + {"lease-logs", "1"}, + {"lease-events", "1"}, + {"get-manifest", "1"}, + {"lease-shell", "--dseq", "1", "--", "/bin/sh"}, + {"send-manifest", "deploy.yaml"}, + {"migrate-hostnames", "--dseq", "1", "--hostnames", "a.example.com"}, + {"migrate-endpoints", "--dseq", "1", "--endpoints", "ep1"}, + } + + for _, args := range cases { + root := Commands() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs(args) + + err := root.Execute() + if err == nil { + t.Errorf("%v without a provider must fail", args) + continue + } + if !strings.Contains(err.Error(), "provider address is required") { + t.Errorf("%v: unexpected error %v", args, err) + } + } +} diff --git a/internal/cli/required_flags_test.go b/internal/cli/required_flags_test.go new file mode 100644 index 00000000..26be5b2c --- /dev/null +++ b/internal/cli/required_flags_test.go @@ -0,0 +1,71 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// requiredFlagAllowlist enumerates the only command flags that may be marked +// required. The CLI convention (SPEC §3.8, AKT-650) is positional-primary: +// a command's primary value is a positional argument and flags are optional +// overrides, so new required flags need an explicit entry here (and a good +// reason). +var requiredFlagAllowlist = map[string]bool{ + // Explicit signer identity is the point of offline signing (cosmos convention). + "tx sign --from": true, + "tx sign-batch --from": true, + // The auditor identity must be explicit when creating/deleting audited attributes. + "tx audit attr create --from": true, + "tx audit attr delete --from": true, + // Governance proposal metadata: multiple required fields, flag-shaped upstream in cosmos-sdk. + "tx ibc client recover-client --title": true, + "tx ibc client schedule-ibc-upgrade --title": true, + "tx upgrade software-upgrade --title": true, + "tx upgrade cancel-software-upgrade --title": true, +} + +// TestNoUnapprovedRequiredFlags walks the full command tree and fails when a +// flag is marked required without an allowlist entry. This pins the +// positional-primary convention: primary values must be accepted as +// positional arguments, with flags as optional overrides. +func TestNoUnapprovedRequiredFlags(t *testing.T) { + root := NewRootCmd(BuildInfo{Version: "test"}) + + var violations []string + + var walk func(cmd *cobra.Command, path string) + walk = func(cmd *cobra.Command, path string) { + check := func(f *pflag.Flag) { + required := f.Annotations[cobra.BashCompOneRequiredFlag] + if len(required) == 0 || required[0] != "true" { + return + } + + key := strings.TrimSpace(path) + " --" + f.Name + if !requiredFlagAllowlist[key] { + violations = append(violations, key) + } + } + + cmd.LocalFlags().VisitAll(check) + + for _, sub := range cmd.Commands() { + name := sub.Name() + if path == "" { + walk(sub, name) + } else { + walk(sub, path+" "+name) + } + } + } + + walk(root, "") + + if len(violations) > 0 { + t.Errorf("commands with required flags not in the allowlist (primary values should be positional per SPEC §3.8):\n %s", + strings.Join(violations, "\n ")) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index b34c920c..c52dcc2c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "path/filepath" + "runtime" + "runtime/debug" "strings" sdkclient "github.com/cosmos/cosmos-sdk/client" @@ -16,10 +18,13 @@ import ( "pkg.akt.dev/akt/internal/actionlog" "pkg.akt.dev/akt/internal/bootstrap" + "pkg.akt.dev/akt/internal/capability" chaincli "pkg.akt.dev/akt/internal/cli/chain" + cliconsole "pkg.akt.dev/akt/internal/cli/console" clicontext "pkg.akt.dev/akt/internal/cli/context" cliprovider "pkg.akt.dev/akt/internal/cli/provider" + clisdl "pkg.akt.dev/akt/internal/cli/sdl" clistore "pkg.akt.dev/akt/internal/cli/store" cliworkflow "pkg.akt.dev/akt/internal/cli/workflow" aktclient "pkg.akt.dev/akt/internal/client" @@ -144,9 +149,13 @@ func NewRootCmd(bi BuildInfo) *cobra.Command { resolvedCfgRoot = cfgRoot // First-run bootstrap: if no config file exists, offer to - // fetch networks from github.com/akash-network/net. + // fetch networks from github.com/akash-network/net. Help + // invocations never bootstrap — help must work on a machine + // with nothing configured — and neither do commands that work + // entirely without configuration. cfgPath := aktctx.ConfigPath(cfgRoot) - if _, statErr := os.Stat(cfgPath); os.IsNotExist(statErr) { + if _, statErr := os.Stat(cfgPath); os.IsNotExist(statErr) && + !isHelpInvocation(cmd, os.Args[1:]) && requiresConfig(cmd) { // Initialize glyphs before bootstrap (config not yet // available — uses flag/env/auto-detect only). initGlyphs(v) @@ -184,11 +193,41 @@ func NewRootCmd(bi BuildInfo) *cobra.Command { } // 4. If no context was resolved, only allow commands that do not - // require a configured context to proceed. - if !resolved && requiresContext(cmd) { + // require a configured context to proceed. Help requests are + // always allowed: SDK group commands disable flag parsing, so + // cobra cannot short-circuit their --help before these hooks + // run, and help must never require a context. + if !resolved && requiresContext(cmd) && !isHelpInvocation(cmd, os.Args[1:]) { return noContextError(mgr) } + // 4b. Capability gating: derive the feature set from the active + // context (chain RPC present? Console key present?) and gate + // the command surface accordingly — commands whose transport + // is not configured are dimmed or hidden in help (mode from + // defaults.command-gating: dim | hide | off) and fail fast + // with an explanation instead of erroring mid-transport. + if resolved { + if rc, rcErr := mgr.Resolve(activeContextName(mgr, v.GetString("context"))); rcErr == nil { + mode := capability.ParseMode(gatingMode(v, mgr)) + + // The feature set describes the configuration; explicit + // per-invocation overrides (--node, --console-api-key, a + // positional monitor endpoint) grant their capability so + // gating never rejects a command that carries its own + // connection details. + set := invocationCapabilities(capability.Resolve(rc), cmd, os.Args[1:], args) + + applyCapabilityGating(cmd.Root(), set, mode) + + if mode != capability.ModeOff && !isHelpInvocation(cmd, os.Args[1:]) { + if err := requirementError(cmd, set); err != nil { + return err + } + } + } + } + // 5. Open the action log for the current context (if any). if current := mgr.CurrentContext(); current != "" { logPath := aktctx.ActionLogPath(cfgRoot, current) @@ -202,20 +241,41 @@ func NewRootCmd(bi BuildInfo) *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { - if err := checkInteractive(v); err != nil { - return err + // The TUI shell is DISABLED pending UX feedback (2026-07): bare + // akt prints help instead of launching the dashboard. The code + // path is kept compiled behind AKT_EXPERIMENTAL_TUI=1 so it can + // be exercised while feedback is collected; akt monitor (the + // real-time monitoring hub) is unaffected. Re-enable by removing + // this gate once the TUI resource views are production-ready. + if os.Getenv("AKT_EXPERIMENTAL_TUI") == "1" { + if err := checkInteractive(v); err != nil { + return err + } + + cctx := sdkclient.GetClientContextFromCmd(cmd) + cfgRoot, _ := aktctx.ConfigHome(v.GetString("home")) + + return akttui.Run(akttui.Config{ + Viper: v, + RPCEndpoint: cctx.NodeURI, + RESTEndpoint: "", // resolved by rpc.NewClient default when empty + CacheDir: filepath.Join(cfgRoot, "cache"), + Insecure: true, + }) } - cctx := sdkclient.GetClientContextFromCmd(cmd) - cfgRoot, _ := aktctx.ConfigHome(v.GetString("home")) + if interactive, _ := cmd.Flags().GetBool("interactive"); interactive { + return fmt.Errorf("the TUI is currently disabled while UX feedback is collected; use the CLI commands or akt monitor") + } - return akttui.Run(akttui.Config{ - Viper: v, - RPCEndpoint: cctx.NodeURI, - RESTEndpoint: "", // resolved by rpc.NewClient default when empty - CacheDir: filepath.Join(cfgRoot, "cache"), - Insecure: true, - }) + return cmd.Help() + }, + PersistentPostRunE: func(cmd *cobra.Command, _ []string) error { + // Close the action logger opened in PersistentPreRunE (SPEC §5.6). + if l := cliutil.ActionLogFromContext(cmd.Context()); l != nil { + return l.Close() + } + return nil }, SilenceUsage: true, SilenceErrors: true, @@ -254,24 +314,65 @@ func NewRootCmd(bi BuildInfo) *cobra.Command { root.AddCommand(monitorCmd(v)) root.AddCommand(mcpCmd()) root.AddCommand(cliprovider.Commands()) + root.AddCommand(clisdl.Commands()) homeFn := func() string { return resolvedCfgRoot } + // ctxNameFn resolves the active context name for the command groups that + // need it. The global --context override wins over current-context so + // that `akt --context staging ...` stores credentials under and bills + // the context the user named. ctxNameFn := func() string { + if name := v.GetString("context"); name != "" { + return name + } if mgr != nil { return mgr.CurrentContext() } return "" } + root.AddCommand(cliconsole.Commands(mgrFn)) root.AddCommand(clistore.Commands(homeFn, ctxNameFn)) // Workflow commands are discovered dynamically from workflow definitions. // Only workflows that exist (built-in or user-defined YAML) produce commands. - for _, wfCmd := range cliworkflow.Commands(homeFn, ctxNameFn) { + for _, wfCmd := range cliworkflow.CommandsWithManager(homeFn, ctxNameFn, mgrFn) { root.AddCommand(wfCmd) } root.AddCommand(versionCmd(bi)) root.AddCommand(completionCmd()) + // Capability gating must also shape help output when cobra + // short-circuits --help before the persistent hooks run (parsed help + // flags never reach PersistentPreRunE). Resolution here is best-effort: + // any failure falls back to ungated help rather than blocking it. + defaultHelp := root.HelpFunc() + root.SetHelpFunc(func(c *cobra.Command, args []string) { + // Help can run before PersistentPreRunE, so mirror the flags it + // would have bound. Both --home and --context matter: help must + // describe the context the user actually named. + for _, name := range []string{"home", "context"} { + if f := root.PersistentFlags().Lookup(name); f != nil && f.Changed { + v.Set(name, f.Value.String()) + } + } + + if cfgRoot, err := aktctx.ConfigHome(v.GetString("home")); err == nil { + if m, mErr := aktctx.NewManager(cfgRoot); mErr == nil { + if ctxName := activeContextName(m, v.GetString("context")); ctxName != "" { + if rc, rcErr := m.Resolve(ctxName); rcErr == nil { + // Same mode resolution as enforcement (flag > env > + // config) so help and execution never disagree. + mode := capability.ParseMode(gatingMode(v, m)) + set := invocationCapabilities(capability.Resolve(rc), c, os.Args[1:], nil) + applyCapabilityGating(root, set, mode) + } + } + } + } + + defaultHelp(c, args) + }) + return root } @@ -287,6 +388,37 @@ func Execute(root *cobra.Command) error { return root.ExecuteContext(ctx) } +// requiresConfig returns true if the command needs akt configuration to +// exist, and so may trigger the first-run bootstrap wizard when none is +// found. It is deliberately narrower than requiresContext: a command is +// exempt here only when it produces the same output with or without any +// configuration at all. +// +// Without this, `akt version` on an unconfigured machine either launched +// the wizard (in a terminal) or printed the wizard's "no terminal +// available" notice to stderr (outside one) before printing the version. +// Both are wrong for the command people run to check that a binary works +// — the first blocks a scripted smoke test on interactive input, the +// second makes a clean run look like a failure. +func requiresConfig(cmd *cobra.Command) bool { + path := cmd.CommandPath() + + switch { + // Build metadata is compiled in; configuration cannot change it. + case strings.HasPrefix(path, "akt version"): + return false + // Completion scripts are generated from the command tree alone. + case strings.HasPrefix(path, "akt completion"): + return false + // SDL authoring is entirely local (see requiresContext), so demanding + // a network fetch before linting a local file would be backwards. + case strings.HasPrefix(path, "akt sdl"): + return false + } + + return true +} + // requiresContext returns true if the command needs a fully resolved akt // context (network, keyring, RPC) to operate. Commands that manage // contexts themselves or display basic info are exempt. @@ -306,11 +438,80 @@ func requiresContext(cmd *cobra.Command) bool { return false case strings.HasPrefix(path, "akt completion"): return false + // SDL authoring is entirely local: parsing, scaffolding, and linting + // never touch a network or a credential. + case strings.HasPrefix(path, "akt sdl"): + return false + // The console group resolves its own credential (flag > env > stored) + // and works with no context at all when a key comes from the + // environment; capability gating reports a missing key instead. + case strings.HasPrefix(path, "akt console"): + return false } return true } +// helpRequested reports whether the invocation asks for help. Detection is +// argv-based because several clean-copied SDK group commands set +// DisableFlagParsing, which prevents cobra from seeing --help before the +// persistent hooks execute. +func helpRequested(args []string) bool { + for _, a := range args { + // Everything after the terminator is data for the command + // (e.g. `provider lease-shell -- sh -h`), never a help request. + if a == "--" { + return false + } + + // Only the flag forms count. A bare "help" is matched separately + // via the resolved command: as a positional value (`tx deployment + // close help`) it must NOT disable context or capability checks. + if a == "--help" || a == "-h" || strings.HasPrefix(a, "--help=") { + return true + } + } + + return false +} + +// activeContextName resolves which context a run targets: the explicit +// override, else current-context, else the sole configured context (the +// same auto-selection the SDK client bootstrap performs). Returns "" when +// no single context can be determined. +func activeContextName(mgr *aktctx.Manager, override string) string { + if mgr == nil { + return override + } + + return mgr.ActiveContext(override) +} + +// gatingMode resolves the command-gating mode with the standard precedence +// (flag/env via viper, then config file). +func gatingMode(v *viper.Viper, mgr *aktctx.Manager) string { + if mode := v.GetString("defaults.command-gating"); mode != "" { + return mode + } + + if mgr != nil { + return mgr.Config().Defaults.CommandGating + } + + return "" +} + +// isHelpInvocation reports whether this run only prints help — either via a +// help flag or via cobra's built-in `help` command — in which case context +// and capability requirements are not enforced. +func isHelpInvocation(cmd *cobra.Command, args []string) bool { + if cmd != nil && cmd.Name() == "help" { + return true + } + + return helpRequested(args) +} + // checkInteractive returns an error if interactive mode is disabled in config // and the --interactive (-i) flag was not passed. func checkInteractive(v *viper.Viper) error { @@ -413,6 +614,8 @@ func monitorCmd(v *viper.Viper) *cobra.Command { cmd := &cobra.Command{ Use: "monitor [rpc-endpoint]", Short: "Real-time monitoring hub", + // Capability gating: monitoring needs a chain RPC endpoint. + Annotations: map[string]string{capability.AnnotationKey: string(capability.ChainQuery)}, Long: `Interactive TUI for monitoring Akash Network state in real time. The monitor hub provides three dashboards navigable via Tab/Shift-Tab: @@ -609,14 +812,51 @@ in your shell. Follow the instructions for your shell below.`, } func versionCmd(bi BuildInfo) *cobra.Command { - return &cobra.Command{ - Use: "version", - Short: "Print version information", - Args: cobra.NoArgs, - Example: ` akt version`, - RunE: func(cmd *cobra.Command, args []string) error { - _, err := fmt.Fprintf(cmd.OutOrStdout(), "akt %s (commit: %s, built: %s)\n", bi.Version, bi.Commit, bi.Date) + cmd := &cobra.Command{ + Use: "version", + Short: "Print version information", + Args: cobra.NoArgs, + Example: ` # Short form + akt version + + # Full build info (Go version, platform, build tags) + akt version --long`, + RunE: func(cmd *cobra.Command, _ []string) error { + out := cmd.OutOrStdout() + + if long, _ := cmd.Flags().GetBool("long"); long { + info := []struct{ k, v string }{ + {"version", bi.Version}, + {"commit", bi.Commit}, + {"built", bi.Date}, + {"go", runtime.Version()}, + {"platform", runtime.GOOS + "/" + runtime.GOARCH}, + } + + if bi, ok := debug.ReadBuildInfo(); ok { + for _, setting := range bi.Settings { + if setting.Key == "-tags" && setting.Value != "" { + info = append(info, struct{ k, v string }{"build tags", setting.Value}) + } + } + } + + for _, i := range info { + if _, err := fmt.Fprintf(out, "%-11s %s\n", i.k+":", i.v); err != nil { + return err + } + } + + return nil + } + + _, err := fmt.Fprintf(out, "akt %s (commit: %s, built: %s)\n", bi.Version, bi.Commit, bi.Date) return err }, } + + // Build-time detail for bug reports (TASKS T035). + cmd.Flags().Bool("long", false, "Print full build information") + + return cmd } diff --git a/internal/cli/sdl/commands.go b/internal/cli/sdl/commands.go new file mode 100644 index 00000000..bebb8a51 --- /dev/null +++ b/internal/cli/sdl/commands.go @@ -0,0 +1,303 @@ +package sdl + +import ( + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "pkg.akt.dev/akt/internal/cliutil" +) + +// Commands returns the `akt sdl` command group. The group is transport- +// independent: scaffolding and validation run entirely locally, so no +// context, keyring, or RPC endpoint is required and no capability +// annotations are declared. +func Commands() *cobra.Command { + cmd := &cobra.Command{ + Use: "sdl", + Short: "Author and validate deployment SDLs", + Long: `Generate SDL manifests from built-in scaffolds and validate them offline. + +All sdl subcommands run locally and work without a configured context, +key, or network connection.`, + } + + cmd.AddCommand( + scaffoldsCmd(), + initCmd(), + validateCmd(), + ) + + return cmd +} + +func scaffoldsCmd() *cobra.Command { + return &cobra.Command{ + Use: "scaffolds", + // Back-compat with the reference CLI, where this command was named + // `sdl templates` before "template" was taken by the Console catalog. + Aliases: []string{"templates"}, + Short: "List the built-in SDL scaffolds", + Long: "List the local SDL scaffolds that `akt sdl init` can generate, with the flags each one honors.", + Args: cobra.NoArgs, + Example: ` akt sdl scaffolds`, + RunE: func(cmd *cobra.Command, _ []string) error { + w := tabwriter.NewWriter(cmd.OutOrStdout(), 2, 0, 2, ' ', 0) + + fmt.Fprintln(w, "NAME\tDESCRIPTION\tFLAGS") + for _, s := range Scaffolds() { + fmt.Fprintf(w, "%s\t%s\t%s\n", s.Name, s.Description, strings.Join(s.Params, " ")) + } + + if err := w.Flush(); err != nil { + return err + } + + fmt.Fprintln(cmd.OutOrStdout(), "\nGenerate one with: akt sdl init [flags] > deploy.yaml") + + return nil + }, + } +} + +func initCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "init ", + Short: "Generate an SDL from a scaffold (YAML on stdout)", + Long: `Generate a deployable SDL from a built-in scaffold and print it to stdout, +ready to redirect to a file or pipe into another command. The output is +self-checked against "akt sdl validate" before it is printed.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return ScaffoldNames(), cobra.ShellCompDirectiveNoFileComp + }, + Example: ` # Generate a web service SDL + akt sdl init web > deploy.yaml + + # GPU workload with a specific model + akt sdl init gpu --gpu-model h100 --image myorg/model:1.0 + + # Validate the generated SDL from stdin + akt sdl init web --image nginx:1.27 | akt sdl validate -`, + RunE: func(cmd *cobra.Command, args []string) error { + sc := Lookup(args[0]) + if sc == nil { + return &cliutil.CLIError{ + Code: cliutil.ExitUsage, + Message: fmt.Sprintf("unknown scaffold %q", args[0]), + Suggestion: fmt.Sprintf("Available scaffolds: %s (see \"akt sdl scaffolds\").", strings.Join(ScaffoldNames(), ", ")), + } + } + + opts, err := optionsFromFlags(cmd) + if err != nil { + return err + } + + out, err := Marshal(sc.Build(opts)) + if err != nil { + return fmt.Errorf("marshal SDL: %w", err) + } + + // Safety net: a scaffold must never emit an SDL that its own + // validator rejects. + if res := Validate(out); !res.Valid { + msgs := make([]string, len(res.Errors)) + for i, e := range res.Errors { + msgs[i] = e.Message + } + + return fmt.Errorf("internal error: generated SDL failed validation: %s", strings.Join(msgs, "; ")) + } + + _, err = cmd.OutOrStdout().Write(out) + + return err + }, + } + + // Generation parameters, not positional-argument twins: each flag is an + // optional knob with a per-scaffold default (see Options), so the + // zero-flag invocation always produces a deployable SDL. Int flags are + // range-checked in optionsFromFlags (Changed() tells an explicit value + // apart from the unset default), so out-of-range input — including an + // explicit 0 — is a usage error, never an internal one. + fl := cmd.Flags() + fl.String("name", "", "Service name (default per scaffold)") + fl.String("image", "", "Container image; must be tagged, e.g. nginx:1.27") + fl.Int("port", 0, "Container port to expose, 1-65535 (default per scaffold)") + fl.Int("as", 0, "External port, 1-65535 (default per scaffold)") + fl.String("cpu", "", "CPU units, e.g. 0.5 or 500m") + fl.String("memory", "", "Memory size, e.g. 512Mi, 2Gi") + fl.String("storage", "", "Storage size, e.g. 1Gi") + fl.Int("count", 0, "Replica count, minimum 1 (default per scaffold)") + fl.Int("price", 0, "Max price per block in uact, minimum 1 (default per scaffold)") + fl.StringArray("env", nil, "Environment variable KEY=value (repeatable)") + fl.Int("gpu", 0, "GPU units, minimum 1 (gpu scaffold)") + fl.String("gpu-model", "", "NVIDIA GPU model, e.g. a100 (gpu scaffold)") + + return cmd +} + +func validateCmd() *cobra.Command { + return &cobra.Command{ + Use: "validate ", + Short: "Validate an SDL offline (use - for stdin)", + Long: `Parse and validate an SDL document without touching the network, using +the same parser as "akt deploy" and the chain tx commands, then apply +best-practice lint rules (pinned image tags, pricing denoms). + +Exits 0 when the SDL is valid (warnings allowed) and 1 when it is not.`, + Args: cobra.ExactArgs(1), + Example: ` # Validate a file + akt sdl validate deploy.yaml + + # Validate from stdin + akt sdl init web | akt sdl validate -`, + RunE: func(cmd *cobra.Command, args []string) error { + data, name, err := readFileOrStdin(cmd, args[0]) + if err != nil { + return err + } + + res := Validate(data) + + if res.Valid { + out := cmd.OutOrStdout() + fmt.Fprintf(out, "valid: %d service(s), %d group(s), %d warning(s)\n", + res.Services, res.Groups, len(res.Warnings)) + printIssues(out, "warning", res.Warnings) + + return nil + } + + errOut := cmd.ErrOrStderr() + fmt.Fprintf(errOut, "invalid: %d error(s), %d warning(s)\n", len(res.Errors), len(res.Warnings)) + printIssues(errOut, "error", res.Errors) + printIssues(errOut, "warning", res.Warnings) + + return &cliutil.CLIError{ + Code: cliutil.ExitGeneral, + Message: fmt.Sprintf("SDL validation failed with %d error(s)", len(res.Errors)), + Context: fmt.Sprintf("validating %s", name), + Suggestion: "Fix the errors above and re-run \"akt sdl validate\".", + } + }, + } +} + +func printIssues(w io.Writer, kind string, issues []Issue) { + for _, i := range issues { + fmt.Fprintf(w, " %s: %s: %s\n", kind, i.Path, i.Message) + + if i.Hint != "" { + fmt.Fprintf(w, " hint: %s\n", i.Hint) + } + } +} + +// readFileOrStdin reads the SDL bytes from a path, or from stdin when the +// argument is "-". The returned name is used in error context. +func readFileOrStdin(cmd *cobra.Command, arg string) (data []byte, name string, err error) { + if arg == "-" { + data, err = io.ReadAll(cmd.InOrStdin()) + if err != nil { + return nil, "", fmt.Errorf("read stdin: %w", err) + } + + return data, "stdin", nil + } + + data, err = os.ReadFile(arg) + if err != nil { + return nil, "", &cliutil.CLIError{ + Code: cliutil.ExitUsage, + Message: fmt.Sprintf("cannot read SDL file %q", arg), + Cause: err, + } + } + + return data, arg, nil +} + +// optionsFromFlags collects the generation parameters. Int flags use +// Changed() so a scaffold can tell "not set" (use its default) apart from +// an explicit value. +func optionsFromFlags(cmd *cobra.Command) (Options, error) { + fl := cmd.Flags() + + o := Options{} + o.Name, _ = fl.GetString("name") + o.Image, _ = fl.GetString("image") + o.CPU, _ = fl.GetString("cpu") + o.Memory, _ = fl.GetString("memory") + o.Storage, _ = fl.GetString("storage") + o.GPUModel, _ = fl.GetString("gpu-model") + + var err error + if o.Port, err = intFlag(fl, "port", 1, maxPort); err != nil { + return o, err + } + if o.As, err = intFlag(fl, "as", 1, maxPort); err != nil { + return o, err + } + if o.Count, err = intFlag(fl, "count", 1, 0); err != nil { + return o, err + } + if o.Price, err = intFlag(fl, "price", 1, 0); err != nil { + return o, err + } + if o.GPU, err = intFlag(fl, "gpu", 1, 0); err != nil { + return o, err + } + + env, _ := fl.GetStringArray("env") + for _, e := range env { + if !strings.Contains(e, "=") { + return o, cliutil.ErrUsage(fmt.Sprintf("--env expects KEY=value, got %q", e), nil) + } + } + o.Env = env + + return o, nil +} + +// maxPort is the highest valid TCP/UDP port for --port/--as. +const maxPort = 65535 + +// intFlag returns the flag value only when it was explicitly set — +// pflag's Changed() distinguishes an explicit zero from the unset default, +// which falls back to the per-scaffold value. Explicitly set values are +// range-checked here so invalid input (an explicit 0, a negative number, a +// port above 65535) fails as a usage error (exit 2) before generation +// instead of surfacing later as an internal self-validation failure. +// maxVal <= 0 means "no upper bound". +func intFlag(fl *pflag.FlagSet, name string, minVal, maxVal int) (*int, error) { + if !fl.Changed(name) { + return nil, nil + } + + v, err := fl.GetInt(name) + if err != nil { + return nil, err + } + + if v < minVal || (maxVal > 0 && v > maxVal) { + if maxVal > 0 { + return nil, cliutil.ErrUsage(fmt.Sprintf("--%s must be between %d and %d, got %d", name, minVal, maxVal, v), nil) + } + + return nil, cliutil.ErrUsage(fmt.Sprintf("--%s must be at least %d, got %d", name, minVal, v), nil) + } + + return &v, nil +} diff --git a/internal/cli/sdl/commands_test.go b/internal/cli/sdl/commands_test.go new file mode 100644 index 00000000..b0887642 --- /dev/null +++ b/internal/cli/sdl/commands_test.go @@ -0,0 +1,341 @@ +package sdl + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "pkg.akt.dev/akt/internal/cliutil" +) + +// runSDL executes the sdl command group with the given args, capturing +// stdout and stderr. stdin feeds commands that read from "-". +func runSDL(t *testing.T, stdin string, args ...string) (stdout, stderr string, err error) { + t.Helper() + + cmd := Commands() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + var out, errBuf bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + cmd.SetIn(strings.NewReader(stdin)) + cmd.SetArgs(args) + + err = cmd.Execute() + + return out.String(), errBuf.String(), err +} + +func writeFixture(t *testing.T, content string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "sdl.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + return path +} + +// validSDL is a minimal on-rails SDL priced in uact. +const validSDL = `version: "2.0" +services: + web: + image: nginx:1.27 + expose: + - port: 80 + as: 80 + to: + - global: true +profiles: + compute: + web: + resources: + cpu: + units: 0.5 + memory: + size: 512Mi + storage: + size: 512Mi + placement: + dcloud: + pricing: + web: + denom: uact + amount: 10000 +deployment: + web: + dcloud: + profile: web + count: 1 +` + +func TestScaffoldsListsAll(t *testing.T) { + stdout, _, err := runSDL(t, "", "scaffolds") + require.NoError(t, err) + + for _, name := range []string{"web", "gpu", "multi-service", "ip-lease"} { + require.Contains(t, stdout, name) + } + require.Contains(t, stdout, "akt sdl init") +} + +func TestInitRoundTripAllScaffolds(t *testing.T) { + for _, name := range ScaffoldNames() { + t.Run(name, func(t *testing.T) { + stdout, _, err := runSDL(t, "", "init", name) + require.NoError(t, err) + + res := Validate([]byte(stdout)) + require.Truef(t, res.Valid, "generated %s SDL must validate, errors: %+v", name, res.Errors) + require.Empty(t, res.Errors) + require.Emptyf(t, res.Warnings, "generated %s SDL must be warning-free", name) + + // Every scaffold prices in uact and follows SDL section order. + require.Contains(t, stdout, "denom: uact") + vi := strings.Index(stdout, "version:") + si := strings.Index(stdout, "services:") + pi := strings.Index(stdout, "profiles:") + di := strings.Index(stdout, "deployment:") + require.True(t, vi >= 0 && vi < si && si < pi && pi < di, + "expected version < services < profiles < deployment order in:\n%s", stdout) + + // The generated SDL must also pass through the CLI validator. + valOut, _, valErr := runSDL(t, stdout, "validate", "-") + require.NoError(t, valErr) + require.Contains(t, valOut, "0 warning(s)") + }) + } +} + +func TestInitWebShape(t *testing.T) { + stdout, _, err := runSDL(t, "", "init", "web") + require.NoError(t, err) + + require.Contains(t, stdout, `version: "2.0"`) + require.Contains(t, stdout, "image: nginx:1.27") + require.Contains(t, stdout, "global: true") + require.Contains(t, stdout, "port: 80") + require.Contains(t, stdout, "amount: 10000") + require.Contains(t, stdout, "profile: web") + require.Contains(t, stdout, "count: 1") + require.Contains(t, stdout, "units: 0.5") + require.Contains(t, stdout, "size: 512Mi") +} + +func TestInitGPUShape(t *testing.T) { + stdout, _, err := runSDL(t, "", "init", "gpu") + require.NoError(t, err) + + require.Contains(t, stdout, "image: pytorch/pytorch:2.2.0-cuda12.1-cudnn8-runtime") + require.Contains(t, stdout, "gpu:") + require.Contains(t, stdout, "nvidia:") + require.Contains(t, stdout, "model: a100") + require.Contains(t, stdout, "units: 4") // cpu default + require.Contains(t, stdout, "size: 16Gi") // memory default + require.Contains(t, stdout, "size: 50Gi") // storage default + require.Contains(t, stdout, "port: 8080") // container port default + require.Contains(t, stdout, "as: 80") // external port default + require.Contains(t, stdout, "amount: 100000") // GPU price ceiling +} + +func TestInitMultiServiceShape(t *testing.T) { + stdout, _, err := runSDL(t, "", "init", "multi-service") + require.NoError(t, err) + + require.Contains(t, stdout, "image: postgres:16") + require.Contains(t, stdout, "service: db") // app depends on db + require.Contains(t, stdout, "service: app") // db exposes 5432 to app only + require.Contains(t, stdout, "port: 5432") + require.Contains(t, stdout, "persistent: true") + require.Contains(t, stdout, "class: beta2") + require.Contains(t, stdout, "name: db-data") + require.Contains(t, stdout, "size: 10Gi") + require.Contains(t, stdout, "mount: /var/lib/postgresql/data") + require.Contains(t, stdout, "readOnly: false") + require.Contains(t, stdout, "POSTGRES_PASSWORD=changeme") +} + +func TestInitIPLeaseShape(t *testing.T) { + stdout, _, err := runSDL(t, "", "init", "ip-lease") + require.NoError(t, err) + + require.Contains(t, stdout, `version: "2.1"`) + require.Contains(t, stdout, "endpoints:") + require.Contains(t, stdout, "appip:") + require.Contains(t, stdout, "kind: ip") + require.Contains(t, stdout, "ip: appip") +} + +func TestInitFlagOverrides(t *testing.T) { + stdout, _, err := runSDL(t, "", "init", "web", + "--name", "svc", + "--image", "myapp:2.1", + "--port", "3000", + "--as", "8080", + "--cpu", "500m", + "--memory", "1Gi", + "--storage", "2Gi", + "--count", "3", + "--price", "500", + "--env", "FOO=bar", + "--env", "BAZ=qux", + ) + require.NoError(t, err) + + require.Contains(t, stdout, "svc:") + require.Contains(t, stdout, "image: myapp:2.1") + require.Contains(t, stdout, "port: 3000") + require.Contains(t, stdout, "as: 8080") + require.Contains(t, stdout, "units: 500m") + require.Contains(t, stdout, "size: 1Gi") + require.Contains(t, stdout, "size: 2Gi") + require.Contains(t, stdout, "count: 3") + require.Contains(t, stdout, "amount: 500") + require.Contains(t, stdout, "- FOO=bar") + require.Contains(t, stdout, "- BAZ=qux") + + res := Validate([]byte(stdout)) + require.Truef(t, res.Valid, "overridden SDL must still validate, errors: %+v", res.Errors) +} + +func TestInitDeterministicOutput(t *testing.T) { + first, _, err := runSDL(t, "", "init", "multi-service") + require.NoError(t, err) + + second, _, err := runSDL(t, "", "init", "multi-service") + require.NoError(t, err) + + require.Equal(t, first, second, "scaffold output must be byte-for-byte deterministic") +} + +func TestInitUnknownScaffold(t *testing.T) { + _, _, err := runSDL(t, "", "init", "nope") + require.Error(t, err) + require.Contains(t, err.Error(), "unknown scaffold") + require.Equal(t, cliutil.ExitUsage, cliutil.ExitCode(err)) +} + +func TestInitEnvRequiresKeyValue(t *testing.T) { + _, _, err := runSDL(t, "", "init", "web", "--env", "MISSING_EQUALS") + require.Error(t, err) + require.Contains(t, err.Error(), "KEY=value") +} + +// TestInitOutOfRangeIntFlagsAreUsageErrors pins the zero-vs-unset semantics: +// an unset int flag keeps the per-scaffold default, while an explicitly set +// out-of-range value — including an explicit 0 — is rejected as a usage +// error (exit 2), never misreported as an internal generation error. +func TestInitOutOfRangeIntFlagsAreUsageErrors(t *testing.T) { + cases := []struct { + name string + args []string + wantMsg string + }{ + {"count zero", []string{"init", "web", "--count", "0"}, "--count must be at least 1, got 0"}, + {"count negative", []string{"init", "web", "--count", "-3"}, "--count must be at least 1, got -3"}, + {"port zero", []string{"init", "web", "--port", "0"}, "--port must be between 1 and 65535, got 0"}, + {"port too large", []string{"init", "web", "--port", "70000"}, "--port must be between 1 and 65535, got 70000"}, + {"as zero", []string{"init", "web", "--as", "0"}, "--as must be between 1 and 65535, got 0"}, + {"price zero", []string{"init", "web", "--price", "0"}, "--price must be at least 1, got 0"}, + {"gpu zero", []string{"init", "gpu", "--gpu", "0"}, "--gpu must be at least 1, got 0"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stdout, _, err := runSDL(t, "", tc.args...) + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantMsg) + require.NotContains(t, err.Error(), "internal error") + require.Equal(t, cliutil.ExitUsage, cliutil.ExitCode(err)) + require.Empty(t, stdout, "no SDL must be emitted for invalid input") + }) + } +} + +// TestInitExplicitMinimumValuesAccepted proves that legitimate explicit +// values at the low end of each range still generate a valid SDL — the +// Changed() discrimination must not reject valid input. +func TestInitExplicitMinimumValuesAccepted(t *testing.T) { + stdout, _, err := runSDL(t, "", "init", "gpu", + "--count", "1", "--port", "1", "--as", "65535", "--gpu", "1", "--price", "1") + require.NoError(t, err) + + res := Validate([]byte(stdout)) + require.Truef(t, res.Valid, "minimum-value SDL must validate, errors: %+v", res.Errors) + require.Contains(t, stdout, "count: 1") + require.Contains(t, stdout, "port: 1") + require.Contains(t, stdout, "as: 65535") + require.Contains(t, stdout, "amount: 1") +} + +func TestValidateValidFile(t *testing.T) { + path := writeFixture(t, validSDL) + + stdout, _, err := runSDL(t, "", "validate", path) + require.NoError(t, err) + require.Contains(t, stdout, "valid: 1 service(s), 1 group(s), 0 warning(s)") +} + +func TestValidateUnpinnedImage(t *testing.T) { + path := writeFixture(t, strings.Replace(validSDL, "image: nginx:1.27", "image: nginx", 1)) + + _, stderr, err := runSDL(t, "", "validate", path) + require.Error(t, err) + require.Equal(t, cliutil.ExitGeneral, cliutil.ExitCode(err)) + require.Contains(t, stderr, "has no tag") + require.Contains(t, stderr, "services/web/image") +} + +func TestValidateLatestTag(t *testing.T) { + path := writeFixture(t, strings.Replace(validSDL, "image: nginx:1.27", "image: nginx:latest", 1)) + + _, stderr, err := runSDL(t, "", "validate", path) + require.Error(t, err) + require.Equal(t, cliutil.ExitGeneral, cliutil.ExitCode(err)) + require.Contains(t, stderr, ":latest") +} + +func TestValidateUaktWarnsButPasses(t *testing.T) { + path := writeFixture(t, strings.Replace(validSDL, "denom: uact", "denom: uakt", 1)) + + stdout, _, err := runSDL(t, "", "validate", path) + require.NoError(t, err, "uakt is valid on-chain and must only warn") + require.Contains(t, stdout, "valid: 1 service(s), 1 group(s), 1 warning(s)") + require.Contains(t, stdout, `warning:`) + require.Contains(t, stdout, "uakt") +} + +func TestValidateStdin(t *testing.T) { + stdout, _, err := runSDL(t, validSDL, "validate", "-") + require.NoError(t, err) + require.Contains(t, stdout, "valid: 1 service(s), 1 group(s), 0 warning(s)") +} + +func TestValidateGarbageYAML(t *testing.T) { + path := writeFixture(t, "{{{ this is not yaml: [") + + _, stderr, err := runSDL(t, "", "validate", path) + require.Error(t, err) + require.Equal(t, cliutil.ExitGeneral, cliutil.ExitCode(err)) + require.Contains(t, stderr, "invalid: 1 error(s)") +} + +func TestValidateMissingFile(t *testing.T) { + _, _, err := runSDL(t, "", "validate", filepath.Join(t.TempDir(), "does-not-exist.yaml")) + require.Error(t, err) + require.Equal(t, cliutil.ExitUsage, cliutil.ExitCode(err)) +} + +func TestDigestPinnedImagePasses(t *testing.T) { + pinned := strings.Replace(validSDL, "image: nginx:1.27", + "image: nginx@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", 1) + + stdout, _, err := runSDL(t, pinned, "validate", "-") + require.NoError(t, err) + require.Contains(t, stdout, "0 warning(s)") +} diff --git a/internal/cli/sdl/lint.go b/internal/cli/sdl/lint.go new file mode 100644 index 00000000..7d61150b --- /dev/null +++ b/internal/cli/sdl/lint.go @@ -0,0 +1,124 @@ +package sdl + +import ( + "fmt" + "strings" + + manifest "pkg.akt.dev/go/manifest/v2beta3" + dtypes "pkg.akt.dev/go/node/deployment/v1beta4" +) + +// Lint rules ported from console-axi's sdl/lint.ts: best-practice checks +// that the SDL parser does not enforce. Each rule is small and pure so new +// ones are easy to add. + +// lintImages enforces the image-pinning rule: every service image must carry +// an explicit tag or a sha256 digest. An untagged image or ":latest" is not +// reproducible and is rejected. +func lintImages(m manifest.Manifest) []Issue { + var issues []Issue + + seen := make(map[string]struct{}) + + for _, g := range m { + for _, s := range g.Services { + if _, dup := seen[s.Name]; dup { + continue + } + seen[s.Name] = struct{}{} + + if iss := checkImageTag(s.Name, s.Image); iss != nil { + issues = append(issues, *iss) + } + } + } + + return issues +} + +func checkImageTag(service, image string) *Issue { + if image == "" { + return nil // a missing image is the schema validator's problem + } + + if strings.Contains(image, "@sha256:") { + return nil // digest-pinned is reproducible + } + + tag, ok := imageTag(image) + + switch { + case !ok: + return &Issue{ + Path: fmt.Sprintf("services/%s/image", service), + Message: fmt.Sprintf("image %q has no tag; pin an explicit version for reproducible deployments", image), + Hint: fmt.Sprintf("use %q instead of an untagged image", image+":"), + } + case tag == "latest": + return &Issue{ + Path: fmt.Sprintf("services/%s/image", service), + Message: fmt.Sprintf("image %q uses \":latest\", which is not reproducible", image), + Hint: fmt.Sprintf("pin a specific version, e.g. %q", strings.TrimSuffix(image, ":latest")+":1.2.3"), + } + } + + return nil +} + +// imageTag extracts an image tag, ignoring a `host:port/` registry prefix. +// ok is false when the image has no tag. +func imageTag(image string) (tag string, ok bool) { + lastColon := strings.LastIndex(image, ":") + lastSlash := strings.LastIndex(image, "/") + + // A colon before the last slash belongs to a registry host:port, not a tag. + if lastColon == -1 || lastColon < lastSlash { + return "", false + } + + return image[lastColon+1:], true +} + +// lintPricing checks placement pricing denoms. This deliberately softens the +// reference rule: console-axi hard-rejects anything but "uact" because it +// only talks to the managed Console API, whereas akt serves both rails — +// "uakt" is perfectly valid for on-chain deployments. So "uact" passes, +// "uakt" produces a warning (managed console-api contexts price in uact), +// and any other denom is an error, matching the reference. +func lintPricing(groups dtypes.GroupSpecs) (errs, warns []Issue) { + for _, g := range groups { + seen := make(map[string]struct{}) + + for _, ru := range g.Resources { + denom := ru.Price.Denom + if denom == "uact" { + continue + } + + if _, dup := seen[denom]; dup { + continue + } + seen[denom] = struct{}{} + + path := fmt.Sprintf("profiles/placement/%s/pricing", g.Name) + + if denom == "uakt" { + warns = append(warns, Issue{ + Path: path, + Message: `pricing denom "uakt" is on-chain only; managed (console-api) deployments are priced in "uact" (micro-ACT, 1:1 USD)`, + Hint: `keep "uakt" for on-chain deployments, or switch to "uact" before deploying through a console-api context`, + }) + + continue + } + + errs = append(errs, Issue{ + Path: path, + Message: fmt.Sprintf("pricing denom %q is not accepted; use \"uact\" (managed) or \"uakt\" (on-chain)", denom), + Hint: `change the denom to "uact"`, + }) + } + } + + return errs, warns +} diff --git a/internal/cli/sdl/scaffolds.go b/internal/cli/sdl/scaffolds.go new file mode 100644 index 00000000..c6b5bbe6 --- /dev/null +++ b/internal/cli/sdl/scaffolds.go @@ -0,0 +1,400 @@ +// Package sdl implements the transport-independent `akt sdl` command group: +// scaffold listing, SDL generation, and offline validation. Everything runs +// locally — no context, keyring, or RPC endpoint is required, so the group +// declares no capability requirements. +// +// The scaffold shapes are ported from console-axi's sdl/templates +// (registry.ts, common.ts, web.ts, gpu.ts, multi-service.ts, ip-lease.ts). +package sdl + +import ( + "bytes" + "regexp" + "strconv" + + "gopkg.in/yaml.v3" +) + +// Pricing defaults ported from console-axi's sdl/templates/common.ts and +// gpu.ts: generous per-block ceilings (in uact) so bids arrive. +const ( + defaultPrice = 10000 // uact/block + gpuDefaultPrice = 100000 // uact/block — GPU compute is priced higher +) + +// Options are the generation parameters for `akt sdl init`. These are not +// positional-argument twins: every field is an optional knob with a +// per-scaffold default, so `akt sdl init ` with no flags always +// produces a deployable SDL. Pointer fields distinguish "flag not set" from +// an explicit zero. +type Options struct { + Name string + Image string + Port *int + As *int + CPU string + Memory string + Storage string + Count *int + Price *int // max price per block, in uact + Env []string + GPU *int + GPUModel string +} + +// Scaffold is one built-in SDL shape `akt sdl init` can generate. +type Scaffold struct { + Name string + Description string + // Params lists the flags that meaningfully affect this scaffold, + // shown by `akt sdl scaffolds`. + Params []string + // Build assembles the SDL document as a yaml.Node tree so that field + // order is stable and follows SDL conventions (version, services, + // profiles, deployment). + Build func(o Options) *yaml.Node +} + +// scaffoldRegistry is the ordered scaffold registry, ported from +// console-axi's sdl/templates/registry.ts. Add a scaffold by defining a new +// Scaffold value and appending it here — nothing else needs to change. +var scaffoldRegistry = []Scaffold{webScaffold, gpuScaffold, multiServiceScaffold, ipLeaseScaffold} + +// Scaffolds returns the built-in scaffolds in registry order. +func Scaffolds() []Scaffold { return scaffoldRegistry } + +// Lookup returns the scaffold with the given name, or nil if none exists. +func Lookup(name string) *Scaffold { + for i := range scaffoldRegistry { + if scaffoldRegistry[i].Name == name { + return &scaffoldRegistry[i] + } + } + + return nil +} + +// ScaffoldNames returns the registry names in order. +func ScaffoldNames() []string { + names := make([]string, len(scaffoldRegistry)) + for i, s := range scaffoldRegistry { + names[i] = s.Name + } + + return names +} + +// Marshal renders a scaffold document as YAML with 2-space indentation. +func Marshal(doc *yaml.Node) ([]byte, error) { + var buf bytes.Buffer + + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + + if err := enc.Encode(doc); err != nil { + _ = enc.Close() + return nil, err + } + + if err := enc.Close(); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// webScaffold ports console-axi's templates/web.ts: a single container with +// one HTTP port exposed to the internet. +var webScaffold = Scaffold{ + Name: "web", + Description: "Single web service with one HTTP port exposed to the internet.", + Params: []string{"--image", "--port", "--as", "--cpu", "--memory", "--storage", "--count", "--price", "--env"}, + Build: func(o Options) *yaml.Node { + name := orStr(o.Name, "web") + + svc := mapNode(pair("image", strNode(orStr(o.Image, "nginx:1.27")))) + if len(o.Env) > 0 { + appendPair(svc, "env", envNode(o.Env)) + } + appendPair(svc, "expose", exposeNode(orInt(o.Port, 80), orInt(o.As, 80), + mapNode(pair("global", boolNode(true))))) + + return mapNode( + pair("version", quotedNode("2.0")), + pair("services", mapNode(pair(name, svc))), + pair("profiles", mapNode( + pair("compute", mapNode(pair(name, mapNode( + pair("resources", computeResources(o, "0.5", "512Mi", "512Mi")))))), + pair("placement", placementNode([]string{name}, orInt(o.Price, defaultPrice))), + )), + pair("deployment", deploymentNode(name, name, orInt(o.Count, 1))), + ) + }, +} + +// gpuScaffold ports console-axi's templates/gpu.ts: a GPU workload +// (ML/inference) with an nvidia model requirement. +var gpuScaffold = Scaffold{ + Name: "gpu", + Description: "GPU workload (ML/inference) with an nvidia model requirement.", + Params: []string{"--image", "--gpu", "--gpu-model", "--port", "--as", "--cpu", "--memory", "--storage", "--count", "--price", "--env"}, + Build: func(o Options) *yaml.Node { + name := orStr(o.Name, "app") + + svc := mapNode(pair("image", strNode(orStr(o.Image, "pytorch/pytorch:2.2.0-cuda12.1-cudnn8-runtime")))) + if len(o.Env) > 0 { + appendPair(svc, "env", envNode(o.Env)) + } + appendPair(svc, "expose", exposeNode(orInt(o.Port, 8080), orInt(o.As, 80), + mapNode(pair("global", boolNode(true))))) + + resources := computeResources(o, "4", "16Gi", "50Gi") + appendPair(resources, "gpu", mapNode( + pair("units", intNode(orInt(o.GPU, 1))), + pair("attributes", mapNode(pair("vendor", mapNode(pair("nvidia", + seqNode(mapNode(pair("model", strNode(orStr(o.GPUModel, "a100")))))))))), + )) + + return mapNode( + pair("version", quotedNode("2.0")), + pair("services", mapNode(pair(name, svc))), + pair("profiles", mapNode( + pair("compute", mapNode(pair(name, mapNode(pair("resources", resources))))), + pair("placement", placementNode([]string{name}, orInt(o.Price, gpuDefaultPrice))), + )), + pair("deployment", deploymentNode(name, name, orInt(o.Count, 1))), + ) + }, +} + +// multiServiceScaffold ports console-axi's templates/multi-service.ts: a +// public `app` plus a `db` with a persistent volume and internal-only +// networking. A starting point the user edits (images, env, sizes). +var multiServiceScaffold = Scaffold{ + Name: "multi-service", + Description: "App + database with a persistent volume and service-to-service networking.", + Params: []string{"--image", "--port", "--as", "--cpu", "--memory", "--storage", "--count", "--price", "--env"}, + Build: func(o Options) *yaml.Node { + app := mapNode( + pair("image", strNode(orStr(o.Image, "nginx:1.27"))), + pair("dependencies", seqNode(mapNode(pair("service", strNode("db"))))), + ) + if len(o.Env) > 0 { + appendPair(app, "env", envNode(o.Env)) + } + appendPair(app, "expose", exposeNode(orInt(o.Port, 80), orInt(o.As, 80), + mapNode(pair("global", boolNode(true))))) + + db := mapNode( + pair("image", strNode("postgres:16")), + pair("env", envNode([]string{"POSTGRES_PASSWORD=changeme", "POSTGRES_USER=app", "POSTGRES_DB=app"})), + pair("expose", seqNode(mapNode( + pair("port", intNode(5432)), + pair("to", seqNode(mapNode(pair("service", strNode("app"))))), + ))), + pair("params", mapNode(pair("storage", mapNode(pair("db-data", mapNode( + pair("mount", strNode("/var/lib/postgresql/data")), + pair("readOnly", boolNode(false)), + )))))), + ) + + dbResources := mapNode( + pair("cpu", mapNode(pair("units", cpuUnitsNode("0.5")))), + pair("memory", mapNode(pair("size", strNode("1Gi")))), + pair("storage", seqNode( + mapNode(pair("size", strNode("1Gi"))), + mapNode( + pair("name", strNode("db-data")), + pair("size", strNode(orStr(o.Storage, "10Gi"))), + pair("attributes", mapNode( + pair("persistent", boolNode(true)), + pair("class", strNode("beta2")), + )), + ), + )), + ) + + return mapNode( + pair("version", quotedNode("2.0")), + pair("services", mapNode(pair("app", app), pair("db", db))), + pair("profiles", mapNode( + pair("compute", mapNode( + pair("app", mapNode(pair("resources", computeResources(o, "0.5", "512Mi", "512Mi")))), + pair("db", mapNode(pair("resources", dbResources))), + )), + pair("placement", placementNode([]string{"app", "db"}, orInt(o.Price, defaultPrice))), + )), + pair("deployment", mapNode( + pair("app", mapNode(pair("dcloud", mapNode( + pair("profile", strNode("app")), + pair("count", intNode(orInt(o.Count, 1))), + )))), + pair("db", mapNode(pair("dcloud", mapNode( + pair("profile", strNode("db")), + pair("count", intNode(1)), + )))), + )), + ) + }, +} + +// ipLeaseScaffold ports console-axi's templates/ip-lease.ts: a service with +// a dedicated public IP (SDL v2.1 endpoints + expose to ip). +var ipLeaseScaffold = Scaffold{ + Name: "ip-lease", + Description: "Service with a dedicated public IP (endpoints + expose to ip).", + Params: []string{"--image", "--port", "--as", "--cpu", "--memory", "--storage", "--count", "--price", "--env"}, + Build: func(o Options) *yaml.Node { + name := orStr(o.Name, "web") + const endpoint = "appip" + + svc := mapNode(pair("image", strNode(orStr(o.Image, "nginx:1.27")))) + if len(o.Env) > 0 { + appendPair(svc, "env", envNode(o.Env)) + } + appendPair(svc, "expose", exposeNode(orInt(o.Port, 80), orInt(o.As, 80), mapNode( + pair("global", boolNode(true)), + pair("ip", strNode(endpoint)), + ))) + + return mapNode( + pair("version", quotedNode("2.1")), + pair("endpoints", mapNode(pair(endpoint, mapNode(pair("kind", strNode("ip")))))), + pair("services", mapNode(pair(name, svc))), + pair("profiles", mapNode( + pair("compute", mapNode(pair(name, mapNode( + pair("resources", computeResources(o, "0.5", "512Mi", "512Mi")))))), + pair("placement", placementNode([]string{name}, orInt(o.Price, defaultPrice))), + )), + pair("deployment", deploymentNode(name, name, orInt(o.Count, 1))), + ) + }, +} + +// ---- shared SDL fragments (ported from templates/common.ts) -------------- + +// computeResources builds a simple (single ephemeral volume) resources block +// from the options plus per-scaffold defaults. +func computeResources(o Options, defCPU, defMemory, defStorage string) *yaml.Node { + return mapNode( + pair("cpu", mapNode(pair("units", cpuUnitsNode(orStr(o.CPU, defCPU))))), + pair("memory", mapNode(pair("size", strNode(orStr(o.Memory, defMemory))))), + pair("storage", mapNode(pair("size", strNode(orStr(o.Storage, defStorage))))), + ) +} + +// placementNode prices every given compute profile at the same uact ceiling +// under a single "dcloud" placement group. +func placementNode(profiles []string, price int) *yaml.Node { + pricing := make([]nodePair, len(profiles)) + for i, p := range profiles { + pricing[i] = pair(p, mapNode( + pair("denom", strNode("uact")), + pair("amount", intNode(price)), + )) + } + + return mapNode(pair("dcloud", mapNode(pair("pricing", mapNode(pricing...))))) +} + +// deploymentNode maps one service onto one compute profile. +func deploymentNode(service, profile string, count int) *yaml.Node { + return mapNode(pair(service, mapNode(pair("dcloud", mapNode( + pair("profile", strNode(profile)), + pair("count", intNode(count)), + ))))) +} + +// exposeNode builds a single-entry expose list with the given `to` targets. +func exposeNode(port, as int, to ...*yaml.Node) *yaml.Node { + return seqNode(mapNode( + pair("port", intNode(port)), + pair("as", intNode(as)), + pair("to", seqNode(to...)), + )) +} + +// ---- yaml.Node construction helpers --------------------------------------- + +type nodePair struct { + key string + val *yaml.Node +} + +func pair(key string, val *yaml.Node) nodePair { return nodePair{key: key, val: val} } + +func mapNode(pairs ...nodePair) *yaml.Node { + n := &yaml.Node{Kind: yaml.MappingNode} + for _, p := range pairs { + n.Content = append(n.Content, strNode(p.key), p.val) + } + + return n +} + +func appendPair(m *yaml.Node, key string, val *yaml.Node) { + m.Content = append(m.Content, strNode(key), val) +} + +func seqNode(items ...*yaml.Node) *yaml.Node { + return &yaml.Node{Kind: yaml.SequenceNode, Content: items} +} + +func strNode(v string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: v} +} + +// quotedNode forces double quotes so values like "2.0" stay strings. +func quotedNode(v string) *yaml.Node { + n := strNode(v) + n.Style = yaml.DoubleQuotedStyle + + return n +} + +func intNode(v int) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(v)} +} + +func boolNode(v bool) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strconv.FormatBool(v)} +} + +var numericValueRe = regexp.MustCompile(`^\d+(\.\d+)?$`) + +// cpuUnitsNode keeps numeric cpu strings numeric (0.5, 2) and passes +// millicore strings ("500m") through as strings, mirroring the reference's +// cpuUnits helper in templates/common.ts. +func cpuUnitsNode(v string) *yaml.Node { + if numericValueRe.MatchString(v) { + // Plain scalar: the YAML encoder resolves it to an int or float. + return &yaml.Node{Kind: yaml.ScalarNode, Value: v} + } + + return strNode(v) +} + +func envNode(env []string) *yaml.Node { + items := make([]*yaml.Node, len(env)) + for i, e := range env { + items[i] = strNode(e) + } + + return seqNode(items...) +} + +func orStr(v, def string) string { + if v != "" { + return v + } + + return def +} + +func orInt(v *int, def int) int { + if v != nil { + return *v + } + + return def +} diff --git a/internal/cli/sdl/validate.go b/internal/cli/sdl/validate.go new file mode 100644 index 00000000..927697fa --- /dev/null +++ b/internal/cli/sdl/validate.go @@ -0,0 +1,79 @@ +package sdl + +import ( + "fmt" + + manifest "pkg.akt.dev/go/manifest/v2beta3" + gosdl "pkg.akt.dev/go/sdl" +) + +// Issue is a single validation or lint finding. +type Issue struct { + Path string + Message string + Hint string +} + +// Result is the outcome of validating one SDL document. +type Result struct { + Valid bool + Services int + Groups int + Errors []Issue + Warnings []Issue +} + +// Validate parses an SDL document with pkg.akt.dev/go/sdl — the same parser +// and schema/relational validation used by `akt deploy` and the chain tx +// commands — then applies the local lint rules ported from console-axi +// (see lint.go). Parse failures and unpinned images are errors; on-chain +// pricing denoms are warnings. It never panics and collects every issue it +// can find. +func Validate(data []byte) Result { + doc, err := gosdl.Read(data) + if err != nil { + return Result{Errors: []Issue{{ + Path: "(root)", + Message: err.Error(), + Hint: "Check YAML syntax and SDL structure.", + }}} + } + + m, err := doc.Manifest() + if err != nil { + return Result{Errors: []Issue{{Path: "(root)", Message: fmt.Sprintf("manifest: %v", err)}}} + } + + groups, err := doc.DeploymentGroups() + if err != nil { + return Result{Errors: []Issue{{Path: "(root)", Message: fmt.Sprintf("deployment groups: %v", err)}}} + } + + res := Result{ + Services: countServices(m), + Groups: len(groups), + } + + res.Errors = append(res.Errors, lintImages(m)...) + + pricingErrs, pricingWarns := lintPricing(groups) + res.Errors = append(res.Errors, pricingErrs...) + res.Warnings = append(res.Warnings, pricingWarns...) + + res.Valid = len(res.Errors) == 0 + + return res +} + +// countServices counts distinct service names across all manifest groups +// (a service placed in several groups is still one service). +func countServices(m manifest.Manifest) int { + seen := make(map[string]struct{}) + for _, g := range m { + for _, s := range g.Services { + seen[s.Name] = struct{}{} + } + } + + return len(seen) +} diff --git a/internal/cli/workflow/commands.go b/internal/cli/workflow/commands.go index 50573724..97c7f6f9 100644 --- a/internal/cli/workflow/commands.go +++ b/internal/cli/workflow/commands.go @@ -4,19 +4,43 @@ package workflow import ( + "encoding/json" "fmt" + "io" "strconv" "github.com/spf13/cobra" + "github.com/spf13/pflag" + "pkg.akt.dev/akt/internal/capability" + chaincli "pkg.akt.dev/akt/internal/cli/chain" + cflags "pkg.akt.dev/akt/internal/cli/chain/flags" + "pkg.akt.dev/akt/internal/cliutil" + "pkg.akt.dev/akt/internal/console" + aktctx "pkg.akt.dev/akt/internal/context" + "pkg.akt.dev/akt/internal/transport" wf "pkg.akt.dev/akt/internal/workflow" "pkg.akt.dev/akt/internal/workflow/builtin" + "pkg.akt.dev/akt/internal/workflow/steps" ) +// outputJSONL is the --output value selecting JSONL step output (SPEC §2.3.8). +const outputJSONL = "jsonl" + // Commands discovers available workflow definitions and returns a cobra command // for each one. Only workflows that exist (built-in or user-defined) produce // commands. Returns nil if no workflows are found. +// +// Deprecated: commands built without a context manager cannot execute +// workflows (only --dry-run works). Use CommandsWithManager. func Commands(homeFn func() string, ctxNameFn func() string) []*cobra.Command { + return CommandsWithManager(homeFn, ctxNameFn, nil) +} + +// CommandsWithManager is Commands plus the context manager the generated +// commands need to resolve credentials (keyring wallet vs Console API key) +// when executing workflows. +func CommandsWithManager(homeFn func() string, ctxNameFn func() string, mgrFn func() *aktctx.Manager) []*cobra.Command { loader := wf.NewLoader(homeFn(), ctxNameFn(), builtin.Workflows()) names := loader.List() @@ -26,7 +50,7 @@ func Commands(homeFn func() string, ctxNameFn func() string) []*cobra.Command { if err != nil { continue // skip workflows that fail to parse } - cmds = append(cmds, commandFromDef(def, homeFn, ctxNameFn)) + cmds = append(cmds, commandFromDef(def, homeFn, ctxNameFn, mgrFn)) } return cmds @@ -34,7 +58,7 @@ func Commands(homeFn func() string, ctxNameFn func() string) []*cobra.Command { // commandFromDef generates a cobra command from a workflow definition. // Flags are auto-generated from the workflow's declared params. -func commandFromDef(def *wf.WorkflowDef, homeFn func() string, ctxNameFn func() string) *cobra.Command { +func commandFromDef(def *wf.WorkflowDef, homeFn func() string, ctxNameFn func() string, mgrFn func() *aktctx.Manager) *cobra.Command { // Determine positional arg usage from params. use := def.Name var fileParam string @@ -54,10 +78,46 @@ func commandFromDef(def *wf.WorkflowDef, homeFn func() string, ctxNameFn func() } } + // discoverErr records a chain-client discovery failure from PreRunE so + // RunE can surface it inside a clearer, workflow-specific error. + var discoverErr error + cmd := &cobra.Command{ - Use: use, - Short: def.Description, + Use: use, + Short: def.Description, Example: fmt.Sprintf(" akt %s --help", def.Name), + // Workflow commands run on either rail (internal/transport): chain + // tx broadcasting on keyring contexts or Console API calls on + // console-api contexts. Either capability satisfies the gate. + Annotations: map[string]string{ + capability.AnnotationKey: string(capability.ChainTx) + "|" + string(capability.Console), + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + // Dry runs never need clients. + if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun { + return nil + } + + // Chain client discovery only applies when running under the + // real root command (which seeds the SDK client context) with a + // keyring-auth context. console-api contexts do not need a + // wallet or an RPC connection to execute. + if cmd.Context() == nil || cmd.Context().Value(chaincli.ClientContextKey) == nil { + return nil + } + + rc := resolveContext(mgrFn, ctxNameFn) + if rc == nil || rc.AuthMethod == aktctx.AuthMethodConsoleAPI { + return nil // missing context/credentials surfaced in RunE + } + + if err := chaincli.TxPersistentPreRunE(cmd, args); err != nil { + // Keep the cause; RunE reports it with workflow context. + discoverErr = err + } + + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { // Reload the workflow at runtime (config may have changed). rtLoader := wf.NewLoader(homeFn(), ctxNameFn(), builtin.Workflows()) @@ -113,32 +173,12 @@ func commandFromDef(def *wf.WorkflowDef, homeFn func() string, ctxNameFn func() dryRun, _ := cmd.Flags().GetBool("dry-run") out := cmd.OutOrStdout() + jsonl := outputFormat(cmd) == outputJSONL - fmt.Fprintf(out, "Workflow: %s (v%d)\n", rtDef.Name, rtDef.Version) - fmt.Fprintf(out, " %s\n\n", rtDef.Description) - - fmt.Fprintln(out, "Parameters:") - for k, v := range params { - fmt.Fprintf(out, " %-16s %v\n", k+":", v) - } - fmt.Fprintln(out) - - fmt.Fprintf(out, "Steps (%d):\n", len(rtDef.Steps)) - for i, step := range rtDef.Steps { - fmt.Fprintf(out, " %d. [%s] %s", i+1, step.Type, step.Name) - if step.Msg != "" { - fmt.Fprintf(out, " -> %s", step.Msg) - } - if step.Action != "" { - fmt.Fprintf(out, " -> %s", step.Action) - } - if step.OnError != "" { - fmt.Fprintf(out, " (on-error: %s)", step.OnError) - } - if step.Retry != nil { - fmt.Fprintf(out, " (retry: %dx, %s delay)", step.Retry.Max, step.Retry.Delay) - } - fmt.Fprintln(out) + // Print the execution plan, except in JSONL mode where stdout + // must carry only JSONL step lines. + if dryRun || !jsonl { + printPlan(out, rtDef, params) } if dryRun { @@ -146,10 +186,7 @@ func commandFromDef(def *wf.WorkflowDef, homeFn func() string, ctxNameFn func() return nil } - // TODO: Execute the workflow via Engine.Run() once - // ChainClient and ProviderClient are wired. - fmt.Fprintln(out, "\nExecution requires chain client (not yet wired). Use --dry-run to preview.") - return nil + return executeWorkflow(cmd, rtDef, params, mgrFn, ctxNameFn, jsonl, discoverErr) }, } @@ -178,6 +215,11 @@ func commandFromDef(def *wf.WorkflowDef, homeFn func() string, ctxNameFn func() cmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompts") cmd.Flags().Bool("dry-run", false, "Show execution plan without broadcasting transactions") + // Standard chain tx flags (--from, --gas, --node, ...) so keyring-auth + // execution can discover a chain client; flags already defined above + // (e.g. --yes, --dry-run, workflow params) keep their workflow meaning. + addMissingTxFlags(cmd) + // Set args validation based on what we expect. minArgs := 0 maxArgs := 0 @@ -192,3 +234,302 @@ func commandFromDef(def *wf.WorkflowDef, homeFn func() string, ctxNameFn func() return cmd } + +// executeWorkflow resolves credentials for the active context, picks the +// transport for its rail (internal/transport: chain for keyring auth, +// console for console-api auth — abstracted away from the user; the command +// arguments are identical on both), runs the engine, and renders per-step +// results. +func executeWorkflow( + cmd *cobra.Command, + rtDef *wf.WorkflowDef, + params map[string]any, + mgrFn func() *aktctx.Manager, + ctxNameFn func() string, + jsonl bool, + discoverErr error, +) error { + out := cmd.OutOrStdout() + + if mgrFn == nil || mgrFn() == nil { + return fmt.Errorf("no configuration loaded; workflow execution requires a configured context (create one with `akt context create`)") + } + + rc, err := mgrFn().Resolve(ctxNameFn()) + if err != nil { + return fmt.Errorf("no usable context for workflow execution: %w", err) + } + + var ( + chainCl steps.ChainClient + providerCl steps.ProviderClient + ) + + account := rc.DefaultAccount + + if rc.AuthMethod == aktctx.AuthMethodConsoleAPI { + if rc.ConsoleAPIKey == "" { + return fmt.Errorf( + "context %q uses console-api auth but no Console credential is available.\n"+ + "Set %s, store a key with `akt context edit %s --console-api-key `, or switch to a keyring context", + rc.Name, aktctx.EnvConsoleAPIKey, rc.Name, + ) + } + + cc := console.New(rc.ConsoleAPIURL, rc.ConsoleAPIKey). + WithActionLog(cliutil.ActionLogFromContext(cmd.Context())) + + // Chain queries still go directly to the chain when a client is + // available (SPEC §7.4); otherwise the console transport falls back + // to the Console bids endpoint. + var chainQueries steps.ChainClient + if cl, cerr := chaincli.ClientFromContext(cmd.Context()); cerr == nil { + chainQueries = transport.NewChain(cl) + } + + chainCl = transport.NewConsole(cc, chainQueries, rc.Root, rc.Name) + + // Provider gateway steps are not supported with console-api auth: + // the Console API submits the manifest internally during lease + // creation (SPEC §7.4), so drop them instead of failing the run. + rtDef = filterProviderSteps(rtDef, cmd.ErrOrStderr()) + } else { + cl, cerr := chaincli.ClientFromContext(cmd.Context()) + if cerr != nil { + detail := cerr + if discoverErr != nil { + detail = discoverErr + } + + return fmt.Errorf( + "no wallet/chain client available for context %q (keyring auth): %v.\n"+ + "Keyring execution needs a reachable RPC node and a configured key; check the context's network endpoints and keyring, or switch to a console-api context with an API key", + rc.Name, detail, + ) + } + + chainCl = transport.NewChain(cl) + providerCl = transport.NewProvider(cl.ClientContext(), rc.AuthType) + + if addr := cl.ClientContext().GetFromAddress(); !addr.Empty() { + account = addr.String() + } else if name := cl.ClientContext().FromName; name != "" { + account = name + } + } + + registry := steps.NewRegistry(chainCl, providerCl) + if jsonl { + // Keep stdout pure JSONL: output-step text is recorded in the step + // result ("text" output) instead of printed. + registry.Register(&steps.OutputExecutor{Out: io.Discard}) + } + + logger := wf.NewActionLogAdapter(cliutil.ActionLogFromContext(cmd.Context()), rtDef.Name) + engine := wf.NewEngine(registry, logger) + + state, runErr := engine.Run(cmd.Context(), rtDef, account, params) + + if jsonl { + emitJSONL(out, state) + } else { + printResults(out, state, runErr) + } + + if runErr != nil { + return fmt.Errorf("workflow %q failed: %w", rtDef.Name, runErr) + } + + return nil +} + +// resolveContext resolves the active context, returning nil when no manager +// or context is available. +func resolveContext(mgrFn func() *aktctx.Manager, ctxNameFn func() string) *aktctx.Context { + if mgrFn == nil { + return nil + } + + mgr := mgrFn() + if mgr == nil { + return nil + } + + rc, err := mgr.Resolve(ctxNameFn()) + if err != nil { + return nil + } + + return rc +} + +// filterProviderSteps returns a copy of def without provider-type steps, +// noting each skipped step on w. Used for console-api auth, where manifest +// submission is handled by the Console API (SPEC §7.4). +func filterProviderSteps(def *wf.WorkflowDef, w io.Writer) *wf.WorkflowDef { + kept := make([]wf.StepDef, 0, len(def.Steps)) + removed := false + + for _, s := range def.Steps { + if s.Type == wf.StepProvider { + fmt.Fprintf(w, "note: skipping step %q (manifest submission handled by Console)\n", s.Name) + removed = true + continue + } + kept = append(kept, s) + } + + if !removed { + return def + } + + filtered := *def + filtered.Steps = kept + + return &filtered +} + +// printPlan renders the workflow execution plan (name, params, steps). +func printPlan(out io.Writer, rtDef *wf.WorkflowDef, params map[string]any) { + fmt.Fprintf(out, "Workflow: %s (v%d)\n", rtDef.Name, rtDef.Version) + fmt.Fprintf(out, " %s\n\n", rtDef.Description) + + fmt.Fprintln(out, "Parameters:") + for k, v := range params { + fmt.Fprintf(out, " %-16s %v\n", k+":", v) + } + fmt.Fprintln(out) + + fmt.Fprintf(out, "Steps (%d):\n", len(rtDef.Steps)) + for i, step := range rtDef.Steps { + fmt.Fprintf(out, " %d. [%s] %s", i+1, step.Type, step.Name) + if step.Msg != "" { + fmt.Fprintf(out, " -> %s", step.Msg) + } + if step.Action != "" { + fmt.Fprintf(out, " -> %s", step.Action) + } + if step.OnError != "" { + fmt.Fprintf(out, " (on-error: %s)", step.OnError) + } + if step.Retry != nil { + fmt.Fprintf(out, " (retry: %dx, %s delay)", step.Retry.Max, step.Retry.Delay) + } + fmt.Fprintln(out) + } +} + +// printResults renders per-step outcomes and the overall workflow status in +// simple aligned text. +func printResults(out io.Writer, state *wf.RunState, runErr error) { + fmt.Fprintln(out, "\nResults:") + + for _, name := range state.StepOrder { + sr := state.Steps[name] + if sr == nil { + continue + } + + fmt.Fprintf(out, " %-20s %-9s", sr.Name, sr.Status) + if sr.TxHash != "" { + fmt.Fprintf(out, " tx: %s", sr.TxHash) + } + if sr.Error != "" { + fmt.Fprintf(out, " error: %s", sr.Error) + } + fmt.Fprintln(out) + } + + if runErr == nil { + fmt.Fprintf(out, "\nWorkflow %q completed successfully.\n", state.Workflow) + } +} + +// jsonlTx is the tx object of a JSONL step line (SPEC §2.3.8). +type jsonlTx struct { + Hash string `json:"hash"` + Height int64 `json:"height"` + GasUsed int64 `json:"gas_used,omitempty"` + Code uint32 `json:"code"` +} + +// jsonlLine is one JSONL step line (SPEC §2.3.8). +type jsonlLine struct { + Workflow string `json:"workflow"` + ID string `json:"id"` + Step string `json:"step"` + Result string `json:"result"` + Errors []string `json:"errors"` + Txs []jsonlTx `json:"txs"` +} + +// emitJSONL writes one JSONL line per completed step (SPEC §2.3.8). +func emitJSONL(out io.Writer, state *wf.RunState) { + enc := json.NewEncoder(out) + + for _, name := range state.StepOrder { + sr := state.Steps[name] + if sr == nil { + continue + } + + line := jsonlLine{ + Workflow: state.Workflow, + ID: state.WorkflowID, + Step: sr.Name, + Errors: []string{}, + Txs: []jsonlTx{}, + } + + switch sr.Status { + case "success": + line.Result = "completed" + case "skipped": + line.Result = "skipped" + default: + line.Result = "error" + } + + if sr.Error != "" { + line.Errors = append(line.Errors, sr.Error) + } + + if sr.TxHash != "" { + line.Txs = append(line.Txs, jsonlTx{ + Hash: sr.TxHash, + Height: sr.Height, + }) + } + + _ = enc.Encode(line) + } +} + +// outputFormat returns the effective --output value for the command, +// defaulting to "pretty" when the flag is not registered (e.g. bare command +// execution in tests). +func outputFormat(cmd *cobra.Command) string { + if f := cmd.Flags().Lookup("output"); f != nil && f.Value.String() != "" { + return f.Value.String() + } + + return "pretty" +} + +// addMissingTxFlags registers the standard chain tx flags (defined by +// cflags.AddTxFlagsToCmd) on cmd, skipping any flag whose name or shorthand +// the command already uses (e.g. --yes, --dry-run, or a workflow param). +func addMissingTxFlags(cmd *cobra.Command) { + tmp := &cobra.Command{} + cflags.AddTxFlagsToCmd(tmp) + + tmp.Flags().VisitAll(func(f *pflag.Flag) { + if cmd.Flags().Lookup(f.Name) != nil { + return + } + if f.Shorthand != "" && cmd.Flags().ShorthandLookup(f.Shorthand) != nil { + return + } + cmd.Flags().AddFlag(f) + }) +} diff --git a/internal/cli/workflow/commands_test.go b/internal/cli/workflow/commands_test.go new file mode 100644 index 00000000..c0fc3b60 --- /dev/null +++ b/internal/cli/workflow/commands_test.go @@ -0,0 +1,581 @@ +package workflow + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "pkg.akt.dev/akt/internal/capability" + aktctx "pkg.akt.dev/akt/internal/context" + wf "pkg.akt.dev/akt/internal/workflow" + "pkg.akt.dev/akt/internal/workflow/builtin" +) + +// commandNames extracts the sorted names of a set of cobra commands. +func commandNames(cmds []*cobra.Command) []string { + names := make([]string, 0, len(cmds)) + for _, c := range cmds { + names = append(names, c.Name()) + } + sort.Strings(names) + + return names +} + +// findCommand returns the command with the given name, or nil. +func findCommand(cmds []*cobra.Command, name string) *cobra.Command { + for _, c := range cmds { + if c.Name() == name { + return c + } + } + + return nil +} + +// staticFns returns homeFn/ctxNameFn closures over a fixed home and empty context. +func staticFns(home string) (func() string, func() string) { + return func() string { return home }, func() string { return "" } +} + +// TestCommandsBuiltinsOnly verifies that with no user workflows present, +// Commands surfaces exactly the built-in set: close, deploy, update. +func TestCommandsBuiltinsOnly(t *testing.T) { + homeFn, ctxNameFn := staticFns(t.TempDir()) + + cmds := Commands(homeFn, ctxNameFn) + + got := commandNames(cmds) + want := []string{"close", "deploy", "update"} + if len(got) != len(want) { + t.Fatalf("Commands() returned %v, want exactly %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("Commands() returned %v, want exactly %v", got, want) + } + } +} + +// TestCommandsUserWorkflowSurfacing verifies that a command appears if and +// only if its backing workflow exists: "foo" is absent before the workflow +// file is written and present after. +func TestCommandsUserWorkflowSurfacing(t *testing.T) { + home := t.TempDir() + homeFn, ctxNameFn := staticFns(home) + + // Negative case: no foo workflow exists, so no foo command surfaces. + if cmd := findCommand(Commands(homeFn, ctxNameFn), "foo"); cmd != nil { + t.Fatalf("Commands() surfaced %q before its workflow existed", "foo") + } + + // Write a minimal valid user workflow. + dir := filepath.Join(home, "workflows") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + yamlDef := `name: foo +description: A user-defined workflow +version: 1 + +params: + label: + type: string + default: "hello" + description: A label to print + +steps: + - name: display + type: output + template: | + {{ .Params.label }} +` + if err := os.WriteFile(filepath.Join(dir, "foo.yaml"), []byte(yamlDef), 0o644); err != nil { + t.Fatal(err) + } + + // Positive case: the workflow now resolves, so the command surfaces. + cmd := findCommand(Commands(homeFn, ctxNameFn), "foo") + if cmd == nil { + t.Fatalf("Commands() did not surface %q after its workflow was written", "foo") + } + if cmd.Short != "A user-defined workflow" { + t.Fatalf("foo command Short = %q, want %q", cmd.Short, "A user-defined workflow") + } + if cmd.Flags().Lookup("label") == nil { + t.Fatalf("foo command missing --label flag generated from params") + } +} + +// TestCommandsSkipsMalformedWorkflow verifies that a malformed workflow YAML +// is skipped silently and does not produce a command. +func TestCommandsSkipsMalformedWorkflow(t *testing.T) { + home := t.TempDir() + homeFn, ctxNameFn := staticFns(home) + + dir := filepath.Join(home, "workflows") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "bad.yaml"), []byte("{{ not: [valid yaml"), 0o644); err != nil { + t.Fatal(err) + } + + cmds := Commands(homeFn, ctxNameFn) + + if cmd := findCommand(cmds, "bad"); cmd != nil { + t.Fatalf("Commands() surfaced a command for malformed workflow %q", "bad") + } + got := commandNames(cmds) + want := []string{"close", "deploy", "update"} + if len(got) != len(want) { + t.Fatalf("Commands() returned %v, want exactly the built-ins %v", got, want) + } +} + +// loadBuiltin loads a built-in workflow definition through the loader, +// using an empty temp home so only embedded definitions resolve. +func loadBuiltin(t *testing.T, name string) *wf.WorkflowDef { + t.Helper() + + loader := wf.NewLoader(t.TempDir(), "", builtin.Workflows()) + def, err := loader.Load(name) + if err != nil { + t.Fatalf("load built-in workflow %q: %v", name, err) + } + + return def +} + +// TestCommandFromDefClose verifies the generated close command: positional +// [dseq] in Use, a dseq int flag, and the common --yes/--dry-run flags. +func TestCommandFromDefClose(t *testing.T) { + homeFn, ctxNameFn := staticFns(t.TempDir()) + + cmd := commandFromDef(loadBuiltin(t, "close"), homeFn, ctxNameFn, nil) + + if cmd.Use != "close [dseq]" { + t.Fatalf("close Use = %q, want %q", cmd.Use, "close [dseq]") + } + dseq := cmd.Flags().Lookup("dseq") + if dseq == nil { + t.Fatal("close command missing --dseq flag") + } + if dseq.Value.Type() != "int" { + t.Fatalf("--dseq flag type = %q, want %q", dseq.Value.Type(), "int") + } + if cmd.Flags().Lookup("yes") == nil { + t.Fatal("close command missing common --yes flag") + } + if cmd.Flags().Lookup("dry-run") == nil { + t.Fatal("close command missing common --dry-run flag") + } +} + +// TestCommandFromDefDeploy verifies the generated deploy command: the +// required file param is positional in Use (not a flag), the remaining +// params become flags, and the common --yes/--dry-run flags exist. +func TestCommandFromDefDeploy(t *testing.T) { + homeFn, ctxNameFn := staticFns(t.TempDir()) + + cmd := commandFromDef(loadBuiltin(t, "deploy"), homeFn, ctxNameFn, nil) + + if cmd.Use != "deploy " { + t.Fatalf("deploy Use = %q, want %q", cmd.Use, "deploy ") + } + if cmd.Flags().Lookup("sdl-file") != nil { + t.Fatal("deploy command has --sdl-file flag; file param must be positional only") + } + for _, flag := range []string{"deposit", "bid-timeout", "bid-select", "yes", "dry-run"} { + if cmd.Flags().Lookup(flag) == nil { + t.Fatalf("deploy command missing --%s flag", flag) + } + } +} + +// TestUserWorkflowOverridesBuiltin verifies that a user workflow with the +// same name as a built-in takes precedence when the command is generated. +func TestUserWorkflowOverridesBuiltin(t *testing.T) { + home := t.TempDir() + homeFn, ctxNameFn := staticFns(home) + + builtinDesc := loadBuiltin(t, "close").Description + + dir := filepath.Join(home, "workflows") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + override := `name: close +description: Custom close override +version: 2 + +steps: + - name: display + type: output + template: | + overridden +` + if err := os.WriteFile(filepath.Join(dir, "close.yaml"), []byte(override), 0o644); err != nil { + t.Fatal(err) + } + + cmd := findCommand(Commands(homeFn, ctxNameFn), "close") + if cmd == nil { + t.Fatal("Commands() did not surface close") + } + if cmd.Short != "Custom close override" { + t.Fatalf("close Short = %q, want the user override %q", cmd.Short, "Custom close override") + } + if cmd.Short == builtinDesc { + t.Fatalf("close Short = %q still matches the built-in description; override not applied", cmd.Short) + } +} + +// TestCommandFromDefTxFlags verifies that the standard chain tx flags are +// merged onto workflow commands (needed for keyring chain-client discovery) +// without clobbering the workflow-specific --yes/--dry-run flags. +func TestCommandFromDefTxFlags(t *testing.T) { + homeFn, ctxNameFn := staticFns(t.TempDir()) + + cmd := commandFromDef(loadBuiltin(t, "close"), homeFn, ctxNameFn, nil) + + for _, flag := range []string{"from", "gas", "node", "chain-id"} { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("close command missing tx flag --%s", flag) + } + } + + dryRun := cmd.Flags().Lookup("dry-run") + if dryRun == nil { + t.Fatal("close command missing --dry-run flag") + } + if !strings.Contains(dryRun.Usage, "execution plan") { + t.Errorf("--dry-run usage = %q, want the workflow meaning, not the tx simulate meaning", dryRun.Usage) + } +} + +// newTestManager builds a real context manager in home with a single +// context named ctxName using the given auth method, and makes it current. +func newTestManager(t *testing.T, home, ctxName, authMethod string) *aktctx.Manager { + t.Helper() + + m, err := aktctx.NewManager(home) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + if err := m.CreateNetworkFromTemplate("mainnet", "mainnet"); err != nil { + t.Fatalf("CreateNetworkFromTemplate: %v", err) + } + if m.GetKeyring("default") == nil { + if err := m.CreateKeyring(aktctx.Keyring{Name: "default"}); err != nil { + t.Fatalf("CreateKeyring: %v", err) + } + } + if err := m.CreateContext(aktctx.Context{ + Name: ctxName, + Network: aktctx.Network{Name: "mainnet"}, + AuthMethod: authMethod, + }); err != nil { + t.Fatalf("CreateContext: %v", err) + } + if err := m.UseContext(ctxName); err != nil { + t.Fatalf("UseContext: %v", err) + } + + return m +} + +// executeCommand runs a generated workflow command with args, capturing +// combined output. +func executeCommand(t *testing.T, cmd *cobra.Command, args ...string) (string, error) { + t.Helper() + + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs(args) + + err := cmd.Execute() + + return buf.String(), err +} + +// TestExecuteConsoleContextWithoutKey verifies the AKT-647 no-credential +// error: a console-api context without a Console API key fails with guidance +// pointing at AKT_CONSOLE_API_KEY, `akt context edit`, and keyring contexts. +func TestExecuteConsoleContextWithoutKey(t *testing.T) { + home := t.TempDir() + t.Setenv(aktctx.EnvConsoleAPIKey, "") + + m := newTestManager(t, home, "console", aktctx.AuthMethodConsoleAPI) + + cmds := CommandsWithManager( + func() string { return home }, + func() string { return "console" }, + func() *aktctx.Manager { return m }, + ) + + cmd := findCommand(cmds, "close") + if cmd == nil { + t.Fatal("CommandsWithManager() did not surface close") + } + + _, err := executeCommand(t, cmd, "123") + if err == nil { + t.Fatal("expected no-credential error, got nil") + } + for _, want := range []string{"console-api", "no Console credential", aktctx.EnvConsoleAPIKey, "--console-api-key", "keyring"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err.Error(), want) + } + } +} + +// TestExecuteDryRunNeedsNoClient verifies --dry-run prints the plan and +// succeeds without any manager, credentials, or chain client. +func TestExecuteDryRunNeedsNoClient(t *testing.T) { + homeFn, ctxNameFn := staticFns(t.TempDir()) + + cmd := findCommand(Commands(homeFn, ctxNameFn), "close") + if cmd == nil { + t.Fatal("Commands() did not surface close") + } + + out, err := executeCommand(t, cmd, "--dry-run", "456") + if err != nil { + t.Fatalf("dry-run execute: %v", err) + } + for _, want := range []string{"close-deployment", "Dry run — no transactions broadcast."} { + if !strings.Contains(out, want) { + t.Errorf("dry-run output %q does not contain %q", out, want) + } + } +} + +// TestExecuteKeyringContextWithoutChainClient verifies the clear +// wallet/chain-client error when a keyring context has no chain client in +// the command context (the "neither credential" case). +func TestExecuteKeyringContextWithoutChainClient(t *testing.T) { + home := t.TempDir() + + m := newTestManager(t, home, "wallet", aktctx.AuthMethodKeyring) + + cmds := CommandsWithManager( + func() string { return home }, + func() string { return "wallet" }, + func() *aktctx.Manager { return m }, + ) + + cmd := findCommand(cmds, "close") + if cmd == nil { + t.Fatal("CommandsWithManager() did not surface close") + } + + _, err := executeCommand(t, cmd, "123") + if err == nil { + t.Fatal("expected wallet/chain-client error, got nil") + } + if !strings.Contains(err.Error(), "no wallet/chain client available") { + t.Errorf("error %q does not explain that no wallet/chain client is available", err.Error()) + } +} + +// TestExecuteConsoleDeployEndToEnd runs the built-in deploy workflow against +// a fake Console API: create deployment (SDL + USD deposit), poll bids via +// the Console fallback, auto-select the cheapest bid, create the lease with +// the cached manifest, and skip the provider send-manifest step. +func TestExecuteConsoleDeployEndToEnd(t *testing.T) { + home := t.TempDir() + t.Setenv(aktctx.EnvConsoleAPIKey, "secret-key") + + var leaseBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/deployments": + _, _ = w.Write([]byte(`{"data":{"dseq":"4242","manifest":"[{\"name\":\"web\"}]","signTx":{"code":0,"transactionHash":"CREATEHASH"}}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/bids": + _, _ = w.Write([]byte(`{"data":[ + {"bid":{"id":{"owner":"o","dseq":"4242","gseq":1,"oseq":1,"provider":"akash1exp"},"state":"open","price":{"denom":"uakt","amount":"25"}}}, + {"bid":{"id":{"owner":"o","dseq":"4242","gseq":1,"oseq":1,"provider":"akash1cheap"},"state":"open","price":{"denom":"uakt","amount":"10"}}} + ]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/leases": + if err := json.NewDecoder(r.Body).Decode(&leaseBody); err != nil { + t.Errorf("decode lease body: %v", err) + } + _, _ = w.Write([]byte(`{"data":{"deployment":{"id":{"owner":"o","dseq":"4242"},"state":"active"}}}`)) + default: + t.Errorf("unexpected console request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + + m := newTestManager(t, home, "console", aktctx.AuthMethodConsoleAPI) + if err := m.UpdateContext("console", func(c *aktctx.Context) error { + c.ConsoleAPIURL = srv.URL + return nil + }); err != nil { + t.Fatalf("UpdateContext: %v", err) + } + + sdlPath := filepath.Join(t.TempDir(), "app.yaml") + if err := os.WriteFile(sdlPath, []byte("services:\n web:\n image: nginx\n"), 0o600); err != nil { + t.Fatal(err) + } + + cmds := CommandsWithManager( + func() string { return home }, + func() string { return "console" }, + func() *aktctx.Manager { return m }, + ) + + cmd := findCommand(cmds, "deploy") + if cmd == nil { + t.Fatal("CommandsWithManager() did not surface deploy") + } + + out, err := executeCommand(t, cmd, sdlPath, "--deposit", "5", "--bid-select", "cheapest") + if err != nil { + t.Fatalf("deploy execute: %v\noutput:\n%s", err, out) + } + + // The cheapest bid's lease must be created with the cached manifest. + if leaseBody["manifest"] != `[{"name":"web"}]` { + t.Errorf("lease manifest = %v, want the cached create-deployment manifest", leaseBody["manifest"]) + } + leases, _ := leaseBody["leases"].([]any) + if len(leases) != 1 { + t.Fatalf("lease requests = %v, want exactly one", leaseBody["leases"]) + } + lease, _ := leases[0].(map[string]any) + if lease["dseq"] != "4242" || lease["provider"] != "akash1cheap" { + t.Errorf("lease request = %v, want dseq 4242 provider akash1cheap", lease) + } + + for _, want := range []string{ + "create-deployment", "tx: CREATEHASH", + "wait-for-bids", "select-bid", "create-lease", + "skipping step \"send-manifest\" (manifest submission handled by Console)", + "completed successfully", + } { + if !strings.Contains(out, want) { + t.Errorf("output does not contain %q:\n%s", want, out) + } + } + if strings.Contains(out, "send-manifest ") { + t.Errorf("send-manifest must not run for console auth:\n%s", out) + } +} + +// TestCommandsCarryCapabilityAnnotation verifies every generated workflow +// command declares the either-rail capability requirement used by command +// gating: chain-tx (keyring) or console (console-api). +func TestCommandsCarryCapabilityAnnotation(t *testing.T) { + homeFn, ctxNameFn := staticFns(t.TempDir()) + + cmds := Commands(homeFn, ctxNameFn) + if len(cmds) == 0 { + t.Fatal("Commands() surfaced no workflow commands") + } + + for _, cmd := range cmds { + if got := cmd.Annotations[capability.AnnotationKey]; got != "chain-tx|console" { + t.Errorf("%s: annotation %q = %q, want %q", cmd.Name(), capability.AnnotationKey, got, "chain-tx|console") + } + } +} + +// argumentSurface flattens a command's user-visible argument surface — the +// Use line (positionals) plus every flag's name, shorthand, type, default, +// and usage — into a deterministic string for comparison. +func argumentSurface(cmd *cobra.Command) string { + var flags []string + cmd.Flags().VisitAll(func(f *pflag.Flag) { + flags = append(flags, strings.Join([]string{f.Name, f.Shorthand, f.Value.Type(), f.DefValue, f.Usage}, "|")) + }) + sort.Strings(flags) + + return cmd.Use + "\n" + strings.Join(flags, "\n") +} + +// TestArgumentSurfaceAuthIndependent locks in the transport parity guarantee +// (SPEC §2.3): a workflow command's argument surface — positionals in Use and +// the full flag set — is generated from the workflow definition alone and is +// identical whether the active context uses keyring auth, console-api auth, +// or no context manager exists at all. Transports may branch on behavior at +// execution time, never on the argument surface. +func TestArgumentSurfaceAuthIndependent(t *testing.T) { + keyringHome := t.TempDir() + keyringMgr := newTestManager(t, keyringHome, "wallet", aktctx.AuthMethodKeyring) + + consoleHome := t.TempDir() + consoleMgr := newTestManager(t, consoleHome, "console", aktctx.AuthMethodConsoleAPI) + + noneHome := t.TempDir() + + variants := []struct { + name string + cmds []*cobra.Command + }{ + {"no-manager", Commands(staticFns(noneHome))}, + {"keyring", CommandsWithManager( + func() string { return keyringHome }, + func() string { return "wallet" }, + func() *aktctx.Manager { return keyringMgr }, + )}, + {"console-api", CommandsWithManager( + func() string { return consoleHome }, + func() string { return "console" }, + func() *aktctx.Manager { return consoleMgr }, + )}, + } + + for _, name := range []string{"deploy", "update", "close"} { + base := findCommand(variants[0].cmds, name) + if base == nil { + t.Fatalf("variant %q did not surface %q", variants[0].name, name) + } + want := argumentSurface(base) + + for _, v := range variants[1:] { + cmd := findCommand(v.cmds, name) + if cmd == nil { + t.Fatalf("variant %q did not surface %q", v.name, name) + } + if got := argumentSurface(cmd); got != want { + t.Errorf("%s: argument surface differs between %q and %q:\n--- %s\n%s\n--- %s\n%s", + name, variants[0].name, v.name, variants[0].name, want, v.name, got) + } + } + } +} + +// TestExecuteWithoutManager verifies that commands built via the legacy +// Commands constructor (nil manager) fail execution with a clear +// no-configuration message instead of a panic or a silent no-op. +func TestExecuteWithoutManager(t *testing.T) { + homeFn, ctxNameFn := staticFns(t.TempDir()) + + cmd := findCommand(Commands(homeFn, ctxNameFn), "close") + if cmd == nil { + t.Fatal("Commands() did not surface close") + } + + _, err := executeCommand(t, cmd, "123") + if err == nil { + t.Fatal("expected no-configuration error, got nil") + } + if !strings.Contains(err.Error(), "no configuration loaded") { + t.Errorf("error %q does not mention missing configuration", err.Error()) + } +} diff --git a/internal/cli/workflow/helpers_test.go b/internal/cli/workflow/helpers_test.go new file mode 100644 index 00000000..99a8f637 --- /dev/null +++ b/internal/cli/workflow/helpers_test.go @@ -0,0 +1,222 @@ +package workflow + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + aktctx "pkg.akt.dev/akt/internal/context" + wf "pkg.akt.dev/akt/internal/workflow" +) + +// TestResolveContextToleratesMissingManager covers every nil-safe arm of +// resolveContext. It runs before the rail is chosen, so a panic here would +// break `akt deploy` for anyone without a configured context — exactly the +// users the first-run wizard is meant to help. +func TestResolveContextToleratesMissingManager(t *testing.T) { + if got := resolveContext(nil, func() string { return "" }); got != nil { + t.Errorf("nil manager func = %+v, want nil", got) + } + + if got := resolveContext(func() *aktctx.Manager { return nil }, func() string { return "" }); got != nil { + t.Errorf("nil manager = %+v, want nil", got) + } + + // A real manager with no current context: Resolve fails, and the helper + // must translate that into "no context" rather than propagating an error. + m, err := aktctx.NewManager(t.TempDir()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + if got := resolveContext(func() *aktctx.Manager { return m }, func() string { return "" }); got != nil { + t.Errorf("unresolvable context = %+v, want nil", got) + } + + // With a context configured, the resolved record comes back. + if err := m.CreateNetworkFromTemplate("mainnet", "mainnet"); err != nil { + t.Fatalf("CreateNetworkFromTemplate: %v", err) + } + if err := m.CreateContext(aktctx.Context{Name: "prod", Network: aktctx.Network{Name: "mainnet"}}); err != nil { + t.Fatalf("CreateContext: %v", err) + } + + rc := resolveContext(func() *aktctx.Manager { return m }, func() string { return "prod" }) + if rc == nil || rc.Name != "prod" { + t.Fatalf("resolved context = %+v, want prod", rc) + } +} + +// TestEmitJSONLShape pins the machine-readable workflow output (SPEC §2.3.8). +// Scripts consume it line by line, so the per-step result mapping and the +// never-null errors/txs arrays are part of the contract. +func TestEmitJSONLShape(t *testing.T) { + state := wf.NewRunState("run-1", "deploy", "akash1owner", nil) + state.SetStepResult("create", &wf.StepResult{ + Name: "create", + Status: "success", + TxHash: "ABC123", + Height: 42, + }) + state.SetStepResult("skipme", &wf.StepResult{Name: "skipme", Status: "skipped"}) + state.SetStepResult("boom", &wf.StepResult{Name: "boom", Status: "failed", Error: "out of gas"}) + + var buf bytes.Buffer + emitJSONL(&buf, state) + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + if len(lines) != 3 { + t.Fatalf("emitted %d lines, want one per step:\n%s", len(lines), buf.String()) + } + + type jsonl struct { + Workflow string `json:"workflow"` + ID string `json:"id"` + Step string `json:"step"` + Result string `json:"result"` + Errors []string + Txs []struct { + Hash string `json:"hash"` + Height int64 `json:"height"` + } + } + + var got []jsonl + for _, line := range lines { + var l jsonl + if err := json.Unmarshal([]byte(line), &l); err != nil { + t.Fatalf("line %q is not valid JSON: %v", line, err) + } + got = append(got, l) + } + + // Order follows StepOrder, so scripts can correlate with the run. + if got[0].Step != "create" || got[1].Step != "skipme" || got[2].Step != "boom" { + t.Errorf("steps out of order: %+v", got) + } + for _, l := range got { + if l.Workflow != "deploy" || l.ID != "run-1" { + t.Errorf("line missing run identity: %+v", l) + } + if l.Errors == nil || l.Txs == nil { + t.Errorf("errors/txs must be arrays, never null: %+v", l) + } + } + + if got[0].Result != "completed" || len(got[0].Txs) != 1 || got[0].Txs[0].Hash != "ABC123" || got[0].Txs[0].Height != 42 { + t.Errorf("success line = %+v, want completed with the tx", got[0]) + } + if got[1].Result != "skipped" { + t.Errorf("skipped line = %+v", got[1]) + } + if got[2].Result != "error" || len(got[2].Errors) != 1 || got[2].Errors[0] != "out of gas" { + t.Errorf("failure line = %+v, want error with the message", got[2]) + } +} + +// TestEmitJSONLSkipsMissingResults covers the nil-result guard: StepOrder can +// name a step whose result was never recorded, and emitting a null line would +// break line-oriented consumers. +func TestEmitJSONLSkipsMissingResults(t *testing.T) { + state := wf.NewRunState("run-1", "deploy", "akash1owner", nil) + state.SetStepResult("ghost", nil) + + var buf bytes.Buffer + emitJSONL(&buf, state) + + if strings.TrimSpace(buf.String()) != "" { + t.Errorf("a nil step result must emit nothing, got %q", buf.String()) + } +} + +// TestOutputFormatDefaultsToPretty covers both arms of outputFormat. Workflow +// commands are also built standalone (tests, generated subcommands) where +// --output is not registered; defaulting to "" would select an unknown format. +func TestOutputFormatDefaultsToPretty(t *testing.T) { + bare := &cobra.Command{} + if got := outputFormat(bare); got != "pretty" { + t.Errorf("without --output = %q, want pretty", got) + } + + withFlag := &cobra.Command{} + withFlag.Flags().String("output", "", "") + if got := outputFormat(withFlag); got != "pretty" { + t.Errorf("empty --output = %q, want pretty", got) + } + + if err := withFlag.Flags().Set("output", "jsonl"); err != nil { + t.Fatalf("set flag: %v", err) + } + if got := outputFormat(withFlag); got != "jsonl" { + t.Errorf("explicit --output = %q, want jsonl", got) + } +} + +// TestFilterProviderStepsDropsOnlyProviderSteps covers the console-rail +// rewrite (SPEC §7.4): the Console API submits manifests itself, so provider +// steps must be removed — but nothing else may be, and the caller's definition +// must not be mutated (it is the shared, loaded workflow). +func TestFilterProviderStepsDropsOnlyProviderSteps(t *testing.T) { + def := &wf.WorkflowDef{ + Name: "deploy", + Steps: []wf.StepDef{ + {Name: "create", Type: wf.StepTx}, + {Name: "send-manifest", Type: wf.StepProvider}, + {Name: "lease", Type: wf.StepTx}, + }, + } + + var notes bytes.Buffer + filtered := filterProviderSteps(def, ¬es) + + if len(def.Steps) != 3 { + t.Errorf("the input definition must not be mutated, got %d steps", len(def.Steps)) + } + if len(filtered.Steps) != 2 { + t.Fatalf("filtered steps = %+v, want the two tx steps", filtered.Steps) + } + if filtered.Steps[0].Name != "create" || filtered.Steps[1].Name != "lease" { + t.Errorf("wrong steps kept: %+v", filtered.Steps) + } + if !strings.Contains(notes.String(), "send-manifest") { + t.Errorf("the skipped step should be reported to the user, got %q", notes.String()) + } + + // A definition with no provider steps is returned unchanged and silent. + notes.Reset() + plain := &wf.WorkflowDef{Name: "close", Steps: []wf.StepDef{{Name: "close", Type: wf.StepTx}}} + if got := filterProviderSteps(plain, ¬es); got != plain { + t.Error("a definition without provider steps should be returned as-is") + } + if notes.String() != "" { + t.Errorf("nothing was skipped, so nothing should be reported: %q", notes.String()) + } +} + +// TestAddMissingTxFlagsDoesNotClobberWorkflowParams pins the flag-merge rule: +// workflow parameters generate flags first, and the standard tx flags must not +// overwrite one that already exists (nor steal a taken shorthand). A clobbered +// param flag would silently drop the user's value. +func TestAddMissingTxFlagsDoesNotClobberWorkflowParams(t *testing.T) { + cmd := &cobra.Command{Use: "deploy"} + cmd.Flags().String("gas", "workflow-owned", "the workflow's own gas param") + cmd.Flags().StringP("note", "y", "", "a param that squats on -y") + + addMissingTxFlags(cmd) + + if got := cmd.Flags().Lookup("gas").Usage; got != "the workflow's own gas param" { + t.Errorf("an existing flag was replaced: %q", got) + } + if f := cmd.Flags().ShorthandLookup("y"); f == nil || f.Name != "note" { + t.Errorf("a taken shorthand was reassigned: %+v", f) + } + + // Standard tx flags that did not collide are still added. + for _, name := range []string{"from", "fees"} { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("standard tx flag --%s was not added", name) + } + } +} diff --git a/internal/console/actionlog_test.go b/internal/console/actionlog_test.go new file mode 100644 index 00000000..375fb964 --- /dev/null +++ b/internal/console/actionlog_test.go @@ -0,0 +1,203 @@ +package console + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "pkg.akt.dev/akt/internal/actionlog" +) + +func openTestLog(t *testing.T) *actionlog.Logger { + t.Helper() + + l, err := actionlog.Open(filepath.Join(t.TempDir(), "actions.log")) + if err != nil { + t.Fatalf("open action log: %v", err) + } + t.Cleanup(func() { _ = l.Close() }) + + return l +} + +func TestConsoleMutationsRecordedInActionLog(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/deployments": + _, _ = w.Write([]byte(`{"data":{"dseq":"12345","manifest":"m"}}`)) + case r.Method == http.MethodDelete: + // 409 is not mapped to already-closed, so the close genuinely fails. + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"conflict"}`)) + default: + _, _ = w.Write([]byte(`{"data":{}}`)) + } + })) + defer srv.Close() + + l := openTestLog(t) + c := New(srv.URL, "test-key").WithActionLog(l) + + if _, err := c.CreateDeployment(context.Background(), "sdl", 5); err != nil { + t.Fatalf("CreateDeployment: %v", err) + } + + if err := c.CloseDeployment(context.Background(), "999"); err == nil { + t.Fatal("expected close to fail on conflict") + } + + entries, err := l.Read(actionlog.Filter{Type: actionlog.TypeConsole}) + if err != nil { + t.Fatalf("read: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 console entries, got %d: %+v", len(entries), entries) + } + + // Newest first: failed close, then successful create. + if entries[0].Action != "close-deployment" || entries[0].Status != "failed" || entries[0].DSeq != 999 { + t.Errorf("close entry wrong: %+v", entries[0]) + } + if entries[1].Action != "create-deployment" || entries[1].Status != "success" || entries[1].DSeq != 12345 { + t.Errorf("create entry wrong: %+v", entries[1]) + } +} + +func TestCloseAlreadyClosedRecordedAsSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + l := openTestLog(t) + c := New(srv.URL, "test-key").WithActionLog(l) + + err := c.CloseDeployment(context.Background(), "555") + if !errors.Is(err, ErrAlreadyClosed) { + t.Fatalf("expected ErrAlreadyClosed, got %v", err) + } + + entries, readErr := l.Read(actionlog.Filter{Type: actionlog.TypeConsole}) + if readErr != nil { + t.Fatalf("read: %v", readErr) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + // Desired end state was reached, so the idempotent close logs as success. + if entries[0].Action != "close-deployment" || entries[0].Status != "success" || entries[0].DSeq != 555 { + t.Errorf("already-closed entry wrong: %+v", entries[0]) + } +} + +func TestCloseValidation400RecordedAsFailed(t *testing.T) { + // A 400 without already-closed semantics is a genuine failure: it must + // not be logged as a successful close. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"cannot close: deployment has active leases pending settlement"}`)) + })) + defer srv.Close() + + l := openTestLog(t) + c := New(srv.URL, "test-key").WithActionLog(l) + + err := c.CloseDeployment(context.Background(), "777") + if err == nil { + t.Fatal("expected the validation 400 to fail the close") + } + if errors.Is(err, ErrAlreadyClosed) { + t.Fatalf("validation 400 must not map to ErrAlreadyClosed, got %v", err) + } + + entries, readErr := l.Read(actionlog.Filter{Type: actionlog.TypeConsole}) + if readErr != nil { + t.Fatalf("read: %v", readErr) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Action != "close-deployment" || entries[0].Status != "failed" || entries[0].DSeq != 777 { + t.Errorf("validation-400 close entry wrong: %+v", entries[0]) + } + if !strings.Contains(entries[0].Error, "active leases") { + t.Errorf("entry error %q must carry the server's message", entries[0].Error) + } +} + +func TestNewMutationsRecordedInActionLog(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPut && r.URL.Path == "/v1/wallet-settings": + _, _ = w.Write([]byte(`{"data":{"autoReloadEnabled":true}}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/v2/deployment-settings/42": + _, _ = w.Write([]byte(`{"data":{"dseq":"42","autoTopUpEnabled":true}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/api-keys": + _, _ = w.Write([]byte(`{"data":{"id":"k1","name":"n","apiKey":"secret"}}`)) + case r.Method == http.MethodDelete && r.URL.Path == "/v1/api-keys/k1": + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusTeapot) + } + })) + defer srv.Close() + + l := openTestLog(t) + c := New(srv.URL, "test-key").WithActionLog(l) + + ctx := context.Background() + if _, err := c.UpdateWalletSettings(ctx, true); err != nil { + t.Fatalf("UpdateWalletSettings: %v", err) + } + if _, err := c.SetDeploymentAutoTopUp(ctx, "42", true); err != nil { + t.Fatalf("SetDeploymentAutoTopUp: %v", err) + } + if _, err := c.CreateAPIKey(ctx, "n", ""); err != nil { + t.Fatalf("CreateAPIKey: %v", err) + } + if err := c.DeleteAPIKey(ctx, "k1"); err != nil { + t.Fatalf("DeleteAPIKey: %v", err) + } + + entries, err := l.Read(actionlog.Filter{Type: actionlog.TypeConsole}) + if err != nil { + t.Fatalf("read: %v", err) + } + + // Newest first. + want := []struct { + action string + dseq uint64 + }{ + {"delete-api-key", 0}, + {"create-api-key", 0}, + {"update-deployment-settings", 42}, + {"update-wallet-settings", 0}, + } + + if len(entries) != len(want) { + t.Fatalf("expected %d entries, got %d: %+v", len(want), len(entries), entries) + } + for i, w := range want { + if entries[i].Action != w.action || entries[i].Status != "success" || entries[i].DSeq != w.dseq { + t.Errorf("entry %d wrong: want action=%s dseq=%d, got %+v", i, w.action, w.dseq, entries[i]) + } + } +} + +func TestConsoleWithoutActionLogIsNoop(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"dseq":"1"}}`)) + })) + defer srv.Close() + + c := New(srv.URL, "test-key") + if _, err := c.CreateDeployment(context.Background(), "sdl", 5); err != nil { + t.Fatalf("CreateDeployment without logger: %v", err) + } +} diff --git a/internal/console/apikeys.go b/internal/console/apikeys.go new file mode 100644 index 00000000..0fa0bed7 --- /dev/null +++ b/internal/console/apikeys.go @@ -0,0 +1,82 @@ +package console + +import ( + "context" + "errors" + "net/http" + "net/url" +) + +// ListAPIKeys lists the account's API keys. Secrets are never included. +// +// Wire: GET /v1/api-keys, response {"data":[...]}. +func (c *Client) ListAPIKeys(ctx context.Context) ([]APIKey, error) { + var out []APIKey + if err := c.doData(ctx, http.MethodGet, "/v1/api-keys", nil, &out); err != nil { + return nil, err + } + + return out, nil +} + +// CreateAPIKey creates a new API key. expiresAt is optional (RFC 3339; empty +// means no expiry). The returned CreatedAPIKey.APIKey secret is shown exactly +// once — persist it immediately. +// +// Wire: POST /v1/api-keys, body {"data":{"name":..., "expiresAt"?:...}}, +// data-enveloped response. +func (c *Client) CreateAPIKey(ctx context.Context, name, expiresAt string) (*CreatedAPIKey, error) { + payload := map[string]any{"name": name} + if expiresAt != "" { + payload["expiresAt"] = expiresAt + } + + var out CreatedAPIKey + err := c.doData(ctx, http.MethodPost, "/v1/api-keys", envelope(payload), &out) + c.record("create-api-key", "", err) + if err != nil { + return nil, err + } + + return &out, nil +} + +// DeleteAPIKey deletes an API key by ID. A missing key (404) is treated as a +// no-op and returns nil. +// +// Wire: DELETE /v1/api-keys/{id} → 204. +func (c *Client) DeleteAPIKey(ctx context.Context, id string) error { + err := c.doJSON(ctx, http.MethodDelete, "/v1/api-keys/"+url.PathEscape(id), nil, nil) + if errors.Is(err, ErrNotFound) { + err = nil + } + + c.record("delete-api-key", "", err) + + return err +} + +// CreateJWTToken mints a short-lived JWT scoped to the given lease +// permissions, for direct provider access. ttl is in seconds. +// +// Wire: POST /v1/create-jwt-token, body +// {"data":{"ttl":..., "leases":{"access":"scoped","scope":[...]}}}, +// response {"data":{"token":...}}. +func (c *Client) CreateJWTToken(ctx context.Context, ttl int, scope []string) (string, error) { + body := envelope(map[string]any{ + "ttl": ttl, + "leases": map[string]any{ + "access": "scoped", + "scope": scope, + }, + }) + + var out struct { + Token string `json:"token"` + } + if err := c.doData(ctx, http.MethodPost, "/v1/create-jwt-token", body, &out); err != nil { + return "", err + } + + return out.Token, nil +} diff --git a/internal/console/apikeys_test.go b/internal/console/apikeys_test.go new file mode 100644 index 00000000..d65be2e2 --- /dev/null +++ b/internal/console/apikeys_test.go @@ -0,0 +1,115 @@ +package console_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "pkg.akt.dev/akt/internal/console" +) + +func TestListAPIKeys(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v1/api-keys", r.URL.Path) + + _, _ = w.Write([]byte(`{"data":[ + {"id":"key-1","name":"ci","createdAt":"2026-01-01T00:00:00Z","lastUsedAt":"2026-07-20T10:00:00Z","keyFormat":"ak-****"}, + {"id":"key-2","name":"laptop","expiresAt":"2027-01-01T00:00:00Z"} + ]}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + keys, err := c.ListAPIKeys(context.Background()) + require.NoError(t, err) + require.Len(t, keys, 2) + assert.Equal(t, "ci", keys[0].Name) + assert.Equal(t, "ak-****", keys[0].KeyFormat) + assert.Equal(t, "2027-01-01T00:00:00Z", keys[1].ExpiresAt) +} + +func TestCreateAPIKeyReturnsSecret(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v1/api-keys", r.URL.Path) + + data := decodeDataBody(t, r) + assert.Equal(t, "ci-key", data["name"]) + assert.NotContains(t, data, "expiresAt", "empty expiresAt must be omitted") + + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"data":{"id":"key-9","name":"ci-key","apiKey":"ak-live-supersecret"}}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + created, err := c.CreateAPIKey(context.Background(), "ci-key", "") + require.NoError(t, err) + assert.Equal(t, "key-9", created.ID) + assert.Equal(t, "ak-live-supersecret", created.APIKey, "secret is only returned at creation") +} + +func TestCreateAPIKeyWithExpiry(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data := decodeDataBody(t, r) + assert.Equal(t, "2027-01-01T00:00:00Z", data["expiresAt"]) + + _, _ = w.Write([]byte(`{"data":{"id":"key-10","name":"temp","apiKey":"ak-live-x"}}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + _, err := c.CreateAPIKey(context.Background(), "temp", "2027-01-01T00:00:00Z") + require.NoError(t, err) +} + +func TestDeleteAPIKey(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/v1/api-keys/key-1", r.URL.Path) + + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + require.NoError(t, c.DeleteAPIKey(context.Background(), "key-1")) +} + +func TestDeleteAPIKeyNotFoundIsNoop(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + assert.NoError(t, c.DeleteAPIKey(context.Background(), "missing"), "404 on delete is a no-op") +} + +func TestCreateJWTToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v1/create-jwt-token", r.URL.Path) + + data := decodeDataBody(t, r) + assert.InDelta(t, 900.0, data["ttl"], 0.001) + + leases, ok := data["leases"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "scoped", leases["access"]) + assert.Equal(t, []any{"send-manifest", "logs"}, leases["scope"]) + + _, _ = w.Write([]byte(`{"data":{"token":"eyJhbGciOi..."}}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + token, err := c.CreateJWTToken(context.Background(), 900, []string{"send-manifest", "logs"}) + require.NoError(t, err) + assert.Equal(t, "eyJhbGciOi...", token) +} diff --git a/internal/console/client.go b/internal/console/client.go index 49c70118..a54ca5aa 100644 --- a/internal/console/client.go +++ b/internal/console/client.go @@ -1,53 +1,85 @@ -// Package console provides an HTTP client for the Akash Console Managed -// Wallet API at https://console-api.akash.network. +// Package console provides an HTTP client for the Akash Console API at +// https://console-api.akash.network (managed wallets, deployments, and the +// public marketplace/catalog endpoints). +// +// Authenticated endpoints require an API key sent via the x-api-key header. +// Public endpoints work without a key; the key is still sent when configured. +// +// Wire conventions: most write bodies are wrapped in a {"data": ...} envelope +// and most responses arrive as {"data": ...}. Exceptions (top-level arrays or +// objects) are noted on the individual methods. package console import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" - "net/url" "strconv" "time" -) -// Client interacts with the Akash Console Managed Wallet API. -type Client struct { - baseURL string - apiKey string - httpClient *http.Client -} + "pkg.akt.dev/akt/internal/actionlog" +) -// DeploymentResponse represents a deployment returned by the API. -type DeploymentResponse struct { - DSeq string `json:"dseq"` - Manifest string `json:"manifest,omitempty"` -} +// DefaultBaseURL is the production Console API endpoint. +const DefaultBaseURL = "https://console-api.akash.network" + +// MinDepositUSD is the minimum deployment deposit the Console API accepts. +// It lives here — the leaf package every Console caller already imports — +// so the CLI, the workflow transport, and the client itself cannot drift +// apart on the limit they enforce. +const MinDepositUSD = 0.5 + +// Sentinel errors returned by the client. Match with errors.Is. +var ( + // ErrUnauthorized indicates the API key is missing, invalid, or expired + // (HTTP 401). + ErrUnauthorized = errors.New("console: invalid or expired API key") + + // ErrInsufficientFunds indicates the managed wallet cannot cover the + // operation (HTTP 402). + ErrInsufficientFunds = errors.New("console: insufficient funds") + + // ErrNotFound indicates the requested resource does not exist (HTTP 404). + ErrNotFound = errors.New("console: resource not found") + + // ErrAlreadyClosed is returned by CloseDeployment when the deployment is + // already closed (the API answers 404, or 400 with an already-closed + // message). Callers that want idempotent close semantics should treat it + // as success: + // + // if err := c.CloseDeployment(ctx, dseq); err != nil && !errors.Is(err, console.ErrAlreadyClosed) { ... } + ErrAlreadyClosed = errors.New("console: deployment already closed") +) -// DeploymentListResponse wraps a paginated list of deployments. -type DeploymentListResponse struct { - Data []DeploymentResponse `json:"data"` +// HTTPError is returned for non-2xx statuses that have no dedicated mapping. +type HTTPError struct { + StatusCode int + Body string } -// BidResponse represents a bid from a provider. -type BidResponse struct { - Provider string `json:"provider"` - Price float64 `json:"price"` +func (e *HTTPError) Error() string { + return fmt.Sprintf("console: unexpected status %d: %s", e.StatusCode, e.Body) } -// LeaseRequest identifies a specific bid to accept. -type LeaseRequest struct { - DSeq string `json:"dseq"` - GSeq uint32 `json:"gseq"` - OSeq uint32 `json:"oseq"` - Provider string `json:"provider"` +// Client interacts with the Akash Console API. +type Client struct { + baseURL string + apiKey string + httpClient *http.Client + actionLog *actionlog.Logger } -// New creates a Console API client. +// New creates a Console API client. An empty baseURL selects DefaultBaseURL. +// The apiKey may be empty when only public endpoints are used. func New(baseURL, apiKey string) *Client { + if baseURL == "" { + baseURL = DefaultBaseURL + } + return &Client{ baseURL: baseURL, apiKey: apiKey, @@ -57,118 +89,121 @@ func New(baseURL, apiKey string) *Client { } } -// CreateDeployment creates a deployment via managed wallet. Deposit is in USD. -func (c *Client) CreateDeployment(ctx context.Context, sdl string, depositUSD float64) (*DeploymentResponse, error) { - body := map[string]any{ - "sdl": sdl, - "deposit": depositUSD, - } - - var resp DeploymentResponse - if err := c.doJSON(ctx, http.MethodPost, "/v1/deployments", body, &resp); err != nil { - return nil, err - } - - return &resp, nil +// WithActionLog attaches a per-context action logger; state-changing Console +// API calls are then recorded as type=console entries (SPEC §5.6). A nil +// logger disables recording. Returns the client for chaining. +func (c *Client) WithActionLog(l *actionlog.Logger) *Client { + c.actionLog = l + return c } -// ListDeployments lists deployments with pagination. -func (c *Client) ListDeployments(ctx context.Context, skip, limit int) (*DeploymentListResponse, error) { - path := fmt.Sprintf("/v1/deployments?skip=%d&limit=%d", skip, limit) - - var resp DeploymentListResponse - if err := c.doJSON(ctx, http.MethodGet, path, nil, &resp); err != nil { - return nil, err +// record writes a console action entry. Best-effort: logging failures never +// affect the API call result. +func (c *Client) record(action, dseq string, opErr error) { + if c.actionLog == nil { + return } - return &resp, nil -} - -// GetDeployment gets a single deployment. -func (c *Client) GetDeployment(ctx context.Context, dseq string) (*DeploymentResponse, error) { - var resp DeploymentResponse - if err := c.doJSON(ctx, http.MethodGet, "/v1/deployments/"+url.PathEscape(dseq), nil, &resp); err != nil { - return nil, err + entry := actionlog.Entry{ + Type: actionlog.TypeConsole, + Action: action, + Status: "success", } - return &resp, nil -} - -// UpdateDeployment updates a deployment's SDL. -func (c *Client) UpdateDeployment(ctx context.Context, dseq, sdl string) (*DeploymentResponse, error) { - body := map[string]any{ - "sdl": sdl, + if n, err := strconv.ParseUint(dseq, 10, 64); err == nil { + entry.DSeq = n } - var resp DeploymentResponse - if err := c.doJSON(ctx, http.MethodPut, "/v1/deployments/"+url.PathEscape(dseq), body, &resp); err != nil { - return nil, err + if opErr != nil { + entry.Status = "failed" + entry.Error = opErr.Error() } - return &resp, nil + _ = c.actionLog.Log(entry) } -// CloseDeployment closes a deployment. -func (c *Client) CloseDeployment(ctx context.Context, dseq string) error { - return c.doJSON(ctx, http.MethodDelete, "/v1/deployments/"+url.PathEscape(dseq), nil, nil) +// envelope wraps a request payload in the {"data": ...} envelope used by most +// Console API write endpoints. +func envelope(v any) any { + return map[string]any{"data": v} } -// FetchBids fetches bids for a deployment. -func (c *Client) FetchBids(ctx context.Context, dseq string) ([]BidResponse, error) { - path := "/v1/bids?dseq=" + url.QueryEscape(dseq) +// doData executes a request whose response body is a {"data": X} envelope and +// unmarshals X into result. A nil result discards the payload. +func (c *Client) doData(ctx context.Context, method, path string, reqBody, result any) error { + var env struct { + Data json.RawMessage `json:"data"` + } - var resp []BidResponse - if err := c.doJSON(ctx, http.MethodGet, path, nil, &resp); err != nil { - return nil, err + if err := c.doJSON(ctx, method, path, reqBody, &env); err != nil { + return err } - return resp, nil -} + if result == nil { + return nil + } -// CreateLease creates a lease from bids. -func (c *Client) CreateLease(ctx context.Context, manifest string, leases []LeaseRequest) (*DeploymentResponse, error) { - body := map[string]any{ - "manifest": manifest, - "leases": leases, + // A caller that wants a payload must get one: without this check a + // response that omits the envelope (an API change, a proxy's error + // page, a wrong-shaped success) leaves result zero-valued and reads + // as success — e.g. a deployment with an empty dseq. + if len(env.Data) == 0 { + return fmt.Errorf("console: %s %s returned no data envelope", method, path) } - var resp DeploymentResponse - if err := c.doJSON(ctx, http.MethodPost, "/v1/leases", body, &resp); err != nil { - return nil, err + if err := json.Unmarshal(env.Data, result); err != nil { + return fmt.Errorf("console: decode data envelope for %s %s: %w", method, path, err) } - return &resp, nil + return nil } -// Deposit adds funds to a deployment. Amount is in USD. -func (c *Client) Deposit(ctx context.Context, dseq string, amountUSD float64) error { - body := map[string]any{ - "dseq": dseq, - "amount": amountUSD, +const maxRetries = 3 + +// retryableStatus reports whether a failed attempt with the given method and +// status may safely be re-sent. +// +// Money-safety rationale: GET/HEAD/PUT/DELETE are idempotent by HTTP +// semantics, so replaying them on 429 or 5xx is always safe. POST (and any +// other non-idempotent method, e.g. PATCH with fallback semantics) is only +// retried on 429 — a rate-limit rejection guarantees the server did NOT +// process the request. A 5xx gives no such guarantee: a gateway 502 can hide +// a write that already succeeded server-side, and replaying +// POST /v1/deployments, /v1/leases, or /v1/deposit-deployment would create +// duplicate deployments or charge the managed wallet twice. +func retryableStatus(method string, status int) bool { + if status == http.StatusTooManyRequests { + return true } - return c.doJSON(ctx, http.MethodPost, "/v1/deposit-deployment", body, nil) + // Remaining retryable statuses are 5xx: idempotent methods only. + switch method { + case http.MethodGet, http.MethodHead, http.MethodPut, http.MethodDelete: + return true + default: + return false + } } -const maxRetries = 3 - -// doJSON executes an HTTP request with JSON encoding, error mapping, and retry. -func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, result any) error { - var bodyReader io.Reader +// doJSON executes an HTTP request with JSON encoding, error mapping, and +// retry with backoff (up to maxRetries attempts) on 429 and — for idempotent +// methods only, see retryableStatus — 5xx. The response body is unmarshaled +// into result as-is (no envelope handling); a nil result discards the body. +func (c *Client) doJSON(ctx context.Context, method, path string, reqBody, result any) error { + var payload []byte if reqBody != nil { data, err := json.Marshal(reqBody) if err != nil { return fmt.Errorf("console: marshal request: %w", err) } - bodyReader = bytes.NewReader(data) + payload = data } var lastErr error for attempt := range maxRetries { - // Reset body reader for retries. - if reqBody != nil { - data, _ := json.Marshal(reqBody) // already validated above - bodyReader = bytes.NewReader(data) + var bodyReader io.Reader + if payload != nil { + bodyReader = bytes.NewReader(payload) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader) @@ -176,8 +211,10 @@ func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, r return fmt.Errorf("console: create request: %w", err) } - req.Header.Set("x-api-key", c.apiKey) - if reqBody != nil { + if c.apiKey != "" { + req.Header.Set("x-api-key", c.apiKey) + } + if payload != nil { req.Header.Set("Content-Type", "application/json") } @@ -202,25 +239,32 @@ func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, r return nil case resp.StatusCode == http.StatusUnauthorized: - return fmt.Errorf("console: invalid or expired API key (HTTP 401)") + return fmt.Errorf("%w (HTTP 401)", ErrUnauthorized) case resp.StatusCode == http.StatusPaymentRequired: - return fmt.Errorf("console: insufficient funds (HTTP 402)") + return fmt.Errorf("%w (HTTP 402)", ErrInsufficientFunds) case resp.StatusCode == http.StatusNotFound: - return fmt.Errorf("console: deployment not found (HTTP 404)") + return fmt.Errorf("%w (HTTP 404): %s %s", ErrNotFound, method, path) case resp.StatusCode == http.StatusTooManyRequests: lastErr = fmt.Errorf("console: rate limited (HTTP 429)") case resp.StatusCode >= 500: - lastErr = fmt.Errorf("console: server error (HTTP %s)", strconv.Itoa(resp.StatusCode)) + lastErr = fmt.Errorf("console: server error (HTTP %d)", resp.StatusCode) default: - return fmt.Errorf("console: unexpected status %d: %s", resp.StatusCode, string(respBody)) + return &HTTPError{StatusCode: resp.StatusCode, Body: string(respBody)} + } + + // Never replay a request that could have been processed server-side + // (non-idempotent method + 5xx): duplicating a deployment or a USD + // deposit is worse than surfacing the error. + if !retryableStatus(method, resp.StatusCode) { + return lastErr } - // Backoff before retry for 429 and 5xx. + // Backoff before retry for 429 and (idempotent-only) 5xx. if attempt < maxRetries-1 { backoff := time.Duration(1< USD conversion for +// all three balance fields. DeploymentsUSD reports escrowed funds and is the +// one with no other caller inside this package, so a regression there would +// misreport locked money by six orders of magnitude. +func TestBalancesUSDHelpersScaleEveryField(t *testing.T) { + b := console.Balances{Balance: 12340000, Deployments: 7500000, Total: 19840000} + + cases := []struct { + name string + got float64 + want float64 + }{ + {"BalanceUSD", b.BalanceUSD(), 12.34}, + {"DeploymentsUSD", b.DeploymentsUSD(), 7.5}, + {"TotalUSD", b.TotalUSD(), 19.84}, + } + + for _, c := range cases { + if diff := c.got - c.want; diff > 1e-9 || diff < -1e-9 { + t.Errorf("%s = %v, want %v", c.name, c.got, c.want) + } + } + + // Zero must stay zero rather than becoming a NaN or a scaled artifact. + var zero console.Balances + if zero.BalanceUSD() != 0 || zero.DeploymentsUSD() != 0 || zero.TotalUSD() != 0 { + t.Errorf("zero balances must render as 0, got %+v", zero) + } +} + +// TestScreenBidsValidatesLocally covers the three guards that keep a malformed +// screening request from reaching the API as an opaque HTTP 400. Bid screening +// decides which providers are offered a deployment, so a silently-dropped +// requirement is a real correctness problem. +func TestScreenBidsValidatesLocally(t *testing.T) { + var requests int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&requests, 1) + _, _ = w.Write([]byte(`{"providers":[]}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "k") + + cases := []struct { + name string + req *console.BidScreeningRequest + want string + }{ + {"nil request", nil, "request is nil"}, + { + "no resources", + &console.BidScreeningRequest{Timezone: "America/Chicago"}, + "resources are required", + }, + { + "no timezone", + &console.BidScreeningRequest{Resources: []byte(`{"cpu":1}`)}, + "timezone is required", + }, + } + + for _, tc := range cases { + _, err := c.ScreenBids(context.Background(), tc.req) + if err == nil { + t.Errorf("%s: expected a local rejection", tc.name) + continue + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: error %q should mention %q", tc.name, err, tc.want) + } + } + + if n := atomic.LoadInt32(&requests); n != 0 { + t.Errorf("locally-invalid screening requests must not be sent, got %d", n) + } +} + +// TestRetryAbortsOnContextCancellation covers the ctx.Done() arm of the retry +// backoff. Without it a cancelled command (Ctrl-C, a shell timeout) would keep +// re-sending for the full backoff schedule. +func TestRetryAbortsOnContextCancellation(t *testing.T) { + var calls atomic.Int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + + // Cancel while the client is sitting in its first backoff (100ms). + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + + _, err := console.New(srv.URL, "k").GetDeployment(ctx, "1") + if err == nil { + t.Fatal("a cancelled context must surface an error") + } + if got := calls.Load(); got > 1 { + t.Errorf("cancellation should stop further attempts, got %d calls", got) + } +} + +// TestDataEnvelopeDecodeErrorIsReported covers the unmarshal branch of doData: +// a data envelope whose payload has the wrong JSON type must be an error, not +// a silently zero-valued result. +func TestDataEnvelopeDecodeErrorIsReported(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Balances expects an object; hand it a string. + _, _ = w.Write([]byte(`{"data":"not-an-object"}`)) + })) + defer srv.Close() + + _, err := console.New(srv.URL, "k").GetBalances(context.Background()) + if err == nil { + t.Fatal("a wrong-typed data envelope must be an error") + } + if !strings.Contains(err.Error(), "decode data envelope") { + t.Errorf("error should name the decode failure, got %q", err) + } +} + +// TestUndecodableSuccessBodyIsReported covers the non-enveloped decode branch +// of doJSON (used by the top-level-array endpoints): a 200 with a body that +// does not match the expected shape must fail rather than return an empty +// slice that reads as "no usage". +func TestUndecodableSuccessBodyIsReported(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"unexpected":"object"}`)) + })) + defer srv.Close() + + _, err := console.New(srv.URL, "k").GetUsageHistory(context.Background(), "akash1x", "", "") + if err == nil { + t.Fatal("a body that is not the documented array must be an error") + } + if !strings.Contains(err.Error(), "decode response") { + t.Errorf("error should name the decode failure, got %q", err) + } +} + +// TestTransportFailureIsNotRetried covers the request-error branch: a dial +// failure returns immediately rather than burning the retry budget, and the +// message names the method and path so the failing call is identifiable. +func TestTransportFailureIsNotRetried(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := srv.URL + srv.Close() // nothing is listening now + + start := time.Now() + _, err := console.New(url, "k").GetDeployment(context.Background(), "1") + if err == nil { + t.Fatal("a dial failure must surface") + } + if !strings.Contains(err.Error(), "/v1/deployments/1") { + t.Errorf("error should name the failing path, got %q", err) + } + // Three attempts with backoff would take at least 300ms. + if elapsed := time.Since(start); elapsed > 250*time.Millisecond { + t.Errorf("a dial failure must not be retried, took %s", elapsed) + } +} diff --git a/internal/console/client_test.go b/internal/console/client_test.go index 4d9f0f96..9eae356c 100644 --- a/internal/console/client_test.go +++ b/internal/console/client_test.go @@ -3,8 +3,10 @@ package console_test import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" @@ -14,119 +16,261 @@ import ( "pkg.akt.dev/akt/internal/console" ) -func TestCreateDeployment(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "/v1/deployments", r.URL.Path) +// decodeDataBody unmarshals a {"data": ...} request body and returns the +// inner payload, failing the test on malformed envelopes. +func decodeDataBody(t *testing.T, r *http.Request) map[string]any { + t.Helper() + + var body struct { + Data map[string]any `json:"data"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.NotNil(t, body.Data, "request body must be data-enveloped") + + return body.Data +} + +func TestAPIKeyHeader(t *testing.T) { + const expectedKey = "my-secret-key" - var body map[string]any - require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - assert.Equal(t, "test-sdl", body["sdl"]) - assert.InDelta(t, 5.0, body["deposit"], 0.001) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, expectedKey, r.Header.Get("x-api-key")) w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(console.DeploymentResponse{ - DSeq: "12345", - }) + _, _ = w.Write([]byte(`{"data":{"deployment":{"id":{"owner":"akash1x","dseq":"1"}}}}`)) })) defer srv.Close() - c := console.New(srv.URL, "test-key") - resp, err := c.CreateDeployment(context.Background(), "test-sdl", 5.0) + c := console.New(srv.URL, expectedKey) + + _, err := c.GetDeployment(context.Background(), "1") require.NoError(t, err) - assert.Equal(t, "12345", resp.DSeq) } -func TestListDeployments(t *testing.T) { +func TestNoAPIKeyHeaderWhenUnset(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "/v1/deployments", r.URL.Path) - assert.Equal(t, "0", r.URL.Query().Get("skip")) - assert.Equal(t, "10", r.URL.Query().Get("limit")) + _, present := r.Header["X-Api-Key"] + assert.False(t, present, "x-api-key must not be sent when no key is configured") - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(console.DeploymentListResponse{ - Data: []console.DeploymentResponse{ - {DSeq: "100"}, - {DSeq: "200"}, - }, + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "") + _, err := c.ListProviders(context.Background(), "", nil) + require.NoError(t, err) +} + +func TestErrorMapping(t *testing.T) { + tests := []struct { + name string + status int + sentinel error + }{ + {"unauthorized", http.StatusUnauthorized, console.ErrUnauthorized}, + {"insufficient funds", http.StatusPaymentRequired, console.ErrInsufficientFunds}, + {"not found", http.StatusNotFound, console.ErrNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.status) + })) + defer srv.Close() + + c := console.New(srv.URL, "key") + _, err := c.GetDeployment(context.Background(), "1") + require.Error(t, err) + assert.ErrorIs(t, err, tt.sentinel) }) + } +} + +func TestUnexpectedStatusReturnsHTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"conflict"}`)) })) defer srv.Close() - c := console.New(srv.URL, "test-key") - resp, err := c.ListDeployments(context.Background(), 0, 10) + c := console.New(srv.URL, "key") + _, err := c.GetDeployment(context.Background(), "1") + require.Error(t, err) + + var httpErr *console.HTTPError + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, http.StatusConflict, httpErr.StatusCode) + assert.Contains(t, httpErr.Body, "conflict") +} + +func TestRetryOn429ThenSuccess(t *testing.T) { + var calls atomic.Int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if calls.Add(1) == 1 { + w.WriteHeader(http.StatusTooManyRequests) + return + } + + _, _ = w.Write([]byte(`{"data":{"deployment":{"id":{"owner":"akash1x","dseq":"42"}}}}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "key") + resp, err := c.GetDeployment(context.Background(), "42") require.NoError(t, err) - require.Len(t, resp.Data, 2) - assert.Equal(t, "100", resp.Data[0].DSeq) - assert.Equal(t, "200", resp.Data[1].DSeq) + assert.Equal(t, "42", resp.Deployment.ID.DSeq.String()) + assert.Equal(t, int32(2), calls.Load(), "expected exactly 2 calls (1 retry)") } -func TestCloseDeployment(t *testing.T) { +func TestRetryOn5xxExhausted(t *testing.T) { + var calls atomic.Int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + c := console.New(srv.URL, "key") + _, err := c.GetDeployment(context.Background(), "1") + require.Error(t, err) + assert.Contains(t, err.Error(), "server error") + assert.Equal(t, int32(3), calls.Load(), "expected max 3 attempts") +} + +func TestPostNotRetriedOn5xx(t *testing.T) { + // A 5xx does not prove the server failed to process the request (a + // gateway 502 can hide a completed write). Replaying a POST could + // duplicate a deployment or charge the managed wallet twice, so POST + // must do exactly one attempt and surface the error. + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodDelete, r.Method) - assert.Equal(t, "/v1/deployments/99999", r.URL.Path) + calls.Add(1) + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusBadGateway) + })) + defer srv.Close() + + c := console.New(srv.URL, "key") + err := c.Deposit(context.Background(), "123", 5) + require.Error(t, err) + assert.Contains(t, err.Error(), "server error") + assert.Equal(t, int32(1), calls.Load(), "POST + 5xx must not be retried") +} + +func TestPostRetriedOn429(t *testing.T) { + // 429 means the request was rejected before processing, so replaying a + // POST is safe. + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + if calls.Add(1) == 1 { + w.WriteHeader(http.StatusTooManyRequests) + return + } w.WriteHeader(http.StatusOK) })) defer srv.Close() - c := console.New(srv.URL, "test-key") - err := c.CloseDeployment(context.Background(), "99999") - require.NoError(t, err) + c := console.New(srv.URL, "key") + require.NoError(t, c.Deposit(context.Background(), "123", 5)) + assert.Equal(t, int32(2), calls.Load(), "POST + 429 must retry") } -func TestAPIKeyHeader(t *testing.T) { - const expectedKey = "my-secret-key" +func TestDeleteRetriedOn5xx(t *testing.T) { + // DELETE is idempotent by HTTP semantics: replaying it on 5xx is safe. + var calls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, expectedKey, r.Header.Get("x-api-key")) - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(console.DeploymentResponse{DSeq: "1"}) + assert.Equal(t, http.MethodDelete, r.Method) + if calls.Add(1) < 3 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) })) defer srv.Close() - c := console.New(srv.URL, expectedKey) + c := console.New(srv.URL, "key") + require.NoError(t, c.CloseDeployment(context.Background(), "1")) + assert.Equal(t, int32(3), calls.Load(), "DELETE + 5xx must retry") +} - // Test across different methods. - _, err := c.GetDeployment(context.Background(), "1") - require.NoError(t, err) +func TestDataEnvelopeUnwrapping(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"balance":1500000,"deployments":500000,"total":2000000}}`)) + })) + defer srv.Close() - _, err = c.CreateDeployment(context.Background(), "sdl", 1.0) + c := console.New(srv.URL, "key") + b, err := c.GetBalances(context.Background()) require.NoError(t, err) + assert.Equal(t, int64(1500000), b.Balance) + assert.InDelta(t, 1.5, b.BalanceUSD(), 1e-9) + assert.InDelta(t, 2.0, b.TotalUSD(), 1e-9) } -func TestErrorHandling(t *testing.T) { +func TestNoRetryAfterUnexpectedStatus(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) + calls.Add(1) + w.WriteHeader(http.StatusTeapot) })) defer srv.Close() - c := console.New(srv.URL, "bad-key") + c := console.New(srv.URL, "key") _, err := c.GetDeployment(context.Background(), "1") require.Error(t, err) - assert.Contains(t, err.Error(), "API key") + assert.Equal(t, int32(1), calls.Load(), "4xx (non-429) must not retry") + + var httpErr *console.HTTPError + require.True(t, errors.As(err, &httpErr)) + assert.Equal(t, http.StatusTeapot, httpErr.StatusCode) } -func TestRetryOn5xx(t *testing.T) { - var calls atomic.Int32 +func TestFlexStringAcceptsNumbers(t *testing.T) { + var v struct { + DSeq console.FlexString `json:"dseq"` + } + + require.NoError(t, json.Unmarshal([]byte(`{"dseq":123456}`), &v)) + assert.Equal(t, "123456", v.DSeq.String()) + + require.NoError(t, json.Unmarshal([]byte(`{"dseq":"7890"}`), &v)) + assert.Equal(t, "7890", v.DSeq.String()) +} +func TestMissingDataEnvelopeIsAnError(t *testing.T) { + // A success-shaped response that omits the envelope must not read as a + // successful call with a zero-valued result (e.g. an empty dseq). srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - n := calls.Add(1) - if n == 1 { - w.WriteHeader(http.StatusInternalServerError) - return - } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(console.DeploymentResponse{DSeq: "42"}) + c := console.New(srv.URL, "k") + + if _, err := c.GetUser(context.Background()); err == nil { + t.Fatal("a response without a data envelope must be an error") + } else if !strings.Contains(err.Error(), "no data envelope") { + t.Errorf("error %q should name the missing envelope", err) + } +} + +func TestEmptyBodyIsFineWhenNoResultExpected(t *testing.T) { + // Endpoints that return no payload (e.g. DELETE) pass a nil result and + // must keep succeeding on an empty body. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) })) defer srv.Close() - c := console.New(srv.URL, "test-key") - resp, err := c.GetDeployment(context.Background(), "42") - require.NoError(t, err) - assert.Equal(t, "42", resp.DSeq) - assert.Equal(t, int32(2), calls.Load(), "expected exactly 2 calls (1 retry)") + if err := console.New(srv.URL, "k").DeleteAPIKey(context.Background(), "k1"); err != nil { + t.Fatalf("empty body with no expected result must succeed: %v", err) + } } diff --git a/internal/console/contract_test.go b/internal/console/contract_test.go new file mode 100644 index 00000000..4d97021a --- /dev/null +++ b/internal/console/contract_test.go @@ -0,0 +1,439 @@ +package console_test + +// Contract tests: every request the console client produces is validated +// against the vendored Console API OpenAPI document +// (testdata/openapi.json). The httptest server used here matches each +// incoming request to a spec route, validates path/query/header/body against +// the schema, and records violations; canned responses are only rich enough +// for the client methods to decode. Response-schema validation is explicitly +// out of scope — only REQUEST validation matters, because that is the side +// our httptest-based unit tests cannot see (e.g. the empty startDate/endDate +// 400 that motivated this suite). + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + "github.com/getkin/kin-openapi/routers/gorillamux" + + "pkg.akt.dev/akt/internal/console" +) + +// loadContractRouter loads the vendored OpenAPI spec and builds a router for +// request matching. Servers are cleared so httptest URLs match. +func loadContractRouter(t *testing.T) routers.Router { + t.Helper() + + data, err := os.ReadFile("testdata/openapi.json") + if err != nil { + t.Fatalf("read vendored spec: %v", err) + } + + // Upstream escaping bug: 44 dseq patterns read "^d+$" (one or more + // literal 'd' characters) where "^\d+$" (digits) is clearly intended — + // the correctly escaped "^\\d+$" appears elsewhere in the same document. + // Restore the intended pattern so numeric dseq values validate. The + // vendored file itself is kept verbatim. + data = bytes.ReplaceAll(data, []byte(`"^d+$"`), []byte(`"^\\d+$"`)) + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = false + + doc, err := loader.LoadFromData(data) + if err != nil { + t.Fatalf("load OpenAPI spec: %v", err) + } + + // Ignore the production server URL so route matching is host-agnostic. + doc.Servers = nil + + router, err := gorillamux.NewRouter(doc) + if err != nil { + t.Fatalf("build OpenAPI router: %v", err) + } + + return router +} + +// contractRecorder collects validation failures observed by the test server. +type contractRecorder struct { + mu sync.Mutex + errs []string +} + +func (r *contractRecorder) add(msg string) { + r.mu.Lock() + defer r.mu.Unlock() + r.errs = append(r.errs, msg) +} + +// take returns the collected errors and resets the recorder. +func (r *contractRecorder) take() []string { + r.mu.Lock() + defer r.mu.Unlock() + errs := r.errs + r.errs = nil + return errs +} + +// cannedResponse is the minimal reply for one "METHOD /spec/path" so the +// client method under test can decode a success. +type cannedResponse struct { + status int + body string +} + +// cannedResponses maps "METHOD " to a response body just +// rich enough for the client to decode. These encode the documented response +// envelopes (data-enveloped vs top-level); response-schema validation is out +// of scope here. +var cannedResponses = map[string]cannedResponse{ + "GET /v1/user/me": {http.StatusOK, `{"data":{"id":"123e4567-e89b-12d3-a456-426614174000","userId":"u1","username":"tester","email":"tester@example.com","emailVerified":true}}`}, + "GET /v1/balances": {http.StatusOK, `{"data":{"balance":1000000,"deployments":500000,"total":1500000}}`}, + "GET /v1/wallets": {http.StatusOK, `{"data":[{"address":"akash1xtestwallet","creditAmount":10,"isTrialing":false,"denom":"uakt"}]}`}, + + "GET /v1/wallet-settings": {http.StatusOK, `{"data":{"autoReloadEnabled":false}}`}, + "PUT /v1/wallet-settings": {http.StatusOK, `{"data":{"autoReloadEnabled":true}}`}, + "GET /v1/weekly-cost": {http.StatusOK, `{"data":{"weeklyCost":1.25}}`}, + + // Top-level array, NOT data-enveloped. + "GET /v1/usage/history": {http.StatusOK, `[{"date":"2026-01-01","activeDeployments":1,"dailyUsdcSpent":0.5,"totalUsdcSpent":10.5}]`}, + + "GET /v1/deployments": {http.StatusOK, `{"data":{"deployments":[],"pagination":{"total":0,"skip":0,"limit":20,"hasMore":false}}}`}, + "POST /v1/deployments": {http.StatusOK, `{"data":{"dseq":"1","manifest":"m"}}`}, + "GET /v1/deployments/{dseq}": {http.StatusOK, `{"data":{"deployment":{"id":{"owner":"akash1x","dseq":"1"},"state":"active"},"leases":[]}}`}, + "PUT /v1/deployments/{dseq}": {http.StatusOK, `{"data":{"deployment":{"id":{"owner":"akash1x","dseq":"1"},"state":"active"},"leases":[]}}`}, + "DELETE /v1/deployments/{dseq}": {http.StatusOK, `{"data":{}}`}, + "POST /v1/deposit-deployment": {http.StatusOK, `{"data":{}}`}, + + "GET /v1/bids": {http.StatusOK, `{"data":[{"bid":{"id":{"owner":"akash1x","dseq":"1","gseq":1,"oseq":1,"provider":"akash1p"},"state":"open","price":{"denom":"uakt","amount":"100"}}}]}`}, + "POST /v1/leases": {http.StatusOK, `{"data":{"deployment":{"id":{"owner":"akash1x","dseq":"1"},"state":"active"},"leases":[{"id":{"owner":"akash1x","dseq":"1","gseq":1,"oseq":1,"provider":"akash1p"},"state":"active"}]}}`}, + + "GET /v2/deployment-settings/{dseq}": {http.StatusOK, `{"data":{"dseq":"1","autoTopUpEnabled":true,"estimatedTopUpAmount":5,"topUpFrequencyMs":60000}}`}, + "PATCH /v2/deployment-settings/{dseq}": {http.StatusOK, `{"data":{"dseq":"1","autoTopUpEnabled":true,"estimatedTopUpAmount":5,"topUpFrequencyMs":60000}}`}, + "POST /v2/deployment-settings": {http.StatusOK, `{"data":{"dseq":"404","autoTopUpEnabled":true,"estimatedTopUpAmount":5,"topUpFrequencyMs":60000}}`}, + + "POST /v1/create-jwt-token": {http.StatusOK, `{"data":{"token":"jwt-token"}}`}, + + "GET /v1/api-keys": {http.StatusOK, `{"data":[{"id":"123e4567-e89b-12d3-a456-426614174000","name":"ci"}]}`}, + "POST /v1/api-keys": {http.StatusOK, `{"data":{"id":"123e4567-e89b-12d3-a456-426614174000","name":"ci","apiKey":"secret"}}`}, + "DELETE /v1/api-keys/{id}": {http.StatusNoContent, ""}, + + // NOT data-enveloped. + "POST /v1/bid-screening": {http.StatusOK, `{"providers":[{"owner":"akash1p","hostUri":"https://p.example.com:8443","isAudited":true}]}`}, + + // Top-level array / object catalog endpoints. + "GET /v1/providers": {http.StatusOK, `[{"owner":"akash1p","name":"provider","isOnline":true}]`}, + "GET /v1/providers/{address}": {http.StatusOK, `{"owner":"akash1p","name":"provider","hostUri":"https://p.example.com:8443"}`}, + "GET /v1/provider-regions": {http.StatusOK, `[{"key":"us-west","description":"US West","providers":["akash1p"]}]`}, + "GET /v1/auditors": {http.StatusOK, `[{"id":"a1","name":"auditor","address":"akash1aud"}]`}, + "GET /v1/gpu-prices": {http.StatusOK, `{"availability":{"total":10,"available":4},"models":[{"vendor":"nvidia","model":"h100","ram":"80Gi","price":{"min":1,"max":3,"avg":2}}]}`}, + + "GET /v1/templates-list": {http.StatusOK, `{"data":{"categories":[]}}`}, + "GET /v1/templates/{id}": {http.StatusOK, `{"data":{"id":"tpl-1","name":"template"}}`}, +} + +// newContractServer returns an httptest server that validates every incoming +// request against the OpenAPI router and then serves the canned response for +// the matched route. +func newContractServer(router routers.Router, rec *contractRecorder) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + route, pathParams, err := router.FindRoute(r) + if err != nil { + rec.add(r.Method + " " + r.URL.Path + ": no matching route in OpenAPI spec: " + err.Error()) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{}`) + return + } + + input := &openapi3filter.RequestValidationInput{ + Request: r, + PathParams: pathParams, + Route: route, + Options: &openapi3filter.Options{ + // Security schemes (x-api-key / bearer) are the transport's + // concern; don't fail validation on them. + AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, + // Validate what the client actually sends: without this, + // the validator rewrites JSON null into schema defaults + // before checking, masking null-vs-array violations. + SkipSettingDefaults: true, + // Enforce format assertions (date, date-time, uuid): the + // motivating bug was an empty string sent for a + // format=date query parameter. + SchemaValidationOptions: []openapi3.SchemaValidationOption{ + openapi3.EnableFormatValidation(), + }, + }, + } + if err := openapi3filter.ValidateRequest(r.Context(), input); err != nil { + rec.add(r.Method + " " + route.Path + ": request violates OpenAPI contract: " + err.Error()) + } + + // Force the PATCH -> POST create fallback of SetDeploymentAutoTopUp: + // settings for dseq 404 "do not exist yet". + key := r.Method + " " + route.Path + if key == "PATCH /v2/deployment-settings/{dseq}" && pathParams["dseq"] == "404" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"error":"not found"}`) + return + } + + resp, ok := cannedResponses[key] + if !ok { + rec.add(key + ": no canned response registered; add one to cannedResponses") + resp = cannedResponse{status: http.StatusOK, body: `{}`} + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.status) + _, _ = io.WriteString(w, resp.body) + })) +} + +// TestClientRequestsMatchOpenAPIContract calls every public client method +// with happy-path arguments and fails if any produced request violates the +// vendored Console API contract. A failing subtest names the offending +// client method. +func TestClientRequestsMatchOpenAPIContract(t *testing.T) { + // The spec uses format=uuid on request parameters (wallets userId, + // api-key id); kin-openapi only validates uuid when registered. + openapi3.DefineStringFormatValidator("uuid", openapi3.NewRegexpFormatValidator(openapi3.FormatOfStringForUUIDOfRFC4122)) + + router := loadContractRouter(t) + rec := &contractRecorder{} + srv := newContractServer(router, rec) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + + const ( + address = "akash1xf29ao02u23nippl3ddjrec9dkjzwmz4t7g8dl" + provider = "akash1p" + uuid = "123e4567-e89b-12d3-a456-426614174000" + sdl = "version: \"2.0\"\nservices: {}\n" + ) + + // Minimal valid /v1/bid-screening resources per the vendored schema: + // resource{id,cpu,memory,gpu,storage} + count + price are all required. + screeningResources := json.RawMessage(`[{ + "resource": { + "id": 1, + "cpu": {"units": {"val": "1000"}}, + "memory": {"quantity": {"val": "536870912"}}, + "gpu": {"units": {"val": "0"}}, + "storage": [{"name": "default", "quantity": {"val": "1073741824"}}], + "endpoints": [] + }, + "count": 1, + "price": {"denom": "uakt", "amount": "10000"} + }]`) + + tests := []struct { + name string + call func(ctx context.Context) error + }{ + {"GetUser", func(ctx context.Context) error { + _, err := c.GetUser(ctx) + return err + }}, + {"GetBalances", func(ctx context.Context) error { + _, err := c.GetBalances(ctx) + return err + }}, + {"ListWallets", func(ctx context.Context) error { + _, err := c.ListWallets(ctx, uuid) + return err + }}, + {"GetWalletSettings", func(ctx context.Context) error { + _, err := c.GetWalletSettings(ctx) + return err + }}, + {"UpdateWalletSettings", func(ctx context.Context) error { + _, err := c.UpdateWalletSettings(ctx, true) + return err + }}, + {"GetWeeklyCost", func(ctx context.Context) error { + _, err := c.GetWeeklyCost(ctx) + return err + }}, + // The exact repro of the production 400: no dates supplied. The + // client must omit startDate/endDate rather than send them empty + // (format=date rejects ""). + {"GetUsageHistory/no-dates", func(ctx context.Context) error { + _, err := c.GetUsageHistory(ctx, address, "", "") + return err + }}, + {"GetUsageHistory/date-range", func(ctx context.Context) error { + _, err := c.GetUsageHistory(ctx, address, "2026-01-01", "2026-01-31") + return err + }}, + {"ListDeployments", func(ctx context.Context) error { + _, err := c.ListDeployments(ctx, 0, 20) + return err + }}, + {"GetDeployment", func(ctx context.Context) error { + _, err := c.GetDeployment(ctx, "1") + return err + }}, + {"CreateDeployment", func(ctx context.Context) error { + _, err := c.CreateDeployment(ctx, sdl, 5) + return err + }}, + {"UpdateDeployment", func(ctx context.Context) error { + _, err := c.UpdateDeployment(ctx, "1", sdl) + return err + }}, + {"CloseDeployment", func(ctx context.Context) error { + return c.CloseDeployment(ctx, "1") + }}, + {"Deposit", func(ctx context.Context) error { + return c.Deposit(ctx, "1", 5) + }}, + {"FetchBids", func(ctx context.Context) error { + _, err := c.FetchBids(ctx, "1") + return err + }}, + {"CreateLease", func(ctx context.Context) error { + _, err := c.CreateLease(ctx, "m", []console.LeaseRequest{ + {DSeq: "1", GSeq: 1, OSeq: 1, Provider: provider}, + }) + return err + }}, + {"GetDeploymentSettings", func(ctx context.Context) error { + _, err := c.GetDeploymentSettings(ctx, "1") + return err + }}, + {"SetDeploymentAutoTopUp/patch", func(ctx context.Context) error { + _, err := c.SetDeploymentAutoTopUp(ctx, "1", true) + return err + }}, + // dseq 404 makes the server answer the PATCH with 404, forcing the + // POST /v2/deployment-settings create fallback through validation. + {"SetDeploymentAutoTopUp/create-fallback", func(ctx context.Context) error { + _, err := c.SetDeploymentAutoTopUp(ctx, "404", true) + return err + }}, + {"CreateJWTToken", func(ctx context.Context) error { + _, err := c.CreateJWTToken(ctx, 300, []string{"logs"}) + return err + }}, + {"ListAPIKeys", func(ctx context.Context) error { + _, err := c.ListAPIKeys(ctx) + return err + }}, + {"CreateAPIKey/no-expiry", func(ctx context.Context) error { + _, err := c.CreateAPIKey(ctx, "n", "") + return err + }}, + // The schema declares expiresAt as format=date-time (RFC 3339). + {"CreateAPIKey/expiry", func(ctx context.Context) error { + _, err := c.CreateAPIKey(ctx, "n", "2027-01-01T00:00:00Z") + return err + }}, + // The spec requires a UUID key id (format=uuid path parameter). + {"DeleteAPIKey", func(ctx context.Context) error { + return c.DeleteAPIKey(ctx, uuid) + }}, + {"ScreenBids/minimal", func(ctx context.Context) error { + _, err := c.ScreenBids(ctx, &console.BidScreeningRequest{ + Resources: screeningResources, + Timezone: "America/Chicago", + }) + return err + }}, + {"ScreenBids/with-requirements", func(ctx context.Context) error { + _, err := c.ScreenBids(ctx, &console.BidScreeningRequest{ + Requirements: console.BidScreeningRequirements{ + SignedBy: console.SignedBy{AnyOf: []string{"akash1aud"}}, + Attributes: []console.Attribute{{Key: "region", Value: "us-west"}}, + }, + Resources: screeningResources, + Timezone: "America/Chicago", + }) + return err + }}, + {"ListProviders/all", func(ctx context.Context) error { + _, err := c.ListProviders(ctx, "", nil) + return err + }}, + {"ListProviders/trial-with-addresses", func(ctx context.Context) error { + _, err := c.ListProviders(ctx, "trial", []string{provider}) + return err + }}, + {"GetProvider", func(ctx context.Context) error { + _, err := c.GetProvider(ctx, provider) + return err + }}, + {"ListProviderRegions", func(ctx context.Context) error { + _, err := c.ListProviderRegions(ctx) + return err + }}, + {"ListAuditors", func(ctx context.Context) error { + _, err := c.ListAuditors(ctx) + return err + }}, + {"GetGPUPrices", func(ctx context.Context) error { + _, err := c.GetGPUPrices(ctx) + return err + }}, + {"ListTemplates", func(ctx context.Context) error { + _, err := c.ListTemplates(ctx) + return err + }}, + {"GetTemplate", func(ctx context.Context) error { + _, err := c.GetTemplate(ctx, "tpl-1") + return err + }}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec.take() // drop anything left over from a previous subtest + + if err := tc.call(context.Background()); err != nil { + t.Errorf("client call failed: %v", err) + } + + for _, violation := range rec.take() { + t.Errorf("contract violation: %s", violation) + } + }) + } +} + +// TestContractHarnessDetectsViolations is the negative control: it replays +// the exact request shape that broke `akt console usage` in production (an +// empty startDate against a format=date query parameter) and asserts the +// harness flags it. If this fails, the contract suite has silently stopped +// validating and TestClientRequestsMatchOpenAPIContract proves nothing. +func TestContractHarnessDetectsViolations(t *testing.T) { + openapi3.DefineStringFormatValidator("uuid", openapi3.NewRegexpFormatValidator(openapi3.FormatOfStringForUUIDOfRFC4122)) + + router := loadContractRouter(t) + rec := &contractRecorder{} + srv := newContractServer(router, rec) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/v1/usage/history?address=akash1x&startDate=&endDate=") + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() + + violations := rec.take() + if len(violations) == 0 { + t.Fatal("expected the harness to flag empty startDate/endDate (format=date), but no violation was recorded") + } +} diff --git a/internal/console/deployments.go b/internal/console/deployments.go new file mode 100644 index 00000000..5f93ffe5 --- /dev/null +++ b/internal/console/deployments.go @@ -0,0 +1,240 @@ +package console + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" +) + +// CreateDeployment creates a deployment via the managed wallet. Deposit is in +// USD. The returned manifest should be cached (see SaveManifest) so that +// CreateLease can send it after bid selection. +// +// Wire: POST /v1/deployments, body {"data":{"sdl":..., "deposit":...}}. +func (c *Client) CreateDeployment(ctx context.Context, sdl string, depositUSD float64) (*CreateDeploymentResult, error) { + body := envelope(map[string]any{ + "sdl": sdl, + "deposit": depositUSD, + }) + + var out CreateDeploymentResult + if err := c.doData(ctx, http.MethodPost, "/v1/deployments", body, &out); err != nil { + c.record("create-deployment", "", err) + return nil, err + } + + c.record("create-deployment", out.DSeq.String(), nil) + + return &out, nil +} + +// ListDeployments lists deployments with pagination. Out-of-range values are +// omitted from the query instead of being sent — the API requires skip >= 0 +// and limit >= 1, so a negative skip falls back to 0 and a non-positive limit +// falls back to the server default page size. +// +// Wire: GET /v1/deployments?skip=&limit=, data-enveloped response. +func (c *Client) ListDeployments(ctx context.Context, skip, limit int) (*DeploymentList, error) { + var params []string + if skip >= 0 { + params = append(params, "skip="+strconv.Itoa(skip)) + } + if limit >= 1 { + params = append(params, "limit="+strconv.Itoa(limit)) + } + + path := "/v1/deployments" + if len(params) > 0 { + path += "?" + strings.Join(params, "&") + } + + var out DeploymentList + if err := c.doData(ctx, http.MethodGet, path, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} + +// GetDeployment fetches a single deployment with its leases and escrow +// account. +// +// Wire: GET /v1/deployments/{dseq}, data-enveloped response. +func (c *Client) GetDeployment(ctx context.Context, dseq string) (*DeploymentDetail, error) { + var out DeploymentDetail + if err := c.doData(ctx, http.MethodGet, "/v1/deployments/"+url.PathEscape(dseq), nil, &out); err != nil { + return nil, err + } + + return &out, nil +} + +// UpdateDeployment updates a deployment's SDL. +// +// Wire: PUT /v1/deployments/{dseq}, body {"data":{"sdl":...}}, data-enveloped +// response. +func (c *Client) UpdateDeployment(ctx context.Context, dseq, sdl string) (*DeploymentDetail, error) { + body := envelope(map[string]any{"sdl": sdl}) + + var out DeploymentDetail + err := c.doData(ctx, http.MethodPut, "/v1/deployments/"+url.PathEscape(dseq), body, &out) + c.record("update-deployment", dseq, err) + if err != nil { + return nil, err + } + + return &out, nil +} + +// CloseDeployment closes a deployment. If the deployment is already closed +// (the API answers 404, or 400 with an already-closed message) it returns +// ErrAlreadyClosed, which callers may treat as success for idempotent +// behavior. Any other 400 is a genuine failure and is returned as-is. +// +// Wire: DELETE /v1/deployments/{dseq}. +func (c *Client) CloseDeployment(ctx context.Context, dseq string) error { + err := c.doJSON(ctx, http.MethodDelete, "/v1/deployments/"+url.PathEscape(dseq), nil, nil) + + switch { + case err == nil: + c.record("close-deployment", dseq, nil) + return nil + + case isAlreadyClosed(err): + // Desired end state reached; log as success but surface the sentinel. + c.record("close-deployment", dseq, nil) + return fmt.Errorf("%w (dseq %s)", ErrAlreadyClosed, dseq) + + default: + c.record("close-deployment", dseq, err) + return err + } +} + +// isAlreadyClosed reports whether a close attempt failed because the +// deployment no longer exists or is already closed. A 404 always qualifies: +// the resource is gone, which is the desired end state. A 400 qualifies only +// when its body actually says already-closed/not-found — the pinned contract +// (testdata/openapi.json) documents only 200 for DELETE +// /v1/deployments/{dseq}, so a generic 400 (validation, not-owner, active +// leases, ...) is a real failure that must surface to the caller. +func isAlreadyClosed(err error) bool { + if errors.Is(err, ErrNotFound) { + return true + } + + var httpErr *HTTPError + if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusBadRequest { + return false + } + + // "closed" also covers "already closed". + body := strings.ToLower(httpErr.Body) + + return strings.Contains(body, "closed") || strings.Contains(body, "not found") +} + +// Deposit adds funds to a deployment's escrow. Amount is in USD. +// +// Wire: POST /v1/deposit-deployment, body {"data":{"dseq":..., "deposit":...}}. +func (c *Client) Deposit(ctx context.Context, dseq string, amountUSD float64) error { + body := envelope(map[string]any{ + "dseq": dseq, + "deposit": amountUSD, + }) + + err := c.doJSON(ctx, http.MethodPost, "/v1/deposit-deployment", body, nil) + c.record("deposit", dseq, err) + + return err +} + +// FetchBids fetches bids for a deployment's open orders. +// +// Wire: GET /v1/bids?dseq=, response {"data":[{"bid":{...}}]}. +func (c *Client) FetchBids(ctx context.Context, dseq string) ([]Bid, error) { + path := "/v1/bids?dseq=" + url.QueryEscape(dseq) + + var wrapped []struct { + Bid Bid `json:"bid"` + } + if err := c.doData(ctx, http.MethodGet, path, nil, &wrapped); err != nil { + return nil, err + } + + bids := make([]Bid, 0, len(wrapped)) + for _, w := range wrapped { + bids = append(bids, w.Bid) + } + + return bids, nil +} + +// CreateLease accepts one or more bids and sends the deployment manifest to +// the winning providers. +// +// Wire: POST /v1/leases. NOTE: unlike other writes, the request body is NOT +// data-enveloped: {"manifest":..., "leases":[{dseq,gseq,oseq,provider}]}. +// The response is data-enveloped. +func (c *Client) CreateLease(ctx context.Context, manifest string, leases []LeaseRequest) (*DeploymentDetail, error) { + body := map[string]any{ + "manifest": manifest, + "leases": leases, + } + + leaseDSeq := "" + if len(leases) > 0 { + leaseDSeq = leases[0].DSeq + } + + var out DeploymentDetail + err := c.doData(ctx, http.MethodPost, "/v1/leases", body, &out) + c.record("create-lease", leaseDSeq, err) + if err != nil { + return nil, err + } + + return &out, nil +} + +// GetDeploymentSettings fetches auto-top-up settings for a deployment. +// Returns an error matching ErrNotFound when no settings exist yet. +// +// Wire: GET /v2/deployment-settings/{dseq}, data-enveloped response. +func (c *Client) GetDeploymentSettings(ctx context.Context, dseq string) (*DeploymentSettings, error) { + var out DeploymentSettings + if err := c.doData(ctx, http.MethodGet, "/v2/deployment-settings/"+url.PathEscape(dseq), nil, &out); err != nil { + return nil, err + } + + return &out, nil +} + +// SetDeploymentAutoTopUp enables or disables auto-top-up for a deployment. +// It PATCHes existing settings and transparently falls back to creating them +// (POST) when none exist yet. +// +// Wire: PATCH /v2/deployment-settings/{dseq}, body +// {"data":{"autoTopUpEnabled":...}}; on 404, POST /v2/deployment-settings, +// body {"data":{"dseq":..., "autoTopUpEnabled":...}}. +func (c *Client) SetDeploymentAutoTopUp(ctx context.Context, dseq string, enabled bool) (*DeploymentSettings, error) { + var out DeploymentSettings + + err := c.doData(ctx, http.MethodPatch, "/v2/deployment-settings/"+url.PathEscape(dseq), + envelope(map[string]any{"autoTopUpEnabled": enabled}), &out) + if errors.Is(err, ErrNotFound) { + err = c.doData(ctx, http.MethodPost, "/v2/deployment-settings", + envelope(map[string]any{"dseq": dseq, "autoTopUpEnabled": enabled}), &out) + } + + c.record("update-deployment-settings", dseq, err) + if err != nil { + return nil, err + } + + return &out, nil +} diff --git a/internal/console/deployments_test.go b/internal/console/deployments_test.go new file mode 100644 index 00000000..88a90279 --- /dev/null +++ b/internal/console/deployments_test.go @@ -0,0 +1,341 @@ +package console_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "pkg.akt.dev/akt/internal/console" +) + +func TestCreateDeployment(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v1/deployments", r.URL.Path) + + data := decodeDataBody(t, r) + assert.Equal(t, "test-sdl", data["sdl"]) + assert.InDelta(t, 5.0, data["deposit"], 0.001) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"dseq":"12345","manifest":"[{\"name\":\"web\"}]","signTx":{"code":0,"transactionHash":"ABC123","rawLog":""}}}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + resp, err := c.CreateDeployment(context.Background(), "test-sdl", 5.0) + require.NoError(t, err) + assert.Equal(t, "12345", resp.DSeq.String()) + assert.Equal(t, `[{"name":"web"}]`, resp.Manifest) + require.NotNil(t, resp.SignTx) + assert.Equal(t, "ABC123", resp.SignTx.TransactionHash) +} + +func TestListDeployments(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v1/deployments", r.URL.Path) + assert.Equal(t, "0", r.URL.Query().Get("skip")) + assert.Equal(t, "10", r.URL.Query().Get("limit")) + + _, _ = w.Write([]byte(`{"data":{ + "deployments":[ + {"deployment":{"id":{"owner":"akash1x","dseq":"100"},"state":"active","created_at":"123"}, + "leases":[{"id":{"owner":"akash1x","dseq":"100","gseq":1,"oseq":1,"provider":"akash1p"},"state":"active","price":{"denom":"uakt","amount":"1.5"}}]}, + {"deployment":{"id":{"owner":"akash1x","dseq":"200"},"state":"closed"},"leases":[]} + ], + "pagination":{"total":25,"skip":0,"limit":10,"hasMore":true} + }}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + resp, err := c.ListDeployments(context.Background(), 0, 10) + require.NoError(t, err) + require.Len(t, resp.Deployments, 2) + assert.Equal(t, "100", resp.Deployments[0].Deployment.ID.DSeq.String()) + assert.Equal(t, "active", resp.Deployments[0].Deployment.State) + require.Len(t, resp.Deployments[0].Leases, 1) + assert.Equal(t, "akash1p", resp.Deployments[0].Leases[0].ID.Provider) + assert.Equal(t, "200", resp.Deployments[1].Deployment.ID.DSeq.String()) + assert.Equal(t, 25, resp.Pagination.Total) + assert.True(t, resp.Pagination.HasMore) +} + +func TestGetDeployment(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v1/deployments/777", r.URL.Path) + + _, _ = w.Write([]byte(`{"data":{ + "deployment":{"id":{"owner":"akash1x","dseq":"777"},"state":"active"}, + "leases":[{ + "id":{"owner":"akash1x","dseq":"777","gseq":1,"oseq":1,"provider":"akash1p"}, + "state":"active", + "price":{"denom":"uakt","amount":"2.75"}, + "status":{"services":{"web":{"uris":["web.example.com"]}},"forwarded_ports":{},"ips":null} + }], + "escrow_account":{"state":{"funds":{"denom":"uakt","amount":"5000000"},"transferred":{"denom":"uakt","amount":"100"}}} + }}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + resp, err := c.GetDeployment(context.Background(), "777") + require.NoError(t, err) + assert.Equal(t, "777", resp.Deployment.ID.DSeq.String()) + require.Len(t, resp.Leases, 1) + + lease := resp.Leases[0] + assert.Equal(t, uint32(1), lease.ID.GSeq) + require.NotNil(t, lease.Price) + assert.Equal(t, "2.75", lease.Price.Amount.String()) + require.NotNil(t, lease.Status) + assert.Contains(t, string(lease.Status.Services), "web.example.com") + assert.Contains(t, string(resp.EscrowAccount), "transferred") +} + +func TestUpdateDeployment(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/v1/deployments/555", r.URL.Path) + + data := decodeDataBody(t, r) + assert.Equal(t, "new-sdl", data["sdl"]) + + _, _ = w.Write([]byte(`{"data":{"deployment":{"id":{"owner":"akash1x","dseq":"555"},"state":"active"}}}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + resp, err := c.UpdateDeployment(context.Background(), "555", "new-sdl") + require.NoError(t, err) + assert.Equal(t, "555", resp.Deployment.ID.DSeq.String()) +} + +func TestCloseDeployment(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/v1/deployments/99999", r.URL.Path) + + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + require.NoError(t, c.CloseDeployment(context.Background(), "99999")) +} + +func TestCloseDeploymentAlreadyClosedSentinel(t *testing.T) { + tests := []struct { + name string + status int + body string + }{ + // 404 = resource gone = desired end state, regardless of body. + {"404", http.StatusNotFound, `{"error":"no such deployment"}`}, + // 400 maps to the sentinel only when the body says so. + {"400 already closed", http.StatusBadRequest, `{"error":"deployment already closed"}`}, + {"400 closed", http.StatusBadRequest, `{"error":"Deployment closed"}`}, + {"400 not found", http.StatusBadRequest, `{"error":"Deployment Not Found"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(tt.body)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + err := c.CloseDeployment(context.Background(), "1") + require.Error(t, err) + assert.ErrorIs(t, err, console.ErrAlreadyClosed, + "HTTP %d %q must map to ErrAlreadyClosed for idempotent close", tt.status, tt.body) + }) + } +} + +func TestCloseDeploymentBadRequestValidationIsRealError(t *testing.T) { + // The pinned contract documents only 200 for DELETE + // /v1/deployments/{dseq}: a 400 whose body does not indicate + // already-closed semantics is a genuine failure (validation, not-owner, + // active leases) and must NOT be swallowed as ErrAlreadyClosed. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid dseq parameter"}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + err := c.CloseDeployment(context.Background(), "1") + require.Error(t, err) + assert.NotErrorIs(t, err, console.ErrAlreadyClosed, + "a validation 400 must not be reported as already-closed") + + var httpErr *console.HTTPError + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, http.StatusBadRequest, httpErr.StatusCode) + assert.Contains(t, httpErr.Body, "invalid dseq parameter") +} + +func TestDepositBodyShape(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v1/deposit-deployment", r.URL.Path) + + data := decodeDataBody(t, r) + assert.Equal(t, "321", data["dseq"]) + assert.InDelta(t, 12.5, data["deposit"], 0.001) + assert.NotContains(t, data, "amount", "wire field is deposit, not amount") + + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + require.NoError(t, c.Deposit(context.Background(), "321", 12.5)) +} + +func TestFetchBids(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v1/bids", r.URL.Path) + assert.Equal(t, "444", r.URL.Query().Get("dseq")) + + _, _ = w.Write([]byte(`{"data":[ + {"bid":{"id":{"owner":"akash1x","dseq":"444","gseq":1,"oseq":1,"provider":"akash1p1"},"state":"open","price":{"denom":"uakt","amount":"1.1"}}}, + {"bid":{"id":{"owner":"akash1x","dseq":"444","gseq":1,"oseq":1,"provider":"akash1p2"},"state":"open","price":{"denom":"uakt","amount":"0.9"}}} + ]}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + bids, err := c.FetchBids(context.Background(), "444") + require.NoError(t, err) + require.Len(t, bids, 2) + assert.Equal(t, "akash1p1", bids[0].ID.Provider) + assert.Equal(t, "0.9", bids[1].Price.Amount.String()) +} + +func TestCreateLeaseBodyNotEnveloped(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v1/leases", r.URL.Path) + + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.NotContains(t, body, "data", "/v1/leases request must NOT be data-enveloped") + assert.Equal(t, "manifest-json", body["manifest"]) + + leases, ok := body["leases"].([]any) + require.True(t, ok) + require.Len(t, leases, 1) + lease := leases[0].(map[string]any) + assert.Equal(t, "888", lease["dseq"]) + assert.InDelta(t, 1.0, lease["gseq"], 0.001, "gseq must be a JSON number") + assert.InDelta(t, 1.0, lease["oseq"], 0.001, "oseq must be a JSON number") + assert.Equal(t, "akash1p", lease["provider"]) + + _, _ = w.Write([]byte(`{"data":{ + "deployment":{"id":{"owner":"akash1x","dseq":"888"},"state":"active"}, + "leases":[{"id":{"owner":"akash1x","dseq":"888","gseq":1,"oseq":1,"provider":"akash1p"},"state":"active"}] + }}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + resp, err := c.CreateLease(context.Background(), "manifest-json", []console.LeaseRequest{ + {DSeq: "888", GSeq: 1, OSeq: 1, Provider: "akash1p"}, + }) + require.NoError(t, err) + require.Len(t, resp.Leases, 1) + assert.Equal(t, "active", resp.Leases[0].State) +} + +func TestGetDeploymentSettings(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v2/deployment-settings/123", r.URL.Path) + + _, _ = w.Write([]byte(`{"data":{"dseq":"123","autoTopUpEnabled":true,"estimatedTopUpAmount":4.2,"topUpFrequencyMs":86400000}}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + s, err := c.GetDeploymentSettings(context.Background(), "123") + require.NoError(t, err) + assert.True(t, s.AutoTopUpEnabled) + assert.InDelta(t, 4.2, s.EstimatedTopUpAmount, 0.001) + assert.Equal(t, int64(86400000), s.TopUpFrequencyMs) +} + +func TestGetDeploymentSettingsNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + _, err := c.GetDeploymentSettings(context.Background(), "123") + assert.ErrorIs(t, err, console.ErrNotFound) +} + +func TestSetDeploymentAutoTopUpPatch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/v2/deployment-settings/321", r.URL.Path) + + data := decodeDataBody(t, r) + assert.Equal(t, true, data["autoTopUpEnabled"]) + + _, _ = w.Write([]byte(`{"data":{"dseq":"321","autoTopUpEnabled":true}}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + s, err := c.SetDeploymentAutoTopUp(context.Background(), "321", true) + require.NoError(t, err) + assert.True(t, s.AutoTopUpEnabled) +} + +func TestSetDeploymentAutoTopUpFallsBackToPost(t *testing.T) { + var patchCalled, postCalled bool + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPatch: + patchCalled = true + assert.Equal(t, "/v2/deployment-settings/654", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + + case http.MethodPost: + postCalled = true + assert.Equal(t, "/v2/deployment-settings", r.URL.Path) + + data := decodeDataBody(t, r) + assert.Equal(t, "654", data["dseq"]) + assert.Equal(t, false, data["autoTopUpEnabled"]) + + _, _ = w.Write([]byte(`{"data":{"dseq":"654","autoTopUpEnabled":false}}`)) + + default: + t.Errorf("unexpected method %s", r.Method) + } + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + s, err := c.SetDeploymentAutoTopUp(context.Background(), "654", false) + require.NoError(t, err) + assert.True(t, patchCalled, "PATCH must be attempted first") + assert.True(t, postCalled, "POST fallback must run after PATCH 404") + assert.False(t, s.AutoTopUpEnabled) + assert.Equal(t, "654", s.DSeq.String()) +} diff --git a/internal/console/manifests.go b/internal/console/manifests.go new file mode 100644 index 00000000..e6c35b5b --- /dev/null +++ b/internal/console/manifests.go @@ -0,0 +1,77 @@ +package console + +import ( + "fmt" + "os" + "path/filepath" + "strconv" +) + +// Manifest cache: `deployment create` stores the manifest returned by the +// Console API so `lease create` can default to it later. Pure file helpers, +// independent of the Client. + +// validateDSeq rejects any dseq that is not a plain unsigned decimal number. +// The dseq becomes a filesystem path component, and it can arrive from +// untrusted places (CLI arguments, a Console API response), so anything else +// — "../..", absolute paths, empty strings — must never reach +// filepath.Join. +func validateDSeq(dseq string) error { + if _, err := strconv.ParseUint(dseq, 10, 64); err != nil { + return fmt.Errorf("console: invalid dseq %q: must be a plain numeric sequence", dseq) + } + + return nil +} + +// ManifestPath returns the cache path for a deployment's manifest: +// /contexts//manifests/.json. The dseq must be a plain +// numeric sequence; anything else (e.g. a path-traversal attempt) is an +// error. +func ManifestPath(root, ctxName, dseq string) (string, error) { + if err := validateDSeq(dseq); err != nil { + return "", err + } + + return filepath.Join(root, "contexts", ctxName, "manifests", dseq+".json"), nil +} + +// SaveManifest caches a deployment manifest under the context's manifests +// directory with mode 0600 (manifests can reference private registries or +// env values). The dseq is validated as a plain numeric sequence first: it +// comes from the Console API response, and a hostile value must not steer +// the write outside the config root. +func SaveManifest(root, ctxName, dseq, manifest string) error { + path, err := ManifestPath(root, ctxName, dseq) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("console: create manifests dir: %w", err) + } + + if err := os.WriteFile(path, []byte(manifest), 0o600); err != nil { + return fmt.Errorf("console: write manifest: %w", err) + } + + return nil +} + +// LoadManifest reads a previously cached manifest. The dseq is validated as +// a plain numeric sequence so user-supplied values cannot read arbitrary +// files. The returned error wraps fs.ErrNotExist when no manifest has been +// cached for the dseq. +func LoadManifest(root, ctxName, dseq string) (string, error) { + path, err := ManifestPath(root, ctxName, dseq) + if err != nil { + return "", err + } + + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("console: load manifest for dseq %s: %w", dseq, err) + } + + return string(data), nil +} diff --git a/internal/console/manifests_test.go b/internal/console/manifests_test.go new file mode 100644 index 00000000..6e0f1484 --- /dev/null +++ b/internal/console/manifests_test.go @@ -0,0 +1,113 @@ +package console_test + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "pkg.akt.dev/akt/internal/console" +) + +func TestManifestCacheRoundTrip(t *testing.T) { + root := t.TempDir() + manifest := `[{"name":"web","services":[{"image":"nginx"}]}]` + + require.NoError(t, console.SaveManifest(root, "prod", "12345", manifest)) + + // Stored at /contexts//manifests/.json with mode 0600. + path := filepath.Join(root, "contexts", "prod", "manifests", "12345.json") + info, err := os.Stat(path) + require.NoError(t, err) + if runtime.GOOS != "windows" { + assert.Equal(t, fs.FileMode(0o600), info.Mode().Perm()) + } + + got, err := console.LoadManifest(root, "prod", "12345") + require.NoError(t, err) + assert.Equal(t, manifest, got) +} + +func TestManifestCacheOverwrite(t *testing.T) { + root := t.TempDir() + + require.NoError(t, console.SaveManifest(root, "prod", "1", "old")) + require.NoError(t, console.SaveManifest(root, "prod", "1", "new")) + + got, err := console.LoadManifest(root, "prod", "1") + require.NoError(t, err) + assert.Equal(t, "new", got) +} + +func TestManifestCacheIsolatedPerContext(t *testing.T) { + root := t.TempDir() + + require.NoError(t, console.SaveManifest(root, "prod", "1", "prod-manifest")) + require.NoError(t, console.SaveManifest(root, "dev", "1", "dev-manifest")) + + got, err := console.LoadManifest(root, "dev", "1") + require.NoError(t, err) + assert.Equal(t, "dev-manifest", got) +} + +func TestLoadManifestMissing(t *testing.T) { + _, err := console.LoadManifest(t.TempDir(), "prod", "404404") + require.Error(t, err) + assert.True(t, errors.Is(err, fs.ErrNotExist), "missing manifest must wrap fs.ErrNotExist") +} + +func TestManifestDSeqTraversalRejected(t *testing.T) { + // The dseq lands in a filesystem path and can come from untrusted input + // (CLI args on load, the Console API response on save), so anything but + // a plain numeric sequence must be rejected before touching the disk. + hostile := []string{ + "../../../../etc/passwd", + "../../../../x", + "..", + "/etc/passwd", + "12345/../../escape", + "12345x", + "-1", + "", + } + + root := t.TempDir() + + for _, dseq := range hostile { + t.Run(dseq, func(t *testing.T) { + // Write side: a hostile Console API response must not steer the + // write outside the config root. + err := console.SaveManifest(root, "prod", dseq, "owned") + require.Error(t, err, "SaveManifest must reject dseq %q", dseq) + assert.Contains(t, err.Error(), "invalid dseq") + + // Read side: a hostile CLI argument must not read arbitrary files. + _, err = console.LoadManifest(root, "prod", dseq) + require.Error(t, err, "LoadManifest must reject dseq %q", dseq) + assert.Contains(t, err.Error(), "invalid dseq") + + _, err = console.ManifestPath(root, "prod", dseq) + require.Error(t, err, "ManifestPath must reject dseq %q", dseq) + }) + } + + // Nothing may have been written outside the manifests directory: the + // root must contain no entries at all (SaveManifest failed before + // MkdirAll every time). + entries, err := os.ReadDir(root) + require.NoError(t, err) + assert.Empty(t, entries, "hostile dseqs must not create any files or directories") +} + +func TestManifestPathValidDSeq(t *testing.T) { + root := t.TempDir() + + path, err := console.ManifestPath(root, "prod", "12345") + require.NoError(t, err) + assert.Equal(t, filepath.Join(root, "contexts", "prod", "manifests", "12345.json"), path) +} diff --git a/internal/console/marketplace.go b/internal/console/marketplace.go new file mode 100644 index 00000000..c044c8ca --- /dev/null +++ b/internal/console/marketplace.go @@ -0,0 +1,149 @@ +package console + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" +) + +// Public marketplace/catalog endpoints. None of these require an API key; +// the key is still sent when the client has one configured. + +// ScreenBids asks the Console API which providers can satisfy the given +// requirements/resources before a deployment is created. The API contract +// requires both Resources and Timezone (an IANA zone name); requests missing +// either are rejected locally instead of triggering an HTTP 400. +// +// Wire: POST /v1/bid-screening (request and response are NOT data-enveloped; +// the response is {"providers":[...]}). +func (c *Client) ScreenBids(ctx context.Context, req *BidScreeningRequest) ([]ScreenedProvider, error) { + if req == nil { + return nil, fmt.Errorf("console: bid screening: request is nil") + } + if len(req.Resources) == 0 { + return nil, fmt.Errorf("console: bid screening: resources are required") + } + if req.Timezone == "" { + return nil, fmt.Errorf("console: bid screening: timezone is required (IANA zone name, e.g. America/Chicago)") + } + + var out struct { + Providers []ScreenedProvider `json:"providers"` + } + if err := c.doJSON(ctx, http.MethodPost, "/v1/bid-screening", req, &out); err != nil { + return nil, err + } + + return out.Providers, nil +} + +// ListProviders lists providers. scope is optional (e.g. "trial"); +// addresses optionally restricts the result to specific provider addresses. +// +// Wire: GET /v1/providers?scope=&addresses=. NOTE: the response is a +// TOP-LEVEL array, not data-enveloped. +func (c *Client) ListProviders(ctx context.Context, scope string, addresses []string) ([]Provider, error) { + q := url.Values{} + if scope != "" { + q.Set("scope", scope) + } + if len(addresses) > 0 { + q.Set("addresses", strings.Join(addresses, ",")) + } + + path := "/v1/providers" + if enc := q.Encode(); enc != "" { + path += "?" + enc + } + + var out []Provider + if err := c.doJSON(ctx, http.MethodGet, path, nil, &out); err != nil { + return nil, err + } + + return out, nil +} + +// GetProvider fetches detailed information (including stats and hostUri) for +// one provider. Common fields are typed; the full document is preserved in +// ProviderDetail.Raw. +// +// Wire: GET /v1/providers/{address}, top-level object. +func (c *Client) GetProvider(ctx context.Context, address string) (*ProviderDetail, error) { + var raw json.RawMessage + if err := c.doJSON(ctx, http.MethodGet, "/v1/providers/"+url.PathEscape(address), nil, &raw); err != nil { + return nil, err + } + + var out ProviderDetail + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("console: decode provider detail: %w", err) + } + out.Raw = raw + + return &out, nil +} + +// ListProviderRegions lists the regions providers advertise. +// +// Wire: GET /v1/provider-regions, TOP-LEVEL array. +func (c *Client) ListProviderRegions(ctx context.Context) ([]ProviderRegion, error) { + var out []ProviderRegion + if err := c.doJSON(ctx, http.MethodGet, "/v1/provider-regions", nil, &out); err != nil { + return nil, err + } + + return out, nil +} + +// ListAuditors lists known auditors. +// +// Wire: GET /v1/auditors, TOP-LEVEL array. +func (c *Client) ListAuditors(ctx context.Context) ([]Auditor, error) { + var out []Auditor + if err := c.doJSON(ctx, http.MethodGet, "/v1/auditors", nil, &out); err != nil { + return nil, err + } + + return out, nil +} + +// GetGPUPrices fetches the network-wide GPU availability and price catalog. +// +// Wire: GET /v1/gpu-prices, top-level object (not data-enveloped). +func (c *Client) GetGPUPrices(ctx context.Context) (*GPUPrices, error) { + var out GPUPrices + if err := c.doJSON(ctx, http.MethodGet, "/v1/gpu-prices", nil, &out); err != nil { + return nil, err + } + + return &out, nil +} + +// ListTemplates fetches the template catalog. The category/template tree is +// loosely specified upstream, so the enveloped payload is returned raw. +// +// Wire: GET /v1/templates-list, data-enveloped response. +func (c *Client) ListTemplates(ctx context.Context) (json.RawMessage, error) { + var out json.RawMessage + if err := c.doData(ctx, http.MethodGet, "/v1/templates-list", nil, &out); err != nil { + return nil, err + } + + return out, nil +} + +// GetTemplate fetches one deployment template by ID. +// +// Wire: GET /v1/templates/{id}, data-enveloped response. +func (c *Client) GetTemplate(ctx context.Context, id string) (*Template, error) { + var out Template + if err := c.doData(ctx, http.MethodGet, "/v1/templates/"+url.PathEscape(id), nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/internal/console/marketplace_test.go b/internal/console/marketplace_test.go new file mode 100644 index 00000000..70999797 --- /dev/null +++ b/internal/console/marketplace_test.go @@ -0,0 +1,162 @@ +package console_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "pkg.akt.dev/akt/internal/console" +) + +func TestListProvidersTopLevelArray(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/providers", r.URL.Path) + assert.Equal(t, "trial", r.URL.Query().Get("scope")) + assert.Equal(t, "akash1p1,akash1p2", r.URL.Query().Get("addresses")) + + _, _ = w.Write([]byte(`[ + {"owner":"akash1p1","name":"provider-one","hostUri":"https://p1.example.com:8443","isOnline":true,"isAudited":true,"uptime7d":0.999,"gpuModels":["rtx4090"],"ipRegion":{"country":"US"}}, + {"owner":"akash1p2","name":"provider-two","hostUri":"https://p2.example.com:8443","isOnline":false} + ]`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "") + ps, err := c.ListProviders(context.Background(), "trial", []string{"akash1p1", "akash1p2"}) + require.NoError(t, err) + require.Len(t, ps, 2) + assert.Equal(t, "akash1p1", ps[0].Owner) + assert.True(t, ps[0].IsAudited) + assert.InDelta(t, 0.999, ps[0].Uptime7D, 1e-6) + assert.Contains(t, string(ps[0].GPUModels), "rtx4090") + assert.False(t, ps[1].IsOnline) +} + +func TestGetProviderDetailKeepsRaw(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/providers/akash1p1", r.URL.Path) + _, _ = w.Write([]byte(`{"owner":"akash1p1","hostUri":"https://p1.example.com:8443","stats":{"leaseCount":42}}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "") + p, err := c.GetProvider(context.Background(), "akash1p1") + require.NoError(t, err) + assert.Equal(t, "akash1p1", p.Owner) + assert.Equal(t, "https://p1.example.com:8443", p.HostURI) + assert.Contains(t, string(p.Raw), "leaseCount", "full document preserved in Raw") +} + +func TestListProviderRegionsAndAuditors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/provider-regions": + _, _ = w.Write([]byte(`[{"key":"us-west","description":"US West","providers":["akash1p1"]}]`)) + case "/v1/auditors": + _, _ = w.Write([]byte(`[{"id":"1","name":"audited-by-overclock","address":"akash1aud","website":"https://ovrclk.com"}]`)) + default: + t.Errorf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + c := console.New(srv.URL, "") + + regions, err := c.ListProviderRegions(context.Background()) + require.NoError(t, err) + require.Len(t, regions, 1) + assert.Equal(t, "us-west", regions[0].Key) + assert.Equal(t, []string{"akash1p1"}, regions[0].Providers) + + auditors, err := c.ListAuditors(context.Background()) + require.NoError(t, err) + require.Len(t, auditors, 1) + assert.Equal(t, "akash1aud", auditors[0].Address) +} + +func TestGetGPUPrices(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/gpu-prices", r.URL.Path) + _, _ = w.Write([]byte(`{ + "availability":{"total":100,"available":40}, + "models":[{"vendor":"nvidia","model":"h100","ram":"80Gi","price":{"min":1.0,"max":3.0,"avg":2.1}}] + }`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "") + prices, err := c.GetGPUPrices(context.Background()) + require.NoError(t, err) + assert.Equal(t, 40, prices.Availability.Available) + require.Len(t, prices.Models, 1) + assert.Equal(t, "h100", prices.Models[0].Model) + assert.InDelta(t, 2.1, prices.Models[0].Price.Avg, 0.001) +} + +func TestScreenBids(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v1/bid-screening", r.URL.Path) + + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.NotContains(t, body, "data", "bid-screening request must NOT be data-enveloped") + + reqs, ok := body["requirements"].(map[string]any) + require.True(t, ok) + signedBy := reqs["signedBy"].(map[string]any) + assert.Equal(t, []any{"akash1aud"}, signedBy["anyOf"]) + assert.Equal(t, "America/Los_Angeles", body["timezone"]) + assert.Contains(t, body, "resources") + + _, _ = w.Write([]byte(`{"providers":[ + {"owner":"akash1p1","hostUri":"https://p1.example.com:8443","isAudited":true,"location":{"region":"us-west"},"organization":"Overclock"} + ]}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "") + providers, err := c.ScreenBids(context.Background(), &console.BidScreeningRequest{ + Requirements: console.BidScreeningRequirements{ + SignedBy: console.SignedBy{AnyOf: []string{"akash1aud"}, AllOf: []string{}}, + Attributes: []console.Attribute{{Key: "region", Value: "us-west"}}, + }, + Resources: json.RawMessage(`[{"cpu":{"units":1000}}]`), + Timezone: "America/Los_Angeles", + }) + require.NoError(t, err) + require.Len(t, providers, 1) + assert.Equal(t, "akash1p1", providers[0].Owner) + assert.True(t, providers[0].IsAudited) + assert.Equal(t, "Overclock", providers[0].Organization) +} + +func TestTemplates(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/templates-list": + _, _ = w.Write([]byte(`{"data":{"categories":[{"title":"AI","templates":[{"id":"tpl-1"}]}]}}`)) + case "/v1/templates/tpl-1": + _, _ = w.Write([]byte(`{"data":{"id":"tpl-1","name":"Jupyter","summary":"notebooks","deploy":"---\nversion: \"2.0\"","readme":"# Jupyter"}}`)) + default: + t.Errorf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + c := console.New(srv.URL, "") + + raw, err := c.ListTemplates(context.Background()) + require.NoError(t, err) + assert.Contains(t, string(raw), "categories", "envelope unwrapped, raw categories returned") + + tpl, err := c.GetTemplate(context.Background(), "tpl-1") + require.NoError(t, err) + assert.Equal(t, "Jupyter", tpl.Name) + assert.Contains(t, tpl.Deploy, "version") +} diff --git a/internal/console/testdata/README.md b/internal/console/testdata/README.md new file mode 100644 index 00000000..c2856916 --- /dev/null +++ b/internal/console/testdata/README.md @@ -0,0 +1,22 @@ +# Console API OpenAPI contract + +`openapi.json` is the OpenAPI 3.0 document for the Akash Console API +(`https://console-api.akash.network`), vendored verbatim from the Console API +via the `console-axi` reference repository. It is consumed by +`contract_test.go`, which validates every request the `internal/console` +client produces against this contract. + +## Refreshing + +1. Regenerate/fetch the current `openapi.json` from the Console API's + `console-axi` reference repo (the API's generated OpenAPI document). +2. Replace this file verbatim — do not hand-edit it. +3. Run `GOWORK=off go test ./internal/console/` and fix any client methods + that the contract test now flags. + +## Known upstream quirk + +44 `dseq` schemas carry the pattern `^d+$` (literal letter `d`), an escaping +artifact of the intended `^\d+$` (digits) — the correctly escaped `^\\d+$` +appears elsewhere in the same document. `contract_test.go` normalizes this at +load time; the vendored file is kept byte-for-byte as published. diff --git a/internal/console/testdata/openapi.json b/internal/console/testdata/openapi.json new file mode 100644 index 00000000..7a7eadf9 --- /dev/null +++ b/internal/console/testdata/openapi.json @@ -0,0 +1,15850 @@ +{ + "openapi": "3.0.0", + "servers": [ + { + "url": "https://console-api.akash.network" + } + ], + "info": { + "title": "Akash Network Console API", + "description": "API providing data to the Akash Network Console", + "version": "v1" + }, + "paths": { + "/v1/start-trial": { + "post": { + "summary": "Start a trial period for a user", + "description": "Creates a managed wallet for a user and initiates a trial period. This endpoint handles payment method validation and may require 3D Secure authentication for certain payment methods. Returns wallet information and trial status.", + "tags": [ + "Wallet" + ], + "security": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "userId": { + "type": "string" + } + }, + "required": [ + "userId" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Trial started successfully and wallet created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "number", + "nullable": true + }, + "userId": { + "type": "string", + "nullable": true + }, + "creditAmount": { + "type": "number" + }, + "address": { + "type": "string", + "nullable": true + }, + "denom": { + "type": "string" + }, + "isTrialing": { + "type": "boolean" + }, + "topUpMinAmountUsd": { + "type": "number", + "description": "Minimum USD amount accepted by the next paid top-up for this wallet." + }, + "createdAt": { + "type": "string", + "nullable": true + }, + "requires3DS": { + "type": "boolean" + }, + "clientSecret": { + "type": "string", + "nullable": true + }, + "paymentIntentId": { + "type": "string", + "nullable": true + }, + "paymentMethodId": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "userId", + "creditAmount", + "address", + "denom", + "isTrialing", + "topUpMinAmountUsd", + "createdAt" + ], + "additionalProperties": false + } + }, + "required": [ + "data" + ] + } + } + } + }, + "202": { + "description": "3D Secure authentication required to complete trial setup", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "number", + "nullable": true + }, + "userId": { + "type": "string", + "nullable": true + }, + "creditAmount": { + "type": "number" + }, + "address": { + "type": "string", + "nullable": true + }, + "denom": { + "type": "string" + }, + "isTrialing": { + "type": "boolean" + }, + "topUpMinAmountUsd": { + "type": "number", + "description": "Minimum USD amount accepted by the next paid top-up for this wallet." + }, + "createdAt": { + "type": "string", + "nullable": true + }, + "requires3DS": { + "type": "boolean" + }, + "clientSecret": { + "type": "string", + "nullable": true + }, + "paymentIntentId": { + "type": "string", + "nullable": true + }, + "paymentMethodId": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "userId", + "creditAmount", + "address", + "denom", + "isTrialing", + "topUpMinAmountUsd", + "createdAt" + ], + "additionalProperties": false + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/wallets": { + "get": { + "summary": "Get a list of wallets", + "tags": [ + "Wallet" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "description": "The ID of the user to get the wallets for", + "name": "userId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns a created wallet", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "nullable": true + }, + "userId": { + "type": "string", + "nullable": true + }, + "creditAmount": { + "type": "number" + }, + "address": { + "type": "string", + "nullable": true + }, + "denom": { + "type": "string" + }, + "isTrialing": { + "type": "boolean" + }, + "topUpMinAmountUsd": { + "type": "number", + "description": "Minimum USD amount accepted by the next paid top-up for this wallet." + }, + "createdAt": { + "type": "string", + "nullable": true + }, + "requires3DS": { + "type": "boolean" + }, + "clientSecret": { + "type": "string", + "nullable": true + }, + "paymentIntentId": { + "type": "string", + "nullable": true + }, + "paymentMethodId": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "userId", + "creditAmount", + "address", + "denom", + "isTrialing", + "topUpMinAmountUsd", + "createdAt" + ] + } + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/wallet-settings": { + "get": { + "summary": "Get wallet settings", + "description": "Retrieves the wallet settings for the current user's wallet", + "tags": [ + "WalletSetting" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Wallet settings retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "autoReloadEnabled": { + "type": "boolean" + } + }, + "required": [ + "autoReloadEnabled" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "description": "UserWallet Not Found" + } + } + }, + "post": { + "summary": "Create wallet settings", + "description": "Creates wallet settings for a user wallet", + "tags": [ + "WalletSetting" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "autoReloadEnabled": { + "type": "boolean" + } + }, + "required": [ + "autoReloadEnabled" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Wallet settings created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "autoReloadEnabled": { + "type": "boolean" + } + }, + "required": [ + "autoReloadEnabled" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "description": "UserWallet Not Found" + } + } + }, + "put": { + "summary": "Update wallet settings", + "description": "Updates wallet settings for a user wallet", + "tags": [ + "WalletSetting" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "autoReloadEnabled": { + "type": "boolean" + } + }, + "required": [ + "autoReloadEnabled" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Wallet settings updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "autoReloadEnabled": { + "type": "boolean" + } + }, + "required": [ + "autoReloadEnabled" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "description": "UserWallet Not Found" + } + } + }, + "delete": { + "summary": "Delete wallet settings", + "description": "Deletes wallet settings for a user wallet", + "tags": [ + "WalletSetting" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "responses": { + "204": { + "description": "Wallet settings deleted successfully" + }, + "404": { + "description": "UserWallet Not Found" + } + } + } + }, + "/v1/tx": { + "post": { + "summary": "Signs a transaction via a user managed wallet", + "tags": [ + "Wallet" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "typeUrl": { + "type": "string", + "enum": [ + "/akash.deployment.v1beta4.MsgCreateDeployment", + "/akash.cert.v1.MsgCreateCertificate", + "/akash.market.v1beta5.MsgCreateLease", + "/akash.deployment.v1beta4.MsgUpdateDeployment", + "/akash.deployment.v1beta4.MsgCloseDeployment", + "/akash.escrow.v1.MsgAccountDeposit" + ] + }, + "value": { + "type": "string" + } + }, + "required": [ + "typeUrl", + "value" + ] + }, + "minItems": 1 + } + }, + "required": [ + "userId", + "messages" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Returns a signed transaction", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "code": { + "type": "number" + }, + "transactionHash": { + "type": "string" + }, + "rawLog": { + "type": "string" + } + }, + "required": [ + "code", + "transactionHash", + "rawLog" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/usage/history": { + "get": { + "summary": "Get historical data of billing and usage for a wallet address.", + "tags": [ + "Billing" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "description": "The wallet address to get billing and usage data for", + "example": "akash18andxgtd6r08zzfpcdqg9pdr6smks7gv76tyt6" + }, + "required": true, + "name": "address", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date", + "description": "Start date (YYYY-MM-DD). Defaults to 30 days before endDate", + "example": "2024-01-01" + }, + "required": false, + "name": "startDate", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date", + "default": "2026-07-01", + "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", + "example": "2024-01-31" + }, + "required": false, + "name": "endDate", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns billing and usage data", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "description": "Date in YYYY-MM-DD format", + "example": "2024-01-15" + }, + "activeDeployments": { + "type": "number", + "description": "Number of active deployments on this date", + "example": 3 + }, + "dailyAktSpent": { + "type": "number", + "description": "AKT tokens spent on this date", + "example": 12.5 + }, + "totalAktSpent": { + "type": "number", + "description": "Cumulative AKT tokens spent up to this date", + "example": 125.75 + }, + "dailyUsdcSpent": { + "type": "number", + "description": "USDC spent on this date", + "example": 5.25 + }, + "totalUsdcSpent": { + "type": "number", + "description": "Cumulative USDC spent up to this date", + "example": 52.5 + }, + "dailyActSpent": { + "type": "number", + "description": "ACT spent on this date (includes legacy USDC)", + "example": 5.25 + }, + "totalActSpent": { + "type": "number", + "description": "Cumulative ACT spent up to this date (includes legacy USDC)", + "example": 52.5 + }, + "dailyUsdSpent": { + "type": "number", + "description": "Total USD value spent on this date (AKT + USDC)", + "example": 17.75 + }, + "totalUsdSpent": { + "type": "number", + "description": "Cumulative USD value spent up to this date", + "example": 178.25 + } + }, + "required": [ + "date", + "activeDeployments", + "dailyAktSpent", + "totalAktSpent", + "dailyUsdcSpent", + "totalUsdcSpent", + "dailyActSpent", + "totalActSpent", + "dailyUsdSpent", + "totalUsdSpent" + ] + } + } + } + } + }, + "400": { + "description": "Invalid address format" + } + } + } + }, + "/v1/usage/history/stats": { + "get": { + "summary": "Get historical usage stats for a wallet address.", + "tags": [ + "Billing" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "description": "The wallet address to get billing and usage data for", + "example": "akash18andxgtd6r08zzfpcdqg9pdr6smks7gv76tyt6" + }, + "required": true, + "name": "address", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date", + "description": "Start date (YYYY-MM-DD). Defaults to 30 days before endDate", + "example": "2024-01-01" + }, + "required": false, + "name": "startDate", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date", + "default": "2026-07-01", + "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", + "example": "2024-01-31" + }, + "required": false, + "name": "endDate", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns usage stats data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "totalSpent": { + "type": "number", + "description": "Total amount spent in USD", + "example": 1234.56 + }, + "averageSpentPerDay": { + "type": "number", + "description": "Average spending per day in USD", + "example": 12.34 + }, + "totalDeployments": { + "type": "number", + "description": "Total number of deployments deployed", + "example": 15 + }, + "averageDeploymentsPerDay": { + "type": "number", + "description": "Average number of deployments deployed per day", + "example": 1.5 + } + }, + "required": [ + "totalSpent", + "averageSpentPerDay", + "totalDeployments", + "averageDeploymentsPerDay" + ] + } + } + } + }, + "400": { + "description": "Invalid address format" + } + } + } + }, + "/v1/register-user": { + "post": { + "summary": "Registers a new user", + "tags": [ + "Users" + ], + "security": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "wantedUsername": { + "type": "string" + }, + "email": { + "type": "string" + }, + "emailVerified": { + "type": "boolean" + }, + "subscribedToNewsletter": { + "type": "boolean" + } + }, + "required": [ + "wantedUsername", + "email", + "emailVerified" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Returns the registered user", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "emailVerified": { + "type": "boolean" + }, + "stripeCustomerId": { + "type": "string", + "nullable": true + }, + "bio": { + "type": "string", + "nullable": true + }, + "subscribedToNewsletter": { + "type": "boolean" + }, + "youtubeUsername": { + "type": "string", + "nullable": true + }, + "twitterUsername": { + "type": "string", + "nullable": true + }, + "githubUsername": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "userId", + "username", + "email", + "emailVerified", + "subscribedToNewsletter" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/user/me": { + "get": { + "summary": "Retrieves the logged in user", + "tags": [ + "Users" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Returns the logged in user", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "emailVerified": { + "type": "boolean" + }, + "stripeCustomerId": { + "type": "string", + "nullable": true + }, + "bio": { + "type": "string", + "nullable": true + }, + "subscribedToNewsletter": { + "type": "boolean" + }, + "youtubeUsername": { + "type": "string", + "nullable": true + }, + "twitterUsername": { + "type": "string", + "nullable": true + }, + "githubUsername": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "userId", + "username", + "email", + "emailVerified", + "subscribedToNewsletter" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/user/byUsername/{username}": { + "get": { + "summary": "Get user by username", + "tags": [ + "Users" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "username", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns user profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "bio": { + "type": "string", + "nullable": true + } + }, + "required": [ + "username", + "bio" + ] + } + } + } + }, + "404": { + "description": "User not found" + } + } + } + }, + "/v1/user/updateSettings": { + "put": { + "summary": "Update user settings", + "tags": [ + "Users" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "subscribedToNewsletter": { + "type": "boolean" + }, + "bio": { + "type": "string", + "nullable": true, + "maxLength": 5000 + }, + "youtubeUsername": { + "type": "string", + "nullable": true, + "maxLength": 200 + }, + "twitterUsername": { + "type": "string", + "nullable": true, + "maxLength": 200 + }, + "githubUsername": { + "type": "string", + "nullable": true, + "maxLength": 200 + } + }, + "required": [ + "username" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Settings updated successfully" + }, + "400": { + "description": "Invalid input" + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/v1/user/checkUsernameAvailability/{username}": { + "get": { + "summary": "Check if username is available", + "tags": [ + "Users" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "username", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns username availability", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "isAvailable": { + "type": "boolean" + } + }, + "required": [ + "isAvailable" + ] + } + } + } + } + } + } + }, + "/v1/user/subscribeToNewsletter": { + "post": { + "summary": "Subscribe to newsletter", + "tags": [ + "Users" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Subscribed successfully" + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/v1/user/template/{id}": { + "get": { + "summary": "Get template by ID", + "tags": [ + "Users" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns template" + }, + "400": { + "description": "Invalid template ID" + }, + "404": { + "description": "Template not found" + } + } + } + }, + "/v1/user/saveTemplate": { + "post": { + "summary": "Save template", + "tags": [ + "Users" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "sdl": { + "type": "string" + }, + "title": { + "type": "string" + }, + "cpu": { + "type": "number" + }, + "ram": { + "type": "number" + }, + "storage": { + "type": "number" + }, + "isPublic": { + "type": "boolean" + }, + "description": { + "type": "string", + "maxLength": 2000 + } + }, + "required": [ + "sdl", + "title", + "cpu", + "ram", + "storage", + "isPublic" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Template saved successfully" + }, + "400": { + "description": "Invalid input" + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/v1/user/saveTemplateDesc": { + "post": { + "summary": "Save template description", + "tags": [ + "Users" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "description" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Template description saved successfully" + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/v1/user/templates/{username}": { + "get": { + "summary": "Get templates by username", + "tags": [ + "Users" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "username", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns templates" + }, + "404": { + "description": "User not found" + } + } + } + }, + "/v1/user/deleteTemplate/{id}": { + "delete": { + "summary": "Delete template", + "tags": [ + "Users" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Template deleted successfully" + }, + "400": { + "description": "Invalid template ID" + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/v1/user/favoriteTemplates": { + "get": { + "summary": "Get favorite templates", + "tags": [ + "Users" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Returns favorite templates" + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/v1/user/addFavoriteTemplate/{templateId}": { + "post": { + "summary": "Add favorite template", + "tags": [ + "Users" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "templateId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Template added to favorites" + }, + "400": { + "description": "Invalid template ID" + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/v1/user/removeFavoriteTemplate/{templateId}": { + "delete": { + "summary": "Remove favorite template", + "tags": [ + "Users" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "templateId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Template removed from favorites" + }, + "400": { + "description": "Invalid template ID" + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/v1/send-verification-email": { + "post": { + "summary": "Resends a verification email", + "tags": [ + "Users" + ], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "userId": { + "type": "string" + } + }, + "required": [ + "userId" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Returns a created wallet", + "content": {} + } + } + } + }, + "/v1/send-verification-code": { + "post": { + "summary": "Sends a verification code to the authenticated user's email", + "tags": [ + "Users" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Returns the timestamp when the code was sent", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "codeSentAt": { + "type": "string" + } + }, + "required": [ + "codeSentAt" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "429": { + "description": "Too many requests" + } + } + } + }, + "/v1/auth/signup": { + "post": { + "summary": "Creates a new user without sending a verification email", + "tags": [ + "Auth" + ], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "minLength": 8 + } + }, + "required": [ + "email", + "password" + ] + } + } + } + }, + "responses": { + "204": { + "description": "User created successfully" + } + } + } + }, + "/v1/verify-email-code": { + "post": { + "summary": "Verifies the email using a 6-digit code", + "tags": [ + "Users" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "code": { + "type": "string", + "pattern": "^\\d{6}$" + } + }, + "required": [ + "code" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "204": { + "description": "Email verified successfully" + }, + "400": { + "description": "Invalid or expired code" + }, + "429": { + "description": "Too many attempts" + } + } + } + }, + "/v1/verify-email": { + "post": { + "summary": "Checks if the email is verified", + "tags": [ + "Users" + ], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "email" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Returns email verification status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "emailVerified": { + "type": "boolean" + } + }, + "required": [ + "emailVerified" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "User not found" + } + } + } + }, + "/v1/deployment-settings/{userId}/{dseq}": { + "get": { + "summary": "Get deployment settings by user ID and dseq", + "tags": [ + "Deployment Settings" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "User ID" + }, + "required": true, + "name": "userId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^d+$", + "description": "Deployment sequence number" + }, + "required": true, + "name": "dseq", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns deployment settings", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "autoTopUpEnabled": { + "type": "boolean" + }, + "estimatedTopUpAmount": { + "type": "number" + }, + "topUpFrequencyMs": { + "type": "number" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "userId", + "dseq", + "autoTopUpEnabled", + "estimatedTopUpAmount", + "topUpFrequencyMs", + "createdAt", + "updatedAt" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "description": "Deployment settings not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + }, + "patch": { + "summary": "Update deployment settings", + "tags": [ + "Deployment Settings" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "User ID" + }, + "required": true, + "name": "userId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^d+$", + "description": "Deployment sequence number" + }, + "required": true, + "name": "dseq", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "autoTopUpEnabled": { + "type": "boolean", + "description": "Whether auto top-up is enabled for this deployment" + } + }, + "required": [ + "autoTopUpEnabled" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Deployment settings updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "autoTopUpEnabled": { + "type": "boolean" + }, + "estimatedTopUpAmount": { + "type": "number" + }, + "topUpFrequencyMs": { + "type": "number" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "userId", + "dseq", + "autoTopUpEnabled", + "estimatedTopUpAmount", + "topUpFrequencyMs", + "createdAt", + "updatedAt" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "description": "Deployment settings not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + } + }, + "/v1/deployment-settings": { + "post": { + "summary": "Create deployment settings", + "tags": [ + "Deployment Settings" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User ID" + }, + "dseq": { + "type": "string", + "pattern": "^d+$", + "description": "Deployment sequence number" + }, + "autoTopUpEnabled": { + "type": "boolean", + "default": false, + "description": "Whether auto top-up is enabled for this deployment" + } + }, + "required": [ + "userId", + "dseq" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Deployment settings created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "autoTopUpEnabled": { + "type": "boolean" + }, + "estimatedTopUpAmount": { + "type": "number" + }, + "topUpFrequencyMs": { + "type": "number" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "userId", + "dseq", + "autoTopUpEnabled", + "estimatedTopUpAmount", + "topUpFrequencyMs", + "createdAt", + "updatedAt" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v2/deployment-settings/{dseq}": { + "get": { + "summary": "Get deployment settings by dseq", + "tags": [ + "Deployment Settings" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^d+$", + "description": "Deployment sequence number" + }, + "required": true, + "name": "dseq", + "in": "path" + }, + { + "schema": { + "type": "string", + "format": "uuid", + "description": "User ID. Defaults to the current authenticated user if not provided" + }, + "required": false, + "name": "userId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns deployment settings", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "autoTopUpEnabled": { + "type": "boolean" + }, + "estimatedTopUpAmount": { + "type": "number" + }, + "topUpFrequencyMs": { + "type": "number" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "userId", + "dseq", + "autoTopUpEnabled", + "estimatedTopUpAmount", + "topUpFrequencyMs", + "createdAt", + "updatedAt" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "description": "Deployment settings not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + }, + "patch": { + "summary": "Update deployment settings", + "tags": [ + "Deployment Settings" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^d+$", + "description": "Deployment sequence number" + }, + "required": true, + "name": "dseq", + "in": "path" + }, + { + "schema": { + "type": "string", + "format": "uuid", + "description": "User ID. Defaults to the current authenticated user if not provided" + }, + "required": false, + "name": "userId", + "in": "query" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "autoTopUpEnabled": { + "type": "boolean", + "description": "Whether auto top-up is enabled for this deployment" + } + }, + "required": [ + "autoTopUpEnabled" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Deployment settings updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "autoTopUpEnabled": { + "type": "boolean" + }, + "estimatedTopUpAmount": { + "type": "number" + }, + "topUpFrequencyMs": { + "type": "number" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "userId", + "dseq", + "autoTopUpEnabled", + "estimatedTopUpAmount", + "topUpFrequencyMs", + "createdAt", + "updatedAt" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "description": "Deployment settings not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + } + }, + "/v2/deployment-settings": { + "post": { + "summary": "Create deployment settings", + "tags": [ + "Deployment Settings" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "dseq": { + "type": "string", + "pattern": "^d+$", + "description": "Deployment sequence number" + }, + "autoTopUpEnabled": { + "type": "boolean", + "default": false, + "description": "Whether auto top-up is enabled for this deployment" + }, + "userId": { + "type": "string", + "format": "uuid", + "description": "User ID. Defaults to the current authenticated user if not provided" + } + }, + "required": [ + "dseq" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Deployment settings created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "autoTopUpEnabled": { + "type": "boolean" + }, + "estimatedTopUpAmount": { + "type": "number" + }, + "topUpFrequencyMs": { + "type": "number" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "userId", + "dseq", + "autoTopUpEnabled", + "estimatedTopUpAmount", + "topUpFrequencyMs", + "createdAt", + "updatedAt" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/deployments/{dseq}": { + "get": { + "summary": "Get a deployment", + "operationId": "getDeployment", + "tags": [ + "Deployments" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^d+$", + "description": "Deployment sequence number" + }, + "required": true, + "description": "Deployment sequence number", + "name": "dseq", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns deployment info", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "deployment": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + } + }, + "required": [ + "owner", + "dseq" + ] + }, + "state": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "created_at": { + "type": "string" + } + }, + "required": [ + "id", + "state", + "hash", + "created_at" + ] + }, + "leases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "gseq": { + "type": "number" + }, + "oseq": { + "type": "number" + }, + "provider": { + "type": "string" + }, + "bseq": { + "type": "number" + } + }, + "required": [ + "owner", + "dseq", + "gseq", + "oseq", + "provider", + "bseq" + ] + }, + "state": { + "type": "string" + }, + "price": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "created_at": { + "type": "string" + }, + "closed_on": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "object", + "nullable": true, + "properties": { + "forwarded_ports": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "port": { + "type": "number" + }, + "externalPort": { + "type": "number" + }, + "host": { + "type": "string" + }, + "available": { + "type": "number" + } + }, + "required": [ + "port", + "externalPort" + ] + } + } + }, + "ips": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "IP": { + "type": "string" + }, + "Port": { + "type": "number" + }, + "ExternalPort": { + "type": "number" + }, + "Protocol": { + "type": "string" + } + }, + "required": [ + "IP", + "Port", + "ExternalPort", + "Protocol" + ] + } + } + }, + "services": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "available": { + "type": "number" + }, + "total": { + "type": "number" + }, + "uris": { + "type": "array", + "items": { + "type": "string" + } + }, + "observed_generation": { + "type": "number" + }, + "replicas": { + "type": "number" + }, + "updated_replicas": { + "type": "number" + }, + "ready_replicas": { + "type": "number" + }, + "available_replicas": { + "type": "number" + } + }, + "required": [ + "name", + "available", + "total", + "uris", + "observed_generation", + "replicas", + "updated_replicas", + "ready_replicas", + "available_replicas" + ] + } + } + }, + "required": [ + "forwarded_ports", + "ips", + "services" + ] + } + }, + "required": [ + "id", + "state", + "price", + "created_at", + "closed_on", + "status" + ] + } + }, + "escrow_account": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "scope": { + "type": "string" + }, + "xid": { + "type": "string" + } + }, + "required": [ + "scope", + "xid" + ] + }, + "state": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "state": { + "type": "string" + }, + "transferred": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "settled_at": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "deposits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "height": { + "type": "string" + }, + "source": { + "type": "string" + }, + "balance": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "owner", + "height", + "source", + "balance" + ] + } + } + }, + "required": [ + "owner", + "state", + "transferred", + "settled_at", + "funds", + "deposits" + ] + } + }, + "required": [ + "id", + "state" + ] + } + }, + "required": [ + "deployment", + "leases", + "escrow_account" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + }, + "delete": { + "summary": "Close a deployment", + "operationId": "closeDeployment", + "tags": [ + "Deployments" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^d+$", + "description": "Deployment sequence number" + }, + "required": true, + "description": "Deployment sequence number", + "name": "dseq", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Deployment closed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + }, + "put": { + "summary": "Update a deployment", + "operationId": "updateDeployment", + "tags": [ + "Deployments" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^d+$", + "description": "Deployment sequence number" + }, + "required": true, + "description": "Deployment sequence number", + "name": "dseq", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "sdl": { + "type": "string" + } + }, + "required": [ + "sdl" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Deployment updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "deployment": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + } + }, + "required": [ + "owner", + "dseq" + ] + }, + "state": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "created_at": { + "type": "string" + } + }, + "required": [ + "id", + "state", + "hash", + "created_at" + ] + }, + "leases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "gseq": { + "type": "number" + }, + "oseq": { + "type": "number" + }, + "provider": { + "type": "string" + }, + "bseq": { + "type": "number" + } + }, + "required": [ + "owner", + "dseq", + "gseq", + "oseq", + "provider", + "bseq" + ] + }, + "state": { + "type": "string" + }, + "price": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "created_at": { + "type": "string" + }, + "closed_on": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "object", + "nullable": true, + "properties": { + "forwarded_ports": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "port": { + "type": "number" + }, + "externalPort": { + "type": "number" + }, + "host": { + "type": "string" + }, + "available": { + "type": "number" + } + }, + "required": [ + "port", + "externalPort" + ] + } + } + }, + "ips": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "IP": { + "type": "string" + }, + "Port": { + "type": "number" + }, + "ExternalPort": { + "type": "number" + }, + "Protocol": { + "type": "string" + } + }, + "required": [ + "IP", + "Port", + "ExternalPort", + "Protocol" + ] + } + } + }, + "services": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "available": { + "type": "number" + }, + "total": { + "type": "number" + }, + "uris": { + "type": "array", + "items": { + "type": "string" + } + }, + "observed_generation": { + "type": "number" + }, + "replicas": { + "type": "number" + }, + "updated_replicas": { + "type": "number" + }, + "ready_replicas": { + "type": "number" + }, + "available_replicas": { + "type": "number" + } + }, + "required": [ + "name", + "available", + "total", + "uris", + "observed_generation", + "replicas", + "updated_replicas", + "ready_replicas", + "available_replicas" + ] + } + } + }, + "required": [ + "forwarded_ports", + "ips", + "services" + ] + } + }, + "required": [ + "id", + "state", + "price", + "created_at", + "closed_on", + "status" + ] + } + }, + "escrow_account": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "scope": { + "type": "string" + }, + "xid": { + "type": "string" + } + }, + "required": [ + "scope", + "xid" + ] + }, + "state": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "state": { + "type": "string" + }, + "transferred": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "settled_at": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "deposits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "height": { + "type": "string" + }, + "source": { + "type": "string" + }, + "balance": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "owner", + "height", + "source", + "balance" + ] + } + } + }, + "required": [ + "owner", + "state", + "transferred", + "settled_at", + "funds", + "deposits" + ] + } + }, + "required": [ + "id", + "state" + ] + } + }, + "required": [ + "deployment", + "leases", + "escrow_account" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/deployments": { + "post": { + "summary": "Create new deployment", + "operationId": "createDeployment", + "tags": [ + "Deployments" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "sdl": { + "type": "string" + }, + "deposit": { + "type": "number", + "description": "Amount to deposit in dollars (e.g. 5.5)" + } + }, + "required": [ + "sdl", + "deposit" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Create deployment successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "manifest": { + "type": "string" + }, + "signTx": { + "type": "object", + "properties": { + "code": { + "type": "number" + }, + "transactionHash": { + "type": "string" + }, + "rawLog": { + "type": "string" + } + }, + "required": [ + "code", + "transactionHash", + "rawLog" + ] + } + }, + "required": [ + "dseq", + "manifest", + "signTx" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + }, + "get": { + "summary": "List deployments with pagination and filtering", + "operationId": "listDeployments", + "tags": [ + "Deployments" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "number", + "nullable": true, + "minimum": 0 + }, + "required": false, + "name": "skip", + "in": "query" + }, + { + "schema": { + "type": "number", + "minimum": 1, + "default": 1000 + }, + "required": false, + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns paginated list of deployments", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "deployments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "deployment": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + } + }, + "required": [ + "owner", + "dseq" + ] + }, + "state": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "created_at": { + "type": "string" + } + }, + "required": [ + "id", + "state", + "hash", + "created_at" + ] + }, + "leases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "gseq": { + "type": "number" + }, + "oseq": { + "type": "number" + }, + "provider": { + "type": "string" + }, + "bseq": { + "type": "number" + } + }, + "required": [ + "owner", + "dseq", + "gseq", + "oseq", + "provider", + "bseq" + ] + }, + "state": { + "type": "string" + }, + "price": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "created_at": { + "type": "string" + }, + "closed_on": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "id", + "state", + "price", + "created_at", + "closed_on" + ] + } + }, + "escrow_account": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "scope": { + "type": "string" + }, + "xid": { + "type": "string" + } + }, + "required": [ + "scope", + "xid" + ] + }, + "state": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "state": { + "type": "string" + }, + "transferred": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "settled_at": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "deposits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "height": { + "type": "string" + }, + "source": { + "type": "string" + }, + "balance": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "owner", + "height", + "source", + "balance" + ] + } + } + }, + "required": [ + "owner", + "state", + "transferred", + "settled_at", + "funds", + "deposits" + ] + } + }, + "required": [ + "id", + "state" + ] + } + }, + "required": [ + "deployment", + "leases", + "escrow_account" + ] + } + }, + "pagination": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "skip": { + "type": "number" + }, + "limit": { + "type": "number" + }, + "hasMore": { + "type": "boolean" + } + }, + "required": [ + "total", + "skip", + "limit", + "hasMore" + ] + } + }, + "required": [ + "deployments", + "pagination" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/deposit-deployment": { + "post": { + "summary": "Deposit into a deployment", + "operationId": "depositDeployment", + "tags": [ + "Deployments" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "dseq": { + "type": "string", + "pattern": "^d+$", + "description": "Deployment sequence number" + }, + "deposit": { + "type": "number", + "description": "Amount to deposit in dollars (e.g. 5.5)" + } + }, + "required": [ + "dseq", + "deposit" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Deposit successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "deployment": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + } + }, + "required": [ + "owner", + "dseq" + ] + }, + "state": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "created_at": { + "type": "string" + } + }, + "required": [ + "id", + "state", + "hash", + "created_at" + ] + }, + "leases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "gseq": { + "type": "number" + }, + "oseq": { + "type": "number" + }, + "provider": { + "type": "string" + }, + "bseq": { + "type": "number" + } + }, + "required": [ + "owner", + "dseq", + "gseq", + "oseq", + "provider", + "bseq" + ] + }, + "state": { + "type": "string" + }, + "price": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "created_at": { + "type": "string" + }, + "closed_on": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "object", + "nullable": true, + "properties": { + "forwarded_ports": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "port": { + "type": "number" + }, + "externalPort": { + "type": "number" + }, + "host": { + "type": "string" + }, + "available": { + "type": "number" + } + }, + "required": [ + "port", + "externalPort" + ] + } + } + }, + "ips": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "IP": { + "type": "string" + }, + "Port": { + "type": "number" + }, + "ExternalPort": { + "type": "number" + }, + "Protocol": { + "type": "string" + } + }, + "required": [ + "IP", + "Port", + "ExternalPort", + "Protocol" + ] + } + } + }, + "services": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "available": { + "type": "number" + }, + "total": { + "type": "number" + }, + "uris": { + "type": "array", + "items": { + "type": "string" + } + }, + "observed_generation": { + "type": "number" + }, + "replicas": { + "type": "number" + }, + "updated_replicas": { + "type": "number" + }, + "ready_replicas": { + "type": "number" + }, + "available_replicas": { + "type": "number" + } + }, + "required": [ + "name", + "available", + "total", + "uris", + "observed_generation", + "replicas", + "updated_replicas", + "ready_replicas", + "available_replicas" + ] + } + } + }, + "required": [ + "forwarded_ports", + "ips", + "services" + ] + } + }, + "required": [ + "id", + "state", + "price", + "created_at", + "closed_on", + "status" + ] + } + }, + "escrow_account": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "scope": { + "type": "string" + }, + "xid": { + "type": "string" + } + }, + "required": [ + "scope", + "xid" + ] + }, + "state": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "state": { + "type": "string" + }, + "transferred": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "settled_at": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "deposits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "height": { + "type": "string" + }, + "source": { + "type": "string" + }, + "balance": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "owner", + "height", + "source", + "balance" + ] + } + } + }, + "required": [ + "owner", + "state", + "transferred", + "settled_at", + "funds", + "deposits" + ] + } + }, + "required": [ + "id", + "state" + ] + } + }, + "required": [ + "deployment", + "leases", + "escrow_account" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/addresses/{address}/deployments/{skip}/{limit}": { + "get": { + "summary": "Get a list of deployments by owner address.", + "tags": [ + "Addresses", + "Deployments" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "description": "Wallet Address", + "example": "akash13265twfqejnma6cc93rw5dxk4cldyz2zyy8cdm" + }, + "required": true, + "name": "address", + "in": "path" + }, + { + "schema": { + "type": "number", + "nullable": true, + "minimum": 0, + "description": "Deployments to skip", + "example": 10 + }, + "required": false, + "name": "skip", + "in": "path" + }, + { + "schema": { + "type": "number", + "minimum": 1, + "maximum": 100, + "description": "Deployments to return", + "example": 10 + }, + "required": true, + "name": "limit", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": [ + "active", + "closed" + ], + "description": "Filter by status", + "example": "closed" + }, + "required": true, + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Reverse sorting", + "example": "true" + }, + "required": false, + "name": "reverseSorting", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns deployment list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "count": { + "type": "number" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "status": { + "type": "string" + }, + "createdHeight": { + "type": "number" + }, + "cpuUnits": { + "type": "number" + }, + "gpuUnits": { + "type": "number" + }, + "memoryQuantity": { + "type": "number" + }, + "storageQuantity": { + "type": "number" + }, + "leases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "provider": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "hostUri": { + "type": "string" + } + }, + "required": [ + "address", + "hostUri" + ] + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "gseq": { + "type": "number" + }, + "oseq": { + "type": "number" + }, + "state": { + "type": "string" + }, + "price": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "id", + "owner", + "dseq", + "gseq", + "oseq", + "state", + "price" + ] + } + } + }, + "required": [ + "owner", + "dseq", + "status", + "createdHeight", + "cpuUnits", + "gpuUnits", + "memoryQuantity", + "storageQuantity", + "leases" + ] + } + } + }, + "required": [ + "count", + "results" + ] + } + } + } + }, + "400": { + "description": "Invalid address" + } + } + } + }, + "/v1/deployment/{owner}/{dseq}": { + "get": { + "summary": "Get deployment details", + "tags": [ + "Deployments" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "description": "Owner's Address", + "example": "akash13265twfqejnma6cc93rw5dxk4cldyz2zyy8cdm" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^d+$", + "description": "Deployment sequence number" + }, + "required": true, + "name": "dseq", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns deployment details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "balance": { + "type": "number" + }, + "denom": { + "type": "string" + }, + "status": { + "type": "string" + }, + "totalMonthlyCostUDenom": { + "type": "number" + }, + "leases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "gseq": { + "type": "number" + }, + "oseq": { + "type": "number" + }, + "provider": { + "type": "object", + "nullable": true, + "properties": { + "address": { + "type": "string" + }, + "hostUri": { + "type": "string" + }, + "isDeleted": { + "type": "boolean" + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "address", + "hostUri", + "isDeleted", + "attributes" + ] + }, + "status": { + "type": "string" + }, + "monthlyCostUDenom": { + "type": "number" + }, + "cpuUnits": { + "type": "number" + }, + "gpuUnits": { + "type": "number" + }, + "memoryQuantity": { + "type": "number" + }, + "storageQuantity": { + "type": "number" + } + }, + "required": [ + "gseq", + "oseq", + "provider", + "status", + "monthlyCostUDenom", + "cpuUnits", + "gpuUnits", + "memoryQuantity", + "storageQuantity" + ] + } + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "txHash": { + "type": "string" + }, + "date": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "txHash", + "date", + "type" + ] + } + }, + "other": { + "type": "object", + "properties": { + "deployment": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string" + } + }, + "required": [ + "owner", + "dseq" + ] + }, + "state": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "created_at": { + "type": "string" + } + }, + "required": [ + "id", + "state", + "hash", + "created_at" + ] + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string" + }, + "gseq": { + "type": "number" + } + }, + "required": [ + "owner", + "dseq", + "gseq" + ] + }, + "state": { + "type": "string" + }, + "group_spec": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "requirements": { + "type": "object", + "properties": { + "signed_by": { + "type": "object", + "properties": { + "all_of": { + "type": "array", + "items": { + "type": "string" + } + }, + "any_of": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "all_of", + "any_of" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "signed_by", + "attributes" + ] + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resource": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "cpu": { + "type": "object", + "properties": { + "units": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "units", + "attributes" + ] + }, + "memory": { + "type": "object", + "properties": { + "quantity": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "quantity", + "attributes" + ] + }, + "storage": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "quantity": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "name", + "quantity", + "attributes" + ] + } + }, + "gpu": { + "type": "object", + "properties": { + "units": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "units", + "attributes" + ] + }, + "endpoints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "sequence_number": { + "type": "number" + } + }, + "required": [ + "kind", + "sequence_number" + ] + } + } + }, + "required": [ + "id", + "cpu", + "memory", + "storage", + "gpu", + "endpoints" + ] + }, + "count": { + "type": "number" + }, + "price": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "resource", + "count", + "price" + ] + } + } + }, + "required": [ + "name", + "requirements", + "resources" + ] + }, + "created_at": { + "type": "string" + } + }, + "required": [ + "id", + "state", + "group_spec", + "created_at" + ] + } + }, + "escrow_account": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "scope": { + "type": "string" + }, + "xid": { + "type": "string" + } + }, + "required": [ + "scope", + "xid" + ] + }, + "state": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "state": { + "type": "string" + }, + "transferred": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "settled_at": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "deposits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "height": { + "type": "string" + }, + "source": { + "type": "string" + }, + "balance": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "owner", + "height", + "source", + "balance" + ] + } + } + }, + "required": [ + "owner", + "state", + "transferred", + "settled_at", + "funds", + "deposits" + ] + } + }, + "required": [ + "id", + "state" + ] + } + }, + "required": [ + "deployment", + "groups", + "escrow_account" + ] + } + }, + "required": [ + "owner", + "dseq", + "balance", + "denom", + "status", + "totalMonthlyCostUDenom", + "leases", + "events", + "other" + ] + } + } + } + }, + "400": { + "description": "Invalid address or dseq" + }, + "404": { + "description": "Deployment not found" + } + } + } + }, + "/akash/deployment/{version}/deployments/list": { + "get": { + "summary": "List deployments (database fallback)", + "tags": [ + "Deployments" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": false, + "name": "filters.owner", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "active", + "closed" + ] + }, + "required": false, + "name": "filters.state", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "pagination.offset", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "pagination.limit", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "pagination.key", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "pagination.count_total", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "nullable": true + }, + "required": false, + "name": "pagination.reverse", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns paginated list of deployments from database", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "deployments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "deployment": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + } + }, + "required": [ + "owner", + "dseq" + ] + }, + "state": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "created_at": { + "type": "string" + } + }, + "required": [ + "id", + "state", + "hash", + "created_at" + ] + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "gseq": { + "type": "number" + } + }, + "required": [ + "owner", + "dseq", + "gseq" + ] + }, + "state": { + "type": "string" + }, + "group_spec": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "requirements": { + "type": "object", + "properties": { + "signed_by": { + "type": "object", + "properties": { + "all_of": { + "type": "array", + "items": { + "type": "string" + } + }, + "any_of": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "all_of", + "any_of" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "signed_by", + "attributes" + ] + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resource": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "cpu": { + "type": "object", + "properties": { + "units": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "units", + "attributes" + ] + }, + "memory": { + "type": "object", + "properties": { + "quantity": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "quantity", + "attributes" + ] + }, + "storage": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "quantity": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "name", + "quantity", + "attributes" + ] + } + }, + "gpu": { + "type": "object", + "properties": { + "units": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "units", + "attributes" + ] + }, + "endpoints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "sequence_number": { + "type": "number" + } + }, + "required": [ + "kind", + "sequence_number" + ] + } + } + }, + "required": [ + "id", + "cpu", + "memory", + "storage", + "gpu", + "endpoints" + ] + }, + "count": { + "type": "number" + }, + "price": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "resource", + "count", + "price" + ] + } + } + }, + "required": [ + "name", + "requirements", + "resources" + ] + }, + "created_at": { + "type": "string" + } + }, + "required": [ + "id", + "state", + "group_spec", + "created_at" + ] + } + }, + "escrow_account": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "scope": { + "type": "string" + }, + "xid": { + "type": "string" + } + }, + "required": [ + "scope", + "xid" + ] + }, + "state": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "state": { + "type": "string" + }, + "transferred": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "settled_at": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "deposits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "height": { + "type": "string" + }, + "source": { + "type": "string" + }, + "balance": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "owner", + "height", + "source", + "balance" + ] + } + } + }, + "required": [ + "owner", + "state", + "transferred", + "settled_at", + "funds", + "deposits" + ] + } + }, + "required": [ + "id", + "state" + ] + } + }, + "required": [ + "deployment", + "groups", + "escrow_account" + ] + } + }, + "pagination": { + "type": "object", + "properties": { + "next_key": { + "type": "string", + "nullable": true + }, + "total": { + "type": "string" + } + }, + "required": [ + "next_key", + "total" + ] + } + }, + "required": [ + "deployments", + "pagination" + ] + } + } + } + } + } + } + }, + "/akash/deployment/{version}/deployments/info": { + "get": { + "summary": "Get deployment info (database fallback)", + "tags": [ + "Deployments" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id.owner", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id.dseq", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns deployment info from database", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "code": { + "type": "number" + }, + "message": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "code", + "message", + "details" + ] + }, + { + "type": "object", + "properties": { + "deployment": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + } + }, + "required": [ + "owner", + "dseq" + ] + }, + "state": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "created_at": { + "type": "string" + } + }, + "required": [ + "id", + "state", + "hash", + "created_at" + ] + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "gseq": { + "type": "number" + } + }, + "required": [ + "owner", + "dseq", + "gseq" + ] + }, + "state": { + "type": "string" + }, + "group_spec": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "requirements": { + "type": "object", + "properties": { + "signed_by": { + "type": "object", + "properties": { + "all_of": { + "type": "array", + "items": { + "type": "string" + } + }, + "any_of": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "all_of", + "any_of" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "signed_by", + "attributes" + ] + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resource": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "cpu": { + "type": "object", + "properties": { + "units": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "units", + "attributes" + ] + }, + "memory": { + "type": "object", + "properties": { + "quantity": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "quantity", + "attributes" + ] + }, + "storage": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "quantity": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "name", + "quantity", + "attributes" + ] + } + }, + "gpu": { + "type": "object", + "properties": { + "units": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "units", + "attributes" + ] + }, + "endpoints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "sequence_number": { + "type": "number" + } + }, + "required": [ + "kind", + "sequence_number" + ] + } + } + }, + "required": [ + "id", + "cpu", + "memory", + "storage", + "gpu", + "endpoints" + ] + }, + "count": { + "type": "number" + }, + "price": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "resource", + "count", + "price" + ] + } + } + }, + "required": [ + "name", + "requirements", + "resources" + ] + }, + "created_at": { + "type": "string" + } + }, + "required": [ + "id", + "state", + "group_spec", + "created_at" + ] + } + }, + "escrow_account": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "scope": { + "type": "string" + }, + "xid": { + "type": "string" + } + }, + "required": [ + "scope", + "xid" + ] + }, + "state": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "state": { + "type": "string" + }, + "transferred": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "settled_at": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "deposits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "height": { + "type": "string" + }, + "source": { + "type": "string" + }, + "balance": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "owner", + "height", + "source", + "balance" + ] + } + } + }, + "required": [ + "owner", + "state", + "transferred", + "settled_at", + "funds", + "deposits" + ] + } + }, + "required": [ + "id", + "state" + ] + } + }, + "required": [ + "deployment", + "groups", + "escrow_account" + ] + } + ] + } + } + } + }, + "404": { + "description": "Deployment not found" + } + } + } + }, + "/v1/weekly-cost": { + "get": { + "summary": "Get weekly deployment cost", + "tags": [ + "Deployments" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Returns weekly cost for all deployments with auto top-up enabled", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "weeklyCost": { + "type": "number", + "description": "Total weekly cost in USD for all deployments with auto top-up enabled" + } + }, + "required": [ + "weeklyCost" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/leases": { + "post": { + "summary": "Create leases and send manifest", + "operationId": "createLease", + "tags": [ + "Leases" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "manifest": { + "type": "string" + }, + "leases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "gseq": { + "type": "number" + }, + "oseq": { + "type": "number" + }, + "provider": { + "type": "string" + } + }, + "required": [ + "dseq", + "gseq", + "oseq", + "provider" + ] + } + } + }, + "required": [ + "manifest", + "leases" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Leases created and manifest sent", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "deployment": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + } + }, + "required": [ + "owner", + "dseq" + ] + }, + "state": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "created_at": { + "type": "string" + } + }, + "required": [ + "id", + "state", + "hash", + "created_at" + ] + }, + "leases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "gseq": { + "type": "number" + }, + "oseq": { + "type": "number" + }, + "provider": { + "type": "string" + }, + "bseq": { + "type": "number" + } + }, + "required": [ + "owner", + "dseq", + "gseq", + "oseq", + "provider", + "bseq" + ] + }, + "state": { + "type": "string" + }, + "price": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "created_at": { + "type": "string" + }, + "closed_on": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "object", + "nullable": true, + "properties": { + "forwarded_ports": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "port": { + "type": "number" + }, + "externalPort": { + "type": "number" + }, + "host": { + "type": "string" + }, + "available": { + "type": "number" + } + }, + "required": [ + "port", + "externalPort" + ] + } + } + }, + "ips": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "IP": { + "type": "string" + }, + "Port": { + "type": "number" + }, + "ExternalPort": { + "type": "number" + }, + "Protocol": { + "type": "string" + } + }, + "required": [ + "IP", + "Port", + "ExternalPort", + "Protocol" + ] + } + } + }, + "services": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "available": { + "type": "number" + }, + "total": { + "type": "number" + }, + "uris": { + "type": "array", + "items": { + "type": "string" + } + }, + "observed_generation": { + "type": "number" + }, + "replicas": { + "type": "number" + }, + "updated_replicas": { + "type": "number" + }, + "ready_replicas": { + "type": "number" + }, + "available_replicas": { + "type": "number" + } + }, + "required": [ + "name", + "available", + "total", + "uris", + "observed_generation", + "replicas", + "updated_replicas", + "ready_replicas", + "available_replicas" + ] + } + } + }, + "required": [ + "forwarded_ports", + "ips", + "services" + ] + } + }, + "required": [ + "id", + "state", + "price", + "created_at", + "closed_on", + "status" + ] + } + }, + "escrow_account": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "scope": { + "type": "string" + }, + "xid": { + "type": "string" + } + }, + "required": [ + "scope", + "xid" + ] + }, + "state": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "state": { + "type": "string" + }, + "transferred": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "settled_at": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "deposits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "height": { + "type": "string" + }, + "source": { + "type": "string" + }, + "balance": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "owner", + "height", + "source", + "balance" + ] + } + } + }, + "required": [ + "owner", + "state", + "transferred", + "settled_at", + "funds", + "deposits" + ] + } + }, + "required": [ + "id", + "state" + ] + } + }, + "required": [ + "deployment", + "leases", + "escrow_account" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/akash/market/{version}/leases/list": { + "get": { + "summary": "List leases (database fallback)", + "tags": [ + "Leases" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": false, + "name": "filters.owner", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "filters.dseq", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "filters.gseq", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "filters.oseq", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "filters.provider", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "filters.state", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "pagination.offset", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "pagination.limit", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "pagination.key", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "pagination.count_total", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "nullable": true + }, + "required": false, + "name": "pagination.reverse", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns paginated list of leases from database", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "leases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lease": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "gseq": { + "type": "number" + }, + "oseq": { + "type": "number" + }, + "provider": { + "type": "string" + }, + "bseq": { + "type": "number" + } + }, + "required": [ + "owner", + "dseq", + "gseq", + "oseq", + "provider", + "bseq" + ] + }, + "state": { + "type": "string" + }, + "price": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "created_at": { + "type": "string" + }, + "closed_on": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "id", + "state", + "price", + "created_at", + "closed_on" + ] + }, + "escrow_payment": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "aid": { + "type": "object", + "properties": { + "scope": { + "type": "string" + }, + "xid": { + "type": "string" + } + }, + "required": [ + "scope", + "xid" + ] + }, + "xid": { + "type": "string" + } + }, + "required": [ + "aid", + "xid" + ] + }, + "state": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "state": { + "type": "string" + }, + "rate": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "balance": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "unsettled": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "withdrawn": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "owner", + "state", + "rate", + "balance", + "unsettled", + "withdrawn" + ] + } + }, + "required": [ + "id", + "state" + ] + } + }, + "required": [ + "lease", + "escrow_payment" + ] + } + }, + "pagination": { + "type": "object", + "properties": { + "next_key": { + "type": "string", + "nullable": true + }, + "total": { + "type": "string" + } + }, + "required": [ + "next_key", + "total" + ] + } + }, + "required": [ + "leases", + "pagination" + ] + } + } + } + } + } + } + }, + "/v1/api-keys": { + "get": { + "summary": "List all API keys", + "operationId": "listApiKeys", + "tags": [ + "API Keys" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Returns list of API keys", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "lastUsedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "keyFormat": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "expiresAt", + "createdAt", + "updatedAt", + "lastUsedAt", + "keyFormat" + ] + } + } + }, + "required": [ + "data" + ] + } + } + } + } + } + }, + "post": { + "summary": "Create new API key", + "tags": [ + "API Keys" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "name" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "201": { + "description": "API key created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "lastUsedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "keyFormat": { + "type": "string" + }, + "apiKey": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "expiresAt", + "createdAt", + "updatedAt", + "lastUsedAt", + "keyFormat", + "apiKey" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/api-keys/{id}": { + "get": { + "summary": "Get API key by ID", + "tags": [ + "API Keys" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns API key details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "lastUsedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "keyFormat": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "expiresAt", + "createdAt", + "updatedAt", + "lastUsedAt", + "keyFormat" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "description": "API key not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + }, + "patch": { + "summary": "Update API key", + "tags": [ + "API Keys" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "description": "API key updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "lastUsedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "keyFormat": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "expiresAt", + "createdAt", + "updatedAt", + "lastUsedAt", + "keyFormat" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "description": "API key not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + }, + "delete": { + "summary": "Delete API key", + "tags": [ + "API Keys" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "204": { + "description": "API key deleted successfully" + }, + "404": { + "description": "API key not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + } + }, + "/v1/bids": { + "get": { + "summary": "List bids", + "operationId": "listBids", + "tags": [ + "Bids" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^d+$" + }, + "required": true, + "name": "dseq", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of bids", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bid": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "gseq": { + "type": "number" + }, + "oseq": { + "type": "number" + }, + "provider": { + "type": "string" + }, + "bseq": { + "type": "number" + } + }, + "required": [ + "owner", + "dseq", + "gseq", + "oseq", + "provider", + "bseq" + ] + }, + "state": { + "type": "string" + }, + "price": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "created_at": { + "type": "string" + }, + "resources_offer": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resources": { + "type": "object", + "properties": { + "cpu": { + "type": "object", + "properties": { + "units": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "units", + "attributes" + ] + }, + "gpu": { + "type": "object", + "properties": { + "units": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "units", + "attributes" + ] + }, + "memory": { + "type": "object", + "properties": { + "quantity": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "quantity", + "attributes" + ] + }, + "storage": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "quantity": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "name", + "quantity", + "attributes" + ] + } + }, + "endpoints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "sequence_number": { + "type": "number" + } + }, + "required": [ + "kind", + "sequence_number" + ] + } + } + }, + "required": [ + "cpu", + "gpu", + "memory", + "storage", + "endpoints" + ] + }, + "count": { + "type": "number" + } + }, + "required": [ + "resources", + "count" + ] + } + }, + "reclamation_window": { + "type": "string" + } + }, + "required": [ + "id", + "state", + "price", + "created_at", + "resources_offer" + ] + }, + "escrow_account": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "scope": { + "type": "string" + }, + "xid": { + "type": "string" + } + }, + "required": [ + "scope", + "xid" + ] + }, + "state": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "state": { + "type": "string" + }, + "transferred": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "settled_at": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "deposits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "height": { + "type": "string" + }, + "source": { + "type": "string" + }, + "balance": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "owner", + "height", + "source", + "balance" + ] + } + } + }, + "required": [ + "owner", + "state", + "transferred", + "settled_at", + "funds", + "deposits" + ] + } + }, + "required": [ + "id", + "state" + ] + } + }, + "required": [ + "bid", + "escrow_account" + ] + } + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/bids/{dseq}": { + "get": { + "summary": "List bids by dseq", + "tags": [ + "Bids" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^d+$" + }, + "required": true, + "name": "dseq", + "in": "path" + } + ], + "responses": { + "200": { + "description": "List of bids", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bid": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "gseq": { + "type": "number" + }, + "oseq": { + "type": "number" + }, + "provider": { + "type": "string" + }, + "bseq": { + "type": "number" + } + }, + "required": [ + "owner", + "dseq", + "gseq", + "oseq", + "provider", + "bseq" + ] + }, + "state": { + "type": "string" + }, + "price": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "created_at": { + "type": "string" + }, + "resources_offer": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resources": { + "type": "object", + "properties": { + "cpu": { + "type": "object", + "properties": { + "units": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "units", + "attributes" + ] + }, + "gpu": { + "type": "object", + "properties": { + "units": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "units", + "attributes" + ] + }, + "memory": { + "type": "object", + "properties": { + "quantity": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "quantity", + "attributes" + ] + }, + "storage": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "quantity": { + "type": "object", + "properties": { + "val": { + "type": "string" + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "name", + "quantity", + "attributes" + ] + } + }, + "endpoints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "sequence_number": { + "type": "number" + } + }, + "required": [ + "kind", + "sequence_number" + ] + } + } + }, + "required": [ + "cpu", + "gpu", + "memory", + "storage", + "endpoints" + ] + }, + "count": { + "type": "number" + } + }, + "required": [ + "resources", + "count" + ] + } + }, + "reclamation_window": { + "type": "string" + } + }, + "required": [ + "id", + "state", + "price", + "created_at", + "resources_offer" + ] + }, + "escrow_account": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "scope": { + "type": "string" + }, + "xid": { + "type": "string" + } + }, + "required": [ + "scope", + "xid" + ] + }, + "state": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "state": { + "type": "string" + }, + "transferred": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "settled_at": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "deposits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "height": { + "type": "string" + }, + "source": { + "type": "string" + }, + "balance": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "owner", + "height", + "source", + "balance" + ] + } + } + }, + "required": [ + "owner", + "state", + "transferred", + "settled_at", + "funds", + "deposits" + ] + } + }, + "required": [ + "id", + "state" + ] + } + }, + "required": [ + "bid", + "escrow_account" + ] + } + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/certificates": { + "post": { + "summary": "Create certificate", + "tags": [ + "Certificate" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "responses": { + "400": { + "description": "This endpoint has been removed. mTLS certificates are no longer required as identity is now verified via API key.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/v1/balances": { + "get": { + "summary": "Get user balances", + "tags": [ + "Wallet" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "description": "Optional wallet address to fetch balances for instead of the current user" + }, + "required": false, + "description": "Optional wallet address to fetch balances for instead of the current user", + "name": "address", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns user balances", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "balance": { + "type": "number" + }, + "deployments": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "balance", + "deployments", + "total" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/providers": { + "get": { + "summary": "Get a list of providers.", + "tags": [ + "Providers" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "all", + "trial" + ], + "default": "all" + }, + "required": false, + "name": "scope", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "addresses", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns a list of providers", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "name": { + "type": "string", + "nullable": true + }, + "hostUri": { + "type": "string" + }, + "createdHeight": { + "type": "number" + }, + "email": { + "type": "string", + "nullable": true + }, + "website": { + "type": "string", + "nullable": true + }, + "lastCheckDate": { + "type": "string", + "nullable": true + }, + "deploymentCount": { + "type": "number", + "nullable": true + }, + "leaseCount": { + "type": "number", + "nullable": true + }, + "cosmosSdkVersion": { + "type": "string" + }, + "akashVersion": { + "type": "string" + }, + "ipRegion": { + "type": "string", + "nullable": true + }, + "ipRegionCode": { + "type": "string", + "nullable": true + }, + "ipCountry": { + "type": "string", + "nullable": true + }, + "ipCountryCode": { + "type": "string", + "nullable": true + }, + "ipLat": { + "type": "string", + "nullable": true + }, + "ipLon": { + "type": "string", + "nullable": true + }, + "uptime1d": { + "type": "number", + "nullable": true + }, + "uptime7d": { + "type": "number", + "nullable": true + }, + "uptime30d": { + "type": "number", + "nullable": true + }, + "isValidVersion": { + "type": "boolean" + }, + "isOnline": { + "type": "boolean" + }, + "lastOnlineDate": { + "type": "string", + "nullable": true + }, + "isAudited": { + "type": "boolean" + }, + "gpuModels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "vendor": { + "type": "string" + }, + "model": { + "type": "string" + }, + "ram": { + "type": "string" + }, + "interface": { + "type": "string" + } + }, + "required": [ + "vendor", + "model", + "ram", + "interface" + ] + } + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + }, + "auditedBy": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "value", + "auditedBy" + ] + } + }, + "host": { + "type": "string", + "nullable": true + }, + "organization": { + "type": "string", + "nullable": true + }, + "statusPage": { + "type": "string", + "nullable": true + }, + "locationRegion": { + "type": "string", + "nullable": true + }, + "country": { + "type": "string", + "nullable": true + }, + "city": { + "type": "string", + "nullable": true + }, + "timezone": { + "type": "string", + "nullable": true + }, + "locationType": { + "type": "string", + "nullable": true + }, + "hostingProvider": { + "type": "string", + "nullable": true + }, + "hardwareCpu": { + "type": "string", + "nullable": true + }, + "hardwareCpuArch": { + "type": "string", + "nullable": true + }, + "hardwareGpuVendor": { + "type": "string", + "nullable": true + }, + "hardwareGpuModels": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "hardwareDisk": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "featPersistentStorage": { + "type": "boolean" + }, + "featPersistentStorageType": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "hardwareMemory": { + "type": "string", + "nullable": true + }, + "networkProvider": { + "type": "string", + "nullable": true + }, + "networkSpeedDown": { + "type": "number" + }, + "networkSpeedUp": { + "type": "number" + }, + "tier": { + "type": "string", + "nullable": true + }, + "featEndpointCustomDomain": { + "type": "boolean" + }, + "workloadSupportChia": { + "type": "boolean" + }, + "workloadSupportChiaCapabilities": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "featEndpointIp": { + "type": "boolean" + } + }, + "required": [ + "owner", + "name", + "hostUri", + "createdHeight", + "cosmosSdkVersion", + "akashVersion", + "ipRegion", + "ipRegionCode", + "ipCountry", + "ipCountryCode", + "ipLat", + "ipLon", + "uptime1d", + "uptime7d", + "uptime30d", + "isValidVersion", + "isOnline", + "lastOnlineDate", + "isAudited", + "gpuModels", + "attributes", + "host", + "organization", + "statusPage", + "locationRegion", + "country", + "city", + "timezone", + "locationType", + "hostingProvider", + "hardwareCpu", + "hardwareCpuArch", + "hardwareGpuVendor", + "hardwareGpuModels", + "hardwareDisk", + "featPersistentStorage", + "featPersistentStorageType", + "hardwareMemory", + "networkProvider", + "networkSpeedDown", + "networkSpeedUp", + "tier", + "featEndpointCustomDomain", + "workloadSupportChia", + "workloadSupportChiaCapabilities", + "featEndpointIp" + ] + } + } + } + } + } + } + } + }, + "/v1/providers/{address}": { + "get": { + "summary": "Get a provider details.", + "tags": [ + "Providers" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "description": "Provider Address", + "example": "akash18ga02jzaq8cw52anyhzkwta5wygufgu6zsz6xc" + }, + "required": true, + "name": "address", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Return a provider details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "name": { + "type": "string", + "nullable": true + }, + "hostUri": { + "type": "string" + }, + "createdHeight": { + "type": "number" + }, + "email": { + "type": "string", + "nullable": true + }, + "website": { + "type": "string", + "nullable": true + }, + "lastCheckDate": { + "type": "string", + "nullable": true + }, + "deploymentCount": { + "type": "number" + }, + "leaseCount": { + "type": "number" + }, + "cosmosSdkVersion": { + "type": "string" + }, + "akashVersion": { + "type": "string" + }, + "ipRegion": { + "type": "string", + "nullable": true + }, + "ipRegionCode": { + "type": "string", + "nullable": true + }, + "ipCountry": { + "type": "string", + "nullable": true + }, + "ipCountryCode": { + "type": "string", + "nullable": true + }, + "ipLat": { + "type": "string", + "nullable": true + }, + "ipLon": { + "type": "string", + "nullable": true + }, + "uptime1d": { + "type": "number" + }, + "uptime7d": { + "type": "number" + }, + "uptime30d": { + "type": "number" + }, + "isValidVersion": { + "type": "boolean" + }, + "isOnline": { + "type": "boolean" + }, + "lastOnlineDate": { + "type": "string", + "nullable": true + }, + "isAudited": { + "type": "boolean" + }, + "stats": { + "type": "object", + "properties": { + "cpu": { + "type": "object", + "properties": { + "active": { + "type": "number" + }, + "available": { + "type": "number" + }, + "pending": { + "type": "number" + } + }, + "required": [ + "active", + "available", + "pending" + ] + }, + "gpu": { + "type": "object", + "properties": { + "active": { + "type": "number" + }, + "available": { + "type": "number" + }, + "pending": { + "type": "number" + } + }, + "required": [ + "active", + "available", + "pending" + ] + }, + "memory": { + "type": "object", + "properties": { + "active": { + "type": "number" + }, + "available": { + "type": "number" + }, + "pending": { + "type": "number" + } + }, + "required": [ + "active", + "available", + "pending" + ] + }, + "storage": { + "type": "object", + "properties": { + "ephemeral": { + "type": "object", + "properties": { + "active": { + "type": "number" + }, + "available": { + "type": "number" + }, + "pending": { + "type": "number" + } + }, + "required": [ + "active", + "available", + "pending" + ] + }, + "persistent": { + "type": "object", + "properties": { + "active": { + "type": "number" + }, + "available": { + "type": "number" + }, + "pending": { + "type": "number" + } + }, + "required": [ + "active", + "available", + "pending" + ] + } + }, + "required": [ + "ephemeral", + "persistent" + ] + } + }, + "required": [ + "cpu", + "gpu", + "memory", + "storage" + ] + }, + "gpuModels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "vendor": { + "type": "string" + }, + "model": { + "type": "string" + }, + "ram": { + "type": "string" + }, + "interface": { + "type": "string" + } + }, + "required": [ + "vendor", + "model", + "ram", + "interface" + ] + } + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + }, + "auditedBy": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "value", + "auditedBy" + ] + } + }, + "host": { + "type": "string", + "nullable": true + }, + "organization": { + "type": "string", + "nullable": true + }, + "statusPage": { + "type": "string", + "nullable": true + }, + "locationRegion": { + "type": "string", + "nullable": true + }, + "country": { + "type": "string", + "nullable": true + }, + "city": { + "type": "string", + "nullable": true + }, + "timezone": { + "type": "string", + "nullable": true + }, + "locationType": { + "type": "string", + "nullable": true + }, + "hostingProvider": { + "type": "string", + "nullable": true + }, + "hardwareCpu": { + "type": "string", + "nullable": true + }, + "hardwareCpuArch": { + "type": "string", + "nullable": true + }, + "hardwareGpuVendor": { + "type": "string", + "nullable": true + }, + "hardwareGpuModels": { + "type": "array", + "items": { + "type": "string" + } + }, + "hardwareDisk": { + "type": "array", + "items": { + "type": "string" + } + }, + "featPersistentStorage": { + "type": "boolean" + }, + "featPersistentStorageType": { + "type": "array", + "items": { + "type": "string" + } + }, + "hardwareMemory": { + "type": "string", + "nullable": true + }, + "networkProvider": { + "type": "string", + "nullable": true + }, + "networkSpeedDown": { + "type": "number" + }, + "networkSpeedUp": { + "type": "number" + }, + "tier": { + "type": "string", + "nullable": true + }, + "featEndpointCustomDomain": { + "type": "boolean" + }, + "workloadSupportChia": { + "type": "boolean" + }, + "workloadSupportChiaCapabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "featEndpointIp": { + "type": "boolean" + }, + "uptime": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "isOnline": { + "type": "boolean" + }, + "checkDate": { + "type": "string" + } + }, + "required": [ + "id", + "isOnline", + "checkDate" + ] + } + } + }, + "required": [ + "owner", + "name", + "hostUri", + "createdHeight", + "email", + "website", + "lastCheckDate", + "deploymentCount", + "leaseCount", + "cosmosSdkVersion", + "akashVersion", + "ipRegion", + "ipRegionCode", + "ipCountry", + "ipCountryCode", + "ipLat", + "ipLon", + "uptime1d", + "uptime7d", + "uptime30d", + "isValidVersion", + "isOnline", + "lastOnlineDate", + "isAudited", + "stats", + "gpuModels", + "attributes", + "host", + "organization", + "statusPage", + "locationRegion", + "country", + "city", + "timezone", + "locationType", + "hostingProvider", + "hardwareCpu", + "hardwareCpuArch", + "hardwareGpuVendor", + "hardwareGpuModels", + "hardwareDisk", + "featPersistentStorage", + "featPersistentStorageType", + "hardwareMemory", + "networkProvider", + "networkSpeedDown", + "networkSpeedUp", + "tier", + "featEndpointCustomDomain", + "workloadSupportChia", + "workloadSupportChiaCapabilities", + "featEndpointIp", + "uptime" + ] + } + } + } + }, + "400": { + "description": "Invalid address" + }, + "404": { + "description": "Provider not found" + } + } + } + }, + "/v1/providers/{providerAddress}/active-leases-graph-data": { + "get": { + "tags": [ + "Analytics", + "Providers" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "example": "akash18ga02jzaq8cw52anyhzkwta5wygufgu6zsz6xc" + }, + "required": true, + "name": "providerAddress", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns a provider's active leases graph data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "currentValue": { + "type": "number" + }, + "compareValue": { + "type": "number" + }, + "snapshots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "example": "2021-07-01T00:00:00.000Z" + }, + "value": { + "type": "number", + "example": 100 + } + }, + "required": [ + "date", + "value" + ] + } + }, + "now": { + "type": "object", + "properties": { + "count": { + "type": "number", + "example": 100 + } + }, + "required": [ + "count" + ] + }, + "compare": { + "type": "object", + "properties": { + "count": { + "type": "number", + "example": 100 + } + }, + "required": [ + "count" + ] + } + }, + "required": [ + "currentValue", + "compareValue", + "snapshots", + "now", + "compare" + ] + } + } + } + }, + "400": { + "description": "Invalid address" + } + } + } + }, + "/v1/auditors": { + "get": { + "tags": [ + "Providers" + ], + "security": [], + "summary": "Get a list of auditors.", + "responses": { + "200": { + "description": "List of auditors", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "address": { + "type": "string" + }, + "website": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "address", + "website" + ] + } + } + } + } + } + } + } + }, + "/v1/provider-attributes-schema": { + "get": { + "summary": "Get the provider attributes schema", + "tags": [ + "Providers" + ], + "security": [], + "responses": { + "200": { + "description": "Return the provider attributes schema", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "host": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "email": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "organization": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "website": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "tier": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "status-page": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "location-region": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "country": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "city": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "timezone": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "location-type": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "hosting-provider": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "hardware-cpu": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "hardware-cpu-arch": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "hardware-gpu": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "hardware-gpu-model": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "hardware-disk": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "hardware-memory": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "network-provider": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "network-speed-up": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "network-speed-down": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "feat-persistent-storage": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "feat-persistent-storage-type": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "workload-support-chia": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "workload-support-chia-capabilities": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "feat-endpoint-ip": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "feat-endpoint-custom-domain": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + } + }, + "required": [ + "host", + "email", + "organization", + "website", + "tier", + "status-page", + "location-region", + "country", + "city", + "timezone", + "location-type", + "hosting-provider", + "hardware-cpu", + "hardware-cpu-arch", + "hardware-gpu", + "hardware-gpu-model", + "hardware-disk", + "hardware-memory", + "network-provider", + "network-speed-up", + "network-speed-down", + "feat-persistent-storage", + "feat-persistent-storage-type", + "workload-support-chia", + "workload-support-chia-capabilities", + "feat-endpoint-ip", + "feat-endpoint-custom-domain" + ] + } + } + } + } + } + } + }, + "/v1/provider-regions": { + "get": { + "summary": "Get a list of provider regions", + "tags": [ + "Providers" + ], + "security": [], + "responses": { + "200": { + "description": "Return a list of provider regions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "type": "string" + } + }, + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "providers", + "key", + "description" + ] + } + } + } + } + } + } + } + }, + "/v1/provider-dashboard/{owner}": { + "get": { + "summary": "Get dashboard data for provider console.", + "tags": [ + "Providers" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "example": "akash18ga02jzaq8cw52anyhzkwta5wygufgu6zsz6xc" + }, + "required": true, + "name": "owner", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Dashboard data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "current": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "height": { + "type": "number" + }, + "activeLeaseCount": { + "type": "number" + }, + "totalLeaseCount": { + "type": "number" + }, + "dailyLeaseCount": { + "type": "number" + }, + "totalUAktEarned": { + "type": "number" + }, + "dailyUAktEarned": { + "type": "number" + }, + "totalUUsdcEarned": { + "type": "number" + }, + "dailyUUsdcEarned": { + "type": "number" + }, + "totalUActEarned": { + "type": "number" + }, + "dailyUActEarned": { + "type": "number" + }, + "totalUUsdEarned": { + "type": "number" + }, + "dailyUUsdEarned": { + "type": "number" + }, + "activeCPU": { + "type": "number" + }, + "activeGPU": { + "type": "number" + }, + "activeMemory": { + "type": "number" + }, + "activeEphemeralStorage": { + "type": "number" + }, + "activePersistentStorage": { + "type": "number" + }, + "activeStorage": { + "type": "number" + } + }, + "required": [ + "date", + "height", + "activeLeaseCount", + "totalLeaseCount", + "dailyLeaseCount", + "totalUAktEarned", + "dailyUAktEarned", + "totalUUsdcEarned", + "dailyUUsdcEarned", + "totalUActEarned", + "dailyUActEarned", + "totalUUsdEarned", + "dailyUUsdEarned", + "activeCPU", + "activeGPU", + "activeMemory", + "activeEphemeralStorage", + "activePersistentStorage", + "activeStorage" + ] + }, + "previous": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "height": { + "type": "number" + }, + "activeLeaseCount": { + "type": "number" + }, + "totalLeaseCount": { + "type": "number" + }, + "dailyLeaseCount": { + "type": "number" + }, + "totalUAktEarned": { + "type": "number" + }, + "dailyUAktEarned": { + "type": "number" + }, + "totalUUsdcEarned": { + "type": "number" + }, + "dailyUUsdcEarned": { + "type": "number" + }, + "totalUActEarned": { + "type": "number" + }, + "dailyUActEarned": { + "type": "number" + }, + "totalUUsdEarned": { + "type": "number" + }, + "dailyUUsdEarned": { + "type": "number" + }, + "activeCPU": { + "type": "number" + }, + "activeGPU": { + "type": "number" + }, + "activeMemory": { + "type": "number" + }, + "activeEphemeralStorage": { + "type": "number" + }, + "activePersistentStorage": { + "type": "number" + }, + "activeStorage": { + "type": "number" + } + }, + "required": [ + "date", + "height", + "activeLeaseCount", + "totalLeaseCount", + "dailyLeaseCount", + "totalUAktEarned", + "dailyUAktEarned", + "totalUUsdcEarned", + "dailyUUsdcEarned", + "totalUActEarned", + "dailyUActEarned", + "totalUUsdEarned", + "dailyUUsdEarned", + "activeCPU", + "activeGPU", + "activeMemory", + "activeEphemeralStorage", + "activePersistentStorage", + "activeStorage" + ] + } + }, + "required": [ + "current", + "previous" + ] + } + } + } + }, + "404": { + "description": "Provider not found" + } + } + } + }, + "/v1/provider-earnings/{owner}": { + "get": { + "summary": "Get earnings data for provider console.", + "tags": [ + "Providers" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "description": "Provider Address", + "example": "akash18ga02jzaq8cw52anyhzkwta5wygufgu6zsz6xc" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string", + "format": "YYYY-MM-DD", + "description": "Start date in YYYY-MM-DD format", + "example": "2023-01-01" + }, + "required": true, + "name": "from", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "YYYY-MM-DD", + "description": "End date in YYYY-MM-DD format", + "example": "2023-02-01" + }, + "required": true, + "name": "to", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Earnings data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "earnings": { + "type": "object", + "properties": { + "totalUAktEarned": { + "type": "number" + }, + "totalUUsdcEarned": { + "type": "number" + }, + "totalUActEarned": { + "type": "number" + }, + "totalUUsdEarned": { + "type": "number" + } + }, + "required": [ + "totalUAktEarned", + "totalUUsdcEarned", + "totalUActEarned", + "totalUUsdEarned" + ] + } + }, + "required": [ + "earnings" + ] + } + } + } + }, + "404": { + "description": "Provider not found" + } + } + } + }, + "/v1/provider-versions": { + "get": { + "summary": "Get providers grouped by version.", + "tags": [ + "Providers" + ], + "security": [], + "responses": { + "200": { + "description": "List of providers grouped by version.", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "count": { + "type": "number" + }, + "ratio": { + "type": "number" + }, + "providers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "version", + "count", + "ratio", + "providers" + ] + } + } + } + } + } + } + } + }, + "/v1/provider-graph-data/{dataName}": { + "get": { + "tags": [ + "Analytics" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "example": "cpu", + "enum": [ + "count", + "cpu", + "gpu", + "memory", + "storage" + ] + }, + "required": true, + "name": "dataName", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns provider graph data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "currentValue": { + "type": "number" + }, + "compareValue": { + "type": "number" + }, + "snapshots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "example": "2021-07-01T00:00:00.000Z" + }, + "value": { + "type": "number", + "example": 100 + } + }, + "required": [ + "date", + "value" + ] + } + }, + "now": { + "type": "object", + "properties": { + "count": { + "type": "number", + "example": 100 + }, + "cpu": { + "type": "number", + "example": 100 + }, + "gpu": { + "type": "number", + "example": 100 + }, + "memory": { + "type": "number", + "example": 100 + }, + "storage": { + "type": "number", + "example": 100 + } + }, + "required": [ + "count", + "cpu", + "gpu", + "memory", + "storage" + ] + }, + "compare": { + "type": "object", + "properties": { + "count": { + "type": "number", + "example": 100 + }, + "cpu": { + "type": "number", + "example": 100 + }, + "gpu": { + "type": "number", + "example": 100 + }, + "memory": { + "type": "number", + "example": 100 + }, + "storage": { + "type": "number", + "example": 100 + } + }, + "required": [ + "count", + "cpu", + "gpu", + "memory", + "storage" + ] + } + }, + "required": [ + "currentValue", + "compareValue", + "snapshots" + ] + } + } + } + }, + "404": { + "description": "Graph data not found" + } + } + } + }, + "/v1/providers/{provider}/deployments/{skip}/{limit}": { + "get": { + "summary": "Get a list of deployments for a provider.", + "tags": [ + "Providers", + "Deployments" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "description": "Provider Address", + "example": "akash18ga02jzaq8cw52anyhzkwta5wygufgu6zsz6xc" + }, + "required": true, + "name": "provider", + "in": "path" + }, + { + "schema": { + "type": "number", + "nullable": true, + "minimum": 0, + "description": "Deployments to skip", + "example": 10 + }, + "required": false, + "name": "skip", + "in": "path" + }, + { + "schema": { + "type": "number", + "minimum": 1, + "maximum": 100, + "description": "Deployments to return", + "example": 10 + }, + "required": true, + "name": "limit", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": [ + "active", + "closed" + ], + "description": "Filter by status", + "example": "closed" + }, + "required": false, + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns deployment list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "deployments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "denom": { + "type": "string" + }, + "createdHeight": { + "type": "number" + }, + "createdDate": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + }, + "balance": { + "type": "number" + }, + "transferred": { + "type": "number" + }, + "settledAt": { + "type": "number", + "nullable": true + }, + "resources": { + "type": "object", + "properties": { + "cpu": { + "type": "number" + }, + "memory": { + "type": "number" + }, + "gpu": { + "type": "number" + }, + "ephemeralStorage": { + "type": "number" + }, + "persistentStorage": { + "type": "number" + } + }, + "required": [ + "cpu", + "memory", + "gpu", + "ephemeralStorage", + "persistentStorage" + ] + }, + "leases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "gseq": { + "type": "number" + }, + "oseq": { + "type": "number" + }, + "price": { + "type": "number" + }, + "createdHeight": { + "type": "number" + }, + "createdDate": { + "type": "string", + "nullable": true + }, + "closedHeight": { + "type": "number", + "nullable": true + }, + "closedDate": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + }, + "resources": { + "type": "object", + "properties": { + "cpu": { + "type": "number" + }, + "memory": { + "type": "number" + }, + "gpu": { + "type": "number" + }, + "ephemeralStorage": { + "type": "number" + }, + "persistentStorage": { + "type": "number" + } + }, + "required": [ + "cpu", + "memory", + "gpu", + "ephemeralStorage", + "persistentStorage" + ] + } + }, + "required": [ + "provider", + "gseq", + "oseq", + "price", + "createdHeight", + "createdDate", + "closedHeight", + "closedDate", + "status", + "resources" + ] + } + } + }, + "required": [ + "owner", + "dseq", + "denom", + "createdHeight", + "createdDate", + "status", + "balance", + "transferred", + "settledAt", + "resources", + "leases" + ] + } + } + }, + "required": [ + "total", + "deployments" + ] + } + } + } + }, + "400": { + "description": "Invalid status filter" + } + } + } + }, + "/v1/create-jwt-token": { + "post": { + "summary": "Create new JWT token for managed wallet", + "tags": [ + "JWT Token" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "ttl": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "leases": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + }, + "required": [ + "ttl", + "leases" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "201": { + "description": "JWT token created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + }, + "required": [ + "token" + ] + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/graph-data/{dataName}": { + "get": { + "tags": [ + "Analytics" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "example": "dailyUAktSpent", + "enum": [ + "dailyUAktSpent", + "dailyUActSpent", + "dailyUUsdcSpent", + "dailyUUsdSpent", + "dailyLeaseCount", + "totalUAktSpent", + "totalUActSpent", + "totalUUsdcSpent", + "totalUUsdSpent", + "activeLeaseCount", + "totalLeaseCount", + "activeCPU", + "activeGPU", + "activeMemory", + "activeStorage", + "gpuUtilization", + "totalAktBurnedForAct", + "dailyAktBurnedForAct", + "totalActMinted", + "dailyActMinted", + "totalActBurnedForAkt", + "dailyActBurnedForAkt", + "totalAktReminted", + "dailyAktReminted", + "netAktBurned", + "dailyNetAktBurned", + "outstandingAct", + "vaultAkt", + "collateralRatio" + ] + }, + "required": true, + "name": "dataName", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns graph data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "currentValue": { + "type": "number" + }, + "compareValue": { + "type": "number" + }, + "snapshots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "example": "2021-07-01T00:00:00.000Z" + }, + "value": { + "type": "number", + "example": 100 + } + }, + "required": [ + "date", + "value" + ] + } + } + }, + "required": [ + "currentValue", + "compareValue", + "snapshots" + ] + } + } + } + }, + "404": { + "description": "Graph data not found" + } + } + } + }, + "/v1/bme/dashboard-data": { + "get": { + "tags": [ + "Analytics" + ], + "security": [], + "responses": { + "200": { + "description": "Returns BME dashboard data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "now": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "outstandingAct": { + "type": "number" + }, + "vaultAkt": { + "type": "number" + }, + "collateralRatio": { + "type": "number" + }, + "dailyAktBurnedForAct": { + "type": "number" + }, + "totalAktBurnedForAct": { + "type": "number" + }, + "dailyActMinted": { + "type": "number" + }, + "totalActMinted": { + "type": "number" + }, + "dailyActBurnedForAkt": { + "type": "number" + }, + "totalActBurnedForAkt": { + "type": "number" + }, + "dailyAktReminted": { + "type": "number" + }, + "totalAktReminted": { + "type": "number" + }, + "dailyNetAktBurned": { + "type": "number" + }, + "netAktBurned": { + "type": "number" + } + }, + "required": [ + "date", + "outstandingAct", + "vaultAkt", + "collateralRatio", + "dailyAktBurnedForAct", + "totalAktBurnedForAct", + "dailyActMinted", + "totalActMinted", + "dailyActBurnedForAkt", + "totalActBurnedForAkt", + "dailyAktReminted", + "totalAktReminted", + "dailyNetAktBurned", + "netAktBurned" + ] + }, + "compare": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "outstandingAct": { + "type": "number" + }, + "vaultAkt": { + "type": "number" + }, + "collateralRatio": { + "type": "number" + }, + "dailyAktBurnedForAct": { + "type": "number" + }, + "totalAktBurnedForAct": { + "type": "number" + }, + "dailyActMinted": { + "type": "number" + }, + "totalActMinted": { + "type": "number" + }, + "dailyActBurnedForAkt": { + "type": "number" + }, + "totalActBurnedForAkt": { + "type": "number" + }, + "dailyAktReminted": { + "type": "number" + }, + "totalAktReminted": { + "type": "number" + }, + "dailyNetAktBurned": { + "type": "number" + }, + "netAktBurned": { + "type": "number" + } + }, + "required": [ + "date", + "outstandingAct", + "vaultAkt", + "collateralRatio", + "dailyAktBurnedForAct", + "totalAktBurnedForAct", + "dailyActMinted", + "totalActMinted", + "dailyActBurnedForAkt", + "totalActBurnedForAkt", + "dailyAktReminted", + "totalAktReminted", + "dailyNetAktBurned", + "netAktBurned" + ] + } + }, + "required": [ + "now", + "compare" + ] + } + } + } + } + } + } + }, + "/v1/bme/status-history": { + "get": { + "tags": [ + "Analytics" + ], + "security": [], + "responses": { + "200": { + "description": "Returns BME circuit breaker status change history", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "height": { + "type": "integer", + "example": 12345678 + }, + "date": { + "type": "string", + "example": "2024-01-01T00:00:00.000Z" + }, + "previousStatus": { + "type": "string", + "example": "healthy" + }, + "newStatus": { + "type": "string", + "example": "warning" + }, + "collateralRatio": { + "type": "number", + "example": 1.5 + } + }, + "required": [ + "height", + "date", + "previousStatus", + "newStatus", + "collateralRatio" + ] + } + } + } + } + } + } + } + }, + "/v1/dashboard-data": { + "get": { + "tags": [ + "Analytics" + ], + "security": [], + "responses": { + "200": { + "description": "Returns dashboard data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chainStats": { + "type": "object", + "properties": { + "height": { + "type": "number" + }, + "transactionCount": { + "type": "number" + }, + "bondedTokens": { + "type": "number" + }, + "totalSupply": { + "type": "number" + }, + "communityPool": { + "type": "number" + }, + "inflation": { + "type": "number" + }, + "stakingAPR": { + "type": "number" + } + }, + "required": [ + "height", + "transactionCount", + "bondedTokens", + "totalSupply", + "communityPool", + "inflation" + ] + }, + "now": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "height": { + "type": "number" + }, + "activeLeaseCount": { + "type": "number" + }, + "totalLeaseCount": { + "type": "number" + }, + "dailyLeaseCount": { + "type": "number" + }, + "totalUAktSpent": { + "type": "number" + }, + "dailyUAktSpent": { + "type": "number" + }, + "totalUActSpent": { + "type": "number" + }, + "dailyUActSpent": { + "type": "number" + }, + "totalUUsdcSpent": { + "type": "number" + }, + "dailyUUsdcSpent": { + "type": "number" + }, + "totalUUsdSpent": { + "type": "number" + }, + "dailyUUsdSpent": { + "type": "number" + }, + "activeCPU": { + "type": "number" + }, + "activeGPU": { + "type": "number" + }, + "activeMemory": { + "type": "number" + }, + "activeStorage": { + "type": "number" + } + }, + "required": [ + "date", + "height", + "activeLeaseCount", + "totalLeaseCount", + "dailyLeaseCount", + "totalUAktSpent", + "dailyUAktSpent", + "totalUActSpent", + "dailyUActSpent", + "totalUUsdcSpent", + "dailyUUsdcSpent", + "totalUUsdSpent", + "dailyUUsdSpent", + "activeCPU", + "activeGPU", + "activeMemory", + "activeStorage" + ] + }, + "compare": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "height": { + "type": "number" + }, + "activeLeaseCount": { + "type": "number" + }, + "totalLeaseCount": { + "type": "number" + }, + "dailyLeaseCount": { + "type": "number" + }, + "totalUAktSpent": { + "type": "number" + }, + "dailyUAktSpent": { + "type": "number" + }, + "totalUActSpent": { + "type": "number" + }, + "dailyUActSpent": { + "type": "number" + }, + "totalUUsdcSpent": { + "type": "number" + }, + "dailyUUsdcSpent": { + "type": "number" + }, + "totalUUsdSpent": { + "type": "number" + }, + "dailyUUsdSpent": { + "type": "number" + }, + "activeCPU": { + "type": "number" + }, + "activeGPU": { + "type": "number" + }, + "activeMemory": { + "type": "number" + }, + "activeStorage": { + "type": "number" + } + }, + "required": [ + "date", + "height", + "activeLeaseCount", + "totalLeaseCount", + "dailyLeaseCount", + "totalUAktSpent", + "dailyUAktSpent", + "totalUActSpent", + "dailyUActSpent", + "totalUUsdcSpent", + "dailyUUsdcSpent", + "totalUUsdSpent", + "dailyUUsdSpent", + "activeCPU", + "activeGPU", + "activeMemory", + "activeStorage" + ] + }, + "networkCapacity": { + "type": "object", + "properties": { + "activeProviderCount": { + "type": "number" + }, + "activeCPU": { + "type": "number" + }, + "activeGPU": { + "type": "number" + }, + "activeMemory": { + "type": "number" + }, + "activeStorage": { + "type": "number" + }, + "pendingCPU": { + "type": "number" + }, + "pendingGPU": { + "type": "number" + }, + "pendingMemory": { + "type": "number" + }, + "pendingStorage": { + "type": "number" + }, + "availableCPU": { + "type": "number" + }, + "availableGPU": { + "type": "number" + }, + "availableMemory": { + "type": "number" + }, + "availableStorage": { + "type": "number" + }, + "totalCPU": { + "type": "number" + }, + "totalGPU": { + "type": "number" + }, + "totalMemory": { + "type": "number" + }, + "totalStorage": { + "type": "number" + }, + "activeEphemeralStorage": { + "type": "number" + }, + "pendingEphemeralStorage": { + "type": "number" + }, + "availableEphemeralStorage": { + "type": "number" + }, + "activePersistentStorage": { + "type": "number" + }, + "pendingPersistentStorage": { + "type": "number" + }, + "availablePersistentStorage": { + "type": "number" + }, + "totalEphemeralStorage": { + "type": "number" + }, + "totalPersistentStorage": { + "type": "number" + } + }, + "required": [ + "activeProviderCount", + "activeCPU", + "activeGPU", + "activeMemory", + "activeStorage", + "pendingCPU", + "pendingGPU", + "pendingMemory", + "pendingStorage", + "availableCPU", + "availableGPU", + "availableMemory", + "availableStorage", + "totalCPU", + "totalGPU", + "totalMemory", + "totalStorage", + "activeEphemeralStorage", + "pendingEphemeralStorage", + "availableEphemeralStorage", + "activePersistentStorage", + "pendingPersistentStorage", + "availablePersistentStorage", + "totalEphemeralStorage", + "totalPersistentStorage" + ] + }, + "networkCapacityStats": { + "type": "object", + "properties": { + "currentValue": { + "type": "number" + }, + "compareValue": { + "type": "number" + }, + "snapshots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "required": [ + "date", + "value" + ] + } + }, + "now": { + "type": "object", + "properties": { + "count": { + "type": "number" + }, + "cpu": { + "type": "number" + }, + "gpu": { + "type": "number" + }, + "memory": { + "type": "number" + }, + "storage": { + "type": "number" + } + }, + "required": [ + "count", + "cpu", + "gpu", + "memory", + "storage" + ] + }, + "compare": { + "type": "object", + "properties": { + "count": { + "type": "number" + }, + "cpu": { + "type": "number" + }, + "gpu": { + "type": "number" + }, + "memory": { + "type": "number" + }, + "storage": { + "type": "number" + } + }, + "required": [ + "count", + "cpu", + "gpu", + "memory", + "storage" + ] + } + }, + "required": [ + "currentValue", + "compareValue", + "snapshots", + "now", + "compare" + ] + }, + "latestBlocks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "height": { + "type": "number" + }, + "proposer": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "operatorAddress": { + "type": "string" + }, + "moniker": { + "type": "string", + "nullable": true + }, + "avatarUrl": { + "type": "string", + "nullable": true + } + }, + "required": [ + "address", + "operatorAddress", + "moniker", + "avatarUrl" + ] + }, + "transactionCount": { + "type": "number" + }, + "totalTransactionCount": { + "type": "number" + }, + "datetime": { + "type": "string" + } + }, + "required": [ + "height", + "proposer", + "transactionCount", + "totalTransactionCount", + "datetime" + ] + } + }, + "latestTransactions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "height": { + "type": "number" + }, + "datetime": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "isSuccess": { + "type": "boolean" + }, + "error": { + "type": "string", + "nullable": true + }, + "gasUsed": { + "type": "number" + }, + "gasWanted": { + "type": "number" + }, + "fee": { + "type": "number" + }, + "memo": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "amount": { + "type": "number" + } + }, + "required": [ + "id", + "type", + "amount" + ] + } + } + }, + "required": [ + "height", + "datetime", + "hash", + "isSuccess", + "error", + "gasUsed", + "gasWanted", + "fee", + "memo", + "messages" + ] + } + } + }, + "required": [ + "chainStats", + "now", + "compare", + "networkCapacity", + "networkCapacityStats", + "latestBlocks", + "latestTransactions" + ] + } + } + } + } + } + } + }, + "/v1/network-capacity": { + "get": { + "tags": [ + "Analytics" + ], + "security": [], + "responses": { + "200": { + "description": "Returns network capacity stats", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "activeProviderCount": { + "type": "number" + }, + "resources": { + "type": "object", + "properties": { + "cpu": { + "type": "object", + "properties": { + "active": { + "type": "number" + }, + "pending": { + "type": "number" + }, + "available": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "active", + "pending", + "available", + "total" + ] + }, + "gpu": { + "type": "object", + "properties": { + "active": { + "type": "number" + }, + "pending": { + "type": "number" + }, + "available": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "active", + "pending", + "available", + "total" + ] + }, + "memory": { + "type": "object", + "properties": { + "active": { + "type": "number" + }, + "pending": { + "type": "number" + }, + "available": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "active", + "pending", + "available", + "total" + ] + }, + "storage": { + "type": "object", + "properties": { + "ephemeral": { + "type": "object", + "properties": { + "active": { + "type": "number" + }, + "pending": { + "type": "number" + }, + "available": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "active", + "pending", + "available", + "total" + ] + }, + "persistent": { + "type": "object", + "properties": { + "active": { + "type": "number" + }, + "pending": { + "type": "number" + }, + "available": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "active", + "pending", + "available", + "total" + ] + }, + "total": { + "type": "object", + "properties": { + "active": { + "type": "number" + }, + "pending": { + "type": "number" + }, + "available": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "active", + "pending", + "available", + "total" + ] + } + }, + "required": [ + "ephemeral", + "persistent", + "total" + ] + } + }, + "required": [ + "cpu", + "gpu", + "memory", + "storage" + ] + } + }, + "required": [ + "activeProviderCount", + "resources" + ] + } + } + } + } + } + } + }, + "/v1/blocks": { + "get": { + "summary": "Get a list of recent blocks.", + "tags": [ + "Blocks" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "number", + "description": "Number of blocks to return", + "minimum": 1, + "maximum": 100, + "example": 20, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns block list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "height": { + "type": "number" + }, + "proposer": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "operatorAddress": { + "type": "string" + }, + "moniker": { + "type": "string" + }, + "avatarUrl": { + "type": "string", + "nullable": true + } + }, + "required": [ + "address", + "operatorAddress", + "moniker", + "avatarUrl" + ] + }, + "transactionCount": { + "type": "number" + }, + "totalTransactionCount": { + "type": "number" + }, + "datetime": { + "type": "string" + } + }, + "required": [ + "height", + "proposer", + "transactionCount", + "totalTransactionCount", + "datetime" + ] + } + } + } + } + } + } + } + }, + "/v1/blocks/{height}": { + "get": { + "summary": "Get a block by height.", + "tags": [ + "Blocks" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "number", + "nullable": true, + "description": "Block Height", + "example": 12121212 + }, + "required": false, + "name": "height", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns predicted block date", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "height": { + "type": "number" + }, + "datetime": { + "type": "string" + }, + "proposer": { + "type": "object", + "properties": { + "operatorAddress": { + "type": "string" + }, + "moniker": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + }, + "address": { + "type": "string" + } + }, + "required": [ + "operatorAddress", + "moniker", + "address" + ] + }, + "hash": { + "type": "string" + }, + "gasUsed": { + "type": "number" + }, + "gasWanted": { + "type": "number" + }, + "transactions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hash": { + "type": "string" + }, + "isSuccess": { + "type": "boolean" + }, + "error": { + "type": "string", + "nullable": true + }, + "fee": { + "type": "number" + }, + "datetime": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "amount": { + "type": "number" + } + }, + "required": [ + "id", + "type", + "amount" + ] + } + } + }, + "required": [ + "hash", + "isSuccess", + "fee", + "datetime", + "messages" + ] + } + } + }, + "required": [ + "height", + "datetime", + "proposer", + "hash", + "gasUsed", + "gasWanted", + "transactions" + ] + } + } + } + }, + "400": { + "description": "Invalid height" + }, + "404": { + "description": "Block not found" + } + } + } + }, + "/v1/predicted-block-date/{height}": { + "get": { + "summary": "Get the estimated date of a future block.", + "tags": [ + "Blocks" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "number", + "description": "Block height", + "example": 20000000 + }, + "required": false, + "name": "height", + "in": "path" + }, + { + "schema": { + "type": "number", + "default": 10000, + "description": "Block window", + "example": 10000 + }, + "required": false, + "name": "blockWindow", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns predicted block date", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "predictedDate": { + "type": "string" + }, + "height": { + "type": "number", + "example": 10000000 + }, + "blockWindow": { + "type": "number", + "example": 10000 + } + }, + "required": [ + "predictedDate", + "height", + "blockWindow" + ] + } + } + } + }, + "400": { + "description": "Invalid height or block window" + } + } + } + }, + "/v1/predicted-date-height/{timestamp}": { + "get": { + "summary": "Get the estimated height of a future date and time.", + "tags": [ + "Blocks" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "number", + "description": "Unix Timestamp", + "example": 1704392968 + }, + "required": false, + "name": "timestamp", + "in": "path" + }, + { + "schema": { + "type": "number", + "default": 10000, + "description": "Block window", + "example": 10000 + }, + "required": false, + "name": "blockWindow", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns predicted block height", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "predictedHeight": { + "type": "number", + "example": 10000000 + }, + "date": { + "type": "string", + "example": "2024-01-04T18:29:28.000Z" + }, + "blockWindow": { + "type": "number", + "example": 10000 + } + }, + "required": [ + "predictedHeight", + "date", + "blockWindow" + ] + } + } + } + }, + "400": { + "description": "Invalid timestamp or block window" + } + } + } + }, + "/v1/transactions": { + "get": { + "summary": "Get a list of transactions.", + "tags": [ + "Transactions" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "number", + "description": "Number of transactions to return", + "minimum": 1, + "maximum": 100, + "example": 20, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns transaction list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "height": { + "type": "number" + }, + "datetime": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "isSuccess": { + "type": "boolean" + }, + "error": { + "type": "string", + "nullable": true + }, + "gasUsed": { + "type": "number" + }, + "gasWanted": { + "type": "number" + }, + "fee": { + "type": "number" + }, + "memo": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "amount": { + "type": "number" + } + }, + "required": [ + "id", + "type", + "amount" + ] + } + } + }, + "required": [ + "height", + "datetime", + "hash", + "isSuccess", + "error", + "gasUsed", + "gasWanted", + "fee", + "memo", + "messages" + ] + } + } + } + } + } + } + } + }, + "/v1/transactions/{hash}": { + "get": { + "summary": "Get a transaction by hash.", + "tags": [ + "Transactions" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Transaction hash", + "example": "A19F1950D97E576F0D7B591D71A8D0366AA8BA0A7F3DA76F44769188644BE9EB" + }, + "required": true, + "name": "hash", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns predicted block date", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "height": { + "type": "number" + }, + "datetime": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "isSuccess": { + "type": "boolean" + }, + "multisigThreshold": { + "type": "number" + }, + "signers": { + "type": "array", + "items": { + "type": "string" + } + }, + "error": { + "type": "string", + "nullable": true + }, + "gasUsed": { + "type": "number" + }, + "gasWanted": { + "type": "number" + }, + "fee": { + "type": "number" + }, + "memo": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "data": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "relatedDeploymentId": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "type", + "data" + ] + } + } + }, + "required": [ + "height", + "datetime", + "hash", + "isSuccess", + "signers", + "error", + "gasUsed", + "gasWanted", + "fee", + "memo", + "messages" + ] + } + } + } + }, + "404": { + "description": "Transaction not found" + } + } + } + }, + "/v1/market-data/{coin?}": { + "get": { + "tags": [ + "Analytics" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "akash-network", + "akt" + ], + "default": "akt", + "example": "akt" + }, + "required": false, + "name": "coin", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns market stats", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "price": { + "type": "number" + }, + "volume": { + "type": "number" + }, + "marketCap": { + "type": "number" + }, + "marketCapRank": { + "type": "number" + }, + "priceChange24h": { + "type": "number" + }, + "priceChangePercentage24": { + "type": "number" + } + }, + "required": [ + "price", + "volume", + "marketCap", + "marketCapRank", + "priceChange24h", + "priceChangePercentage24" + ] + } + } + } + } + } + } + }, + "/v1/validators": { + "get": { + "tags": [ + "Validators" + ], + "security": [], + "responses": { + "200": { + "description": "Returns validators", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "operatorAddress": { + "type": "string" + }, + "moniker": { + "type": "string" + }, + "votingPower": { + "type": "number" + }, + "commission": { + "type": "number" + }, + "identity": { + "type": "string" + }, + "votingPowerRatio": { + "type": "number" + }, + "rank": { + "type": "number" + }, + "keybaseAvatarUrl": { + "type": "string", + "nullable": true + } + }, + "required": [ + "operatorAddress", + "moniker", + "votingPower", + "commission", + "identity", + "votingPowerRatio", + "rank", + "keybaseAvatarUrl" + ] + } + } + } + } + } + } + } + }, + "/v1/validators/{address}": { + "get": { + "tags": [ + "Validators" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "description": "Validator Address", + "example": "akashvaloper14mt78hz73d9tdwpdvkd59ne9509kxw8yj7qy8f" + }, + "required": true, + "name": "address", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Return a validator information", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "operatorAddress": { + "type": "string" + }, + "address": { + "type": "string", + "nullable": true + }, + "moniker": { + "type": "string" + }, + "keybaseUsername": { + "type": "string", + "nullable": true + }, + "keybaseAvatarUrl": { + "type": "string", + "nullable": true + }, + "votingPower": { + "type": "number" + }, + "commission": { + "type": "number" + }, + "maxCommission": { + "type": "number" + }, + "maxCommissionChange": { + "type": "number" + }, + "identity": { + "type": "string" + }, + "description": { + "type": "string" + }, + "website": { + "type": "string" + }, + "rank": { + "type": "number" + } + }, + "required": [ + "operatorAddress", + "address", + "moniker", + "keybaseUsername", + "keybaseAvatarUrl", + "votingPower", + "commission", + "maxCommission", + "maxCommissionChange", + "identity", + "description", + "website", + "rank" + ] + } + } + } + }, + "400": { + "description": "Invalid address" + }, + "404": { + "description": "Validator not found" + } + } + } + }, + "/v1/pricing": { + "post": { + "tags": [ + "Other" + ], + "security": [], + "summary": "Estimate the price of a deployment on akash and other cloud providers.", + "requestBody": { + "description": "Deployment specs to use for the price estimation. **An array of specs can also be sent, in that case an array of estimations will be returned in the same order.**", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "cpu": { + "type": "number", + "minimum": 0, + "description": "CPU in thousandths of a core. 1000 = 1 core", + "example": 1000 + }, + "memory": { + "type": "number", + "description": "Memory in bytes", + "example": 1000000000 + }, + "storage": { + "type": "number", + "description": "Storage in bytes", + "example": 1000000000 + } + }, + "required": [ + "cpu", + "memory", + "storage" + ] + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "cpu": { + "type": "number", + "minimum": 0, + "description": "CPU in thousandths of a core. 1000 = 1 core", + "example": 1000 + }, + "memory": { + "type": "number", + "description": "Memory in bytes", + "example": 1000000000 + }, + "storage": { + "type": "number", + "description": "Storage in bytes", + "example": 1000000000 + } + }, + "required": [ + "cpu", + "memory", + "storage" + ] + }, + "minItems": 1, + "maxItems": 10 + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Returns a list of deployment templates grouped by categories", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "cpu": { + "type": "number", + "minimum": 0, + "description": "CPU in thousandths of a core. 1000 = 1 core", + "example": 1000 + }, + "memory": { + "type": "number", + "description": "Memory in bytes", + "example": 1000000000 + }, + "storage": { + "type": "number", + "description": "Storage in bytes", + "example": 1000000000 + } + }, + "required": [ + "cpu", + "memory", + "storage" + ] + }, + "akash": { + "type": "number", + "description": "Akash price estimation (USD/month)" + }, + "aws": { + "type": "number", + "description": "AWS price estimation (USD/month)" + }, + "gcp": { + "type": "number", + "description": "GCP price estimation (USD/month)" + }, + "azure": { + "type": "number", + "description": "Azure price estimation (USD/month)" + } + }, + "required": [ + "spec", + "akash", + "aws", + "gcp", + "azure" + ] + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "cpu": { + "type": "number", + "minimum": 0, + "description": "CPU in thousandths of a core. 1000 = 1 core", + "example": 1000 + }, + "memory": { + "type": "number", + "description": "Memory in bytes", + "example": 1000000000 + }, + "storage": { + "type": "number", + "description": "Storage in bytes", + "example": 1000000000 + } + }, + "required": [ + "cpu", + "memory", + "storage" + ] + }, + "akash": { + "type": "number", + "description": "Akash price estimation (USD/month)" + }, + "aws": { + "type": "number", + "description": "AWS price estimation (USD/month)" + }, + "gcp": { + "type": "number", + "description": "GCP price estimation (USD/month)" + }, + "azure": { + "type": "number", + "description": "Azure price estimation (USD/month)" + } + }, + "required": [ + "spec", + "akash", + "aws", + "gcp", + "azure" + ] + } + } + ] + } + } + } + }, + "400": { + "description": "Invalid parameters" + } + } + } + }, + "/v1/gpu": { + "get": { + "summary": "Get a list of gpu models and their availability.", + "tags": [ + "Gpu" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": false, + "name": "provider", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "vendor", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "model", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "memory_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of gpu models and their availability.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "gpus": { + "type": "object", + "properties": { + "total": { + "type": "object", + "properties": { + "allocatable": { + "type": "number" + }, + "allocated": { + "type": "number" + } + }, + "required": [ + "allocatable", + "allocated" + ] + }, + "details": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "ram": { + "type": "string" + }, + "interface": { + "type": "string" + }, + "allocatable": { + "type": "number" + }, + "allocated": { + "type": "number" + } + }, + "required": [ + "model", + "ram", + "interface", + "allocatable", + "allocated" + ] + } + } + } + }, + "required": [ + "total", + "details" + ] + } + }, + "required": [ + "gpus" + ] + } + } + } + }, + "400": { + "description": "Invalid provider parameter, should be a valid akash address or host uri" + } + } + } + }, + "/v1/gpu-models": { + "get": { + "summary": "Get a list of gpu models per vendor. Based on the content from https://raw.githubusercontent.com/akash-network/provider-configs/main/devices/pcie/gpus.json.", + "tags": [ + "Gpu" + ], + "security": [], + "responses": { + "200": { + "description": "List of gpu models per.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "models": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "memory": { + "type": "array", + "items": { + "type": "string" + } + }, + "interface": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "memory", + "interface" + ] + } + } + }, + "required": [ + "name", + "models" + ] + } + } + } + } + } + } + } + }, + "/v1/gpu-breakdown": { + "get": { + "tags": [ + "Gpu" + ], + "security": [], + "summary": "Gets gpu analytics breakdown by vendor and model. If no vendor or model is provided, all GPUs are returned.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": false, + "name": "vendor", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "model", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Gets gpu analytics breakdown by vendor and model. If no vendor or model is provided, all GPUs are returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "vendor": { + "type": "string" + }, + "model": { + "type": "string" + }, + "providerCount": { + "type": "number" + }, + "nodeCount": { + "type": "number" + }, + "totalGpus": { + "type": "number" + }, + "leasedGpus": { + "type": "number" + }, + "gpuUtilization": { + "type": "number" + } + }, + "required": [ + "date", + "vendor", + "model", + "providerCount", + "nodeCount", + "totalGpus", + "leasedGpus", + "gpuUtilization" + ] + } + } + } + } + } + } + } + }, + "/v1/gpu-prices": { + "get": { + "summary": "Get a list of gpu models with their availability and pricing.", + "operationId": "listGpuPrices", + "tags": [ + "Gpu" + ], + "security": [], + "responses": { + "200": { + "description": "List of gpu models with their availability and pricing.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "availability": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "available": { + "type": "number" + } + }, + "required": [ + "total", + "available" + ] + }, + "models": { + "type": "array", + "items": { + "type": "object", + "properties": { + "vendor": { + "type": "string" + }, + "model": { + "type": "string" + }, + "ram": { + "type": "string" + }, + "interface": { + "type": "string" + }, + "availability": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "available": { + "type": "number" + } + }, + "required": [ + "total", + "available" + ] + }, + "providerAvailability": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "available": { + "type": "number" + } + }, + "required": [ + "total", + "available" + ] + }, + "price": { + "type": "object", + "nullable": true, + "properties": { + "currency": { + "type": "string", + "example": "USD" + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + }, + "avg": { + "type": "number" + }, + "weightedAverage": { + "type": "number" + }, + "med": { + "type": "number" + } + }, + "required": [ + "currency", + "min", + "max", + "avg", + "weightedAverage", + "med" + ] + } + }, + "required": [ + "vendor", + "model", + "ram", + "interface", + "availability", + "providerAvailability", + "price" + ] + } + } + }, + "required": [ + "availability", + "models" + ] + } + } + } + } + } + } + }, + "/v1/proposals": { + "get": { + "tags": [ + "Proposals" + ], + "security": [], + "responses": { + "200": { + "description": "Returns a list of proposals", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "status": { + "type": "string" + }, + "submitTime": { + "type": "string" + }, + "votingStartTime": { + "type": "string" + }, + "votingEndTime": { + "type": "string" + }, + "totalDeposit": { + "type": "number" + } + }, + "required": [ + "id", + "title", + "status", + "submitTime", + "votingStartTime", + "votingEndTime", + "totalDeposit" + ] + } + } + } + } + } + } + } + }, + "/v1/proposals/{id}": { + "get": { + "tags": [ + "Proposals" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "number", + "nullable": true, + "description": "Proposal ID", + "example": 1 + }, + "required": false, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Return a proposal by id", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "type": "string" + }, + "submitTime": { + "type": "string" + }, + "votingStartTime": { + "type": "string" + }, + "votingEndTime": { + "type": "string" + }, + "totalDeposit": { + "type": "number" + }, + "tally": { + "type": "object", + "properties": { + "yes": { + "type": "number" + }, + "abstain": { + "type": "number" + }, + "no": { + "type": "number" + }, + "noWithVeto": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "yes", + "abstain", + "no", + "noWithVeto", + "total" + ] + }, + "paramChanges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "subspace": { + "type": "string" + }, + "key": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "subspace", + "key" + ] + } + } + }, + "required": [ + "id", + "title", + "description", + "status", + "submitTime", + "votingStartTime", + "votingEndTime", + "totalDeposit", + "tally", + "paramChanges" + ] + } + } + } + }, + "400": { + "description": "Invalid proposal id" + }, + "404": { + "description": "Proposal not found" + } + } + } + }, + "/v1/templates-list": { + "get": { + "tags": [ + "Other" + ], + "security": [], + "responses": { + "200": { + "description": "Returns a list of deployment templates grouped by categories", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "templates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "logoUrl": { + "type": "string", + "nullable": true + }, + "summary": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "logoUrl", + "summary" + ] + } + } + }, + "required": [ + "title", + "templates" + ] + } + } + }, + "required": [ + "data" + ] + } + } + } + } + } + } + }, + "/v1/templates/{id}": { + "get": { + "tags": [ + "Other" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^[^/\\\\]+$", + "description": "Template ID", + "example": "akash-network-cosmos-omnibus-agoric" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Return a template by id", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "logoUrl": { + "type": "string", + "nullable": true + }, + "summary": { + "type": "string" + }, + "readme": { + "type": "string" + }, + "deploy": { + "type": "string" + }, + "persistentStorageEnabled": { + "type": "boolean" + }, + "guide": { + "type": "string" + }, + "githubUrl": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "ssh": { + "type": "boolean" + } + } + } + }, + "required": [ + "id", + "name", + "path", + "logoUrl", + "summary", + "readme", + "deploy", + "persistentStorageEnabled", + "githubUrl", + "config" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Invalid template ID" + }, + "404": { + "description": "Template not found" + } + } + } + }, + "/v1/leases-duration/{owner}": { + "get": { + "summary": "Get leases durations.", + "tags": [ + "Analytics" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "example": "akash13265twfqejnma6cc93rw5dxk4cldyz2zyy8cdm" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^d+$" + }, + "required": false, + "name": "dseq", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "YYYY-MM-DD", + "default": "2000-01-01" + }, + "required": false, + "name": "startDate", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "YYYY-MM-DD", + "default": "2100-01-01" + }, + "required": false, + "name": "endDate", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of leases durations and total duration.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "leaseCount": { + "type": "number" + }, + "totalDurationInSeconds": { + "type": "number" + }, + "totalDurationInHours": { + "type": "number" + }, + "leases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "dseq": { + "type": "string", + "pattern": "^d+$" + }, + "oseq": { + "type": "number" + }, + "gseq": { + "type": "number" + }, + "provider": { + "type": "string" + }, + "startHeight": { + "type": "number" + }, + "startDate": { + "type": "string" + }, + "closedHeight": { + "type": "number" + }, + "closedDate": { + "type": "string" + }, + "durationInBlocks": { + "type": "number" + }, + "durationInSeconds": { + "type": "number" + }, + "durationInHours": { + "type": "number" + } + }, + "required": [ + "dseq", + "oseq", + "gseq", + "provider", + "startHeight", + "startDate", + "closedHeight", + "closedDate", + "durationInBlocks", + "durationInSeconds", + "durationInHours" + ] + } + } + }, + "required": [ + "leaseCount", + "totalDurationInSeconds", + "totalDurationInHours", + "leases" + ] + } + } + } + }, + "400": { + "description": "Invalid start date, must be in the following format: YYYY-MM-DD" + } + } + } + }, + "/v1/addresses/{address}": { + "get": { + "summary": "Get address details", + "tags": [ + "Addresses" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "description": "Account Address", + "example": "akash13265twfqejnma6cc93rw5dxk4cldyz2zyy8cdm" + }, + "required": true, + "name": "address", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns address details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "delegations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "validator": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "moniker": { + "type": "string" + }, + "operatorAddress": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + } + } + }, + "amount": { + "type": "number" + }, + "reward": { + "type": "number", + "nullable": true + } + }, + "required": [ + "validator", + "amount", + "reward" + ] + } + }, + "available": { + "type": "number" + }, + "delegated": { + "type": "number" + }, + "rewards": { + "type": "number" + }, + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "symbol": { + "type": "string" + }, + "ibcToken": { + "type": "string" + }, + "logoUrl": { + "type": "string" + }, + "description": { + "type": "string" + }, + "amount": { + "type": "number" + } + }, + "required": [ + "amount" + ] + } + }, + "redelegations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "srcAddress": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "moniker": { + "type": "string" + }, + "operatorAddress": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + } + } + }, + "dstAddress": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "moniker": { + "type": "string" + }, + "operatorAddress": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + } + } + }, + "creationHeight": { + "type": "number" + }, + "completionTime": { + "type": "string" + }, + "amount": { + "type": "number" + } + }, + "required": [ + "srcAddress", + "dstAddress", + "creationHeight", + "completionTime", + "amount" + ] + } + }, + "commission": { + "type": "number" + }, + "latestTransactions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "height": { + "type": "number" + }, + "datetime": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "isSuccess": { + "type": "boolean" + }, + "error": { + "type": "string", + "nullable": true + }, + "gasUsed": { + "type": "number" + }, + "gasWanted": { + "type": "number" + }, + "fee": { + "type": "number" + }, + "memo": { + "type": "string", + "nullable": true + }, + "isSigner": { + "type": "boolean" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "isReceiver": { + "type": "boolean" + } + }, + "required": [ + "id", + "type", + "amount", + "isReceiver" + ] + } + } + }, + "required": [ + "height", + "datetime", + "hash", + "isSuccess", + "error", + "gasUsed", + "gasWanted", + "fee", + "memo", + "isSigner", + "messages" + ] + } + } + }, + "required": [ + "total", + "delegations", + "available", + "delegated", + "rewards", + "assets", + "redelegations", + "commission", + "latestTransactions" + ] + } + } + } + }, + "400": { + "description": "Invalid address" + } + } + } + }, + "/v1/addresses/{address}/transactions/{skip}/{limit}": { + "get": { + "summary": "Get a list of transactions for a given address.", + "tags": [ + "Addresses", + "Transactions" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "description": "Wallet Address", + "example": "akash13265twfqejnma6cc93rw5dxk4cldyz2zyy8cdm" + }, + "required": true, + "name": "address", + "in": "path" + }, + { + "schema": { + "type": "number", + "nullable": true, + "minimum": 0, + "description": "Transactions to skip", + "example": 10 + }, + "required": false, + "name": "skip", + "in": "path" + }, + { + "schema": { + "type": "number", + "minimum": 1, + "maximum": 100, + "description": "Transactions to return", + "example": 10 + }, + "required": true, + "name": "limit", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns transaction list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "count": { + "type": "number" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "height": { + "type": "number" + }, + "datetime": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "isSuccess": { + "type": "boolean" + }, + "error": { + "type": "string", + "nullable": true + }, + "gasUsed": { + "type": "number" + }, + "gasWanted": { + "type": "number" + }, + "fee": { + "type": "number" + }, + "memo": { + "type": "string", + "nullable": true + }, + "isSigner": { + "type": "boolean" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "isReceiver": { + "type": "boolean" + } + }, + "required": [ + "id", + "type", + "amount", + "isReceiver" + ] + } + } + }, + "required": [ + "height", + "datetime", + "hash", + "isSuccess", + "error", + "gasUsed", + "gasWanted", + "fee", + "memo", + "isSigner", + "messages" + ] + } + } + }, + "required": [ + "count", + "results" + ] + } + } + } + }, + "400": { + "description": "Invalid address or parameters" + } + } + } + }, + "/v1/blockchain-status": { + "get": { + "summary": "Get blockchain reachability status", + "tags": [ + "Chain" + ], + "security": [], + "responses": { + "200": { + "description": "Returns blockchain reachability status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "isBlockchainReachable": { + "type": "boolean" + } + }, + "required": [ + "isBlockchainReachable" + ] + } + } + } + } + } + } + }, + "/v1/bid-screening": { + "post": { + "operationId": "screenProviders", + "summary": "Screen providers by deployment resource requirements", + "tags": [ + "Bid Screening" + ], + "security": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "requirements": { + "type": "object", + "properties": { + "signedBy": { + "type": "object", + "properties": { + "allOf": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "anyOf": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "default": {} + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$", + "description": "Attribute key", + "example": "persistent" + }, + "value": { + "type": "string", + "description": "Attribute value", + "example": "false" + } + }, + "required": [ + "key", + "value" + ] + }, + "default": [] + } + }, + "default": {} + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resource": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Resource unit ID", + "example": 1 + }, + "cpu": { + "type": "object", + "properties": { + "units": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 80 + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$", + "description": "Attribute key", + "example": "persistent" + }, + "value": { + "type": "string", + "description": "Attribute value", + "example": "false" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "units" + ] + }, + "memory": { + "type": "object", + "properties": { + "quantity": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 80 + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$", + "description": "Attribute key", + "example": "persistent" + }, + "value": { + "type": "string", + "description": "Attribute value", + "example": "false" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "quantity" + ] + }, + "gpu": { + "type": "object", + "properties": { + "units": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 80 + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$", + "description": "Attribute key", + "example": "persistent" + }, + "value": { + "type": "string", + "description": "Attribute value", + "example": "false" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "units" + ] + }, + "storage": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Storage volume name", + "example": "default" + }, + "quantity": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 80 + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$", + "description": "Attribute key", + "example": "persistent" + }, + "value": { + "type": "string", + "description": "Attribute value", + "example": "false" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "name", + "quantity" + ] + } + }, + "endpoints": { + "type": "array", + "items": { + "nullable": true + } + } + }, + "required": [ + "id", + "cpu", + "memory", + "gpu", + "storage" + ] + }, + "count": { + "type": "integer", + "minimum": 1, + "description": "Replica count", + "example": 1 + }, + "price": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string", + "pattern": "^\\d+$" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "resource", + "count", + "price" + ] + }, + "description": "Resource units with replica counts" + }, + "timezone": { + "type": "string", + "description": "Client timezone, validated against supported Node.js Intl timezones", + "example": "America/Chicago" + }, + "reclamationWindow": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "description": "Optional reclamation window in seconds; if provided, only providers with a reclamationWindow greater than or equal to this value will be considered", + "example": 3600 + } + }, + "required": [ + "resources", + "timezone" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Returns matching providers", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Provider address", + "example": "akash1q7spv2cw06yszgfp4f9ed59lkka6ytn8g4tkjf" + }, + "hostUri": { + "type": "string", + "description": "Provider HTTPS endpoint", + "example": "https://provider.europlots.com:8443" + }, + "isAudited": { + "type": "boolean", + "description": "True if signed by a known auditor" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp marking when the provider was first enrolled in the inventory", + "example": "2026-01-01T00:00:00.000Z" + }, + "location": { + "type": "string", + "nullable": true, + "description": "Provider region from the location-region attribute (signed preferred, else self-declared); null if unset", + "example": "us-west" + }, + "organization": { + "type": "string", + "nullable": true, + "description": "Provider organization from the organization attribute (signed preferred, else self-declared); null if unset", + "example": "Akash" + }, + "incidents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "description": "Local calendar day, YYYY-MM-DD", + "example": "2026-06-01" + }, + "hasOpenIncident": { + "type": "boolean", + "description": "True if the provider currently has any open incident" + }, + "incidentCount": { + "type": "integer", + "description": "Number of incident intervals overlapping that day" + }, + "downtimeSeconds": { + "type": "integer", + "description": "Downtime clipped to that day, in seconds (max 86400)" + } + }, + "required": [ + "date", + "hasOpenIncident", + "incidentCount", + "downtimeSeconds" + ] + }, + "description": "Per-day downtime over a rolling 7-day window" + } + }, + "required": [ + "owner", + "hostUri", + "isAudited", + "createdAt", + "location", + "organization", + "incidents" + ] + } + } + }, + "required": [ + "providers" + ] + } + } + } + }, + "400": { + "description": "Invalid request body" + } + } + } + }, + "/v1/confidential-compute/attestation/validate": { + "post": { + "operationId": "validateConfidentialComputeAttestation", + "summary": "Validate confidential compute attestation evidence against the hardware vendors (AMD SEV-SNP, Intel TDX, NVIDIA)", + "tags": [ + "Confidential Compute" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "nonce": { + "type": "string", + "description": "Per-request freshness challenge sent to the hardware, base64 (64 bytes, AEP-83 §5)" + }, + "report": { + "type": "string", + "description": "CPU attestation report (AMD SEV-SNP or Intel TDX quote), base64", + "example": "BQAAAAAA..." + }, + "tee_platform": { + "type": "string", + "enum": [ + "snp", + "tdx", + "snp-gpu", + "tdx-gpu" + ], + "description": "TEE platform that produced the evidence" + }, + "cert_chain": { + "type": "string", + "default": "", + "description": "CPU vendor cert chain, base64; may be empty (e.g. AMD VCEK fetched from KDS)", + "example": "BQAAAAAA..." + }, + "auxblob": { + "type": "string", + "default": "", + "description": "Platform auxiliary blob (e.g. TDX collateral), base64; may be empty", + "example": "BQAAAAAA..." + }, + "gpu_reports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "device_index": { + "type": "integer", + "minimum": 0, + "description": "GPU device index this report attests", + "example": 0 + }, + "report": { + "type": "string", + "description": "Hardware-signed GPU attestation report (embeds the device cert chain), base64", + "example": "BQAAAAAA..." + } + }, + "required": [ + "device_index", + "report" + ] + }, + "default": [], + "description": "Per-GPU attestation reports; empty for CPU-only platforms" + } + }, + "required": [ + "nonce", + "report", + "tee_platform" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Per-report attestation verdicts", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "overall": { + "type": "string", + "enum": [ + "valid", + "invalid", + "unverifiable" + ], + "description": "Rollup: valid only if every report is valid; invalid if any report is invalid; otherwise unverifiable" + }, + "nonce": { + "type": "string", + "description": "Echo of the request nonce, for client correlation" + }, + "reports": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "cpu" + ] + }, + "vendor": { + "type": "string", + "enum": [ + "amd-sev-snp", + "intel-tdx" + ] + }, + "status": { + "type": "string", + "enum": [ + "valid", + "invalid", + "unverifiable" + ], + "description": "valid = chained to the vendor root, signature/EAT verified, and bound to the request nonce; invalid = a check ran and failed (bad signature, untrusted chain, nonce mismatch); unverifiable = the check could not be completed (vendor service unreachable, not configured, missing material)" + }, + "detail": { + "type": "string", + "description": "Human-readable explanation of the verdict" + }, + "checks": { + "type": "object", + "properties": { + "certChainValid": { + "type": "boolean", + "description": "Evidence chains to the vendor root certificate" + }, + "signatureValid": { + "type": "boolean", + "description": "The report/EAT signature verified" + }, + "nonceMatch": { + "type": "boolean", + "description": "The evidence is bound to the request nonce" + }, + "notRevoked": { + "type": "boolean", + "description": "Revocation status; omitted when revocation was not checked" + } + }, + "description": "Granular sub-results behind the verdict; fields are omitted when not evaluated" + } + }, + "required": [ + "kind", + "vendor", + "status", + "detail" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "gpu" + ] + }, + "device_index": { + "type": "integer", + "minimum": 0 + }, + "vendor": { + "type": "string", + "enum": [ + "nvidia" + ] + }, + "status": { + "type": "string", + "enum": [ + "valid", + "invalid", + "unverifiable" + ], + "description": "valid = chained to the vendor root, signature/EAT verified, and bound to the request nonce; invalid = a check ran and failed (bad signature, untrusted chain, nonce mismatch); unverifiable = the check could not be completed (vendor service unreachable, not configured, missing material)" + }, + "detail": { + "type": "string", + "description": "Human-readable explanation of the verdict" + }, + "checks": { + "type": "object", + "properties": { + "certChainValid": { + "type": "boolean", + "description": "Evidence chains to the vendor root certificate" + }, + "signatureValid": { + "type": "boolean", + "description": "The report/EAT signature verified" + }, + "nonceMatch": { + "type": "boolean", + "description": "The evidence is bound to the request nonce" + }, + "notRevoked": { + "type": "boolean", + "description": "Revocation status; omitted when revocation was not checked" + } + }, + "description": "Granular sub-results behind the verdict; fields are omitted when not evaluated" + } + }, + "required": [ + "kind", + "device_index", + "vendor", + "status", + "detail" + ] + } + ] + } + } + }, + "required": [ + "overall", + "nonce", + "reports" + ] + } + } + } + }, + "400": { + "description": "Invalid request body" + } + } + } + } + }, + "components": { + "schemas": {}, + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JWT token for authenticated users, sent in \"Authorization: Bearer %token\" header." + }, + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "x-api-key", + "description": "API key for programmatic access." + } + } + } +} diff --git a/internal/console/types.go b/internal/console/types.go new file mode 100644 index 00000000..75faa361 --- /dev/null +++ b/internal/console/types.go @@ -0,0 +1,342 @@ +package console + +import "encoding/json" + +// FlexString unmarshals from either a JSON string or a JSON number, since the +// Console API is inconsistent about numeric identifiers (dseq, amounts). +// It always marshals as a JSON string. +type FlexString string + +// String returns the underlying string value. +func (s FlexString) String() string { return string(s) } + +// UnmarshalJSON implements json.Unmarshaler. +func (s *FlexString) UnmarshalJSON(b []byte) error { + if len(b) > 0 && b[0] == '"' { + var v string + if err := json.Unmarshal(b, &v); err != nil { + return err + } + *s = FlexString(v) + return nil + } + + var n json.Number + if err := json.Unmarshal(b, &n); err != nil { + return err + } + *s = FlexString(n.String()) + + return nil +} + +// --- Account / wallet ------------------------------------------------------- + +// User describes the authenticated Console user (GET /v1/user/me). +// ID is the internal UUID required by ListWallets. +type User struct { + ID string `json:"id"` + UserID string `json:"userId"` + Username string `json:"username"` + Email string `json:"email"` + EmailVerified bool `json:"emailVerified"` +} + +// Balances reports managed-wallet balances in µACT integer units +// (GET /v1/balances). ACT is pegged 1:1 to USD, so USD = value / 1e6. +type Balances struct { + Balance int64 `json:"balance"` + Deployments int64 `json:"deployments"` + Total int64 `json:"total"` +} + +// BalanceUSD returns the free balance in USD. +func (b Balances) BalanceUSD() float64 { return float64(b.Balance) / 1e6 } + +// DeploymentsUSD returns the funds locked in deployment escrow in USD. +func (b Balances) DeploymentsUSD() float64 { return float64(b.Deployments) / 1e6 } + +// TotalUSD returns the total balance in USD. +func (b Balances) TotalUSD() float64 { return float64(b.Total) / 1e6 } + +// Wallet describes a managed wallet (GET /v1/wallets?userId=). +type Wallet struct { + Address string `json:"address"` + CreditAmount float64 `json:"creditAmount"` + IsTrialing bool `json:"isTrialing"` + Denom string `json:"denom"` +} + +// WalletSettings holds account-level wallet settings +// (GET/PUT /v1/wallet-settings). +type WalletSettings struct { + AutoReloadEnabled bool `json:"autoReloadEnabled"` +} + +// UsagePoint is one day of spend history (GET /v1/usage/history). +type UsagePoint struct { + Date string `json:"date"` + ActiveDeployments int `json:"activeDeployments"` + DailyUsdcSpent float64 `json:"dailyUsdcSpent"` + TotalUsdcSpent float64 `json:"totalUsdcSpent"` +} + +// --- Deployments ------------------------------------------------------------ + +// DeploymentID identifies a deployment on chain. +type DeploymentID struct { + Owner string `json:"owner"` + DSeq FlexString `json:"dseq"` +} + +// Deployment is the chain-side deployment record embedded in Console +// responses. +type Deployment struct { + ID DeploymentID `json:"id"` + State string `json:"state"` + CreatedAt json.RawMessage `json:"created_at,omitempty"` +} + +// LeaseID identifies a lease (and doubles as a bid ID). +type LeaseID struct { + Owner string `json:"owner"` + DSeq FlexString `json:"dseq"` + GSeq uint32 `json:"gseq"` + OSeq uint32 `json:"oseq"` + Provider string `json:"provider"` +} + +// Price is a denominated amount as reported by the chain. +type Price struct { + Denom string `json:"denom"` + Amount FlexString `json:"amount"` +} + +// LeaseStatus carries provider-reported runtime status. The deep structures +// are provider-defined, so they are kept raw. +type LeaseStatus struct { + Services json.RawMessage `json:"services,omitempty"` + ForwardedPorts json.RawMessage `json:"forwarded_ports,omitempty"` + IPs json.RawMessage `json:"ips,omitempty"` +} + +// Lease describes a lease attached to a deployment. +type Lease struct { + ID LeaseID `json:"id"` + State string `json:"state"` + Price *Price `json:"price,omitempty"` + Status *LeaseStatus `json:"status,omitempty"` +} + +// DeploymentListItem is one entry of GET /v1/deployments. +type DeploymentListItem struct { + Deployment Deployment `json:"deployment"` + Leases []Lease `json:"leases"` +} + +// Pagination describes list paging metadata. +type Pagination struct { + Total int `json:"total"` + Skip int `json:"skip"` + Limit int `json:"limit"` + HasMore bool `json:"hasMore"` +} + +// DeploymentList is the payload of GET /v1/deployments. +type DeploymentList struct { + Deployments []DeploymentListItem `json:"deployments"` + Pagination Pagination `json:"pagination"` +} + +// DeploymentDetail is the payload of GET /v1/deployments/{dseq} (and of the +// update/lease-creation responses). EscrowAccount is kept raw; its state +// carries funds/transferred details. +type DeploymentDetail struct { + Deployment Deployment `json:"deployment"` + Leases []Lease `json:"leases"` + EscrowAccount json.RawMessage `json:"escrow_account,omitempty"` +} + +// SignTx reports the broadcast result of a managed-wallet transaction. +type SignTx struct { + Code int `json:"code"` + TransactionHash string `json:"transactionHash"` + RawLog string `json:"rawLog"` +} + +// CreateDeploymentResult is the payload of POST /v1/deployments. +type CreateDeploymentResult struct { + DSeq FlexString `json:"dseq"` + Manifest string `json:"manifest"` + SignTx *SignTx `json:"signTx,omitempty"` +} + +// Bid is a provider bid on an open order (GET /v1/bids?dseq=). +type Bid struct { + ID LeaseID `json:"id"` + State string `json:"state"` + Price *Price `json:"price,omitempty"` +} + +// LeaseRequest identifies a specific bid to accept (POST /v1/leases). +type LeaseRequest struct { + DSeq string `json:"dseq"` + GSeq uint32 `json:"gseq"` + OSeq uint32 `json:"oseq"` + Provider string `json:"provider"` +} + +// DeploymentSettings holds per-deployment automation settings +// (GET/PATCH /v2/deployment-settings/{dseq}). +type DeploymentSettings struct { + DSeq FlexString `json:"dseq"` + AutoTopUpEnabled bool `json:"autoTopUpEnabled"` + EstimatedTopUpAmount float64 `json:"estimatedTopUpAmount"` + TopUpFrequencyMs int64 `json:"topUpFrequencyMs"` +} + +// --- API keys / auth -------------------------------------------------------- + +// APIKey describes an existing API key (GET /v1/api-keys). The secret is +// never returned after creation. +type APIKey struct { + ID string `json:"id"` + Name string `json:"name"` + ExpiresAt string `json:"expiresAt,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + LastUsedAt string `json:"lastUsedAt,omitempty"` + KeyFormat string `json:"keyFormat,omitempty"` +} + +// CreatedAPIKey is the payload of POST /v1/api-keys. APIKey holds the secret, +// which is shown exactly once at creation time. +type CreatedAPIKey struct { + ID string `json:"id"` + Name string `json:"name"` + APIKey string `json:"apiKey"` +} + +// --- Marketplace / catalog (public) ----------------------------------------- + +// Provider is a provider summary (GET /v1/providers). GPUModels and IPRegion +// are kept raw: their shapes vary between deployments of the API. +type Provider struct { + Owner string `json:"owner"` + Name string `json:"name,omitempty"` + HostURI string `json:"hostUri,omitempty"` + IsOnline bool `json:"isOnline,omitempty"` + IsAudited bool `json:"isAudited,omitempty"` + Uptime1D float64 `json:"uptime1d,omitempty"` + Uptime7D float64 `json:"uptime7d,omitempty"` + Uptime30D float64 `json:"uptime30d,omitempty"` + GPUModels json.RawMessage `json:"gpuModels,omitempty"` + IPRegion json.RawMessage `json:"ipRegion,omitempty"` +} + +// ProviderDetail is the payload of GET /v1/providers/{address}: the typed +// summary fields plus the full raw document (stats etc.) in Raw. +type ProviderDetail struct { + Provider + + // Raw is the complete response body for callers that need fields not + // modeled on Provider. + Raw json.RawMessage `json:"-"` +} + +// ProviderRegion is one entry of GET /v1/provider-regions. +type ProviderRegion struct { + Key string `json:"key"` + Description string `json:"description"` + Providers []string `json:"providers"` +} + +// Auditor is one entry of GET /v1/auditors. +type Auditor struct { + ID string `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + Website string `json:"website,omitempty"` +} + +// GPUPriceRange is the observed price spread for a GPU model, USD per month. +type GPUPriceRange struct { + Min float64 `json:"min"` + Max float64 `json:"max"` + Avg float64 `json:"avg"` +} + +// GPUModel is one entry of the GPU price catalog. +type GPUModel struct { + Vendor string `json:"vendor"` + Model string `json:"model"` + RAM string `json:"ram"` + Price GPUPriceRange `json:"price"` +} + +// GPUAvailability summarizes network-wide GPU counts. +type GPUAvailability struct { + Total int `json:"total"` + Available int `json:"available"` +} + +// GPUPrices is the payload of GET /v1/gpu-prices (not data-enveloped). +type GPUPrices struct { + Availability GPUAvailability `json:"availability"` + Models []GPUModel `json:"models"` +} + +// Template is the payload of GET /v1/templates/{id}. Extra fields beyond the +// common ones are intentionally not modeled. +type Template struct { + ID string `json:"id"` + Name string `json:"name"` + Summary string `json:"summary,omitempty"` + Deploy string `json:"deploy,omitempty"` + Readme string `json:"readme,omitempty"` +} + +// Attribute is a provider attribute requirement key/value pair. +type Attribute struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// SignedBy expresses auditor requirements for bid screening. Empty lists are +// omitted on the wire: the API schema types anyOf/allOf as (non-nullable) +// string arrays, so serializing nil slices as JSON null violates the contract. +type SignedBy struct { + AnyOf []string `json:"anyOf,omitempty"` + AllOf []string `json:"allOf,omitempty"` +} + +// BidScreeningRequirements narrows the provider set for bid screening. An +// empty Attributes list is omitted on the wire (the API schema types it as a +// non-nullable array, so JSON null is rejected). +type BidScreeningRequirements struct { + SignedBy SignedBy `json:"signedBy"` + Attributes []Attribute `json:"attributes,omitempty"` +} + +// BidScreeningRequest is the body of POST /v1/bid-screening (not +// data-enveloped). Resources and ReclamationWindow are passed through raw so +// callers can supply whatever shape the SDL produced. +// +// The API requires Resources and Timezone; ScreenBids rejects requests +// missing either before hitting the network. Timezone must be an IANA zone +// name (e.g. "America/Chicago"). +type BidScreeningRequest struct { + Requirements BidScreeningRequirements `json:"requirements"` + Resources json.RawMessage `json:"resources,omitempty"` + Timezone string `json:"timezone,omitempty"` + ReclamationWindow json.RawMessage `json:"reclamationWindow,omitempty"` +} + +// ScreenedProvider is one provider returned by bid screening. Location is +// kept raw (object or string depending on API version). +type ScreenedProvider struct { + Owner string `json:"owner"` + HostURI string `json:"hostUri,omitempty"` + IsAudited bool `json:"isAudited,omitempty"` + Location json.RawMessage `json:"location,omitempty"` + Organization string `json:"organization,omitempty"` +} diff --git a/internal/console/wallet.go b/internal/console/wallet.go new file mode 100644 index 00000000..001a726c --- /dev/null +++ b/internal/console/wallet.go @@ -0,0 +1,128 @@ +package console + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" +) + +// GetUser fetches the authenticated Console user. The returned User.ID is the +// internal UUID needed by ListWallets. +// +// Wire: GET /v1/user/me, data-enveloped response. +func (c *Client) GetUser(ctx context.Context) (*User, error) { + var out User + if err := c.doData(ctx, http.MethodGet, "/v1/user/me", nil, &out); err != nil { + return nil, err + } + + return &out, nil +} + +// GetBalances fetches managed-wallet balances in µACT integer units. Use the +// *USD helpers on Balances for display values. +// +// Wire: GET /v1/balances, data-enveloped response. +func (c *Client) GetBalances(ctx context.Context) (*Balances, error) { + var out Balances + if err := c.doData(ctx, http.MethodGet, "/v1/balances", nil, &out); err != nil { + return nil, err + } + + return &out, nil +} + +// ListWallets lists managed wallets for a user. userID is the internal UUID +// from GetUser().ID, not the userId field. +// +// Wire: GET /v1/wallets?userId=, response {"data":[...]}. +func (c *Client) ListWallets(ctx context.Context, userID string) ([]Wallet, error) { + path := "/v1/wallets?userId=" + url.QueryEscape(userID) + + var out []Wallet + if err := c.doData(ctx, http.MethodGet, path, nil, &out); err != nil { + return nil, err + } + + return out, nil +} + +// GetWalletSettings fetches account-level wallet settings. +// +// Wire: GET /v1/wallet-settings, data-enveloped response. +func (c *Client) GetWalletSettings(ctx context.Context) (*WalletSettings, error) { + var out WalletSettings + if err := c.doData(ctx, http.MethodGet, "/v1/wallet-settings", nil, &out); err != nil { + return nil, err + } + + return &out, nil +} + +// UpdateWalletSettings enables or disables wallet auto-reload and returns the +// stored settings. +// +// Wire: PUT /v1/wallet-settings, body {"data":{"autoReloadEnabled":...}}, +// data-enveloped response. +func (c *Client) UpdateWalletSettings(ctx context.Context, autoReloadEnabled bool) (*WalletSettings, error) { + body := envelope(map[string]any{"autoReloadEnabled": autoReloadEnabled}) + + var out WalletSettings + err := c.doData(ctx, http.MethodPut, "/v1/wallet-settings", body, &out) + c.record("update-wallet-settings", "", err) + if err != nil { + return nil, err + } + + return &out, nil +} + +// GetWeeklyCost fetches the trailing weekly spend in USD. +// +// Wire: GET /v1/weekly-cost, response {"data":{"weeklyCost":...}}. +func (c *Client) GetWeeklyCost(ctx context.Context) (float64, error) { + var out struct { + WeeklyCost float64 `json:"weeklyCost"` + } + if err := c.doData(ctx, http.MethodGet, "/v1/weekly-cost", nil, &out); err != nil { + return 0, err + } + + return out.WeeklyCost, nil +} + +// GetUsageHistory fetches daily spend history for an address between +// startDate and endDate (YYYY-MM-DD). Empty dates are omitted — the API +// then defaults endDate to today and startDate to 30 days before endDate; +// sending an empty string fails the API's format=date validation. +// +// Wire: GET /v1/usage/history?address=&startDate=&endDate=. NOTE: the +// response is a TOP-LEVEL array, not data-enveloped. +func (c *Client) GetUsageHistory(ctx context.Context, address, startDate, endDate string) ([]UsagePoint, error) { + for _, d := range []string{startDate, endDate} { + if d == "" { + continue + } + if _, err := time.Parse("2006-01-02", d); err != nil { + return nil, fmt.Errorf("console: invalid date %q: expected YYYY-MM-DD", d) + } + } + + q := url.Values{} + q.Set("address", address) + if startDate != "" { + q.Set("startDate", startDate) + } + if endDate != "" { + q.Set("endDate", endDate) + } + + var out []UsagePoint + if err := c.doJSON(ctx, http.MethodGet, "/v1/usage/history?"+q.Encode(), nil, &out); err != nil { + return nil, err + } + + return out, nil +} diff --git a/internal/console/wallet_test.go b/internal/console/wallet_test.go new file mode 100644 index 00000000..9101cc21 --- /dev/null +++ b/internal/console/wallet_test.go @@ -0,0 +1,158 @@ +package console_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "pkg.akt.dev/akt/internal/console" +) + +func TestGetUser(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v1/user/me", r.URL.Path) + + _, _ = w.Write([]byte(`{"data":{"id":"uuid-internal","userId":"auth0|abc","username":"alice","email":"a@example.com","emailVerified":true}}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + u, err := c.GetUser(context.Background()) + require.NoError(t, err) + assert.Equal(t, "uuid-internal", u.ID) + assert.Equal(t, "auth0|abc", u.UserID) + assert.True(t, u.EmailVerified) +} + +func TestListWallets(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/wallets", r.URL.Path) + assert.Equal(t, "uuid-internal", r.URL.Query().Get("userId")) + + _, _ = w.Write([]byte(`{"data":[{"address":"akash1abc","creditAmount":25.5,"isTrialing":true,"denom":"usdc"}]}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + ws, err := c.ListWallets(context.Background(), "uuid-internal") + require.NoError(t, err) + require.Len(t, ws, 1) + assert.Equal(t, "akash1abc", ws[0].Address) + assert.InDelta(t, 25.5, ws[0].CreditAmount, 0.001) + assert.True(t, ws[0].IsTrialing) +} + +func TestWalletSettingsRoundTrip(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/wallet-settings", r.URL.Path) + + switch r.Method { + case http.MethodGet: + _, _ = w.Write([]byte(`{"data":{"autoReloadEnabled":false}}`)) + + case http.MethodPut: + data := decodeDataBody(t, r) + assert.Equal(t, true, data["autoReloadEnabled"]) + _, _ = w.Write([]byte(`{"data":{"autoReloadEnabled":true}}`)) + + default: + t.Errorf("unexpected method %s", r.Method) + } + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + + s, err := c.GetWalletSettings(context.Background()) + require.NoError(t, err) + assert.False(t, s.AutoReloadEnabled) + + s, err = c.UpdateWalletSettings(context.Background(), true) + require.NoError(t, err) + assert.True(t, s.AutoReloadEnabled) +} + +func TestGetWeeklyCost(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/weekly-cost", r.URL.Path) + _, _ = w.Write([]byte(`{"data":{"weeklyCost":13.37}}`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + cost, err := c.GetWeeklyCost(context.Background()) + require.NoError(t, err) + assert.InDelta(t, 13.37, cost, 0.001) +} + +func TestGetUsageHistoryTopLevelArray(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/usage/history", r.URL.Path) + q := r.URL.Query() + assert.Equal(t, "akash1abc", q.Get("address")) + assert.Equal(t, "2026-07-01", q.Get("startDate")) + assert.Equal(t, "2026-07-21", q.Get("endDate")) + + // Top-level array, NOT data-enveloped. + _, _ = w.Write([]byte(`[ + {"date":"2026-07-01","activeDeployments":2,"dailyUsdcSpent":1.25,"totalUsdcSpent":10.0}, + {"date":"2026-07-02","activeDeployments":3,"dailyUsdcSpent":2.5,"totalUsdcSpent":12.5} + ]`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "test-key") + points, err := c.GetUsageHistory(context.Background(), "akash1abc", "2026-07-01", "2026-07-21") + require.NoError(t, err) + require.Len(t, points, 2) + assert.Equal(t, "2026-07-01", points[0].Date) + assert.Equal(t, 3, points[1].ActiveDeployments) + assert.InDelta(t, 12.5, points[1].TotalUsdcSpent, 0.001) +} + +func TestGetUsageHistoryOmitsEmptyDates(t *testing.T) { + var gotQuery url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "k") + + if _, err := c.GetUsageHistory(context.Background(), "akash1abc", "", ""); err != nil { + t.Fatalf("GetUsageHistory: %v", err) + } + + if gotQuery.Get("address") != "akash1abc" { + t.Errorf("address missing from query: %v", gotQuery) + } + // Empty dates must be omitted entirely — the API rejects empty strings + // with a format=date validation error and defaults omitted values. + if _, present := gotQuery["startDate"]; present { + t.Errorf("empty startDate must be omitted, got %v", gotQuery) + } + if _, present := gotQuery["endDate"]; present { + t.Errorf("empty endDate must be omitted, got %v", gotQuery) + } +} + +func TestGetUsageHistoryValidatesDateFormat(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("no request should be sent for an invalid date") + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + c := console.New(srv.URL, "k") + + if _, err := c.GetUsageHistory(context.Background(), "akash1abc", "01/02/2026", ""); err == nil { + t.Fatal("expected invalid date format to be rejected client-side") + } +} diff --git a/internal/context/config.go b/internal/context/config.go index d9dea5db..8f14605d 100644 --- a/internal/context/config.go +++ b/internal/context/config.go @@ -2,6 +2,7 @@ package context import ( "bytes" + "errors" "fmt" "os" "path/filepath" @@ -97,7 +98,8 @@ func NewViper(root string) (*viper.Viper, error) { v.SetDefault("current-context", "") if err := v.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + var notFound viper.ConfigFileNotFoundError + if !errors.As(err, ¬Found) { // If the file doesn't exist that's fine (fresh setup). // Any other error (parse error, permission error) is real. if !os.IsNotExist(err) { @@ -167,7 +169,9 @@ func SaveConfig(root string, cfg *Config) error { if err != nil { return fmt.Errorf("create config %s: %w", path, err) } - defer f.Close() + // Closed explicitly below so a failed flush is reported; the deferred + // close only covers the error paths. + defer func() { _ = f.Close() }() var buf bytes.Buffer @@ -197,11 +201,24 @@ func SaveConfig(root string, cfg *Config) error { return fmt.Errorf("write config: %w", err) } + // A write can still fail at close time (flush to disk); reporting it here + // is the difference between a silently truncated config and an error. + if err := f.Close(); err != nil { + return fmt.Errorf("write config %s: %w", path, err) + } + return nil } -// EnsureContextDirs creates the store and action log directories for a context. +// EnsureContextDirs creates the store and action log directories for a +// context. The context directory is 0700: it holds per-context secrets (the +// Console API credential) alongside the store and action log. func EnsureContextDirs(root, name string) error { + ctxDir := ContextDir(root, name) + if err := os.MkdirAll(ctxDir, 0o700); err != nil { + return fmt.Errorf("create context directory %s: %w", ctxDir, err) + } + storeDir := StoreDir(root, name) if err := os.MkdirAll(storeDir, 0o755); err != nil { return fmt.Errorf("create store directory %s: %w", storeDir, err) diff --git a/internal/context/credentials.go b/internal/context/credentials.go new file mode 100644 index 00000000..d7a335f4 --- /dev/null +++ b/internal/context/credentials.go @@ -0,0 +1,92 @@ +package context + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Authentication methods a context can use (SPEC §1.4, §7). +const ( + AuthMethodKeyring = "keyring" + AuthMethodConsoleAPI = "console-api" +) + +// DefaultConsoleAPIURL is the default Console API base URL (SPEC §7.2). +const DefaultConsoleAPIURL = "https://console-api.akash.network" + +// EnvConsoleAPIKey is the environment variable holding the Console API key. +// It overrides the per-context stored credential (SPEC §7.1). +const EnvConsoleAPIKey = "AKT_CONSOLE_API_KEY" + +// consoleAPIKeyFile is the per-context credential file name. +const consoleAPIKeyFile = "console-api-key" + +// ConsoleAPIKeyPath returns the path of a context's stored Console API key. +// The credential lives inside the context's data directory so renames move +// it and deletes remove it (SPEC §7.1). +func ConsoleAPIKeyPath(root, ctxName string) string { + return filepath.Join(ContextDir(root, ctxName), consoleAPIKeyFile) +} + +// SetConsoleAPIKey stores (or, with an empty key, removes) a context's +// Console API key. The file is written with 0600 permissions and is never +// part of config.yaml. +func SetConsoleAPIKey(root, ctxName, key string) error { + path := ConsoleAPIKeyPath(root, ctxName) + + if key == "" { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove console api key: %w", err) + } + + return nil + } + + // 0700: the directory holds the Console credential, so it must not be + // world- or group-readable even though the file itself is 0600. + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create context directory: %w", err) + } + + if err := os.WriteFile(path, []byte(key+"\n"), 0o600); err != nil { + return fmt.Errorf("write console api key: %w", err) + } + + // WriteFile does not change the mode of an existing file. + if err := os.Chmod(path, 0o600); err != nil { + return fmt.Errorf("restrict console api key permissions: %w", err) + } + + return nil +} + +// StoredConsoleAPIKey reads a context's stored Console API key. A missing +// credential file returns an empty key with no error. +func StoredConsoleAPIKey(root, ctxName string) (string, error) { + data, err := os.ReadFile(ConsoleAPIKeyPath(root, ctxName)) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + + return "", fmt.Errorf("read console api key: %w", err) + } + + return strings.TrimSpace(string(data)), nil +} + +// ResolveConsoleAPIKey resolves the effective Console API key for a context +// per SPEC §7.1: environment variable first, then the stored per-context +// credential. The --console-api-key flag override is applied by the CLI +// layer on top of this. +func ResolveConsoleAPIKey(root, ctxName string) string { + if key := os.Getenv(EnvConsoleAPIKey); key != "" { + return key + } + + key, _ := StoredConsoleAPIKey(root, ctxName) + + return key +} diff --git a/internal/context/credentials_perm_test.go b/internal/context/credentials_perm_test.go new file mode 100644 index 00000000..5a3f3a19 --- /dev/null +++ b/internal/context/credentials_perm_test.go @@ -0,0 +1,178 @@ +package context_test + +import ( + "os" + "path/filepath" + "testing" + + aktctx "pkg.akt.dev/akt/internal/context" +) + +// TestSetConsoleAPIKeyTightensExistingPermissions covers the explicit Chmod +// after WriteFile. os.WriteFile does NOT change the mode of a file that +// already exists, so a credential file left world-readable by an earlier akt +// version (or by a user's editor) would silently stay that way across a key +// rotation. This is the branch that fixes it. +func TestSetConsoleAPIKeyTightensExistingPermissions(t *testing.T) { + root := t.TempDir() + path := aktctx.ConsoleAPIKeyPath(root, "prod") + + // Pre-create a world-readable credential file, as a pre-hardening akt or a + // careless editor would leave behind. + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte("sk-old\n"), 0o644); err != nil { + t.Fatalf("seed credential: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if info.Mode().Perm() != 0o644 { + t.Skipf("filesystem does not honor 0644 (got %o); permission test not meaningful", info.Mode().Perm()) + } + + if err := aktctx.SetConsoleAPIKey(root, "prod", "sk-new"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + info, err = os.Stat(path) + if err != nil { + t.Fatalf("stat after write: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("mode after rewrite = %o, want 600 (an existing loose mode must be tightened)", perm) + } + + if got, _ := aktctx.StoredConsoleAPIKey(root, "prod"); got != "sk-new" { + t.Errorf("stored key = %q, want sk-new", got) + } +} + +// TestSetConsoleAPIKeyCreatesRestrictedDirectory covers the MkdirAll branch for +// a context whose directory does not exist yet: the credential's parent must +// be created 0700, not with the process umask default. +func TestSetConsoleAPIKeyCreatesRestrictedDirectory(t *testing.T) { + root := t.TempDir() + + if err := aktctx.SetConsoleAPIKey(root, "fresh", "sk-1"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + info, err := os.Stat(filepath.Dir(aktctx.ConsoleAPIKeyPath(root, "fresh"))) + if err != nil { + t.Fatalf("stat context dir: %v", err) + } + if perm := info.Mode().Perm(); perm&0o077 != 0 { + t.Errorf("context directory mode = %o, want no group/other access", perm) + } +} + +// TestRemoveMissingConsoleAPIKeyIsNoError covers the os.IsNotExist arm of the +// removal branch. `akt console logout` on a context that never had a key must +// succeed, so logout is safe to script. +func TestRemoveMissingConsoleAPIKeyIsNoError(t *testing.T) { + root := t.TempDir() + + if err := aktctx.SetConsoleAPIKey(root, "never-had-one", ""); err != nil { + t.Errorf("removing an absent credential must be a no-op, got %v", err) + } +} + +// TestStoredConsoleAPIKeySurfacesRealReadErrors covers the non-IsNotExist arm +// of the read: a credential path that exists but cannot be read as a file +// (here: it is a directory) must be an error, not an empty key that silently +// downgrades the caller to "unauthenticated". +func TestStoredConsoleAPIKeySurfacesRealReadErrors(t *testing.T) { + root := t.TempDir() + path := aktctx.ConsoleAPIKeyPath(root, "weird") + + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatalf("mkdir credential-as-directory: %v", err) + } + + key, err := aktctx.StoredConsoleAPIKey(root, "weird") + if err == nil { + t.Fatal("an unreadable credential path must be an error, not an empty key") + } + if key != "" { + t.Errorf("key = %q, want empty on error", key) + } +} + +// TestStoredConsoleAPIKeyTrimsWhitespace pins the trim: keys are written with a +// trailing newline, and an untrimmed value would be sent in the x-api-key +// header verbatim and rejected as invalid. +func TestStoredConsoleAPIKeyTrimsWhitespace(t *testing.T) { + root := t.TempDir() + path := aktctx.ConsoleAPIKeyPath(root, "prod") + + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(" sk-padded \n\n"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + got, err := aktctx.StoredConsoleAPIKey(root, "prod") + if err != nil { + t.Fatalf("StoredConsoleAPIKey: %v", err) + } + if got != "sk-padded" { + t.Errorf("key = %q, want the trimmed value", got) + } +} + +// TestResolveConsoleAPIKeyPrecedence covers the documented resolution order +// (SPEC §7.1) at the function that implements it: the environment variable +// wins, then the stored credential, then nothing. +func TestResolveConsoleAPIKeyPrecedence(t *testing.T) { + root := t.TempDir() + + t.Setenv(aktctx.EnvConsoleAPIKey, "") + + if got := aktctx.ResolveConsoleAPIKey(root, "prod"); got != "" { + t.Errorf("with nothing configured: %q, want empty", got) + } + + if err := aktctx.SetConsoleAPIKey(root, "prod", "sk-stored"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + if got := aktctx.ResolveConsoleAPIKey(root, "prod"); got != "sk-stored" { + t.Errorf("with only a stored key: %q, want sk-stored", got) + } + + t.Setenv(aktctx.EnvConsoleAPIKey, "sk-env") + if got := aktctx.ResolveConsoleAPIKey(root, "prod"); got != "sk-env" { + t.Errorf("with the env var set: %q, want sk-env", got) + } + + // Another context's stored key must never leak into this one. + if got := aktctx.ResolveConsoleAPIKey(root, "other"); got != "sk-env" { + t.Errorf("env var applies to any context: %q, want sk-env", got) + } + t.Setenv(aktctx.EnvConsoleAPIKey, "") + if got := aktctx.ResolveConsoleAPIKey(root, "other"); got != "" { + t.Errorf("a different context must not see prod's key, got %q", got) + } +} + +// TestConsoleAPIKeyPathStaysInsideContextDir pins the placement decision that +// makes rename and delete work: the credential lives inside the context's own +// directory, so moving or removing that directory takes the key with it. +func TestConsoleAPIKeyPathStaysInsideContextDir(t *testing.T) { + root := t.TempDir() + + path := aktctx.ConsoleAPIKeyPath(root, "prod") + dir := aktctx.ContextDir(root, "prod") + + rel, err := filepath.Rel(dir, path) + if err != nil { + t.Fatalf("Rel: %v", err) + } + if rel != "console-api-key" { + t.Errorf("credential path %q is not directly inside the context dir %q (rel=%q)", path, dir, rel) + } +} diff --git a/internal/context/credentials_test.go b/internal/context/credentials_test.go new file mode 100644 index 00000000..2248d368 --- /dev/null +++ b/internal/context/credentials_test.go @@ -0,0 +1,304 @@ +package context_test + +import ( + "os" + "path/filepath" + "testing" + + aktctx "pkg.akt.dev/akt/internal/context" +) + +func newCredentialManager(t *testing.T) *aktctx.Manager { + t.Helper() + + m, err := aktctx.NewManager(t.TempDir()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + if err := m.CreateNetworkFromTemplate("mainnet", "mainnet"); err != nil { + t.Fatalf("CreateNetworkFromTemplate: %v", err) + } + + return m +} + +func TestConsoleAPIKeyStoreRoundTrip(t *testing.T) { + root := t.TempDir() + + if err := aktctx.SetConsoleAPIKey(root, "prod", "sk-test-123"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + key, err := aktctx.StoredConsoleAPIKey(root, "prod") + if err != nil { + t.Fatalf("StoredConsoleAPIKey: %v", err) + } + if key != "sk-test-123" { + t.Errorf("key = %q, want sk-test-123", key) + } + + info, err := os.Stat(aktctx.ConsoleAPIKeyPath(root, "prod")) + if err != nil { + t.Fatalf("stat credential: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("credential file mode = %o, want 600", perm) + } + + // Empty key removes the credential. + if err := aktctx.SetConsoleAPIKey(root, "prod", ""); err != nil { + t.Fatalf("SetConsoleAPIKey(remove): %v", err) + } + + key, err = aktctx.StoredConsoleAPIKey(root, "prod") + if err != nil { + t.Fatalf("StoredConsoleAPIKey after remove: %v", err) + } + if key != "" { + t.Errorf("key after removal = %q, want empty", key) + } +} + +func TestConsoleAPIKeysAreIndependentPerContext(t *testing.T) { + root := t.TempDir() + + if err := aktctx.SetConsoleAPIKey(root, "prod", "key-prod"); err != nil { + t.Fatalf("set prod: %v", err) + } + if err := aktctx.SetConsoleAPIKey(root, "staging", "key-staging"); err != nil { + t.Fatalf("set staging: %v", err) + } + + prodKey, _ := aktctx.StoredConsoleAPIKey(root, "prod") + stagingKey, _ := aktctx.StoredConsoleAPIKey(root, "staging") + + if prodKey != "key-prod" || stagingKey != "key-staging" { + t.Errorf("keys not independent: prod=%q staging=%q", prodKey, stagingKey) + } +} + +func TestResolvePopulatesConsoleCredential(t *testing.T) { + m := newCredentialManager(t) + + if err := m.CreateContext(aktctx.Context{ + Name: "console", + Network: aktctx.Network{Name: "mainnet"}, + AuthMethod: aktctx.AuthMethodConsoleAPI, + }); err != nil { + t.Fatalf("CreateContext: %v", err) + } + + if err := aktctx.SetConsoleAPIKey(m.Root(), "console", "sk-stored"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + rc, err := m.Resolve("console") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + if rc.AuthMethod != aktctx.AuthMethodConsoleAPI { + t.Errorf("auth method = %q, want console-api", rc.AuthMethod) + } + if rc.ConsoleAPIURL != aktctx.DefaultConsoleAPIURL { + t.Errorf("console url = %q, want default %q", rc.ConsoleAPIURL, aktctx.DefaultConsoleAPIURL) + } + if rc.ConsoleAPIKey != "sk-stored" { + t.Errorf("console key = %q, want sk-stored", rc.ConsoleAPIKey) + } +} + +func TestResolveEnvOverridesStoredKey(t *testing.T) { + m := newCredentialManager(t) + + if err := m.CreateContext(aktctx.Context{ + Name: "console", + Network: aktctx.Network{Name: "mainnet"}, + AuthMethod: aktctx.AuthMethodConsoleAPI, + }); err != nil { + t.Fatalf("CreateContext: %v", err) + } + + if err := aktctx.SetConsoleAPIKey(m.Root(), "console", "sk-stored"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + t.Setenv(aktctx.EnvConsoleAPIKey, "sk-env") + + rc, err := m.Resolve("console") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if rc.ConsoleAPIKey != "sk-env" { + t.Errorf("console key = %q, want env override sk-env", rc.ConsoleAPIKey) + } +} + +func TestResolveDefaultsToKeyringAuth(t *testing.T) { + m := newCredentialManager(t) + + if err := m.CreateContext(aktctx.Context{ + Name: "prod", + Network: aktctx.Network{Name: "mainnet"}, + }); err != nil { + t.Fatalf("CreateContext: %v", err) + } + + rc, err := m.Resolve("prod") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if rc.AuthMethod != aktctx.AuthMethodKeyring { + t.Errorf("auth method = %q, want keyring default", rc.AuthMethod) + } +} + +func TestCreateContextRejectsInvalidAuthMethod(t *testing.T) { + m := newCredentialManager(t) + + err := m.CreateContext(aktctx.Context{ + Name: "bad", + Network: aktctx.Network{Name: "mainnet"}, + AuthMethod: "carrier-pigeon", + }) + if err == nil { + t.Fatal("expected invalid auth-method to be rejected") + } +} + +func TestAuthMethodRoundTripsThroughConfig(t *testing.T) { + m := newCredentialManager(t) + + if err := m.CreateContext(aktctx.Context{ + Name: "console", + Network: aktctx.Network{Name: "mainnet"}, + AuthMethod: aktctx.AuthMethodConsoleAPI, + ConsoleAPIURL: "https://console-api.example.com", + }); err != nil { + t.Fatalf("CreateContext: %v", err) + } + + // Reload from disk and verify the fields persisted. + m2, err := aktctx.NewManager(m.Root()) + if err != nil { + t.Fatalf("reload manager: %v", err) + } + + ctx := m2.GetContext("console") + if ctx == nil { + t.Fatal("context not found after reload") + } + if ctx.AuthMethod != aktctx.AuthMethodConsoleAPI { + t.Errorf("auth method after reload = %q, want console-api", ctx.AuthMethod) + } + if ctx.ConsoleAPIURL != "https://console-api.example.com" { + t.Errorf("console url after reload = %q", ctx.ConsoleAPIURL) + } +} + +func TestActiveContextResolution(t *testing.T) { + m := newCredentialManager(t) + + if got := m.ActiveContext(""); got != "" { + t.Errorf("no contexts: ActiveContext = %q, want empty", got) + } + + if err := m.CreateContext(aktctx.Context{Name: "only", Network: aktctx.Network{Name: "mainnet"}}); err != nil { + t.Fatalf("CreateContext: %v", err) + } + + // A single configured context is auto-selected even with no current-context. + if got := m.ActiveContext(""); got != "only" { + t.Errorf("single context: ActiveContext = %q, want only", got) + } + + if err := m.CreateContext(aktctx.Context{Name: "second", Network: aktctx.Network{Name: "mainnet"}}); err != nil { + t.Fatalf("CreateContext: %v", err) + } + + if got := m.ActiveContext(""); got != "" { + t.Errorf("two contexts, none current: ActiveContext = %q, want empty", got) + } + + if err := m.UseContext("second"); err != nil { + t.Fatalf("UseContext: %v", err) + } + if got := m.ActiveContext(""); got != "second" { + t.Errorf("current-context: ActiveContext = %q, want second", got) + } + + // The explicit override always wins. + if got := m.ActiveContext("only"); got != "only" { + t.Errorf("override: ActiveContext = %q, want only", got) + } +} + +func TestUpdateNetworklessConsoleContext(t *testing.T) { + m := newCredentialManager(t) + + if err := m.CreateContext(aktctx.Context{ + Name: "console", + AuthMethod: aktctx.AuthMethodConsoleAPI, + }); err != nil { + t.Fatalf("CreateContext: %v", err) + } + + // A network-less console context must remain editable — the remedy + // printed by capability errors is exactly `akt context edit ...`. + if err := m.UpdateContext("console", func(c *aktctx.Context) error { + c.ConsoleAPIURL = "https://console-api.example.com" + return nil + }); err != nil { + t.Fatalf("UpdateContext on a network-less console context: %v", err) + } + + if got := m.GetContext("console").ConsoleAPIURL; got != "https://console-api.example.com" { + t.Errorf("edit not persisted: %q", got) + } + + // A bogus network reference is still rejected. + if err := m.UpdateContext("console", func(c *aktctx.Context) error { + c.Network = aktctx.Network{Name: "nope"} + return nil + }); err == nil { + t.Error("unknown network must still be rejected") + } +} + +func TestCredentialDirectoryIsNotWorldReadable(t *testing.T) { + root := t.TempDir() + + if err := aktctx.SetConsoleAPIKey(root, "prod", "sk-secret"); err != nil { + t.Fatalf("SetConsoleAPIKey: %v", err) + } + + // The file is 0600, but a world-readable parent directory still leaks + // its presence and lets a local user replace it. + dir := filepath.Dir(aktctx.ConsoleAPIKeyPath(root, "prod")) + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat credential directory: %v", err) + } + if perm := info.Mode().Perm(); perm&0o077 != 0 { + t.Errorf("credential directory mode = %o, want no group/other access", perm) + } +} + +func TestContextDirectoriesAreNotWorldReadable(t *testing.T) { + m := newCredentialManager(t) + + // EnsureContextDirs runs on create; the context directory holds the + // credential alongside the store and action log. + if err := m.CreateContext(aktctx.Context{Name: "prod", Network: aktctx.Network{Name: "mainnet"}}); err != nil { + t.Fatalf("CreateContext: %v", err) + } + + info, err := os.Stat(aktctx.ContextDir(m.Root(), "prod")) + if err != nil { + t.Fatalf("stat context directory: %v", err) + } + if perm := info.Mode().Perm(); perm&0o077 != 0 { + t.Errorf("context directory mode = %o, want no group/other access", perm) + } +} diff --git a/internal/context/manager.go b/internal/context/manager.go index 780fee71..8d70260e 100644 --- a/internal/context/manager.go +++ b/internal/context/manager.go @@ -239,6 +239,25 @@ func (m *Manager) CurrentContext() string { return m.cfg.CurrentContext } +// ActiveContext resolves which context a run targets: the explicit override +// (the global --context flag), else current-context, else the sole +// configured context. Returns "" when no single context can be determined. +func (m *Manager) ActiveContext(override string) string { + if override != "" { + return override + } + + if current := m.CurrentContext(); current != "" { + return current + } + + if contexts := m.ListContexts(); len(contexts) == 1 { + return contexts[0].Name + } + + return "" +} + // CreateContext adds a new context. The referenced network and keyring must exist. func (m *Manager) CreateContext(ctx Context) error { m.mu.Lock() @@ -252,7 +271,14 @@ func (m *Manager) CreateContext(ctx Context) error { return fmt.Errorf("context %q already exists", ctx.Name) } - if m.getNetwork(ctx.Network.Name) == nil { + // console-api contexts may omit the network entirely: they operate + // through the Console API alone and gain chain access only when a + // network is attached later. + if ctx.Network.Name == "" { + if ctx.AuthMethod != AuthMethodConsoleAPI { + return fmt.Errorf("a network is required unless auth-method is %q", AuthMethodConsoleAPI) + } + } else if m.getNetwork(ctx.Network.Name) == nil { return fmt.Errorf("network %q not found", ctx.Network.Name) } @@ -272,6 +298,12 @@ func (m *Manager) CreateContext(ctx Context) error { ctx.ProviderDefaults.AuthType = "jwt" } + switch ctx.AuthMethod { + case "", AuthMethodKeyring, AuthMethodConsoleAPI: + default: + return fmt.Errorf("invalid auth-method %q: must be %q or %q", ctx.AuthMethod, AuthMethodKeyring, AuthMethodConsoleAPI) + } + m.cfg.Contexts = append(m.cfg.Contexts, ctx) // Create data directories for the new context. @@ -311,7 +343,10 @@ func (m *Manager) UpdateContext(name string, apply func(*Context) error) error { } // Validate references after mutation. - if m.getNetwork(ctx.Network.Name) == nil { + // console-api contexts may be network-less (CreateContext permits it); + // requiring a network here would make them uneditable — including by the + // `akt context edit ... --console-api-key` remedy other errors suggest. + if ctx.Network.Name != "" && m.getNetwork(ctx.Network.Name) == nil { return fmt.Errorf("network %q not found", ctx.Network.Name) } @@ -460,9 +495,14 @@ func (m *Manager) Resolve(name string) (*Context, error) { return nil, fmt.Errorf("context %q not found", name) } - net := m.getNetwork(ctx.Network.Name) - if net == nil { - return nil, fmt.Errorf("network %q (referenced by context %q) not found", ctx.Network.Name, name) + // Network-less console-api contexts resolve with an empty network; + // capability gating then disables chain-backed commands. + net := &Network{} + if ctx.Network.Name != "" { + net = m.getNetwork(ctx.Network.Name) + if net == nil { + return nil, fmt.Errorf("network %q (referenced by context %q) not found", ctx.Network.Name, name) + } } kr := m.getKeyring(ctx.Keyring.Name) @@ -470,10 +510,22 @@ func (m *Manager) Resolve(name string) (*Context, error) { return nil, fmt.Errorf("keyring %q (referenced by context %q) not found", ctx.Keyring.Name, name) } + authMethod := ctx.AuthMethod + if authMethod == "" { + authMethod = AuthMethodKeyring + } + + consoleURL := ctx.ConsoleAPIURL + if consoleURL == "" { + consoleURL = DefaultConsoleAPIURL + } + return &Context{ Name: ctx.Name, Network: *net, Keyring: *kr, + AuthMethod: authMethod, + ConsoleAPIURL: consoleURL, DefaultAccount: ctx.DefaultAccount, Gas: ctx.Gas, Fees: ctx.Fees, @@ -482,5 +534,6 @@ func (m *Manager) Resolve(name string) (*Context, error) { GasAdjustment: net.GasAdjustment, AuthType: ctx.ProviderDefaults.AuthType, Root: m.root, + ConsoleAPIKey: ResolveConsoleAPIKey(m.root, ctx.Name), }, nil } diff --git a/internal/context/paths_test.go b/internal/context/paths_test.go new file mode 100644 index 00000000..789d9d26 --- /dev/null +++ b/internal/context/paths_test.go @@ -0,0 +1,251 @@ +package context_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + aktctx "pkg.akt.dev/akt/internal/context" +) + +// TestConfigHomeResolutionOrder pins the documented precedence for the akt +// home. Getting it wrong sends every context (and every stored credential) to +// a different directory than the user's existing configuration. +func TestConfigHomeResolutionOrder(t *testing.T) { + t.Setenv("AKT_HOME", "") + t.Setenv("XDG_CONFIG_HOME", "") + + // 1. The --home override wins over everything. + t.Setenv("AKT_HOME", "/env/akt") + t.Setenv("XDG_CONFIG_HOME", "/xdg") + + got, err := aktctx.ConfigHome("/override") + if err != nil { + t.Fatalf("ConfigHome: %v", err) + } + if got != "/override" { + t.Errorf("override home = %q, want /override", got) + } + + // 2. AKT_HOME beats XDG_CONFIG_HOME. + if got, err = aktctx.ConfigHome(""); err != nil || got != "/env/akt" { + t.Errorf("AKT_HOME home = (%q, %v), want /env/akt", got, err) + } + + // 3. XDG_CONFIG_HOME/akt when AKT_HOME is unset. + t.Setenv("AKT_HOME", "") + if got, err = aktctx.ConfigHome(""); err != nil || got != filepath.Join("/xdg", "akt") { + t.Errorf("XDG home = (%q, %v), want /xdg/akt", got, err) + } + + // 4. ~/.config/akt as the last resort. + t.Setenv("XDG_CONFIG_HOME", "") + got, err = aktctx.ConfigHome("") + if err != nil { + t.Fatalf("ConfigHome: %v", err) + } + if !strings.HasSuffix(got, filepath.Join(".config", "akt")) { + t.Errorf("fallback home = %q, want a ~/.config/akt path", got) + } +} + +// TestPathHelpersAreRootedAndDistinct pins the on-disk layout the rename and +// delete paths depend on: everything belonging to a context lives under that +// context's directory, so moving the directory moves the store, the action +// log, the workflows, and the credential together. +func TestPathHelpersAreRootedAndDistinct(t *testing.T) { + const root = "/home/u/.config/akt" + + ctxDir := aktctx.ContextDir(root, "prod") + + inContext := map[string]string{ + "store": aktctx.StoreDir(root, "prod"), + "action log": aktctx.ActionLogPath(root, "prod"), + "workflows": aktctx.ContextWorkflowsDir(root, "prod"), + "credential": aktctx.ConsoleAPIKeyPath(root, "prod"), + } + + for name, path := range inContext { + if !strings.HasPrefix(path, ctxDir+string(filepath.Separator)) { + t.Errorf("%s path %q is not inside the context dir %q", name, path, ctxDir) + } + } + + // Global workflows are deliberately NOT per-context: they are the + // user-wide overrides of the built-ins. + global := aktctx.WorkflowsDir(root) + if global != filepath.Join(root, "workflows") { + t.Errorf("global workflows dir = %q", global) + } + if strings.HasPrefix(global, ctxDir) { + t.Error("global workflows must not live inside a context directory") + } + + // Two contexts must never share a directory. + if aktctx.ContextDir(root, "prod") == aktctx.ContextDir(root, "staging") { + t.Error("context directories must be distinct") + } +} + +// TestKeyringDirHonorsExplicitDirectory covers both arms of KeyringDir. An +// explicit dir is how users point akt at an existing cosmos keyring; ignoring +// it would create a second, empty keyring and report "key not found". +func TestKeyringDirHonorsExplicitDirectory(t *testing.T) { + const root = "/home/u/.config/akt" + + if got := aktctx.KeyringDir(root, aktctx.Keyring{Name: "default"}); got != filepath.Join(root, "keyrings", "default") { + t.Errorf("default keyring dir = %q", got) + } + + explicit := aktctx.Keyring{Name: "default", Dir: "/home/u/.akash"} + if got := aktctx.KeyringDir(root, explicit); got != "/home/u/.akash" { + t.Errorf("explicit keyring dir = %q, want /home/u/.akash", got) + } +} + +// TestLoadConfigOnEmptyRootReturnsDefaults covers the fresh-install path: with +// no config.yaml, LoadConfig must hand back usable defaults rather than an +// error, because that is what triggers the first-run wizard instead of a crash. +func TestLoadConfigOnEmptyRootReturnsDefaults(t *testing.T) { + cfg, err := aktctx.LoadConfig(t.TempDir()) + if err != nil { + t.Fatalf("LoadConfig on an empty root: %v", err) + } + if cfg == nil { + t.Fatal("LoadConfig returned a nil config") + } + if cfg.Version != aktctx.ConfigVersion { + t.Errorf("version = %d, want %d", cfg.Version, aktctx.ConfigVersion) + } +} + +// TestLoadConfigRejectsMalformedYAML covers the parse-error branch. A corrupt +// config must be reported, not silently replaced with defaults — that would +// look like every context vanished. +func TestLoadConfigRejectsMalformedYAML(t *testing.T) { + root := t.TempDir() + + if err := os.WriteFile(aktctx.ConfigPath(root), []byte("current-context: [unclosed\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + if _, err := aktctx.LoadConfig(root); err == nil { + t.Fatal("a malformed config.yaml must be an error") + } +} + +// TestSaveConfigRoundTrip covers SaveConfig plus the custom Context YAML +// marshalling (network and keyring serialize as name strings). A round-trip +// failure here loses the user's whole configuration on the next write. +func TestSaveConfigRoundTrip(t *testing.T) { + root := t.TempDir() + + cfg := &aktctx.Config{ + Version: aktctx.ConfigVersion, + CurrentContext: "prod", + Networks: []aktctx.Network{{ + Name: "mainnet", + ChainID: "akashnet-2", + Endpoints: aktctx.Endpoints{RPC: []string{"https://rpc.example"}}, + GasPrices: "0.0025uakt", + }}, + Keyrings: []aktctx.Keyring{{Name: "default", Backend: "test"}}, + Contexts: []aktctx.Context{{ + Name: "prod", + Network: aktctx.Network{Name: "mainnet"}, + Keyring: aktctx.Keyring{Name: "default"}, + DefaultAccount: "alice", + Gas: "auto", + }}, + } + + if err := aktctx.SaveConfig(root, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + loaded, err := aktctx.LoadConfig(root) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + if loaded.CurrentContext != "prod" { + t.Errorf("current-context = %q", loaded.CurrentContext) + } + if len(loaded.Contexts) != 1 || loaded.Contexts[0].Network.Name != "mainnet" { + t.Fatalf("contexts did not round-trip: %+v", loaded.Contexts) + } + if loaded.Contexts[0].Keyring.Name != "default" { + t.Errorf("keyring reference did not round-trip: %+v", loaded.Contexts[0].Keyring) + } + if len(loaded.Networks) != 1 || loaded.Networks[0].ChainID != "akashnet-2" { + t.Errorf("networks did not round-trip: %+v", loaded.Networks) + } + + // The config carries no secrets and is not a credential file, but it does + // describe the user's accounts; it must not be world-writable. + info, err := os.Stat(aktctx.ConfigPath(root)) + if err != nil { + t.Fatalf("stat config: %v", err) + } + if perm := info.Mode().Perm(); perm&0o022 != 0 { + t.Errorf("config mode = %o, want no group/other write", perm) + } +} + +// TestKeyringRegistryOperations covers GetKeyring, ListKeyrings, and +// CreateKeyring, including the duplicate and missing-name guards. Contexts +// reference keyrings by name, so a duplicate would make resolution ambiguous. +func TestKeyringRegistryOperations(t *testing.T) { + m, err := aktctx.NewManager(t.TempDir()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + // DefaultConfig seeds "default". + if kr := m.GetKeyring("default"); kr == nil { + t.Fatal("the default keyring should exist") + } + if kr := m.GetKeyring("nope"); kr != nil { + t.Errorf("unknown keyring = %+v, want nil", kr) + } + + if err := m.CreateKeyring(aktctx.Keyring{Name: "ledger-kr"}); err != nil { + t.Fatalf("CreateKeyring: %v", err) + } + + kr := m.GetKeyring("ledger-kr") + if kr == nil { + t.Fatal("created keyring not found") + } + if kr.Backend != "os" { + t.Errorf("backend = %q, want the os default", kr.Backend) + } + + if err := m.CreateKeyring(aktctx.Keyring{Name: "ledger-kr"}); err == nil { + t.Error("a duplicate keyring name must be rejected") + } + if err := m.CreateKeyring(aktctx.Keyring{}); err == nil { + t.Error("an unnamed keyring must be rejected") + } + + list := m.ListKeyrings() + if len(list) != 2 { + t.Fatalf("ListKeyrings = %+v, want 2", list) + } + + // The returned slice is a copy: mutating it must not corrupt the config. + list[0].Name = "mutated" + if m.GetKeyring("mutated") != nil { + t.Error("ListKeyrings must return a copy, not the live slice") + } + + // The new keyring survives a reload from disk. + m2, err := aktctx.NewManager(m.Root()) + if err != nil { + t.Fatalf("reload: %v", err) + } + if m2.GetKeyring("ledger-kr") == nil { + t.Error("created keyring was not persisted") + } +} diff --git a/internal/context/types.go b/internal/context/types.go index a64e79ca..42c4940b 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -43,6 +43,8 @@ type Context struct { Name string `yaml:"-" json:"name"` Network Network `yaml:"-" json:"network"` Keyring Keyring `yaml:"-" json:"keyring"` + AuthMethod string `yaml:"auth-method,omitempty" json:"auth_method,omitempty"` + ConsoleAPIURL string `yaml:"console-api-url,omitempty" json:"console_api_url,omitempty"` DefaultAccount string `yaml:"default-account,omitempty" json:"default_account,omitempty"` Gas string `yaml:"gas,omitempty" json:"gas,omitempty"` Fees string `yaml:"fees,omitempty" json:"fees,omitempty"` @@ -54,6 +56,9 @@ type Context struct { GasAdjustment string `yaml:"-" json:"-"` AuthType string `yaml:"-" json:"-"` Root string `yaml:"-" json:"-"` // config root directory + // ConsoleAPIKey is the resolved Console API key (env var overrides the + // per-context credential file, SPEC §7.1). Never serialized. + ConsoleAPIKey string `yaml:"-" json:"-"` } // contextYAML is the on-disk representation where Network and Keyring are name strings. @@ -61,6 +66,8 @@ type contextYAML struct { Name string `yaml:"name"` Network string `yaml:"network"` Keyring string `yaml:"keyring,omitempty"` + AuthMethod string `yaml:"auth-method,omitempty"` + ConsoleAPIURL string `yaml:"console-api-url,omitempty"` DefaultAccount string `yaml:"default-account,omitempty"` Gas string `yaml:"gas,omitempty"` Fees string `yaml:"fees,omitempty"` @@ -73,6 +80,8 @@ func (c Context) MarshalYAML() (interface{}, error) { Name: c.Name, Network: c.Network.Name, Keyring: c.Keyring.Name, + AuthMethod: c.AuthMethod, + ConsoleAPIURL: c.ConsoleAPIURL, DefaultAccount: c.DefaultAccount, Gas: c.Gas, Fees: c.Fees, @@ -92,6 +101,8 @@ func (c *Context) UnmarshalYAML(value *yaml.Node) error { c.Name = raw.Name c.Network = Network{Name: raw.Network} c.Keyring = Keyring{Name: raw.Keyring} + c.AuthMethod = raw.AuthMethod + c.ConsoleAPIURL = raw.ConsoleAPIURL c.DefaultAccount = raw.DefaultAccount c.Gas = raw.Gas c.Fees = raw.Fees @@ -118,6 +129,10 @@ type PluginSettings struct { type Defaults struct { Output string `yaml:"output,omitempty" json:"output,omitempty"` BroadcastMode string `yaml:"broadcast-mode,omitempty" json:"broadcast_mode,omitempty"` + // CommandGating selects how commands the context configuration cannot + // execute are presented: dim (default), hide, or off. Both dim and + // hide are supported while UX feedback is collected. + CommandGating string `yaml:"command-gating,omitempty" json:"command_gating,omitempty"` } // Config is the top-level configuration structure persisted in config.yaml. diff --git a/internal/monitor/cache/cache.go b/internal/monitor/cache/cache.go index 5dfad621..f8b46300 100644 --- a/internal/monitor/cache/cache.go +++ b/internal/monitor/cache/cache.go @@ -119,7 +119,7 @@ func (c *ProviderCache) GetProvider(owner string) (*CachedProvider, bool) { return nil } if err := json.Unmarshal(data, &p); err != nil { - return nil + return nil //nolint:nilerr // returning the error would abort the whole bbolt scan over one unreadable row; skipping the row is the intended behaviour } found = true return nil @@ -141,7 +141,7 @@ func (c *ProviderCache) GetAllProviders() map[string]*CachedProvider { return b.ForEach(func(k, v []byte) error { var p CachedProvider if err := json.Unmarshal(v, &p); err != nil { - return nil // skip corrupt entries + return nil //nolint:nilerr // returning the error would abort the whole bbolt scan over one unreadable row; skipping the row is the intended behaviour } result[string(k)] = &p return nil @@ -161,7 +161,7 @@ func (c *ProviderCache) GetOnlineProviders() []*CachedProvider { return b.ForEach(func(k, v []byte) error { var p CachedProvider if err := json.Unmarshal(v, &p); err != nil { - return nil + return nil //nolint:nilerr // returning the error would abort the whole bbolt scan over one unreadable row; skipping the row is the intended behaviour } if p.IsOnline { cp := p @@ -186,7 +186,7 @@ func (c *ProviderCache) MarkProviderOnline(owner, version string, cpuAvail, cpuT } var p CachedProvider if err := json.Unmarshal(data, &p); err != nil { - return nil + return nil //nolint:nilerr // returning the error would abort the whole bbolt scan over one unreadable row; skipping the row is the intended behaviour } p.IsOnline = true p.Version = version @@ -217,7 +217,7 @@ func (c *ProviderCache) MarkProviderOffline(owner string) { } var p CachedProvider if err := json.Unmarshal(data, &p); err != nil { - return nil + return nil //nolint:nilerr // returning the error would abort the whole bbolt scan over one unreadable row; skipping the row is the intended behaviour } p.IsOnline = false p.LastChecked = time.Now() @@ -297,7 +297,7 @@ func (c *ProviderCache) GetProvidersDueForCheck() []string { return b.ForEach(func(k, v []byte) error { var p CachedProvider if err := json.Unmarshal(v, &p); err != nil { - return nil + return nil //nolint:nilerr // returning the error would abort the whole bbolt scan over one unreadable row; skipping the row is the intended behaviour } var interval time.Duration if p.IsOnline { @@ -334,7 +334,7 @@ func (c *ProviderCache) GetProvidersByPriority() []string { return b.ForEach(func(k, v []byte) error { var p CachedProvider if err := json.Unmarshal(v, &p); err != nil { - return nil + return nil //nolint:nilerr // returning the error would abort the whole bbolt scan over one unreadable row; skipping the row is the intended behaviour } priority := calculatePriority(&p) providers = append(providers, providerPriority{string(k), priority, p.LastChecked}) @@ -381,7 +381,7 @@ func (c *ProviderCache) OnlineCount() int { return b.ForEach(func(k, v []byte) error { var p CachedProvider if err := json.Unmarshal(v, &p); err != nil { - return nil + return nil //nolint:nilerr // returning the error would abort the whole bbolt scan over one unreadable row; skipping the row is the intended behaviour } if p.IsOnline { count++ diff --git a/internal/monitor/consensus/consensus.go b/internal/monitor/consensus/consensus.go index 3d1a6b42..ec961d22 100644 --- a/internal/monitor/consensus/consensus.go +++ b/internal/monitor/consensus/consensus.go @@ -122,17 +122,17 @@ func ParseHeightRoundStep(hrs string) (height int64, round int, step int, err er height, err = strconv.ParseInt(parts[0], 10, 64) if err != nil { - return 0, 0, 0, fmt.Errorf("invalid height: %v", err) + return 0, 0, 0, fmt.Errorf("invalid height: %w", err) } round, err = strconv.Atoi(parts[1]) if err != nil { - return 0, 0, 0, fmt.Errorf("invalid round: %v", err) + return 0, 0, 0, fmt.Errorf("invalid round: %w", err) } step, err = strconv.Atoi(parts[2]) if err != nil { - return 0, 0, 0, fmt.Errorf("invalid step: %v", err) + return 0, 0, 0, fmt.Errorf("invalid step: %w", err) } return height, round, step, nil diff --git a/internal/monitor/rpc/client.go b/internal/monitor/rpc/client.go index b436352b..ed30d48f 100644 --- a/internal/monitor/rpc/client.go +++ b/internal/monitor/rpc/client.go @@ -16,8 +16,8 @@ import ( "sync" "time" - "github.com/cosmos/gogoproto/proto" querytypes "github.com/cosmos/cosmos-sdk/types/query" + "github.com/cosmos/gogoproto/proto" bmetypes "pkg.akt.dev/go/node/bme/v1" oracletypes "pkg.akt.dev/go/node/oracle/v2" @@ -473,11 +473,11 @@ func CompareVersions(a, b string) int { if i < len(partsA) { // Remove any non-numeric suffix (e.g., "6-rc3" -> "6") numStr := strings.Split(partsA[i], "-")[0] - fmt.Sscanf(numStr, "%d", &numA) + _, _ = fmt.Sscanf(numStr, "%d", &numA) } if i < len(partsB) { numStr := strings.Split(partsB[i], "-")[0] - fmt.Sscanf(numStr, "%d", &numB) + _, _ = fmt.Sscanf(numStr, "%d", &numB) } if numA > numB { @@ -815,27 +815,6 @@ func (c *Client) probeOracleVersion(ctx context.Context) string { return "none" } -// restGet performs a GET request and returns the response body. -func (c *Client) restGet(ctx context.Context, reqURL string) (json.RawMessage, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) - if err != nil { - return nil, err - } - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("status %d", resp.StatusCode) - } - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - return json.RawMessage(body), nil -} - // extractOracleDenoms extracts unique asset denoms from a prices response. func extractOracleDenoms(resp *oracletypes.QueryPricesResponse) []string { if resp == nil { diff --git a/internal/monitor/rpc/provider_grpc.go b/internal/monitor/rpc/provider_grpc.go index 10f36aed..e4b9428f 100644 --- a/internal/monitor/rpc/provider_grpc.go +++ b/internal/monitor/rpc/provider_grpc.go @@ -65,7 +65,7 @@ func QueryProviderStatusGRPC(ctx context.Context, hostURI string) ([]ProviderNod if err != nil { return nil, fmt.Errorf("failed to connect to provider: %w", err) } - defer conn.Close() + defer func() { _ = conn.Close() }() client := providerv1.NewProviderRPCClient(conn) diff --git a/internal/monitor/ui/model.go b/internal/monitor/ui/model.go index 4dec1dec..5bc0a550 100644 --- a/internal/monitor/ui/model.go +++ b/internal/monitor/ui/model.go @@ -189,10 +189,10 @@ type Model struct { governanceParams *governance.AllParams // Bubbles component models for tables/lists/viewports - providerTable table.Model - nodeTable table.Model - validatorTable table.Model - blockTable table.Model + providerTable table.Model + nodeTable table.Model + validatorTable table.Model + blockTable table.Model govModuleIdx int // selected module index in governance.ModuleOrder govModuleScroll int // first visible module index (scroll offset) govModuleHeight int // visible rows for the module list @@ -312,7 +312,6 @@ type ModelConfig struct { Bus pubsub.Bus // shared event bus; may be nil } - // NewModel creates a new UI model func NewModel(cfg ModelConfig) Model { monikers := cfg.MonikerCache.Get() diff --git a/internal/monitor/ui/styles.go b/internal/monitor/ui/styles.go index 2df5dac7..24093af1 100644 --- a/internal/monitor/ui/styles.go +++ b/internal/monitor/ui/styles.go @@ -2,6 +2,7 @@ package ui import ( "charm.land/bubbles/v2/progress" + "charm.land/lipgloss/v2" "pkg.akt.dev/akt/internal/glyphs" "pkg.akt.dev/akt/internal/tui/components" @@ -17,27 +18,27 @@ var ( headerStyle = theme.SectionTitle labelStyle = theme.KVLabel.Width(12) valueStyle = theme.KVValue - percentLowStyle = theme.PercentLow - percentHighStyle = theme.PercentHigh - gridVotedStyle = theme.GridVoted - gridNotVotedStyle = theme.GridNotVoted - errorStyle = theme.Error - helpStyle = theme.HelpBar - statusBarStyle = theme.StatusBar + percentLowStyle = theme.StateYellow + percentHighStyle = theme.StateGreen.Bold(true) + gridVotedStyle = theme.StateGreen + gridNotVotedStyle = theme.Muted + errorStyle = theme.ErrorLabel + helpStyle = theme.Muted.MarginTop(1) + statusBarStyle = theme.Muted.MarginTop(1) mutedStyle = theme.Muted - proposerStyle = theme.Proposer + proposerStyle = theme.StateYellow.Bold(true) tabActiveStyle = theme.NavTabActive tabInactiveStyle = theme.NavTabInactive - monikerStyle = theme.Moniker - highlightStyle = theme.Highlight + monikerStyle = theme.Body + highlightStyle = theme.PrimaryValue detailHeaderStyle = theme.SectionTitle detailLabelStyle = theme.KVLabel detailValueStyle = theme.KVValue - voteYesStyle = theme.VoteYes - voteNoStyle = theme.VoteNo + voteYesStyle = theme.StateGreen + voteNoStyle = lipgloss.NewStyle().Foreground(theme.AccentRed) // Progress bar color — used by DoubleProgressBar for the precommit bar. - precommitBarColor = theme.ProgressPrecommit + precommitBarColor = theme.BlueColor ) // ProgressBar renders a progress bar with the given percentage (0-1). @@ -75,7 +76,7 @@ func DoubleProgressBar(prevotePct, precommitPct float64, width int) string { precommitPct = clamp(precommitPct) pvBar := progress.New( - progress.WithColors(theme.ProgressSuccess), + progress.WithColors(theme.GreenColor), progress.WithoutPercentage(), ) pvBar.SetWidth(width) diff --git a/internal/monitor/ui/testhelpers_test.go b/internal/monitor/ui/testhelpers_test.go index a1ed766b..ab137489 100644 --- a/internal/monitor/ui/testhelpers_test.go +++ b/internal/monitor/ui/testhelpers_test.go @@ -405,10 +405,10 @@ func newTestViewContext(opts ...func(*ViewContext)) ViewContext { Oracle: OracleState{ Aggregated: make(map[string]*oracletypes.EventAggregatedPrice), }, - BlockTable: newTestBlockTableModel(state, history), - ValidatorTable: newTestValidatorTableModel(state, monikers, signHist, proposerHist), - ProviderTable: newTestProviderTableModel(providers), - NodeTable: newTestNodeTableModel(newTestNodes(3)), + BlockTable: newTestBlockTableModel(state, history), + ValidatorTable: newTestValidatorTableModel(state, monikers, signHist, proposerHist), + ProviderTable: newTestProviderTableModel(providers), + NodeTable: newTestNodeTableModel(newTestNodes(3)), GovModuleIdx: 0, GovModuleScroll: 0, GovModuleHeight: 20, diff --git a/internal/monitor/ui/view.go b/internal/monitor/ui/view.go index 11ec7e65..bf2c137b 100644 --- a/internal/monitor/ui/view.go +++ b/internal/monitor/ui/view.go @@ -89,10 +89,10 @@ type ViewContext struct { Oracle OracleState // Bubbles component models - ProviderTable table.Model - NodeTable table.Model - ValidatorTable table.Model - BlockTable table.Model + ProviderTable table.Model + NodeTable table.Model + ValidatorTable table.Model + BlockTable table.Model GovModuleIdx int // selected module index GovModuleScroll int // first visible module index GovModuleHeight int // visible rows for module list @@ -760,12 +760,12 @@ func renderGridSection(state *consensus.State, termWidth int) string { return header + "\n" + grid + "\n\n" + mutedStyle.Render(legend) } -func clamp(value, min, max int) int { - if value < min { - return min +func clamp(value, lo, hi int) int { + if value < lo { + return lo } - if value > max { - return max + if value > hi { + return hi } return value } diff --git a/internal/output/formatter.go b/internal/output/formatter.go index 551cac7a..fa5d84f2 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -98,7 +98,7 @@ func PrintTable(w io.Writer, columns []Column, rows [][]string) { fmt.Fprintln(tw, strings.Join(row, "\t")) } - tw.Flush() + _ = tw.Flush() } func writeYAML(w io.Writer, data any, indent int) error { diff --git a/internal/output/pretty/auth.go b/internal/output/pretty/auth.go index c4c03da5..8edfab81 100644 --- a/internal/output/pretty/auth.go +++ b/internal/output/pretty/auth.go @@ -30,6 +30,9 @@ func formatAccountResponse(w io.Writer, _ *cobra.Command, cctx sdkclient.Context // Can't unpack — show type URL as fallback. fmt.Fprintln(w, Section("Account")) KV(w, "Type", res.Account.TypeUrl) + //nolint:nilerr // the fallback above already rendered what is known + // about the account; a renderer that cannot decode one field should + // degrade, not fail the query the user just ran. return nil } @@ -63,16 +66,16 @@ func formatAccountsResponse(w io.Writer, _ *cobra.Command, cctx sdkclient.Contex headers := []string{"ADDRESS", "TYPE", "ACCOUNT #", "SEQUENCE"} rows := make([][]string, 0, len(res.Accounts)) - for _, any := range res.Accounts { - acct, err := unpackAccount(cctx.InterfaceRegistry, any) + for _, anyAcct := range res.Accounts { + acct, err := unpackAccount(cctx.InterfaceRegistry, anyAcct) if err != nil { - rows = append(rows, []string{"?", shortTypeName(any.TypeUrl), "-", "-"}) + rows = append(rows, []string{"?", shortTypeName(anyAcct.TypeUrl), "-", "-"}) continue } rows = append(rows, []string{ acct.GetAddress().String(), - shortTypeName(any.TypeUrl), + shortTypeName(anyAcct.TypeUrl), fmt.Sprintf("%d", acct.GetAccountNumber()), fmt.Sprintf("%d", acct.GetSequence()), }) @@ -92,10 +95,10 @@ func formatModuleAccountsResponse(w io.Writer, _ *cobra.Command, cctx sdkclient. headers := []string{"NAME", "ADDRESS", "PERMISSIONS"} rows := make([][]string, 0, len(res.Accounts)) - for _, any := range res.Accounts { - acct, err := unpackAccount(cctx.InterfaceRegistry, any) + for _, anyAcct := range res.Accounts { + acct, err := unpackAccount(cctx.InterfaceRegistry, anyAcct) if err != nil { - rows = append(rows, []string{"-", shortTypeName(any.TypeUrl), "-"}) + rows = append(rows, []string{"-", shortTypeName(anyAcct.TypeUrl), "-"}) continue } @@ -121,13 +124,13 @@ func formatModuleAccountsResponse(w io.Writer, _ *cobra.Command, cctx sdkclient. // unpackAccount unpacks a codectypes.Any into an AccountI using the interface // registry from the client context. -func unpackAccount(registry codectypes.InterfaceRegistry, any *codectypes.Any) (sdk.AccountI, error) { +func unpackAccount(registry codectypes.InterfaceRegistry, anyAcct *codectypes.Any) (sdk.AccountI, error) { if registry == nil { return nil, fmt.Errorf("no interface registry available") } var acct sdk.AccountI - if err := registry.UnpackAny(any, &acct); err != nil { + if err := registry.UnpackAny(anyAcct, &acct); err != nil { return nil, err } diff --git a/internal/output/pretty/context.go b/internal/output/pretty/context.go index eb6997e8..1f051a10 100644 --- a/internal/output/pretty/context.go +++ b/internal/output/pretty/context.go @@ -2,8 +2,10 @@ package pretty import ( "fmt" + "io" "strings" + "pkg.akt.dev/akt/internal/capability" aktctx "pkg.akt.dev/akt/internal/context" ) @@ -45,6 +47,25 @@ func RenderContextShow(rc aktctx.Context) string { SubKV(w, "Gas Adj", rc.GasAdjustment) Newline(w) + + authMethod := rc.AuthMethod + if authMethod == "" { + authMethod = aktctx.AuthMethodKeyring + } + KV(w, "Auth Method", authMethod) + + if authMethod == aktctx.AuthMethodConsoleAPI { + KVHeader(w, " Console API") + SubKV(w, "URL", rc.ConsoleAPIURL) + + // The key itself is never printed (SPEC §7.1). + if rc.ConsoleAPIKey != "" { + SubKV(w, "API Key", "configured") + } else { + SubKV(w, "API Key", Dim("(not set)")) + } + } + KV(w, "Keyring", fmt.Sprintf("%s (backend: %s)", rc.Keyring.Name, rc.Keyring.Backend)) if rc.DefaultAccount != "" { @@ -65,5 +86,26 @@ func RenderContextShow(rc aktctx.Context) string { KV(w, "Store", aktctx.StoreDir(rc.Root, rc.Name)) KV(w, "Action Log", aktctx.ActionLogPath(rc.Root, rc.Name)) + // Feature set: what this configuration can actually do (SPEC §2.10). + // Missing capabilities name their remedy so the user knows how to + // enable the corresponding commands. + set := capability.Resolve(&rc) + + Newline(w) + KVHeader(w, " Capabilities") + renderCapability(w, "Chain queries", set.ChainQuery, "add an RPC endpoint to the network") + renderCapability(w, "Chain transactions", set.ChainTx, "add an RPC endpoint to the network") + renderCapability(w, "Provider gateway", set.Provider, "add an RPC endpoint to the network") + renderCapability(w, "Console API", set.Console, "run akt console login") + return buf.String() } + +func renderCapability(w io.Writer, label string, available bool, remedy string) { + if available { + SubKV(w, label, "available") + return + } + + SubKV(w, label, Dim("unavailable — "+remedy)) +} diff --git a/internal/output/pretty/formatters_test.go b/internal/output/pretty/formatters_test.go new file mode 100644 index 00000000..41f8066e --- /dev/null +++ b/internal/output/pretty/formatters_test.go @@ -0,0 +1,344 @@ +package pretty + +import ( + "bytes" + "strings" + "testing" + "time" + + "cosmossdk.io/math" + sdkclient "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/gogoproto/proto" + "github.com/spf13/cobra" + + feegrant "cosmossdk.io/x/feegrant" + upgradetypes "cosmossdk.io/x/upgrade/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + distrtypes "github.com/cosmos/cosmos-sdk/x/distribution/types" + govv1 "github.com/cosmos/cosmos-sdk/x/gov/types/v1" + slashingtypes "github.com/cosmos/cosmos-sdk/x/slashing/types" + + wasmtypes "github.com/CosmWasm/wasmd/x/wasm/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + minttypes "github.com/cosmos/cosmos-sdk/x/mint/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + + atypes "pkg.akt.dev/go/node/audit/v1" + bmetypes "pkg.akt.dev/go/node/bme/v1" + ctypes "pkg.akt.dev/go/node/cert/v1" + dvbeta "pkg.akt.dev/go/node/deployment/v1beta4" + etypes "pkg.akt.dev/go/node/escrow/v1" + mvbeta "pkg.akt.dev/go/node/market/v1beta5" + oraclev2 "pkg.akt.dev/go/node/oracle/v2" + ptypes "pkg.akt.dev/go/node/provider/v1beta4" + + "pkg.akt.dev/akt/internal/codec" +) + +// dec is a shorthand for a populated LegacyDec fixture. Several chain params +// carry LegacyDec fields whose zero value has a nil big.Int, which the +// renderers cannot operate on; the wire never produces those. +func dec(s string) math.LegacyDec { return math.LegacyMustNewDecFromStr(s) } + +// TestRegisteredFormattersDispatch walks the pretty-print registry the way the +// CLI does: look the response type up by its proto name and render it. This is +// the test that fails if a formatter is registered under the wrong message +// type or an init() is dropped in a refactor — both of which are silent in +// production, because an unregistered response falls back to raw JSON. +func TestRegisteredFormattersDispatch(t *testing.T) { + enc := codec.MakeEncodingConfig() + cctx := sdkclient.Context{}. + WithCodec(enc.Codec). + WithInterfaceRegistry(enc.InterfaceRegistry) + + cmd := &cobra.Command{} + cmd.Flags().String("output", "pretty", "") + + // Empty (but non-nil) responses: every list formatter must render its + // header without a live chain, and must not panic on an empty result. + messages := []proto.Message{ + &dvbeta.QueryDeploymentsResponse{}, + &dvbeta.QueryDeploymentResponse{}, + &dvbeta.QueryGroupResponse{}, + &dvbeta.QueryParamsResponse{}, + &mvbeta.QueryBidsResponse{}, + &mvbeta.QueryBidResponse{}, + &mvbeta.QueryLeasesResponse{}, + &mvbeta.QueryLeaseResponse{}, + &mvbeta.QueryOrdersResponse{}, + &mvbeta.QueryOrderResponse{}, + &mvbeta.QueryParamsResponse{}, + &ptypes.QueryProvidersResponse{}, + &ptypes.QueryProviderResponse{}, + &ctypes.QueryCertificatesResponse{}, + &atypes.QueryProvidersResponse{}, + &etypes.QueryAccountsResponse{}, + &etypes.QueryPaymentsResponse{}, + &banktypes.QueryAllBalancesResponse{}, + &banktypes.QuerySpendableBalancesResponse{}, + &banktypes.QueryBalanceResponse{}, + &banktypes.QueryTotalSupplyResponse{}, + &feegrant.QueryAllowancesResponse{}, + &feegrant.QueryAllowancesByGranterResponse{}, + &govv1.QueryProposalsResponse{}, + &distrtypes.QueryDelegationTotalRewardsResponse{}, + &distrtypes.ValidatorAccumulatedCommission{}, + &slashingtypes.QuerySigningInfosResponse{}, + &slashingtypes.ValidatorSigningInfo{}, + &upgradetypes.QueryModuleVersionsResponse{}, + &upgradetypes.Plan{}, + + // Staking, gov, and module params. + &stakingtypes.QueryValidatorsResponse{}, + &stakingtypes.QueryDelegatorDelegationsResponse{}, + // Pool carries math.Int fields; a zero value leaves them nil, which + // the renderer cannot divide. Populate them as the chain would. + &stakingtypes.Pool{ + BondedTokens: math.NewInt(1_000_000), + NotBondedTokens: math.NewInt(250_000), + }, + &stakingtypes.Params{MinCommissionRate: dec("0.05")}, + &govv1.QueryParamsResponse{}, + &minttypes.Params{ + InflationRateChange: dec("0.13"), + InflationMax: dec("0.2"), + InflationMin: dec("0.07"), + GoalBonded: dec("0.67"), + }, + &slashingtypes.Params{ + MinSignedPerWindow: dec("0.5"), + SlashFractionDoubleSign: dec("0.05"), + SlashFractionDowntime: dec("0.0001"), + }, + &distrtypes.Params{ + CommunityTax: dec("0.02"), + BaseProposerReward: dec("0.01"), + BonusProposerReward: dec("0.04"), + WithdrawAddrEnabled: true, + }, + &authtypes.Params{}, + &wasmtypes.Params{}, + + // Oracle / BME. + &oraclev2.QueryParamsResponse{}, + &oraclev2.QueryPricesResponse{}, + &oraclev2.QueryAggregatedPriceResponse{}, + &bmetypes.QueryStatusResponse{}, + &bmetypes.QueryVaultStateResponse{}, + &bmetypes.QueryLedgerRecordsResponse{}, + + // CosmWasm. + &wasmtypes.QueryCodesResponse{}, + &wasmtypes.QueryContractsByCodeResponse{}, + &wasmtypes.QueryContractHistoryResponse{}, + &wasmtypes.QueryPinnedCodesResponse{}, + &wasmtypes.QueryContractsByCreatorResponse{}, + + // Auth. + &authtypes.QueryAccountsResponse{}, + &authtypes.QueryModuleAccountsResponse{}, + } + + for _, msg := range messages { + name := proto.MessageName(msg) + + t.Run(name, func(t *testing.T) { + f, ok := Lookup(msg) + if !ok { + t.Fatalf("no pretty formatter registered for %s; output would fall back to raw JSON", name) + } + + var buf bytes.Buffer + if err := f.Format(&buf, cmd, cctx, msg); err != nil { + t.Fatalf("format %s: %v", name, err) + } + }) + } +} + +// TestLookupMissesUnregisteredTypes is the negative control for the dispatch +// test above: Lookup must report a miss rather than returning some other +// module's formatter (which would render nonsense for the wrong response). +func TestLookupMissesUnregisteredTypes(t *testing.T) { + // A well-known proto that pretty deliberately does not format. + if _, ok := Lookup(&banktypes.QueryDenomMetadataResponse{}); ok { + t.Error("an unregistered response type must miss, not resolve to another formatter") + } +} + +// TestFormatDecAsAKTScalesMicroDenoms is a money-display test. Chain amounts +// arrive in uakt; the AKT/mAKT/uAKT selection decides which decimal point the +// user reads. A wrong bucket boundary misreports a balance by 1000x. +func TestFormatDecAsAKTScalesMicroDenoms(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"0", "0 AKT"}, + {"1", "1 uAKT"}, + {"999", "999 uAKT"}, + {"1000", "1 mAKT"}, + {"999999", "999.999 mAKT"}, + {"1000000", "1 AKT"}, + {"1500000", "1.5 AKT"}, + {"-1500000", "-1.5 AKT"}, + {"-999", "-999 uAKT"}, + } + + for _, c := range cases { + got := FormatDecAsAKT(math.LegacyMustNewDecFromStr(c.in)) + if got != c.want { + t.Errorf("FormatDecAsAKT(%s) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestFormatDecAmountHonorsDenom covers both arms of FormatDecAmount: a +// micro-denom is scaled and re-symbolized, while a non-micro denom (an IBC +// hash, a bare "akt") must be shown verbatim — scaling it would invent value +// that is not there. +func TestFormatDecAmountHonorsDenom(t *testing.T) { + amount := math.LegacyMustNewDecFromStr("2500000") + + if got := FormatDecAmount(amount, "uatom"); got != "2.5 ATOM" { + t.Errorf("uatom = %q, want 2.5 ATOM", got) + } + + for _, denom := range []string{"akt", "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", "u"} { + got := FormatDecAmount(amount, denom) + if !strings.HasSuffix(got, denom) { + t.Errorf("denom %q rendered as %q; non-micro denoms must be shown as-is", denom, got) + } + if strings.Contains(got, "2.5 ") { + t.Errorf("denom %q was scaled to %q; only micro-denoms may be scaled", denom, got) + } + } +} + +// TestIsMicroDenomBoundaries pins the classifier that decides whether an +// amount is divided by a million. "u" alone, "u/..." and uppercase forms must +// not be treated as micro-denoms. +func TestIsMicroDenomBoundaries(t *testing.T) { + cases := map[string]bool{ + "uakt": true, + "uatom": true, + "uosmo": true, + "u": false, // no base symbol + "": false, + "akt": false, + "u/abc": false, // not a denom symbol + "UAKT": false, // uppercase is not the convention + "u1": false, // digit after the prefix + "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2": false, + } + + for denom, want := range cases { + if got := isMicroDenom(denom); got != want { + t.Errorf("isMicroDenom(%q) = %v, want %v", denom, got, want) + } + } +} + +// TestFormatBytesBinaryUnits pins the byte formatter used for provider memory +// and storage. It uses binary units, so the boundaries are powers of 1024, not +// 1000; an off-by-one at a boundary shows "1024Mi" instead of "1Gi". +func TestFormatBytesBinaryUnits(t *testing.T) { + const ( + ki = uint64(1024) + mi = 1024 * ki + gi = 1024 * mi + ti = 1024 * gi + ) + + cases := []struct { + in uint64 + want string + }{ + {0, "0"}, + {999, "999"}, + {mi - 1, "1048575"}, + {mi, "1Mi"}, + {512 * mi, "512Mi"}, + {gi, "1Gi"}, + {ti, "1Ti"}, + } + + for _, c := range cases { + if got := FormatBytes(c.in); got != c.want { + t.Errorf("FormatBytes(%d) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestResourceRatiosHandleZeroTotal covers the divide-by-nothing guard on both +// ratio helpers: a provider reporting no capacity must render "-" rather than +// "0/0", which reads as a real (and full) machine. +func TestResourceRatiosHandleZeroTotal(t *testing.T) { + if got := FormatMemoryRatio(0, 0); got != "-" { + t.Errorf("FormatMemoryRatio(0,0) = %q, want -", got) + } + if got := FormatResourceRatio(0, 0); got != "-" { + t.Errorf("FormatResourceRatio(0,0) = %q, want -", got) + } + + if got := FormatMemoryRatio(512*1024*1024, 1024*1024*1024); got != "512Mi/1Gi" { + t.Errorf("FormatMemoryRatio = %q, want 512Mi/1Gi", got) + } + if got := FormatResourceRatio(3, 8); got != "3/8" { + t.Errorf("FormatResourceRatio = %q, want 3/8", got) + } +} + +// TestFormatNumberAndPower covers the two numeric display helpers shared with +// the TUI: thousands separators for exact counts, and compact suffixes for +// voting power. +func TestFormatNumberAndPower(t *testing.T) { + numbers := map[int64]string{ + 0: "0", + 999: "999", + 1000: "1,000", + 18234567: "18,234,567", + -1234: "-1,234", + } + for in, want := range numbers { + if got := FormatNumber(in); got != want { + t.Errorf("FormatNumber(%d) = %q, want %q", in, got, want) + } + } + + powers := map[int64]string{ + 0: "0", + 999: "999", + 2500: "2.5K", + 1_500_000: "1.5M", + 2_500_000_000: "2.5B", + } + for in, want := range powers { + if got := FormatPower(in); got != want { + t.Errorf("FormatPower(%d) = %q, want %q", in, got, want) + } + } +} + +// TestFormatShortDuration covers the block-time formatter's three ranges. It +// is read live during upgrades (`akt monitor`), where a wrong unit makes a +// stalled chain look healthy. +func TestFormatShortDuration(t *testing.T) { + cases := []struct { + in time.Duration + want string + }{ + {350 * time.Millisecond, "350ms"}, + {999 * time.Millisecond, "999ms"}, + {time.Second, "1.0s"}, + {3500 * time.Millisecond, "3.5s"}, + {59 * time.Second, "59.0s"}, + {90 * time.Second, "1m30s"}, + } + + for _, c := range cases { + if got := FormatShortDuration(c.in); got != c.want { + t.Errorf("FormatShortDuration(%s) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/output/pretty/helpers.go b/internal/output/pretty/helpers.go index caecb416..6c57924e 100644 --- a/internal/output/pretty/helpers.go +++ b/internal/output/pretty/helpers.go @@ -19,19 +19,23 @@ import ( // Styles used throughout pretty output. All colors are sourced from the // shared theme package so CLI pretty output and TUI views are consistent. +// Each style below is byte-identical to the deprecated theme alias it +// replaced; where the theme has no canonical style with the same rendering +// (bold-only, underlined section, Slate500 key), the style is built here from +// canonical theme colors rather than from a deprecated alias. var ( - StyleBold = theme.Bold - StyleDim = theme.Dim - StyleSection = theme.Section - StyleKey = theme.Key - StyleGreen = theme.Green - StyleYellow = theme.Yellow - StyleRed = theme.Red - StyleGray = theme.Gray - StyleCyan = theme.Cyan - StyleMagenta = theme.Magenta - - StyleHeader = theme.Header + StyleBold = lipgloss.NewStyle().Bold(true) + StyleDim = theme.Secondary + StyleSection = lipgloss.NewStyle().Bold(true).Underline(true) + StyleKey = lipgloss.NewStyle().Foreground(theme.Slate500) + StyleGreen = theme.StateGreen + StyleYellow = theme.StateYellow + StyleRed = lipgloss.NewStyle().Foreground(theme.AccentRed) + StyleGray = theme.Secondary + StyleCyan = lipgloss.NewStyle().Foreground(theme.BlueColor) + StyleMagenta = lipgloss.NewStyle().Foreground(theme.PurpleColor) + + StyleHeader = lipgloss.NewStyle().Bold(true).Foreground(theme.Slate500) ) // FormatCoin formats a Cosmos SDK Coin for human display. @@ -66,6 +70,7 @@ func isMicroDenom(denom string) bool { func formatMicroDenom(amount math.Int, denom string) string { base := strings.ToUpper(denom[1:]) // "uakt" → "AKT" + amount = IntOrZero(amount) uamt := amount.Int64() switch { @@ -85,6 +90,28 @@ func formatMicroDenom(amount math.Int, denom string) string { } } +// DecOrZero returns d, or zero when d carries no value. proto3 omits +// zero-valued fields on the wire, so a field a node never set unmarshals +// into a LegacyDec whose inner big.Int is nil — semantically zero, but any +// arithmetic on it panics. Every formatter takes its Dec through here so a +// sparse response renders as "0" instead of crashing the command. +func DecOrZero(d math.LegacyDec) math.LegacyDec { + if d.IsNil() { + return math.LegacyZeroDec() + } + + return d +} + +// IntOrZero is DecOrZero for math.Int, for the same wire reason. +func IntOrZero(i math.Int) math.Int { + if i.IsNil() { + return math.ZeroInt() + } + + return i +} + func abs64(n int64) int64 { if n < 0 { return -n @@ -104,7 +131,7 @@ func FormatDecCoin(coin sdk.DecCoin) string { // FormatDecAsAKT formats a math.LegacyDec amount (in uakt) using AKT sub-denominations. func FormatDecAsAKT(amount math.LegacyDec) string { - return formatMicroDenomDec(amount, "uakt") + return formatMicroDenomDec(DecOrZero(amount), "uakt") } // FormatDecAmount formats a LegacyDec amount with a denom. @@ -550,7 +577,7 @@ func FormatPercent(s string) string { // FormatPercentDec converts a math.LegacyDec to a percentage string. func FormatPercentDec(d math.LegacyDec) string { - pct := d.MulInt64(100) + pct := DecOrZero(d).MulInt64(100) return TrimDecTrailingZeros(pct.String()) + "%" } diff --git a/internal/output/pretty/oracle.go b/internal/output/pretty/oracle.go index af3ce2ea..69b4e616 100644 --- a/internal/output/pretty/oracle.go +++ b/internal/output/pretty/oracle.go @@ -76,7 +76,7 @@ func RenderAggregatedPrice(res *types.QueryAggregatedPriceResponse) string { Newline(&buf) fmt.Fprintln(&buf, Section("Price Health")) - healthy := "yes" + var healthy string if !ph.IsHealthy { healthy = StyleRed.Render("no") if len(ph.FailureReason) > 0 { diff --git a/internal/output/pretty/params_nil_test.go b/internal/output/pretty/params_nil_test.go new file mode 100644 index 00000000..a01f0415 --- /dev/null +++ b/internal/output/pretty/params_nil_test.go @@ -0,0 +1,49 @@ +package pretty + +import ( + "strings" + "testing" + + "cosmossdk.io/math" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" +) + +// nilDec returns a LegacyDec with a nil inner big.Int, exactly as proto +// unmarshalling produces for an omitted field. +func nilDec() math.LegacyDec { return math.LegacyDec{} } + +// A node that omits a zero-valued proto field sends nothing on the wire, so +// the field unmarshals with a nil inner big.Int. Rendering must degrade to +// "0" rather than panicking the command. +func TestRenderStakingParamsZeroValueFields(t *testing.T) { + // Deliberately zero-value: MinCommissionRate carries a nil Dec. + out := RenderStakingParams(&stakingtypes.Params{BondDenom: "uakt"}) + + if !strings.Contains(out, "Min Commission") { + t.Fatalf("expected the params table to render, got:\n%s", out) + } + if !strings.Contains(out, "0%") { + t.Errorf("nil commission rate should render as 0%%, got:\n%s", out) + } +} + +func TestRenderStakingPoolZeroValueFields(t *testing.T) { + out := RenderStakingPool(&stakingtypes.Pool{}) + + if !strings.Contains(out, "Bonded Tokens") { + t.Fatalf("expected the pool table to render, got:\n%s", out) + } + if !strings.Contains(out, "0 AKT") { + t.Errorf("nil token amounts should render as 0 AKT, got:\n%s", out) + } +} + +func TestFormatHelpersTolerateNilValues(t *testing.T) { + // Each of these panicked before the DecOrZero/IntOrZero guards. + if got := FormatPercentDec(nilDec()); got != "0%" { + t.Errorf("FormatPercentDec(nil) = %q, want 0%%", got) + } + if got := FormatDecAsAKT(nilDec()); !strings.Contains(got, "0") { + t.Errorf("FormatDecAsAKT(nil) = %q, want a zero amount", got) + } +} diff --git a/internal/output/pretty/params_test.go b/internal/output/pretty/params_test.go index e4f7e30d..e73b6e54 100644 --- a/internal/output/pretty/params_test.go +++ b/internal/output/pretty/params_test.go @@ -56,18 +56,18 @@ func TestRenderGovParams(t *testing.T) { return &govv1.QueryParamsResponse{ Params: &govv1.Params{ - VotingPeriod: &votingPeriod, - MinDeposit: sdk.NewCoins(sdk.NewInt64Coin("uakt", 50000000000)), - MaxDepositPeriod: &maxDepositPeriod, - Quorum: "0.334000000000000000", - Threshold: "0.500000000000000000", - VetoThreshold: "0.334000000000000000", - ExpeditedVotingPeriod: &expeditedVotingPeriod, - ExpeditedThreshold: "0.667000000000000000", - ExpeditedMinDeposit: sdk.NewCoins(sdk.NewInt64Coin("uakt", 100000000000)), - BurnVoteQuorum: false, + VotingPeriod: &votingPeriod, + MinDeposit: sdk.NewCoins(sdk.NewInt64Coin("uakt", 50000000000)), + MaxDepositPeriod: &maxDepositPeriod, + Quorum: "0.334000000000000000", + Threshold: "0.500000000000000000", + VetoThreshold: "0.334000000000000000", + ExpeditedVotingPeriod: &expeditedVotingPeriod, + ExpeditedThreshold: "0.667000000000000000", + ExpeditedMinDeposit: sdk.NewCoins(sdk.NewInt64Coin("uakt", 100000000000)), + BurnVoteQuorum: false, BurnProposalDepositPrevote: true, - BurnVoteVeto: true, + BurnVoteVeto: true, }, } }(), diff --git a/internal/output/pretty/printer.go b/internal/output/pretty/printer.go index 58bc2c39..1ea55be3 100644 --- a/internal/output/pretty/printer.go +++ b/internal/output/pretty/printer.go @@ -47,5 +47,10 @@ func PrintQueryResultAny(cmd *cobra.Command, cctx sdkclient.Context, msg interfa } // Not a proto.Message — use legacy print (e.g., for amino-encoded types). + //nolint:staticcheck // SA1019: PrintObjectLegacy is the only client-context + // printer that handles non-proto (amino) values. The proto-only + // replacements cannot encode this branch's input, so swapping it would + // change the rendered encoding for amino types -- a spec decision, not a + // lint fix. Revisit when chain-sdk stops returning interface{}. return cctx.PrintObjectLegacy(msg) } diff --git a/internal/output/pretty/staking.go b/internal/output/pretty/staking.go index d7094e78..f908f8dd 100644 --- a/internal/output/pretty/staking.go +++ b/internal/output/pretty/staking.go @@ -113,8 +113,8 @@ func RenderDelegatorDelegations(res *stakingtypes.QueryDelegatorDelegationsRespo func RenderStakingPool(pool *stakingtypes.Pool) string { var buf strings.Builder fmt.Fprintln(&buf, Section("Staking Pool")) - KV(&buf, "Bonded Tokens", Bold(FormatDecAsAKT(math.LegacyNewDecFromInt(pool.BondedTokens)))) - KV(&buf, "Not Bonded", FormatDecAsAKT(math.LegacyNewDecFromInt(pool.NotBondedTokens))) + KV(&buf, "Bonded Tokens", Bold(FormatDecAsAKT(math.LegacyNewDecFromInt(IntOrZero(pool.BondedTokens))))) + KV(&buf, "Not Bonded", FormatDecAsAKT(math.LegacyNewDecFromInt(IntOrZero(pool.NotBondedTokens)))) return buf.String() } diff --git a/internal/output/pretty/testdata/TestRenderContextShow/Full.golden b/internal/output/pretty/testdata/TestRenderContextShow/Full.golden index 7414c09a..92d4d275 100644 --- a/internal/output/pretty/testdata/TestRenderContextShow/Full.golden +++ b/internal/output/pretty/testdata/TestRenderContextShow/Full.golden @@ -9,6 +9,7 @@ Gas Prices: 0.025uakt Gas Adj: 1.5 + Auth Method: keyring Keyring: default (backend: os) Default Account: deployer Gas: auto @@ -16,3 +17,9 @@ Provider Auth: certificate Store: /home/user/.akt/contexts/production/store Action Log: /home/user/.akt/contexts/production/actions.log + +  Capabilities: + Chain queries: available + Chain transactions: available + Provider gateway: available + Console API: unavailable — run akt console login diff --git a/internal/output/pretty/testdata/TestRenderContextShow/Minimal.golden b/internal/output/pretty/testdata/TestRenderContextShow/Minimal.golden index b0d67598..04cd4e40 100644 --- a/internal/output/pretty/testdata/TestRenderContextShow/Minimal.golden +++ b/internal/output/pretty/testdata/TestRenderContextShow/Minimal.golden @@ -7,6 +7,7 @@ Gas Prices: Gas Adj: + Auth Method: keyring Keyring: test (backend: test) Default Account: (not set) Gas: 200000 @@ -14,3 +15,9 @@ Provider Auth: Store: /tmp/akt-test/contexts/local/store Action Log: /tmp/akt-test/contexts/local/actions.log + +  Capabilities: + Chain queries: available + Chain transactions: available + Provider gateway: available + Console API: unavailable — run akt console login diff --git a/internal/output/pretty/tx.go b/internal/output/pretty/tx.go index 5e764a12..61a817f7 100644 --- a/internal/output/pretty/tx.go +++ b/internal/output/pretty/tx.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "os" - "strings" sdkclient "github.com/cosmos/cosmos-sdk/client" sdk "github.com/cosmos/cosmos-sdk/types" @@ -33,12 +32,18 @@ func PrintTxResult(cmd *cobra.Command, cctx sdkclient.Context, resp interface{}) return cctx.WithOutputFormat("json").PrintProto(pm) } + //nolint:staticcheck // SA1019: PrintObjectLegacy is the only client-context + // printer that accepts a non-proto (amino) value, which is exactly what + // this fallback branch has. See PrintQueryResultAny in printer.go. return cctx.PrintObjectLegacy(resp) case cflags.OutputYAML: if pm, ok := resp.(proto.Message); ok { return cctx.WithOutputFormat("text").PrintProto(pm) } + //nolint:staticcheck // SA1019: PrintObjectLegacy is the only client-context + // printer that accepts a non-proto (amino) value, which is exactly what + // this fallback branch has. See PrintQueryResultAny in printer.go. return cctx.PrintObjectLegacy(resp) } @@ -50,6 +55,9 @@ func PrintTxResult(cmd *cobra.Command, cctx sdkclient.Context, resp interface{}) return cctx.WithOutputFormat("json").PrintProto(pm) } + //nolint:staticcheck // SA1019: PrintObjectLegacy is the only client-context + // printer that accepts a non-proto (amino) value, which is exactly what + // this fallback branch has. See PrintQueryResultAny in printer.go. return cctx.PrintObjectLegacy(resp) } @@ -159,7 +167,7 @@ func renderMsgJSON(w io.Writer, cctx sdkclient.Context, msg sdk.Msg) { return } - WriteHighlightedJSON(w, bz) + _ = WriteHighlightedJSON(w, bz) } // decodeTxFee extracts the FeeTx interface from a TxResponse using the codec. @@ -212,15 +220,3 @@ func extractSigner(resp *sdk.TxResponse) string { return "" } - -// msgTypeShort returns a short name for a message type URL. -// e.g. "/cosmos.bank.v1beta1.MsgSend" -> "MsgSend" -func msgTypeShort(msg sdk.Msg) string { - name := proto.MessageName(msg) - parts := strings.Split(name, ".") - if len(parts) > 0 { - return parts[len(parts)-1] - } - - return name -} diff --git a/internal/output/pretty/tx_formatters.go b/internal/output/pretty/tx_formatters.go index c228d84e..7d10eaec 100644 --- a/internal/output/pretty/tx_formatters.go +++ b/internal/output/pretty/tx_formatters.go @@ -10,6 +10,7 @@ import ( sdkclient "github.com/cosmos/cosmos-sdk/client" sdk "github.com/cosmos/cosmos-sdk/types" + vestingtypes "github.com/cosmos/cosmos-sdk/x/auth/vesting/types" "github.com/cosmos/cosmos-sdk/x/authz" banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" crisistypes "github.com/cosmos/cosmos-sdk/x/crisis/types" @@ -17,7 +18,6 @@ import ( govv1 "github.com/cosmos/cosmos-sdk/x/gov/types/v1" slashingtypes "github.com/cosmos/cosmos-sdk/x/slashing/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" - vestingtypes "github.com/cosmos/cosmos-sdk/x/auth/vesting/types" "cosmossdk.io/x/feegrant" upgradetypes "cosmossdk.io/x/upgrade/types" diff --git a/internal/output/pretty/tx_test.go b/internal/output/pretty/tx_test.go index a6b9b819..96552858 100644 --- a/internal/output/pretty/tx_test.go +++ b/internal/output/pretty/tx_test.go @@ -135,19 +135,19 @@ func TestTxFmtMultipleFormatters(t *testing.T) { expectedTitle string }{ "BankSend": { - msg: &banktypes.MsgSend{}, + msg: &banktypes.MsgSend{}, expectedTitle: "Send", }, "DeploymentCreate": { - msg: &dv1beta.MsgCreateDeployment{}, + msg: &dv1beta.MsgCreateDeployment{}, expectedTitle: "Deployment Created", }, "Delegate": { - msg: &stakingtypes.MsgDelegate{}, + msg: &stakingtypes.MsgDelegate{}, expectedTitle: "Delegate", }, "Vote": { - msg: &govv1.MsgVote{}, + msg: &govv1.MsgVote{}, expectedTitle: "Vote", }, } diff --git a/internal/output/pretty/wasm_test.go b/internal/output/pretty/wasm_test.go index 72aa2a3a..c93b87ad 100644 --- a/internal/output/pretty/wasm_test.go +++ b/internal/output/pretty/wasm_test.go @@ -182,4 +182,3 @@ func TestRenderWasmContractsByCreator(t *testing.T) { }) } } - diff --git a/internal/store/bbolt/export.go b/internal/store/bbolt/export.go index 55814dc9..9fee4f41 100644 --- a/internal/store/bbolt/export.go +++ b/internal/store/bbolt/export.go @@ -63,10 +63,14 @@ func (s *BoltStore) export(ctx context.Context, w io.Writer, format store.Export return fmt.Errorf("write YAML document start: %w", err) } enc := yaml.NewEncoder(w) - defer enc.Close() if err := enc.Encode(env); err != nil { + _ = enc.Close() return fmt.Errorf("encode YAML: %w", err) } + // Close flushes the encoder; a failure here means a truncated export. + if err := enc.Close(); err != nil { + return fmt.Errorf("flush YAML export: %w", err) + } case store.FormatJSON: enc := json.NewEncoder(w) enc.SetIndent("", " ") diff --git a/internal/store/bbolt/migrate.go b/internal/store/bbolt/migrate.go index 93de7f5e..9ab9ca1a 100644 --- a/internal/store/bbolt/migrate.go +++ b/internal/store/bbolt/migrate.go @@ -43,13 +43,13 @@ func RegisterMigration(m Migration) { // latestVersion returns the highest registered migration version, // or currentSchemaVersion if no migrations are registered. func latestVersion() uint64 { - max := currentSchemaVersion + highest := currentSchemaVersion for _, m := range migrations { - if m.Version > max { - max = m.Version + if m.Version > highest { + highest = m.Version } } - return max + return highest } // migrate applies all pending migrations within a single bbolt transaction. @@ -81,7 +81,7 @@ func (s *BoltStore) migrate() error { // schemaVersionFromTx reads the schema version from the meta bucket within a transaction. func schemaVersionFromTx(meta *bolt.Bucket) uint64 { data := meta.Get(keySchemaVersion) - if data == nil || len(data) < 8 { + if len(data) < 8 { return 0 } return binary.BigEndian.Uint64(data) diff --git a/internal/store/bbolt/store.go b/internal/store/bbolt/store.go index 20dfe97c..fc7af3cc 100644 --- a/internal/store/bbolt/store.go +++ b/internal/store/bbolt/store.go @@ -126,7 +126,7 @@ func (s *BoltStore) ListDeployments(_ context.Context, filter store.DeploymentFi return b.ForEach(func(k, v []byte) error { var d store.DeploymentRecord if err := json.Unmarshal(v, &d); err != nil { - return nil // skip corrupt entries + return nil //nolint:nilerr // returning the error would abort the whole bbolt scan over one unreadable row; skipping the row is the intended behaviour } if matchDeployment(&d, filter) { cp := d @@ -190,7 +190,7 @@ func (s *BoltStore) ListLeases(_ context.Context, filter store.LeaseFilter) ([]* return b.ForEach(func(k, v []byte) error { var l store.LeaseRecord if err := json.Unmarshal(v, &l); err != nil { - return nil // skip corrupt entries + return nil //nolint:nilerr // returning the error would abort the whole bbolt scan over one unreadable row; skipping the row is the intended behaviour } if matchLease(&l, filter) { cp := l @@ -254,7 +254,7 @@ func (s *BoltStore) ListBids(_ context.Context, filter store.BidFilter) ([]*stor return b.ForEach(func(k, v []byte) error { var bid store.BidRecord if err := json.Unmarshal(v, &bid); err != nil { - return nil // skip corrupt entries + return nil //nolint:nilerr // returning the error would abort the whole bbolt scan over one unreadable row; skipping the row is the intended behaviour } if matchBid(&bid, filter) { cp := bid @@ -307,7 +307,7 @@ func (s *BoltStore) SchemaVersion() uint64 { _ = s.db.View(func(tx *bolt.Tx) error { b := tx.Bucket(bucketMeta) data := b.Get(keySchemaVersion) - if data == nil || len(data) < 8 { + if len(data) < 8 { return nil } version = binary.BigEndian.Uint64(data) @@ -345,7 +345,7 @@ func (s *BoltStore) Stats(_ context.Context) (*store.StoreStats, error) { if err := depBucket.ForEach(func(k, v []byte) error { var d store.DeploymentRecord if err := json.Unmarshal(v, &d); err != nil { - return nil // skip corrupt + return nil //nolint:nilerr // returning the error would abort the whole bbolt scan over one unreadable row; skipping the row is the intended behaviour } stats.Deployments++ switch d.State { diff --git a/internal/store/store.go b/internal/store/store.go index d04b6cec..b9248cc2 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -149,7 +149,7 @@ type BidRecord struct { // Provider info ProviderAttributes map[string]string `json:"provider_attributes" yaml:"provider_attributes"` - ProviderAudited bool `json:"provider_audited" yaml:"provider_audited"` + ProviderAudited bool `json:"provider_audited" yaml:"provider_audited"` // Timestamps CreatedAt int64 `json:"created_at" yaml:"created_at"` diff --git a/internal/transport/deposit.go b/internal/transport/deposit.go new file mode 100644 index 00000000..0102ab35 --- /dev/null +++ b/internal/transport/deposit.go @@ -0,0 +1,194 @@ +package transport + +import ( + "fmt" + "math" + "strconv" + "strings" + + "pkg.akt.dev/akt/internal/console" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// depositAuto is the deposit value that defers to the rail's default: the +// chain rail resolves it to the chain-minimum deployment deposit; the +// console rail rejects it (an explicit USD amount is required). +const depositAuto = "auto" + +// MinConsoleDepositUSD is the minimum deployment deposit the Console API +// accepts, in USD (SPEC §7.4: console-api deposits are USD, not uakt). +// Exported so every surface enforcing the console minimum (CLI commands, +// workflow adapters) shares one value instead of hard-coding 0.5. +const MinConsoleDepositUSD = console.MinDepositUSD + +// Deposit is a deployment deposit parsed from the unified --deposit syntax +// accepted on every rail (see ParseDeposit). +type Deposit struct { + // Raw is the original input string, preserved for pass-through and + // error messages. + Raw string + + // Auto reports the rail-default form: empty or "auto". + Auto bool + + // IsUSD reports whether the deposit is a USD amount (explicit "5usd"/ + // "$5" forms, and bare numbers, which are USD on the console rail). + IsUSD bool + + // USD is the deposit amount in USD when IsUSD is true. + USD float64 + + // Coin is the chain coin string (e.g. "5000000uakt") when the deposit + // is a coin amount. + Coin string + + // Bare reports that the input was a bare number with no unit. Bare + // amounts are USD on the console rail; the chain rail rejects them + // (coins require a denomination), exactly as it always has. + Bare bool +} + +// ParseDeposit parses the unified deposit syntax shared by every transport +// (SPEC §7.4). Exactly one syntax is accepted on all rails; each rail then +// interprets it via Deposit.RailValue: +// +// ""/"auto" -> rail default (chain: chain-minimum deposit; +// console: error, explicit USD required) +// "5usd", "$5", "5.50usd" -> USD amount (console rail only) +// "5000000uakt", "5akt" -> coin amount (chain rail only) +// "5", "5.50" -> bare number: USD on the console rail; +// rejected on the chain rail (coins need a +// denomination — the historical behavior) +// +// The "usd" unit is case-insensitive and always wins over coin parsing, so a +// value ending in "usd" is never treated as a chain denomination. +func ParseDeposit(s string) (Deposit, error) { + t := strings.TrimSpace(s) + + if t == "" || t == depositAuto { + return Deposit{Raw: t, Auto: true}, nil + } + + // USD: "$5", "$5.50". + if rest, ok := strings.CutPrefix(t, "$"); ok { + usd, err := parseUSDAmount(rest) + if err != nil { + return Deposit{}, fmt.Errorf("invalid deposit %q: %w", s, err) + } + + return Deposit{Raw: t, IsUSD: true, USD: usd}, nil + } + + // USD: "5usd", "5.50USD". Checked before coin parsing so "usd" is never + // treated as a chain denomination. + if rest, ok := cutSuffixFold(t, "usd"); ok { + usd, err := parseUSDAmount(rest) + if err != nil { + return Deposit{}, fmt.Errorf("invalid deposit %q: %w", s, err) + } + + return Deposit{Raw: t, IsUSD: true, USD: usd}, nil + } + + // Bare number: valid syntax on both rails, interpreted per rail. + if usd, err := strconv.ParseFloat(t, 64); err == nil { + if err := validUSDAmount(usd); err != nil { + return Deposit{}, fmt.Errorf("invalid deposit %q: %w", s, err) + } + + return Deposit{Raw: t, IsUSD: true, USD: usd, Bare: true}, nil + } + + // Coin: "5000000uakt", "5akt", "5.5akt", ... + if _, err := sdk.ParseDecCoin(t); err == nil { + return Deposit{Raw: t, Coin: t}, nil + } + + return Deposit{}, fmt.Errorf( + "invalid deposit %q: use a USD amount (e.g. 5usd or $5) or a coin amount (e.g. 5000000uakt)", s) +} + +// RailValue returns the rail-native deposit string for the given transport +// kind, or a clear cross-rail error when the deposit form is not valid on +// that rail. +// +// Chain rail: ""/"auto" pass through (the adapter resolves the chain-minimum +// deposit), coin strings pass through to coin parsing, and USD or bare +// amounts are rejected. Console rail: USD and bare amounts become a plain +// USD number (the Console API wire form), ""/"auto" pass through (the +// adapter reports that an explicit USD deposit is required), and coin +// amounts are rejected. +func (d Deposit) RailValue(kind Kind) (string, error) { + switch kind { + case KindChain: + switch { + case d.Auto: + return d.Raw, nil + case d.Coin != "": + return d.Coin, nil + case d.Bare: + return "", fmt.Errorf( + "deposit %q: a bare amount is a USD deposit, and USD deposits require a console-api context; specify a coin amount like 5000000uakt", d.Raw) + default: + return "", fmt.Errorf( + "deposit %q: USD deposits require a console-api context; specify a coin amount like 5000000uakt", d.Raw) + } + + case KindConsole: + switch { + case d.Auto: + return d.Raw, nil + case d.IsUSD: + return strconv.FormatFloat(d.USD, 'f', -1, 64), nil + default: + return "", fmt.Errorf( + "deposit %q: console deposits are in USD; use e.g. 5usd", d.Raw) + } + + default: + return "", fmt.Errorf("unknown transport kind %q", kind) + } +} + +// parseUSDAmount parses the numeric part of a USD deposit form. +func parseUSDAmount(s string) (float64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("missing USD amount") + } + + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, fmt.Errorf("invalid USD amount %q", s) + } + + if err := validUSDAmount(v); err != nil { + return 0, err + } + + return v, nil +} + +// validUSDAmount rejects negative and non-finite USD amounts. Zero is left +// to the rail (the console adapter enforces its own USD minimum). +func validUSDAmount(v float64) error { + if math.IsNaN(v) || math.IsInf(v, 0) { + return fmt.Errorf("USD amount must be a finite number") + } + + if v < 0 { + return fmt.Errorf("USD amount must not be negative") + } + + return nil +} + +// cutSuffixFold is strings.CutSuffix with ASCII case-insensitive matching. +func cutSuffixFold(s, suffix string) (string, bool) { + if len(s) < len(suffix) || !strings.EqualFold(s[len(s)-len(suffix):], suffix) { + return s, false + } + + return s[:len(s)-len(suffix)], true +} diff --git a/internal/transport/deposit_edges_test.go b/internal/transport/deposit_edges_test.go new file mode 100644 index 00000000..e443de2b --- /dev/null +++ b/internal/transport/deposit_edges_test.go @@ -0,0 +1,142 @@ +package transport + +import ( + "math" + "strconv" + "strings" + "testing" +) + +// TestRailValueUnknownKind covers the default arm of the rail switch. A new +// Kind added without a RailValue case must produce an explicit error rather +// than silently returning an empty deposit string, which downstream would read +// as "auto" and could deploy with the wrong funding. +func TestRailValueUnknownKind(t *testing.T) { + dep, err := ParseDeposit("5usd") + if err != nil { + t.Fatalf("ParseDeposit: %v", err) + } + + value, err := dep.RailValue(Kind("ibc")) + if err == nil { + t.Fatal("an unknown transport kind must be rejected") + } + if value != "" { + t.Errorf("value = %q, want empty on error", value) + } + if !strings.Contains(err.Error(), "unknown transport kind") { + t.Errorf("unexpected error: %v", err) + } +} + +// TestParseDepositRejectsNonFiniteAmounts covers validUSDAmount's NaN/Inf arm. +// Go's ParseFloat accepts "NaN" and "Inf", and either would serialize into a +// Console deposit request as a value the API cannot interpret. +func TestParseDepositRejectsNonFiniteAmounts(t *testing.T) { + for _, in := range []string{"NaN", "Inf", "+Inf", "-Inf", "$NaN", "Infusd", "NaNusd"} { + if dep, err := ParseDeposit(in); err == nil { + t.Errorf("ParseDeposit(%q) = %+v, want an error", in, dep) + } + } + + // Sanity: the guard is what rejects them, not a parse failure upstream. + if _, err := strconv.ParseFloat("NaN", 64); err != nil { + t.Fatalf("precondition: ParseFloat should accept NaN, got %v", err) + } + if err := validUSDAmount(math.NaN()); err == nil { + t.Error("validUSDAmount(NaN) must be an error") + } + if err := validUSDAmount(math.Inf(1)); err == nil { + t.Error("validUSDAmount(+Inf) must be an error") + } +} + +// TestCutSuffixFoldBoundaries covers the length guard in the case-insensitive +// suffix cut. Without it a short input like "sd" would slice out of range. +func TestCutSuffixFoldBoundaries(t *testing.T) { + cases := []struct { + in string + want string + wantOK bool + }{ + {"5usd", "5", true}, + {"5USD", "5", true}, + {"5UsD", "5", true}, + {"usd", "", true}, // suffix equals the whole string + {"sd", "sd", false}, + {"", "", false}, + {"5uakt", "5uakt", false}, + } + + for _, c := range cases { + got, ok := cutSuffixFold(c.in, "usd") + if got != c.want || ok != c.wantOK { + t.Errorf("cutSuffixFold(%q) = (%q, %v), want (%q, %v)", c.in, got, ok, c.want, c.wantOK) + } + } +} + +// TestTranslateDepositParamDoesNotMutateInput pins that the rail translation +// leaves the caller's params map alone. Workflow steps reuse their params map +// across retries, so an in-place rewrite would double-translate on the second +// attempt. +func TestTranslateDepositParamDoesNotMutateInput(t *testing.T) { + params := map[string]string{"deposit": "5usd", "dseq": "12345"} + + out, err := translateDepositParam(KindConsole, params) + if err != nil { + t.Fatalf("translateDepositParam: %v", err) + } + + if params["deposit"] != "5usd" { + t.Errorf("input map was mutated: deposit = %q", params["deposit"]) + } + if out["deposit"] != "5" { + t.Errorf("translated deposit = %q, want the console wire form 5", out["deposit"]) + } + if out["dseq"] != "12345" { + t.Errorf("unrelated params must be carried over, got %v", out) + } +} + +// TestTranslateDepositParamReusesMapWhenUnchanged covers the fast path: when +// the rail-native value equals the raw input there is nothing to rewrite, and +// the original map is returned as-is. +func TestTranslateDepositParamReusesMapWhenUnchanged(t *testing.T) { + params := map[string]string{"deposit": "5000000uakt"} + + out, err := translateDepositParam(KindChain, params) + if err != nil { + t.Fatalf("translateDepositParam: %v", err) + } + if out["deposit"] != "5000000uakt" { + t.Errorf("chain coin deposits must pass through, got %q", out["deposit"]) + } + + // "auto" also passes through untouched on the chain rail. + autoParams := map[string]string{"deposit": "auto"} + out, err = translateDepositParam(KindChain, autoParams) + if err != nil { + t.Fatalf("translateDepositParam(auto): %v", err) + } + if out["deposit"] != "auto" { + t.Errorf(`"auto" must pass through on the chain rail, got %q`, out["deposit"]) + } +} + +// TestMinConsoleDepositMatchesClientConstant pins the single-source-of-truth +// claim in the doc comment. If the console client's minimum ever drifts from +// the transport constant, the CLI would accept a deposit the API rejects. +func TestMinConsoleDepositMatchesClientConstant(t *testing.T) { + if MinConsoleDepositUSD != 0.5 { + t.Errorf("MinConsoleDepositUSD = %v, want 0.5", MinConsoleDepositUSD) + } + + dep, err := ParseDeposit("0.5usd") + if err != nil { + t.Fatalf("ParseDeposit: %v", err) + } + if dep.USD < MinConsoleDepositUSD { + t.Errorf("the documented minimum must itself be acceptable, got %v", dep.USD) + } +} diff --git a/internal/transport/deposit_test.go b/internal/transport/deposit_test.go new file mode 100644 index 00000000..14f6e908 --- /dev/null +++ b/internal/transport/deposit_test.go @@ -0,0 +1,167 @@ +package transport + +import ( + "strings" + "testing" +) + +func TestParseDeposit(t *testing.T) { + tests := []struct { + in string + want Deposit + }{ + // Rail-default forms. + {"", Deposit{Raw: "", Auto: true}}, + {"auto", Deposit{Raw: "auto", Auto: true}}, + // Raw carries the trimmed value: it is passed downstream (e.g. into + // a workflow's tx params), where surrounding whitespace from a YAML + // definition would fail the rail's own parsing. + {" auto ", Deposit{Raw: "auto", Auto: true}}, + + // USD forms. + {"5usd", Deposit{Raw: "5usd", IsUSD: true, USD: 5}}, + {"5.50usd", Deposit{Raw: "5.50usd", IsUSD: true, USD: 5.5}}, + {"5USD", Deposit{Raw: "5USD", IsUSD: true, USD: 5}}, + {"5 usd", Deposit{Raw: "5 usd", IsUSD: true, USD: 5}}, + {"$5", Deposit{Raw: "$5", IsUSD: true, USD: 5}}, + {"$5.50", Deposit{Raw: "$5.50", IsUSD: true, USD: 5.5}}, + {"0.5usd", Deposit{Raw: "0.5usd", IsUSD: true, USD: 0.5}}, + + // Bare numbers: USD on console, rejected on chain (per RailValue). + {"5", Deposit{Raw: "5", IsUSD: true, USD: 5, Bare: true}}, + {"5.50", Deposit{Raw: "5.50", IsUSD: true, USD: 5.5, Bare: true}}, + {"0", Deposit{Raw: "0", IsUSD: true, USD: 0, Bare: true}}, + + // Coin forms. + {"5000000uakt", Deposit{Raw: "5000000uakt", Coin: "5000000uakt"}}, + {"5akt", Deposit{Raw: "5akt", Coin: "5akt"}}, + {"5.5akt", Deposit{Raw: "5.5akt", Coin: "5.5akt"}}, + } + + for _, tt := range tests { + got, err := ParseDeposit(tt.in) + if err != nil { + t.Errorf("ParseDeposit(%q): unexpected error: %v", tt.in, err) + continue + } + if got != tt.want { + t.Errorf("ParseDeposit(%q) = %+v, want %+v", tt.in, got, tt.want) + } + } +} + +func TestParseDepositErrors(t *testing.T) { + for _, in := range []string{ + "abc", // not a USD, bare, or coin form + "$", // missing USD amount + "$abc", // non-numeric USD amount + "$-5", // negative USD + "-5usd", // negative USD + "usd", // missing USD amount + "-5", // negative bare amount + "$5usd", // mixed USD forms + "AUTO", // auto is lowercase only (historical chain behavior) + "-5uakt", // negative coin + "5milusd", // usd suffix always wins; "5mil" is not a USD amount + } { + if _, err := ParseDeposit(in); err == nil { + t.Errorf("ParseDeposit(%q): expected error, got nil", in) + } + } +} + +func TestDepositRailValueChain(t *testing.T) { + tests := []struct { + in string + want string // rail-native value when wantErr is empty + wantErr string // substring of the expected error + }{ + {"", "", ""}, + {"auto", "auto", ""}, + {"5000000uakt", "5000000uakt", ""}, + {"5akt", "5akt", ""}, + {"5usd", "", "USD deposits require a console-api context; specify a coin amount like 5000000uakt"}, + {"$5", "", "USD deposits require a console-api context; specify a coin amount like 5000000uakt"}, + {"5", "", "console-api context; specify a coin amount like 5000000uakt"}, + } + + for _, tt := range tests { + dep, err := ParseDeposit(tt.in) + if err != nil { + t.Fatalf("ParseDeposit(%q): %v", tt.in, err) + } + + got, err := dep.RailValue(KindChain) + if tt.wantErr != "" { + if err == nil { + t.Errorf("RailValue(chain) for %q: expected error, got %q", tt.in, got) + } else if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("RailValue(chain) for %q: error %q does not contain %q", tt.in, err, tt.wantErr) + } + continue + } + if err != nil { + t.Errorf("RailValue(chain) for %q: unexpected error: %v", tt.in, err) + continue + } + if got != tt.want { + t.Errorf("RailValue(chain) for %q = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestDepositRailValueConsole(t *testing.T) { + tests := []struct { + in string + want string + wantErr string + }{ + {"", "", ""}, // passes through; the console adapter demands explicit USD + {"auto", "auto", ""}, // same + {"5usd", "5", ""}, + {"$5.50", "5.5", ""}, + {"5", "5", ""}, + {"5.50", "5.5", ""}, + {"0.5usd", "0.5", ""}, + {"5000000uakt", "", "console deposits are in USD; use e.g. 5usd"}, + {"5akt", "", "console deposits are in USD; use e.g. 5usd"}, + } + + for _, tt := range tests { + dep, err := ParseDeposit(tt.in) + if err != nil { + t.Fatalf("ParseDeposit(%q): %v", tt.in, err) + } + + got, err := dep.RailValue(KindConsole) + if tt.wantErr != "" { + if err == nil { + t.Errorf("RailValue(console) for %q: expected error, got %q", tt.in, got) + } else if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("RailValue(console) for %q: error %q does not contain %q", tt.in, err, tt.wantErr) + } + continue + } + if err != nil { + t.Errorf("RailValue(console) for %q: unexpected error: %v", tt.in, err) + continue + } + if got != tt.want { + t.Errorf("RailValue(console) for %q = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestParseDepositTrimsRawForDownstreamUse(t *testing.T) { + // A user workflow YAML can easily carry padded values; Raw is what the + // rail adapters receive, so it must be usable as-is. + for _, in := range []string{" 5usd ", "\t5000000uakt\n", " 5 "} { + dep, err := ParseDeposit(in) + if err != nil { + t.Fatalf("ParseDeposit(%q): %v", in, err) + } + if dep.Raw != strings.TrimSpace(in) { + t.Errorf("ParseDeposit(%q).Raw = %q, want the trimmed value %q", in, dep.Raw, strings.TrimSpace(in)) + } + } +} diff --git a/internal/transport/transport.go b/internal/transport/transport.go new file mode 100644 index 00000000..fe08e22b --- /dev/null +++ b/internal/transport/transport.go @@ -0,0 +1,144 @@ +// Package transport is the translation layer between workflow actions and +// the rails that execute them. +// +// Actions (deploy, update, close, and future ones) are defined exactly once, +// as workflow definitions, in terms of abstract tx/query/provider steps. A +// Transport translates those steps onto a concrete backing rail: the chain +// transport signs and broadcasts real transactions (with provider-gateway +// calls for manifests), while the console transport maps the same steps onto +// Console API REST calls (SPEC §7.4–§7.5). The CLI surface is generated from +// the workflow definition and the rail is chosen per context at execution +// time, so adding a new action never requires per-rail redesign: define the +// action once and each Transport decides how to carry it. Transports also +// normalize cross-rail argument syntax — notably the unified --deposit forms +// (see ParseDeposit) — so identical command arguments work on every rail. +package transport + +import ( + "context" + "encoding/json" + + sdkclient "github.com/cosmos/cosmos-sdk/client" + + "pkg.akt.dev/akt/internal/console" + "pkg.akt.dev/akt/internal/workflow/adapters" + "pkg.akt.dev/akt/internal/workflow/steps" + aclient "pkg.akt.dev/go/node/client" +) + +// Kind identifies a backing rail. +type Kind string + +// The supported backing rails. +const ( + // KindChain executes actions by signing and broadcasting chain + // transactions locally (keyring auth). + KindChain Kind = "chain" + + // KindConsole executes actions as Console API REST calls + // (console-api auth). + KindConsole Kind = "console" +) + +// Transport translates workflow actions onto a backing rail. +type Transport interface { + Kind() Kind + steps.ChainClient +} + +// NewChain creates the chain transport: workflow tx steps are built, signed, +// and broadcast locally through the Akash node client, and queries run +// against chain RPC/gRPC. +func NewChain(cl aclient.Client) Transport { + return newChainTransport(adapters.NewChainClient(cl)) +} + +// NewConsole creates the console transport: workflow tx steps are mapped to +// Console API REST calls (SPEC §7.5). chainQueries, when non-nil, handles +// query steps directly against the chain; root/ctxName locate the +// per-context manifest cache used to pass the deployment manifest from +// create to lease. +func NewConsole(cc *console.Client, chainQueries steps.ChainClient, root, ctxName string) Transport { + return newConsoleTransport(adapters.NewConsoleChainClient(cc, chainQueries, root, ctxName)) +} + +// NewProvider creates the provider-gateway client used by workflow provider +// steps on the chain rail (JWT or mTLS auth). The console rail has no +// provider client: the Console API submits manifests internally during lease +// creation (SPEC §7.4). +func NewProvider(cctx sdkclient.Context, authType string) steps.ProviderClient { + return adapters.NewProviderClient(cctx, authType) +} + +// newChainTransport wraps an existing chain-rail steps.ChainClient. Split +// from NewChain so tests can inject fakes. +func newChainTransport(inner steps.ChainClient) Transport { + return &railClient{kind: KindChain, inner: inner} +} + +// newConsoleTransport wraps an existing console-rail steps.ChainClient. +// Split from NewConsole so tests can inject fakes. +func newConsoleTransport(inner steps.ChainClient) Transport { + return &railClient{kind: KindConsole, inner: inner} +} + +// railClient implements Transport by normalizing cross-rail argument syntax +// and delegating to the rail's underlying adapter. +type railClient struct { + kind Kind + inner steps.ChainClient +} + +// Kind reports the backing rail. +func (r *railClient) Kind() Kind { return r.kind } + +// BroadcastTx translates rail-independent argument syntax (currently the +// unified "deposit" param, see ParseDeposit) into the rail's native form, +// then delegates to the rail adapter. Cross-rail mistakes — a USD deposit on +// the chain rail, a coin deposit on the console rail — fail here with a +// clear error before anything is sent. +func (r *railClient) BroadcastTx(ctx context.Context, msgType string, params map[string]string) (*steps.TxResult, error) { + translated, err := translateDepositParam(r.kind, params) + if err != nil { + return nil, err + } + + return r.inner.BroadcastTx(ctx, msgType, translated) +} + +// Query delegates to the rail adapter unchanged. +func (r *railClient) Query(ctx context.Context, path string, params map[string]string) (json.RawMessage, error) { + return r.inner.Query(ctx, path, params) +} + +// translateDepositParam rewrites a "deposit" param from the unified syntax +// into the rail-native value the underlying adapter expects. Params without +// a deposit key pass through untouched; the input map is never mutated. +func translateDepositParam(kind Kind, params map[string]string) (map[string]string, error) { + raw, ok := params["deposit"] + if !ok { + return params, nil + } + + dep, err := ParseDeposit(raw) + if err != nil { + return nil, err + } + + value, err := dep.RailValue(kind) + if err != nil { + return nil, err + } + + if value == raw { + return params, nil + } + + out := make(map[string]string, len(params)) + for k, v := range params { + out[k] = v + } + out["deposit"] = value + + return out, nil +} diff --git a/internal/transport/transport_test.go b/internal/transport/transport_test.go new file mode 100644 index 00000000..498f55df --- /dev/null +++ b/internal/transport/transport_test.go @@ -0,0 +1,280 @@ +package transport + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + sdkclient "github.com/cosmos/cosmos-sdk/client" + sdk "github.com/cosmos/cosmos-sdk/types" + + "pkg.akt.dev/akt/internal/console" + "pkg.akt.dev/akt/internal/workflow/steps" + aclient "pkg.akt.dev/go/node/client" + cv1beta3 "pkg.akt.dev/go/node/client/v1beta3" +) + +// Workflow message identifiers, matching internal/workflow/adapters and the +// builtin workflow definitions. +const ( + msgCreateDeployment = "deployment.MsgCreateDeployment" + msgCloseDeployment = "deployment.MsgCloseDeployment" +) + +// --- fakes (mirroring internal/workflow/adapters test fakes) --- + +// fakeTxClient records broadcast messages and returns a canned response. +type fakeTxClient struct { + resp interface{} + err error + msgs []sdk.Msg +} + +func (f *fakeTxClient) BroadcastMsgs(_ context.Context, msgs []sdk.Msg, _ ...cv1beta3.BroadcastOption) (interface{}, error) { + f.msgs = msgs + return f.resp, f.err +} + +func (f *fakeTxClient) BroadcastTx(_ context.Context, _ sdk.Tx, _ ...cv1beta3.BroadcastOption) (interface{}, error) { + return f.resp, f.err +} + +// fakeChainSDKClient embeds the aclient.Client interface so only the methods +// exercised by a test need to be stubbed; anything else panics. +type fakeChainSDKClient struct { + aclient.Client + + tx cv1beta3.TxClient + cctx sdkclient.Context +} + +func (f *fakeChainSDKClient) Tx() cv1beta3.TxClient { return f.tx } +func (f *fakeChainSDKClient) ClientContext() sdkclient.Context { return f.cctx } + +func testOwner() sdk.AccAddress { + return sdk.AccAddress(bytes.Repeat([]byte{0x01}, 20)) +} + +// recordingStepsClient records delegated calls for translation tests. +type recordingStepsClient struct { + calls int + msgType string + params map[string]string + path string +} + +func (r *recordingStepsClient) BroadcastTx(_ context.Context, msgType string, params map[string]string) (*steps.TxResult, error) { + r.calls++ + r.msgType = msgType + r.params = params + + return &steps.TxResult{TxHash: "REC"}, nil +} + +func (r *recordingStepsClient) Query(_ context.Context, path string, params map[string]string) (json.RawMessage, error) { + r.path = path + r.params = params + + return json.RawMessage(`{"bids":[]}`), nil +} + +// --- Kind routing smoke tests --- + +// TestChainTransportKindAndRouting verifies NewChain reports KindChain and +// routes a tx step through the real chain adapter to the node client. +func TestChainTransportKindAndRouting(t *testing.T) { + tx := &fakeTxClient{resp: &sdk.TxResponse{TxHash: "ABC123", Height: 42}} + tr := NewChain(&fakeChainSDKClient{ + tx: tx, + cctx: sdkclient.Context{FromAddress: testOwner()}, + }) + + if tr.Kind() != KindChain { + t.Errorf("Kind() = %q, want %q", tr.Kind(), KindChain) + } + + res, err := tr.BroadcastTx(context.Background(), msgCloseDeployment, map[string]string{"dseq": "12345"}) + if err != nil { + t.Fatalf("BroadcastTx: %v", err) + } + if res.TxHash != "ABC123" || res.Height != 42 { + t.Errorf("result = %+v, want the fake tx response", res) + } + if len(tx.msgs) != 1 { + t.Errorf("broadcast msgs = %d, want 1", len(tx.msgs)) + } +} + +// TestConsoleTransportKindAndRouting verifies NewConsole reports KindConsole +// and routes a create-deployment through the real console adapter with the +// unified deposit syntax translated to a plain USD number on the wire. +func TestConsoleTransportKindAndRouting(t *testing.T) { + var gotMethod, gotPath string + var gotData map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + + var body struct { + Data map[string]any `json:"data"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request body: %v", err) + } + gotData = body.Data + + _, _ = w.Write([]byte(`{"data":{"dseq":"777","manifest":"[]","signTx":{"code":0,"transactionHash":"H1"}}}`)) + })) + t.Cleanup(srv.Close) + + tr := NewConsole(console.New(srv.URL, "test-key"), nil, t.TempDir(), "test-ctx") + + if tr.Kind() != KindConsole { + t.Errorf("Kind() = %q, want %q", tr.Kind(), KindConsole) + } + + res, err := tr.BroadcastTx(context.Background(), msgCreateDeployment, map[string]string{ + "sdl": "services:\n web:\n image: nginx\n", // raw SDL content + "deposit": "5usd", // unified syntax, not the wire form + }) + if err != nil { + t.Fatalf("BroadcastTx: %v", err) + } + + if gotMethod != http.MethodPost || gotPath != "/v1/deployments" { + t.Errorf("request = %s %s, want POST /v1/deployments", gotMethod, gotPath) + } + if dep, ok := gotData["deposit"].(float64); !ok || dep != 5 { + t.Errorf("wire deposit = %v, want 5 (USD) translated from \"5usd\"", gotData["deposit"]) + } + if res.TxHash != "H1" { + t.Errorf("TxHash = %q, want H1", res.TxHash) + } +} + +// --- deposit translation tests --- + +// TestChainTransportDepositPassThrough verifies coin and auto forms reach +// the chain adapter unchanged. +func TestChainTransportDepositPassThrough(t *testing.T) { + for _, deposit := range []string{"5000000uakt", "5akt", "auto", ""} { + rec := &recordingStepsClient{} + tr := newChainTransport(rec) + + if _, err := tr.BroadcastTx(context.Background(), msgCreateDeployment, map[string]string{ + "sdl": "app.yaml", + "deposit": deposit, + }); err != nil { + t.Errorf("deposit %q: unexpected error: %v", deposit, err) + continue + } + if rec.params["deposit"] != deposit { + t.Errorf("deposit %q: delegated as %q, want pass-through", deposit, rec.params["deposit"]) + } + } +} + +// TestChainTransportRejectsUSDDeposit verifies USD and bare forms fail on +// the chain rail with the cross-rail guidance, before any delegation. +func TestChainTransportRejectsUSDDeposit(t *testing.T) { + for _, deposit := range []string{"5usd", "$5", "5.50usd", "5"} { + rec := &recordingStepsClient{} + tr := newChainTransport(rec) + + _, err := tr.BroadcastTx(context.Background(), msgCreateDeployment, map[string]string{"deposit": deposit}) + if err == nil { + t.Errorf("deposit %q: expected cross-rail error", deposit) + continue + } + for _, want := range []string{"console-api context", "5000000uakt"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("deposit %q: error %q does not mention %q", deposit, err, want) + } + } + if rec.calls != 0 { + t.Errorf("deposit %q: adapter was called %d times, want 0", deposit, rec.calls) + } + } +} + +// TestConsoleTransportRewritesUSDDeposit verifies USD and bare forms are +// rewritten to the plain USD number the console adapter expects, without +// mutating the caller's params. +func TestConsoleTransportRewritesUSDDeposit(t *testing.T) { + tests := []struct { + deposit string + want string + }{ + {"5usd", "5"}, + {"$5.50", "5.5"}, + {"5", "5"}, + {"0.5usd", "0.5"}, + } + + for _, tt := range tests { + rec := &recordingStepsClient{} + tr := newConsoleTransport(rec) + + params := map[string]string{"sdl": "app.yaml", "deposit": tt.deposit} + if _, err := tr.BroadcastTx(context.Background(), msgCreateDeployment, params); err != nil { + t.Errorf("deposit %q: unexpected error: %v", tt.deposit, err) + continue + } + if rec.params["deposit"] != tt.want { + t.Errorf("deposit %q: delegated as %q, want %q", tt.deposit, rec.params["deposit"], tt.want) + } + if rec.params["sdl"] != "app.yaml" { + t.Errorf("deposit %q: sdl param lost in translation: %v", tt.deposit, rec.params) + } + if params["deposit"] != tt.deposit { + t.Errorf("deposit %q: caller's params mutated to %q", tt.deposit, params["deposit"]) + } + } +} + +// TestConsoleTransportRejectsCoinDeposit verifies coin forms fail on the +// console rail with the cross-rail guidance, before any delegation. +func TestConsoleTransportRejectsCoinDeposit(t *testing.T) { + for _, deposit := range []string{"5000000uakt", "5akt"} { + rec := &recordingStepsClient{} + tr := newConsoleTransport(rec) + + _, err := tr.BroadcastTx(context.Background(), msgCreateDeployment, map[string]string{"deposit": deposit}) + if err == nil { + t.Errorf("deposit %q: expected cross-rail error", deposit) + continue + } + if !strings.Contains(err.Error(), "console deposits are in USD; use e.g. 5usd") { + t.Errorf("deposit %q: error %q lacks the console USD guidance", deposit, err) + } + if rec.calls != 0 { + t.Errorf("deposit %q: adapter was called %d times, want 0", deposit, rec.calls) + } + } +} + +// TestTransportWithoutDepositParam verifies steps without a deposit param +// (close, lease, queries) delegate untouched. +func TestTransportWithoutDepositParam(t *testing.T) { + rec := &recordingStepsClient{} + tr := newConsoleTransport(rec) + + params := map[string]string{"dseq": "42"} + if _, err := tr.BroadcastTx(context.Background(), msgCloseDeployment, params); err != nil { + t.Fatalf("BroadcastTx: %v", err) + } + if rec.msgType != msgCloseDeployment || rec.params["dseq"] != "42" { + t.Errorf("delegated call = %q %v", rec.msgType, rec.params) + } + + if _, err := tr.Query(context.Background(), "market.bids", map[string]string{"dseq": "42"}); err != nil { + t.Fatalf("Query: %v", err) + } + if rec.path != "market.bids" { + t.Errorf("delegated query path = %q, want market.bids", rec.path) + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 49c5a392..c97ad2eb 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -75,7 +75,7 @@ type Config struct { type App struct { keys keys.KeyMap router Router - palette *views.Palette // will be nil until palette.go is created + palette *views.Palette // will be nil until palette.go is created confirm *components.ConfirmDialog help *views.HelpOverlay logView *views.LogViewer @@ -866,38 +866,6 @@ func (a *App) showToast(msg string, tone components.ToastTone) tea.Cmd { }) } -// monitorStatusLines returns lines 1 and 2 for the status bar when the -// monitor is active. -func (a App) monitorStatusLines() (string, string) { - if a.monitor == nil { - return "", "" - } - - // Type-assert to get StatusInfo from the embedded top model. - type statusProvider interface { - StatusInfo() monitorui.StatusInfo - } - - sp, ok := a.monitor.(statusProvider) - if !ok { - return "", "" - } - - si := sp.StatusInfo() - - // Line 1: tab-specific keybindings. - line1 := si.TabHelpText() - - // Line 2: RPC endpoint + connection mode. - mode := "HTTP" - if si.WSConnected { - mode = "WS" - } - line2 := fmt.Sprintf("RPC: %s [%s]", si.Endpoint, mode) - - return line1, line2 -} - // ─── Log streaming ─────────────────────────────────────────────────── // streamLogs returns a tea.Cmd that reads the next message from a log stream. @@ -1086,13 +1054,13 @@ func buildMonitorModel(cfg Config) (tea.Model, pubsub.Bus, func()) { provCache, err := monitorcache.Open(db) if err != nil { - db.Close() + _ = db.Close() return nil, nil, nil } monCache, err := monitorcache.OpenMonikerCache(db) if err != nil { - db.Close() + _ = db.Close() return nil, nil, nil } @@ -1136,7 +1104,7 @@ func buildMonitorModel(cfg Config) (tea.Model, pubsub.Bus, func()) { _ = cometClient.Stop() } bus.Close() - db.Close() + _ = db.Close() } return model, bus, cleanup diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index e909e014..5c77ba52 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -46,9 +46,12 @@ type mockMonitor struct { receivedNonKey bool } -func (m *mockMonitor) Init() tea.Cmd { return nil } -func (m *mockMonitor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.receivedNonKey = true; return m, nil } -func (m *mockMonitor) View() tea.View { return tea.NewView("monitor") } +func (m *mockMonitor) Init() tea.Cmd { return nil } +func (m *mockMonitor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m.receivedNonKey = true + return m, nil +} +func (m *mockMonitor) View() tea.View { return tea.NewView("monitor") } func TestAppRenderHeader(t *testing.T) { a := newTestApp() diff --git a/internal/tui/components/panel.go b/internal/tui/components/panel.go index 1ee1ec6b..8ee02da6 100644 --- a/internal/tui/components/panel.go +++ b/internal/tui/components/panel.go @@ -39,7 +39,7 @@ func TitledPanelHeight(title, content string, width, minLines int) string { if fillW < 0 { fillW = 0 } - topLine := borderFg.Render("┌─ ") + titleRendered + borderFg.Render(" " + strings.Repeat("─", fillW) + "─┐") + topLine := borderFg.Render("┌─ ") + titleRendered + borderFg.Render(" "+strings.Repeat("─", fillW)+"─┐") // Content lines: │ content...pad │ contentLines := strings.Split(content, "\n") diff --git a/internal/tui/components/table.go b/internal/tui/components/table.go index 0a22b3d5..de6f8e56 100644 --- a/internal/tui/components/table.go +++ b/internal/tui/components/table.go @@ -23,8 +23,8 @@ const ( // TableColumn defines a single column in a ResourceTable. type TableColumn struct { Header string - Width int // character width (0 = fill remaining space) - Align Alignment // left or right alignment + Width int // character width (0 = fill remaining space) + Align Alignment // left or right alignment RenderFunc func(value string) string // optional custom cell renderer (e.g., state tags) } @@ -388,10 +388,3 @@ func (t ResourceTable) computeColumnWidths(totalWidth int) []int { return widths } - -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/internal/tui/components/toast.go b/internal/tui/components/toast.go index 11c292d3..efa83b69 100644 --- a/internal/tui/components/toast.go +++ b/internal/tui/components/toast.go @@ -16,7 +16,7 @@ const ToastDuration = 2500 * time.Millisecond type ToastTone int const ( - ToastOK ToastTone = iota + ToastOK ToastTone = iota ToastInfo ToastError ) diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go index 1601f401..2814878f 100644 --- a/internal/tui/messages/messages.go +++ b/internal/tui/messages/messages.go @@ -1,8 +1,8 @@ package messages import ( - "cosmossdk.io/math" tea "charm.land/bubbletea/v2" + "cosmossdk.io/math" govv1 "github.com/cosmos/cosmos-sdk/x/gov/types/v1" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" diff --git a/internal/tui/views/dashboard.go b/internal/tui/views/dashboard.go index 6ae31f97..dfbb9631 100644 --- a/internal/tui/views/dashboard.go +++ b/internal/tui/views/dashboard.go @@ -47,9 +47,9 @@ type Dashboard struct { height int // Dependencies - svc data.Service // for data loading - ctx DashboardContext // static context info - km keys.KeyMap // key bindings + svc data.Service // for data loading + ctx DashboardContext // static context info + km keys.KeyMap // key bindings // Context contextName string @@ -240,10 +240,10 @@ func (d *Dashboard) SetPriceHistory(data []float64) { } // SetBlockTimes sets the block time sparkline data and summary stats. -func (d *Dashboard) SetBlockTimes(data []float64, avg, max string) { +func (d *Dashboard) SetBlockTimes(data []float64, avg, peak string) { d.blockTimes = data d.blockTimeAvg = avg - d.blockTimeMax = max + d.blockTimeMax = peak } // SetNetworkInfo sets network-level statistics. @@ -536,7 +536,7 @@ func (d *Dashboard) activeContent(innerW int) string { var totalCost string for _, dep := range d.deployments[:limit] { - name := "—" + var name string if dep.SDLPath != "" { name = strings.TrimSuffix( strings.TrimSuffix(dep.SDLPath, ".yaml"), @@ -775,11 +775,6 @@ func keyPill(key string, accent bool) string { Render(key) } -// col renders text into a fixed-width column using a lipgloss style. -func col(style lipgloss.Style, width int, text string) string { - return style.Render(fmt.Sprintf("%-*s", width, text)) -} - // commaGroup formats an integer with comma-separated thousands. func commaGroup(n int64) string { s := fmt.Sprintf("%d", n) diff --git a/internal/tui/views/dashboard_test.go b/internal/tui/views/dashboard_test.go index 11f71347..a5eeb931 100644 --- a/internal/tui/views/dashboard_test.go +++ b/internal/tui/views/dashboard_test.go @@ -17,18 +17,18 @@ import ( // noopService is a minimal data.Service that returns nil for every load. type noopService struct{} -func (noopService) LoadDeployments(string) tea.Cmd { return nil } -func (noopService) LoadLeases(string) tea.Cmd { return nil } -func (noopService) LoadDeploymentLeases(string, uint64) tea.Cmd { return nil } -func (noopService) LoadBids(string, uint64) tea.Cmd { return nil } -func (noopService) LoadProviders() tea.Cmd { return nil } -func (noopService) LoadProposals() tea.Cmd { return nil } -func (noopService) LoadTallies([]*govv1.Proposal) tea.Cmd { return nil } -func (noopService) LoadValidators() tea.Cmd { return nil } -func (noopService) LoadStakingPool() tea.Cmd { return nil } -func (noopService) LoadBalance(string) tea.Cmd { return nil } -func (noopService) LoadStoreStats() tea.Cmd { return nil } -func (noopService) LoadSyncState() tea.Cmd { return nil } +func (noopService) LoadDeployments(string) tea.Cmd { return nil } +func (noopService) LoadLeases(string) tea.Cmd { return nil } +func (noopService) LoadDeploymentLeases(string, uint64) tea.Cmd { return nil } +func (noopService) LoadBids(string, uint64) tea.Cmd { return nil } +func (noopService) LoadProviders() tea.Cmd { return nil } +func (noopService) LoadProposals() tea.Cmd { return nil } +func (noopService) LoadTallies([]*govv1.Proposal) tea.Cmd { return nil } +func (noopService) LoadValidators() tea.Cmd { return nil } +func (noopService) LoadStakingPool() tea.Cmd { return nil } +func (noopService) LoadBalance(string) tea.Cmd { return nil } +func (noopService) LoadStoreStats() tea.Cmd { return nil } +func (noopService) LoadSyncState() tea.Cmd { return nil } // newTestDashboard creates a Dashboard with a noop service and empty context for tests. func newTestDashboard() *views.Dashboard { diff --git a/internal/tui/views/deployment_detail.go b/internal/tui/views/deployment_detail.go index 5e36e827..a7406c31 100644 --- a/internal/tui/views/deployment_detail.go +++ b/internal/tui/views/deployment_detail.go @@ -156,7 +156,7 @@ func (v *DeploymentDetailView) View() tea.View { b.WriteByte('\n') b.WriteString(theme.Muted.Render(" esc: back")) if len(lines) > visibleH { - b.WriteString(lipgloss.NewStyle().Foreground(theme.ColorMuted).Render( + b.WriteString(lipgloss.NewStyle().Foreground(theme.Slate500).Render( " j/k: scroll 1-4: tabs", )) } diff --git a/internal/tui/views/help_test.go b/internal/tui/views/help_test.go index fb93ed0e..87bbddeb 100644 --- a/internal/tui/views/help_test.go +++ b/internal/tui/views/help_test.go @@ -61,9 +61,9 @@ func TestHelpOverlayViewContainsKeybindings(t *testing.T) { // Spot-check some keybindings from each section. bindings := []string{ - "Switch views", // Navigation - "Next item", // Lists - "New deployment", // Actions + "Switch views", // Navigation + "Next item", // Lists + "New deployment", // Actions "Command palette", // Overlays } for _, b := range bindings { diff --git a/internal/tui/views/logviewer.go b/internal/tui/views/logviewer.go index 206f3c0c..d93cff50 100644 --- a/internal/tui/views/logviewer.go +++ b/internal/tui/views/logviewer.go @@ -64,23 +64,23 @@ func (v *LogViewer) Close() { } // Update handles key events for the log viewer overlay. -func (lv *LogViewer) Update(msg tea.Msg) tea.Cmd { +func (v *LogViewer) Update(msg tea.Msg) tea.Cmd { if kmsg, ok := msg.(tea.KeyPressMsg); ok { switch kmsg.String() { case "esc": return CmdFunc(messages.StopLogStreamMsg{}) case " ": - lv.TogglePause() + v.TogglePause() case "c": - lv.Clear() + v.Clear() case "k", "up": - lv.ScrollUp() + v.ScrollUp() case "j", "down": - lv.ScrollDown() + v.ScrollDown() case "G": - lv.ScrollToBottom() + v.ScrollToBottom() case "s": - lv.CycleServiceFilter() + v.CycleServiceFilter() } } return nil @@ -192,8 +192,8 @@ func (v *LogViewer) ScrollDown() { if !v.paused { return } - max := v.maxScroll() - if v.scroll < max { + limit := v.maxScroll() + if v.scroll < limit { v.scroll++ } } @@ -393,4 +393,3 @@ func (v LogViewer) renderFooter(w int) string { } return components.Footer(w, hints) } - diff --git a/internal/tui/views/proposal_detail.go b/internal/tui/views/proposal_detail.go index 2ff93c25..7bdae042 100644 --- a/internal/tui/views/proposal_detail.go +++ b/internal/tui/views/proposal_detail.go @@ -149,17 +149,6 @@ func (v *ProposalDetailView) Refresh() tea.Cmd { return nil } // ─── Helpers ───────────────────────────────────────────────────────── -// truncateStr truncates a string to maxLen characters, appending "…" if truncated. -func truncateStr(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - if maxLen <= 1 { - return "…" - } - return s[:maxLen-1] + "…" -} - // renderProgressLine renders one progress bar line with label, filled/empty blocks, and percentage. func renderProgressLine(label string, pct float64, detail string, barW int) string { filled := int(math.Round(float64(barW) * pct / 100.0)) diff --git a/internal/workflow/actionlog.go b/internal/workflow/actionlog.go new file mode 100644 index 00000000..c8630c1e --- /dev/null +++ b/internal/workflow/actionlog.go @@ -0,0 +1,44 @@ +package workflow + +import ( + "pkg.akt.dev/akt/internal/actionlog" +) + +// ActionLogAdapter implements Logger by writing one type=workflow entry per +// completed step to the per-context action log (SPEC §5.6). A nil adapter or +// nil underlying logger is a no-op, so wiring is always safe. +type ActionLogAdapter struct { + log *actionlog.Logger + workflow string +} + +// NewActionLogAdapter creates an adapter that records steps of the named +// workflow to l. Returns nil when l is nil, which the engine treats as +// "no logging". +func NewActionLogAdapter(l *actionlog.Logger, workflow string) *ActionLogAdapter { + if l == nil { + return nil + } + + return &ActionLogAdapter{log: l, workflow: workflow} +} + +// LogStep records a completed workflow step. Logging is best-effort and +// never interrupts workflow execution. +func (a *ActionLogAdapter) LogStep(workflowID string, stepIndex int, result *StepResult) { + if a == nil || a.log == nil || result == nil { + return + } + + _ = a.log.Log(actionlog.Entry{ + Type: actionlog.TypeWorkflow, + Action: a.workflow, + WorkflowID: workflowID, + Step: stepIndex, + StepName: result.Name, + Status: result.Status, + Error: result.Error, + TxHash: result.TxHash, + Height: result.Height, + }) +} diff --git a/internal/workflow/actionlog_test.go b/internal/workflow/actionlog_test.go new file mode 100644 index 00000000..a0c6369b --- /dev/null +++ b/internal/workflow/actionlog_test.go @@ -0,0 +1,63 @@ +package workflow + +import ( + "path/filepath" + "testing" + + "pkg.akt.dev/akt/internal/actionlog" +) + +func TestActionLogAdapterRecordsSteps(t *testing.T) { + l, err := actionlog.Open(filepath.Join(t.TempDir(), "actions.log")) + if err != nil { + t.Fatalf("open action log: %v", err) + } + defer l.Close() + + a := NewActionLogAdapter(l, "deploy") + + a.LogStep("wf_123", 0, &StepResult{ + Name: "create-deployment", + Type: StepTx, + Status: "success", + TxHash: "ABC", + Height: 10, + }) + a.LogStep("wf_123", 1, &StepResult{ + Name: "send-manifest", + Type: StepProvider, + Status: "failed", + Error: "provider unreachable", + }) + + entries, err := l.Read(actionlog.Filter{Type: actionlog.TypeWorkflow}) + if err != nil { + t.Fatalf("read: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + + // Newest first. + failed := entries[0] + if failed.StepName != "send-manifest" || failed.Status != "failed" || failed.Error != "provider unreachable" { + t.Errorf("failed step entry wrong: %+v", failed) + } + + ok := entries[1] + if ok.Action != "deploy" || ok.WorkflowID != "wf_123" || ok.Step != 0 { + t.Errorf("workflow identity fields wrong: %+v", ok) + } + if ok.TxHash != "ABC" || ok.Height != 10 { + t.Errorf("tx fields not carried into entry: %+v", ok) + } +} + +func TestActionLogAdapterNilSafety(t *testing.T) { + if NewActionLogAdapter(nil, "deploy") != nil { + t.Fatal("nil logger must produce a nil adapter") + } + + var a *ActionLogAdapter + a.LogStep("wf", 0, &StepResult{Name: "x"}) // must not panic +} diff --git a/internal/workflow/adapters/chain.go b/internal/workflow/adapters/chain.go new file mode 100644 index 00000000..cf96a0ef --- /dev/null +++ b/internal/workflow/adapters/chain.go @@ -0,0 +1,347 @@ +// Package adapters implements the narrow client interfaces the workflow +// engine's step executors depend on (steps.ChainClient and +// steps.ProviderClient), backed by the real Akash node and provider gateway +// clients. +package adapters + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/gogoproto/proto" + + "pkg.akt.dev/akt/internal/workflow/steps" + aclient "pkg.akt.dev/go/node/client" + dv1 "pkg.akt.dev/go/node/deployment/v1" + dv1beta "pkg.akt.dev/go/node/deployment/v1beta4" + mv1 "pkg.akt.dev/go/node/market/v1" + mtypes "pkg.akt.dev/go/node/market/v1beta5" + depositv1 "pkg.akt.dev/go/node/types/deposit/v1" +) + +// Workflow tx message identifiers, matching the `msg:` values used by the +// builtin workflow definitions (internal/workflow/builtin/*.yaml). +const ( + msgCreateDeployment = "deployment.MsgCreateDeployment" + msgUpdateDeployment = "deployment.MsgUpdateDeployment" + msgCloseDeployment = "deployment.MsgCloseDeployment" + msgCreateLease = "market.MsgCreateLease" +) + +// Workflow query path identifiers, matching the `query:` values used by the +// builtin workflow definitions. +const ( + queryMarketBids = "market.bids" + queryMarketLeases = "market.leases" + queryDeployments = "deployment.deployments" +) + +// depositAuto is the deposit param value that requests the chain minimum. +const depositAuto = "auto" + +var ( + errDeploymentUpdate = errors.New("deployment update failed") + errDeploymentUpdateGroupsChanged = fmt.Errorf("%w: groups are different than existing deployment, you cannot update groups", errDeploymentUpdate) +) + +// chainClient adapts the Akash node client to the workflow steps.ChainClient +// interface. +type chainClient struct { + cl aclient.Client +} + +// NewChainClient wraps an Akash node client into the narrow interface the +// workflow step executors use to broadcast transactions and run queries. +func NewChainClient(cl aclient.Client) steps.ChainClient { + return &chainClient{cl: cl} +} + +// BroadcastTx builds the sdk.Msg identified by msgType from the resolved +// workflow step params, broadcasts it, and returns the tx result. The +// TxResult.Data payload is a flat JSON object of the identifying fields of +// the message (always including "dseq" for deployment/market messages) so +// that workflow templates such as +// {{ (index .Steps "create-deployment").dseq }} resolve correctly. +func (c *chainClient) BroadcastTx(ctx context.Context, msgType string, params map[string]string) (*steps.TxResult, error) { + owner := c.cl.ClientContext().GetFromAddress() + + var ( + msg sdk.Msg + data map[string]string + ) + + switch msgType { + case msgCreateDeployment: + dseq, err := c.deriveDSeq(ctx, params) + if err != nil { + return nil, err + } + + dep, err := c.resolveDeposit(ctx, params["deposit"]) + if err != nil { + return nil, err + } + + m, err := buildCreateDeploymentMsg(owner, params["sdl"], dseq, dep) + if err != nil { + return nil, err + } + + msg = m + data = map[string]string{ + "dseq": strconv.FormatUint(dseq, 10), + "owner": owner.String(), + } + + case msgUpdateDeployment: + m, groups, err := buildUpdateDeploymentMsg(owner, params) + if err != nil { + return nil, err + } + + if err := c.verifyGroupsUnchanged(ctx, m.ID, groups); err != nil { + return nil, err + } + + msg = m + data = map[string]string{ + "dseq": strconv.FormatUint(m.ID.DSeq, 10), + "owner": owner.String(), + } + + case msgCloseDeployment: + m, err := buildCloseDeploymentMsg(owner, params) + if err != nil { + return nil, err + } + + msg = m + data = map[string]string{ + "dseq": strconv.FormatUint(m.ID.DSeq, 10), + "owner": owner.String(), + } + + case msgCreateLease: + m, err := buildCreateLeaseMsg(owner, params) + if err != nil { + return nil, err + } + + msg = m + data = map[string]string{ + "dseq": strconv.FormatUint(m.BidID.DSeq, 10), + "gseq": strconv.FormatUint(uint64(m.BidID.GSeq), 10), + "oseq": strconv.FormatUint(uint64(m.BidID.OSeq), 10), + "provider": m.BidID.Provider, + "owner": owner.String(), + } + + default: + return nil, fmt.Errorf("unsupported workflow tx message %q", msgType) + } + + return c.broadcast(ctx, msg, data) +} + +// Query runs the chain query identified by path with the given filter params +// and returns the response as JSON. The JSON shape matches the query proto +// response, i.e. a top-level "bids", "leases", or "deployments" array, which +// is what the wait step's `until:` templates (e.g. +// {{ ge (len .Result.bids) 1 }}) expect. +func (c *chainClient) Query(ctx context.Context, path string, params map[string]string) (json.RawMessage, error) { + switch path { + case queryMarketBids: + filters := mtypes.BidFilters{ + Owner: params["owner"], + Provider: params["provider"], + State: params["state"], + } + + var err error + if filters.DSeq, err = optionalUint64Param(params, "dseq"); err != nil { + return nil, err + } + if filters.GSeq, err = optionalUint32Param(params, "gseq"); err != nil { + return nil, err + } + if filters.OSeq, err = optionalUint32Param(params, "oseq"); err != nil { + return nil, err + } + + res, err := c.cl.Query().Market().Bids(ctx, &mtypes.QueryBidsRequest{Filters: filters}) + if err != nil { + return nil, fmt.Errorf("query %s: %w", path, err) + } + + return c.marshalJSON(res) + + case queryMarketLeases: + filters := mv1.LeaseFilters{ + Owner: params["owner"], + Provider: params["provider"], + State: params["state"], + } + + var err error + if filters.DSeq, err = optionalUint64Param(params, "dseq"); err != nil { + return nil, err + } + if filters.GSeq, err = optionalUint32Param(params, "gseq"); err != nil { + return nil, err + } + if filters.OSeq, err = optionalUint32Param(params, "oseq"); err != nil { + return nil, err + } + + res, err := c.cl.Query().Market().Leases(ctx, &mtypes.QueryLeasesRequest{Filters: filters}) + if err != nil { + return nil, fmt.Errorf("query %s: %w", path, err) + } + + return c.marshalJSON(res) + + case queryDeployments: + filters := dv1beta.DeploymentFilters{ + Owner: params["owner"], + State: params["state"], + } + + var err error + if filters.DSeq, err = optionalUint64Param(params, "dseq"); err != nil { + return nil, err + } + + res, err := c.cl.Query().Deployment().Deployments(ctx, &dv1beta.QueryDeploymentsRequest{Filters: filters}) + if err != nil { + return nil, fmt.Errorf("query %s: %w", path, err) + } + + return c.marshalJSON(res) + + default: + return nil, fmt.Errorf("unsupported workflow query path %q", path) + } +} + +// broadcast sends a single message and converts the response into a +// steps.TxResult carrying the given data payload. +func (c *chainClient) broadcast(ctx context.Context, msg sdk.Msg, data map[string]string) (*steps.TxResult, error) { + res, err := c.cl.Tx().BroadcastMsgs(ctx, []sdk.Msg{msg}) + if err != nil { + return nil, err + } + + txResp, ok := res.(*sdk.TxResponse) + if !ok || txResp == nil { + return nil, fmt.Errorf("unexpected broadcast response type %T", res) + } + + if txResp.Code != 0 { + return nil, fmt.Errorf("tx failed with code %d: %s", txResp.Code, txResp.RawLog) + } + + raw, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("marshal tx result data: %w", err) + } + + return &steps.TxResult{ + TxHash: txResp.TxHash, + Height: txResp.Height, + Code: txResp.Code, + Data: raw, + }, nil +} + +// deriveDSeq returns the deployment sequence from params if present, or +// defaults it to the current block height like `akt tx deployment create`. +func (c *chainClient) deriveDSeq(ctx context.Context, params map[string]string) (uint64, error) { + if v := params["dseq"]; v != "" { + dseq, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse param %q: %w", "dseq", err) + } + + return dseq, nil + } + + syncInfo, err := c.cl.Node().SyncInfo(ctx) + if err != nil { + return 0, err + } + + if syncInfo.CatchingUp { + return 0, fmt.Errorf("cannot generate DSEQ from last block height. node is catching up") + } + + return uint64(syncInfo.LatestBlockHeight), nil // nolint: gosec +} + +// resolveDeposit turns the workflow "deposit" param into a deposit object. +// "auto" (or empty) queries the chain minimum deployment deposit, mirroring +// DetectDeploymentDeposit used by `akt tx deployment create`. +func (c *chainClient) resolveDeposit(ctx context.Context, depositStr string) (depositv1.Deposit, error) { + if depositStr == "" || depositStr == depositAuto { + resp, err := c.cl.Query().Deployment().Params(ctx, &dv1beta.QueryParamsRequest{}) + if err != nil { + return depositv1.Deposit{}, err + } + + depositStr = "" + + // always default to ACT + for _, sCoin := range resp.Params.MinDeposits { + if sCoin.Denom == "uact" { + depositStr = fmt.Sprintf("%s%s", sCoin.Amount, sCoin.Denom) + break + } + } + + if depositStr == "" { + return depositv1.Deposit{}, fmt.Errorf("couldn't query default deposit amount for uAKT") + } + } + + return depositFromString(depositStr) +} + +// verifyGroupsUnchanged replicates the safety check performed by +// `akt tx deployment update`: the SDL's groups must match the deployment's +// existing on-chain groups, since group changes are not allowed on update. +func (c *chainClient) verifyGroupsUnchanged(ctx context.Context, id dv1.DeploymentID, groups dv1beta.GroupSpecs) error { + existingDeployment, err := c.cl.Query().Deployment().Deployment(ctx, &dv1beta.QueryDeploymentRequest{ID: id}) + if err != nil { + return err + } + + existingGroups := existingDeployment.GetGroups() + + if len(existingGroups) != len(groups) { + return errDeploymentUpdateGroupsChanged + } + + for i, existingGroup := range existingGroups { + if !existingGroup.GroupSpec.Equal(&groups[i]) { + return errDeploymentUpdateGroupsChanged + } + } + + return nil +} + +// marshalJSON encodes a query response to JSON, preferring the client's proto +// codec (canonical field names, enums as strings, empty arrays emitted) and +// falling back to encoding/json. +func (c *chainClient) marshalJSON(msg proto.Message) (json.RawMessage, error) { + if cdc := c.cl.ClientContext().Codec; cdc != nil { + if bz, err := cdc.MarshalJSON(msg); err == nil { + return bz, nil + } + } + + return json.Marshal(msg) +} diff --git a/internal/workflow/adapters/chain_test.go b/internal/workflow/adapters/chain_test.go new file mode 100644 index 00000000..377e93a3 --- /dev/null +++ b/internal/workflow/adapters/chain_test.go @@ -0,0 +1,414 @@ +package adapters + +import ( + "bytes" + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" + + sdkclient "github.com/cosmos/cosmos-sdk/client" + sdk "github.com/cosmos/cosmos-sdk/types" + + "pkg.akt.dev/akt/internal/workflow" + aclient "pkg.akt.dev/go/node/client" + cv1beta3 "pkg.akt.dev/go/node/client/v1beta3" + depositv1 "pkg.akt.dev/go/node/types/deposit/v1" +) + +const testSDLPath = "testdata/deployment.yaml" + +func testOwner() sdk.AccAddress { + return sdk.AccAddress(bytes.Repeat([]byte{0x01}, 20)) +} + +func testProviderAddr() sdk.AccAddress { + return sdk.AccAddress(bytes.Repeat([]byte{0x02}, 20)) +} + +func testClientContext() sdkclient.Context { + return sdkclient.Context{FromAddress: testOwner()} +} + +func testDeposit(t *testing.T) depositv1.Deposit { + t.Helper() + + dep, err := depositFromString("5000000uakt") + if err != nil { + t.Fatalf("depositFromString: %v", err) + } + + return dep +} + +// --- fakes --- + +type fakeTxClient struct { + resp interface{} + err error + msgs []sdk.Msg +} + +func (f *fakeTxClient) BroadcastMsgs(_ context.Context, msgs []sdk.Msg, _ ...cv1beta3.BroadcastOption) (interface{}, error) { + f.msgs = msgs + return f.resp, f.err +} + +func (f *fakeTxClient) BroadcastTx(_ context.Context, _ sdk.Tx, _ ...cv1beta3.BroadcastOption) (interface{}, error) { + return f.resp, f.err +} + +// fakeChainSDKClient embeds the aclient.Client interface so only the methods +// exercised by a test need to be stubbed; anything else panics. +type fakeChainSDKClient struct { + aclient.Client + + tx cv1beta3.TxClient + cctx sdkclient.Context +} + +func (f *fakeChainSDKClient) Tx() cv1beta3.TxClient { return f.tx } +func (f *fakeChainSDKClient) ClientContext() sdkclient.Context { return f.cctx } + +func newFakeChainClient(tx cv1beta3.TxClient, owner sdk.AccAddress) *chainClient { + return &chainClient{cl: &fakeChainSDKClient{ + tx: tx, + cctx: sdkclient.Context{FromAddress: owner}, + }} +} + +// --- msg builder tests --- + +func TestBuildCreateDeploymentMsg(t *testing.T) { + owner := testOwner() + dep := testDeposit(t) + + msg, err := buildCreateDeploymentMsg(owner, testSDLPath, 12345, dep) + if err != nil { + t.Fatalf("buildCreateDeploymentMsg: %v", err) + } + + if msg.ID.Owner != owner.String() { + t.Errorf("owner = %s, want %s", msg.ID.Owner, owner.String()) + } + if msg.ID.DSeq != 12345 { + t.Errorf("dseq = %d, want 12345", msg.ID.DSeq) + } + if len(msg.Groups) != 1 { + t.Fatalf("groups = %d, want 1", len(msg.Groups)) + } + if msg.Groups[0].Name != "westcoast" { + t.Errorf("group name = %s, want westcoast", msg.Groups[0].Name) + } + if len(msg.Hash) == 0 { + t.Error("hash is empty, want SDL version hash") + } + if !msg.Deposit.Amount.Equal(dep.Amount) { + t.Errorf("deposit = %s, want %s", msg.Deposit.Amount, dep.Amount) + } + if len(msg.Deposit.Sources) != 2 { + t.Errorf("deposit sources = %v, want [grant balance]", msg.Deposit.Sources) + } +} + +func TestBuildCreateDeploymentMsgErrors(t *testing.T) { + owner := testOwner() + dep := testDeposit(t) + + if _, err := buildCreateDeploymentMsg(owner, "", 1, dep); err == nil { + t.Error("expected error for missing sdl path") + } + if _, err := buildCreateDeploymentMsg(owner, testSDLPath, 0, dep); err == nil { + t.Error("expected error for zero dseq") + } + if _, err := buildCreateDeploymentMsg(owner, filepath.Join("testdata", "nope.yaml"), 1, dep); err == nil { + t.Error("expected error for missing sdl file") + } +} + +func TestBuildUpdateDeploymentMsg(t *testing.T) { + owner := testOwner() + + msg, groups, err := buildUpdateDeploymentMsg(owner, map[string]string{ + "sdl": testSDLPath, + "dseq": "42", + }) + if err != nil { + t.Fatalf("buildUpdateDeploymentMsg: %v", err) + } + + if msg.ID.Owner != owner.String() || msg.ID.DSeq != 42 { + t.Errorf("id = %+v, want owner %s dseq 42", msg.ID, owner.String()) + } + if len(msg.Hash) == 0 { + t.Error("hash is empty, want SDL version hash") + } + if len(groups) != 1 { + t.Errorf("groups = %d, want 1", len(groups)) + } + + // The update hash must match the create hash for the same SDL. + create, err := buildCreateDeploymentMsg(owner, testSDLPath, 42, testDeposit(t)) + if err != nil { + t.Fatalf("buildCreateDeploymentMsg: %v", err) + } + if !bytes.Equal(msg.Hash, create.Hash) { + t.Error("update hash differs from create hash for the same SDL") + } +} + +func TestBuildUpdateDeploymentMsgMissingParams(t *testing.T) { + owner := testOwner() + + if _, _, err := buildUpdateDeploymentMsg(owner, map[string]string{"dseq": "42"}); err == nil { + t.Error("expected error for missing sdl") + } + if _, _, err := buildUpdateDeploymentMsg(owner, map[string]string{"sdl": testSDLPath}); err == nil { + t.Error("expected error for missing dseq") + } +} + +func TestBuildCloseDeploymentMsg(t *testing.T) { + owner := testOwner() + + msg, err := buildCloseDeploymentMsg(owner, map[string]string{"dseq": "7"}) + if err != nil { + t.Fatalf("buildCloseDeploymentMsg: %v", err) + } + + if msg.ID.Owner != owner.String() || msg.ID.DSeq != 7 { + t.Errorf("id = %+v, want owner %s dseq 7", msg.ID, owner.String()) + } + + if _, err := buildCloseDeploymentMsg(owner, nil); err == nil { + t.Error("expected error for missing dseq") + } + if _, err := buildCloseDeploymentMsg(owner, map[string]string{"dseq": "abc"}); err == nil { + t.Error("expected error for non-numeric dseq") + } +} + +func TestBuildCreateLeaseMsg(t *testing.T) { + owner := testOwner() + provider := testProviderAddr().String() + + msg, err := buildCreateLeaseMsg(owner, map[string]string{ + "dseq": "7", + "gseq": "2", + "oseq": "3", + "provider": provider, + }) + if err != nil { + t.Fatalf("buildCreateLeaseMsg: %v", err) + } + + id := msg.BidID + if id.Owner != owner.String() || id.DSeq != 7 || id.GSeq != 2 || id.OSeq != 3 || id.Provider != provider { + t.Errorf("bid id = %+v", id) + } +} + +func TestBuildCreateLeaseMsgDefaults(t *testing.T) { + msg, err := buildCreateLeaseMsg(testOwner(), map[string]string{ + "dseq": "7", + "provider": testProviderAddr().String(), + }) + if err != nil { + t.Fatalf("buildCreateLeaseMsg: %v", err) + } + + if msg.BidID.GSeq != 1 || msg.BidID.OSeq != 1 { + t.Errorf("gseq/oseq = %d/%d, want 1/1", msg.BidID.GSeq, msg.BidID.OSeq) + } +} + +func TestBuildCreateLeaseMsgErrors(t *testing.T) { + owner := testOwner() + provider := testProviderAddr().String() + + if _, err := buildCreateLeaseMsg(owner, map[string]string{"provider": provider}); err == nil { + t.Error("expected error for missing dseq") + } + if _, err := buildCreateLeaseMsg(owner, map[string]string{"dseq": "7"}); err == nil { + t.Error("expected error for missing provider") + } + if _, err := buildCreateLeaseMsg(owner, map[string]string{"dseq": "7", "provider": "not-bech32"}); err == nil { + t.Error("expected error for invalid provider address") + } +} + +func TestDepositFromString(t *testing.T) { + dep, err := depositFromString("5000000uakt") + if err != nil { + t.Fatalf("depositFromString: %v", err) + } + + if dep.Amount.Denom != "uakt" || dep.Amount.Amount.Int64() != 5000000 { + t.Errorf("deposit = %s", dep.Amount) + } + if len(dep.Sources) != 2 || dep.Sources[0] != depositv1.SourceGrant || dep.Sources[1] != depositv1.SourceBalance { + t.Errorf("sources = %v, want [grant balance]", dep.Sources) + } + + if _, err := depositFromString("not-a-coin"); err == nil { + t.Error("expected error for invalid coin string") + } +} + +// --- BroadcastTx plumbing tests --- + +func TestBroadcastTxUnsupportedMsgType(t *testing.T) { + c := newFakeChainClient(&fakeTxClient{}, testOwner()) + + _, err := c.BroadcastTx(context.Background(), "bank.MsgSend", nil) + if err == nil { + t.Fatal("expected error for unsupported msg type") + } + if !strings.Contains(err.Error(), `unsupported workflow tx message "bank.MsgSend"`) { + t.Errorf("error = %v, want unsupported workflow tx message", err) + } +} + +func TestBroadcastTxCloseDeployment(t *testing.T) { + tx := &fakeTxClient{resp: &sdk.TxResponse{ + TxHash: "ABC123", + Height: 42, + Code: 0, + }} + c := newFakeChainClient(tx, testOwner()) + + result, err := c.BroadcastTx(context.Background(), msgCloseDeployment, map[string]string{"dseq": "12345"}) + if err != nil { + t.Fatalf("BroadcastTx: %v", err) + } + + if result.TxHash != "ABC123" || result.Height != 42 || result.Code != 0 { + t.Errorf("result = %+v", result) + } + if len(tx.msgs) != 1 { + t.Fatalf("broadcast msgs = %d, want 1", len(tx.msgs)) + } + + var data map[string]string + if err := json.Unmarshal(result.Data, &data); err != nil { + t.Fatalf("unmarshal result data: %v", err) + } + if data["dseq"] != "12345" { + t.Errorf("data dseq = %q, want \"12345\"", data["dseq"]) + } + if data["owner"] != testOwner().String() { + t.Errorf("data owner = %q, want %q", data["owner"], testOwner().String()) + } +} + +func TestBroadcastTxCreateLease(t *testing.T) { + tx := &fakeTxClient{resp: &sdk.TxResponse{TxHash: "LEASE1", Height: 10}} + c := newFakeChainClient(tx, testOwner()) + provider := testProviderAddr().String() + + result, err := c.BroadcastTx(context.Background(), msgCreateLease, map[string]string{ + "dseq": "9000001", + "provider": provider, + }) + if err != nil { + t.Fatalf("BroadcastTx: %v", err) + } + + var data map[string]string + if err := json.Unmarshal(result.Data, &data); err != nil { + t.Fatalf("unmarshal result data: %v", err) + } + if data["dseq"] != "9000001" || data["gseq"] != "1" || data["oseq"] != "1" || data["provider"] != provider { + t.Errorf("data = %v", data) + } +} + +func TestBroadcastTxNonZeroCode(t *testing.T) { + tx := &fakeTxClient{resp: &sdk.TxResponse{ + TxHash: "DEAD", + Code: 5, + RawLog: "insufficient funds", + }} + c := newFakeChainClient(tx, testOwner()) + + _, err := c.BroadcastTx(context.Background(), msgCloseDeployment, map[string]string{"dseq": "1"}) + if err == nil { + t.Fatal("expected error for non-zero tx code") + } + if !strings.Contains(err.Error(), "code 5") || !strings.Contains(err.Error(), "insufficient funds") { + t.Errorf("error = %v, want code and raw log", err) + } +} + +func TestBroadcastTxUnexpectedResponseType(t *testing.T) { + c := newFakeChainClient(&fakeTxClient{resp: "not-a-tx-response"}, testOwner()) + + if _, err := c.BroadcastTx(context.Background(), msgCloseDeployment, map[string]string{"dseq": "1"}); err == nil { + t.Fatal("expected error for unexpected broadcast response type") + } +} + +// TestTxResultDataDSeqExtraction verifies the deploy workflow's output +// pipeline end to end: TxResult.Data -> workflow.ExtractOutputs -> the +// {{ (index .Steps "create-deployment").dseq }} template used by +// builtin/deploy.yaml. dseq is emitted as a JSON string so that large values +// (block heights in the millions) never render in scientific notation. +func TestTxResultDataDSeqExtraction(t *testing.T) { + tx := &fakeTxClient{resp: &sdk.TxResponse{TxHash: "XYZ", Height: 5335560}} + c := newFakeChainClient(tx, testOwner()) + + // Close reuses the same data plumbing as create (dseq + owner) without + // needing node/deposit queries. + result, err := c.BroadcastTx(context.Background(), msgCloseDeployment, map[string]string{"dseq": "5335559"}) + if err != nil { + t.Fatalf("BroadcastTx: %v", err) + } + + state := workflow.NewRunState("wf-1", "deploy", testOwner().String(), nil) + + outputs, err := workflow.ExtractOutputs(nil, result.Data, state) + if err != nil { + t.Fatalf("ExtractOutputs: %v", err) + } + + state.SetStepResult("create-deployment", &workflow.StepResult{ + Name: "create-deployment", + Status: "success", + Output: outputs, + RawResult: result.Data, + TxHash: result.TxHash, + Height: result.Height, + }) + + rendered, err := workflow.ResolveTemplate(`{{ (index .Steps "create-deployment").dseq }}`, state) + if err != nil { + t.Fatalf("ResolveTemplate: %v", err) + } + + if rendered != "5335559" { + t.Errorf("rendered dseq = %q, want \"5335559\"", rendered) + } +} + +// --- Query tests --- + +func TestQueryUnsupportedPath(t *testing.T) { + c := newFakeChainClient(&fakeTxClient{}, testOwner()) + + _, err := c.Query(context.Background(), "bank.balances", nil) + if err == nil { + t.Fatal("expected error for unsupported query path") + } + if !strings.Contains(err.Error(), `unsupported workflow query path "bank.balances"`) { + t.Errorf("error = %v, want unsupported workflow query path", err) + } +} + +func TestQueryBadNumericParam(t *testing.T) { + c := newFakeChainClient(&fakeTxClient{}, testOwner()) + + if _, err := c.Query(context.Background(), queryMarketBids, map[string]string{"dseq": "abc"}); err == nil { + t.Fatal("expected error for non-numeric dseq filter") + } +} diff --git a/internal/workflow/adapters/console.go b/internal/workflow/adapters/console.go new file mode 100644 index 00000000..687b37a2 --- /dev/null +++ b/internal/workflow/adapters/console.go @@ -0,0 +1,310 @@ +package adapters + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "strings" + + "pkg.akt.dev/akt/internal/console" + "pkg.akt.dev/akt/internal/workflow/steps" +) + +// consoleChainClient adapts the Console API client to the workflow +// steps.ChainClient interface, routing tx steps through the Console API per +// SPEC §7.4/§7.5. Queries are delegated to a real chain client when one is +// available (console deployments live on chain, so chain queries work +// unchanged); without one, market.bids falls back to the Console bids +// endpoint. +type consoleChainClient struct { + cc *console.Client + chainQueries steps.ChainClient + root string + ctxName string +} + +// NewConsoleChainClient wraps a Console API client into the workflow +// steps.ChainClient interface. chainQueries, when non-nil, handles query +// steps directly against the chain; root/ctxName locate the per-context +// manifest cache used to pass the deployment manifest from create to lease. +func NewConsoleChainClient(cc *console.Client, chainQueries steps.ChainClient, root, ctxName string) steps.ChainClient { + return &consoleChainClient{ + cc: cc, + chainQueries: chainQueries, + root: root, + ctxName: ctxName, + } +} + +// BroadcastTx routes the workflow tx message to the matching Console API +// endpoint (SPEC §7.5). Message types without a Console mapping produce an +// unsupported-command error directing the user to a keyring context. +func (c *consoleChainClient) BroadcastTx(ctx context.Context, msgType string, params map[string]string) (*steps.TxResult, error) { + switch msgType { + case msgCreateDeployment: + return c.createDeployment(ctx, params) + case msgUpdateDeployment: + return c.updateDeployment(ctx, params) + case msgCloseDeployment: + return c.closeDeployment(ctx, params) + case msgCreateLease: + return c.createLease(ctx, params) + default: + return nil, fmt.Errorf("command %q is not supported with console-api auth. Use a context with auth-method: keyring for this operation", msgType) + } +} + +// Query delegates to the chain query client when available. Without chain +// access, market.bids is served from the Console bids endpoint (shaped like +// the chain response, i.e. a top-level "bids" array); other query paths +// require a keyring context. +func (c *consoleChainClient) Query(ctx context.Context, path string, params map[string]string) (json.RawMessage, error) { + if c.chainQueries != nil { + return c.chainQueries.Query(ctx, path, params) + } + + switch path { + case queryMarketBids: + dseq := params["dseq"] + if dseq == "" { + return nil, fmt.Errorf("query %s via Console requires a %q filter", path, "dseq") + } + + bids, err := c.cc.FetchBids(ctx, dseq) + if err != nil { + return nil, fmt.Errorf("fetch bids for dseq %s: %w", dseq, err) + } + + // Mirror the chain query shape {"bids":[{"bid":{...}}]} so wait + // conditions like {{ ge (len .Result.bids) 1 }} and the prompt + // step's bid parsing work identically for both auth methods. + wrapped := make([]map[string]console.Bid, 0, len(bids)) + for _, b := range bids { + wrapped = append(wrapped, map[string]console.Bid{"bid": b}) + } + + return json.Marshal(map[string]any{"bids": wrapped}) + + default: + return nil, fmt.Errorf("query %q is not supported with console-api auth without chain access. Use a context with auth-method: keyring for this operation", path) + } +} + +// createDeployment maps deployment.MsgCreateDeployment to +// POST /v1/deployments and caches the returned manifest for the subsequent +// lease creation. +func (c *consoleChainClient) createDeployment(ctx context.Context, params map[string]string) (*steps.TxResult, error) { + sdlStr, err := sdlContent(params["sdl"]) + if err != nil { + return nil, fmt.Errorf("%s: %w", msgCreateDeployment, err) + } + + deposit, err := parseConsoleDeposit(params["deposit"]) + if err != nil { + return nil, fmt.Errorf("%s: %w", msgCreateDeployment, err) + } + + res, err := c.cc.CreateDeployment(ctx, sdlStr, deposit) + if err != nil { + return nil, err + } + + dseq := res.DSeq.String() + + // Cache the manifest only when the API actually returned one (mirroring + // the CLI twin): caching an empty file would make the later lease + // creation send an empty manifest instead of failing with the clear + // no-cached-manifest error. + if res.Manifest != "" { + if err := console.SaveManifest(c.root, c.ctxName, dseq, res.Manifest); err != nil { + return nil, fmt.Errorf("deployment %s was created via Console, but caching its manifest failed (lease creation needs it): %w", dseq, err) + } + } + + return consoleTxResult(res.SignTx, map[string]string{"dseq": dseq}) +} + +// updateDeployment maps deployment.MsgUpdateDeployment to +// PUT /v1/deployments/{dseq}. +func (c *consoleChainClient) updateDeployment(ctx context.Context, params map[string]string) (*steps.TxResult, error) { + dseq, err := requiredDSeqParam(params, msgUpdateDeployment) + if err != nil { + return nil, err + } + + sdlStr, err := sdlContent(params["sdl"]) + if err != nil { + return nil, fmt.Errorf("%s: %w", msgUpdateDeployment, err) + } + + if _, err := c.cc.UpdateDeployment(ctx, dseq, sdlStr); err != nil { + return nil, err + } + + return consoleTxResult(nil, map[string]string{"dseq": dseq}) +} + +// closeDeployment maps deployment.MsgCloseDeployment to +// DELETE /v1/deployments/{dseq}. An already-closed deployment is treated as +// success for idempotent behavior. +func (c *consoleChainClient) closeDeployment(ctx context.Context, params map[string]string) (*steps.TxResult, error) { + dseq, err := requiredDSeqParam(params, msgCloseDeployment) + if err != nil { + return nil, err + } + + if err := c.cc.CloseDeployment(ctx, dseq); err != nil && !errors.Is(err, console.ErrAlreadyClosed) { + return nil, err + } + + return consoleTxResult(nil, map[string]string{"dseq": dseq}) +} + +// createLease maps market.MsgCreateLease to POST /v1/leases, sending the +// manifest cached at deployment creation. +func (c *consoleChainClient) createLease(ctx context.Context, params map[string]string) (*steps.TxResult, error) { + dseq, err := requiredDSeqParam(params, msgCreateLease) + if err != nil { + return nil, err + } + + provider := params["provider"] + if provider == "" { + return nil, fmt.Errorf("%s: required param %q missing", msgCreateLease, "provider") + } + + gseq, err := uint32ParamWithDefault(params, "gseq", 1) + if err != nil { + return nil, fmt.Errorf("%s: %w", msgCreateLease, err) + } + + oseq, err := uint32ParamWithDefault(params, "oseq", 1) + if err != nil { + return nil, fmt.Errorf("%s: %w", msgCreateLease, err) + } + + manifest, err := console.LoadManifest(c.root, c.ctxName, dseq) + if err != nil { + return nil, fmt.Errorf("no cached manifest for deployment %s: the manifest is stored when the deployment is created with this context (e.g. `akt deploy`), and the Console API needs it to create the lease: %w", dseq, err) + } + + _, err = c.cc.CreateLease(ctx, manifest, []console.LeaseRequest{{ + DSeq: dseq, + GSeq: gseq, + OSeq: oseq, + Provider: provider, + }}) + if err != nil { + return nil, err + } + + return consoleTxResult(nil, map[string]string{ + "dseq": dseq, + "gseq": strconv.FormatUint(uint64(gseq), 10), + "oseq": strconv.FormatUint(uint64(oseq), 10), + "provider": provider, + }) +} + +// consoleTxResult builds a steps.TxResult from an optional Console SignTx +// broadcast report and a flat data payload (always carrying "dseq" as a JSON +// string, matching the keyring chain adapter). A non-zero SignTx code means +// the managed-wallet broadcast failed on chain even though the Console API +// answered 200; that is a step failure (mirroring the keyring chain +// adapter's TxResponse.Code check), not a success to wait on. +func consoleTxResult(signTx *console.SignTx, data map[string]string) (*steps.TxResult, error) { + if signTx != nil && signTx.Code != 0 { + return nil, fmt.Errorf("tx %s failed with code %d: %s", signTx.TransactionHash, signTx.Code, signTx.RawLog) + } + + raw, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("marshal tx result data: %w", err) + } + + res := &steps.TxResult{Data: raw} + + if signTx != nil { + res.TxHash = signTx.TransactionHash + res.Code = uint32(signTx.Code) // nolint: gosec + } + + return res, nil +} + +// requiredDSeqParam returns the "dseq" param, validated as an unsigned +// integer but kept as a string (the Console API uses string dseqs). +func requiredDSeqParam(params map[string]string, msgType string) (string, error) { + if _, err := requiredUint64Param(params, "dseq"); err != nil { + return "", fmt.Errorf("%s: %w", msgType, err) + } + + return params["dseq"], nil +} + +// sdlContent interprets the workflow "sdl" param as either a path to an SDL +// file or raw SDL content (mirroring readSDL), returning the SDL text the +// Console API expects. A value that looks like a path but does not exist is +// an error — the provider twin fails on such input too (its SDL parse +// rejects a bare path), and silently POSTing a typo'd filename as "SDL +// content" would create a garbage deployment on the managed wallet. +func sdlContent(param string) (string, error) { + s := strings.TrimSpace(param) + if s == "" { + return "", fmt.Errorf("required param %q missing", "sdl") + } + + if info, err := os.Stat(s); err == nil && info.Mode().IsRegular() { + data, err := os.ReadFile(s) + if err != nil { + return "", fmt.Errorf("read SDL file %q: %w", s, err) + } + + return string(data), nil + } + + if looksLikeSDLPath(s) { + return "", fmt.Errorf("SDL file %q does not exist", s) + } + + return param, nil +} + +// looksLikeSDLPath reports whether s is plausibly a file path rather than +// raw SDL content: a single line ending in .yaml/.yml or containing a path +// separator. Raw SDL is multi-line YAML, so it never matches. +func looksLikeSDLPath(s string) bool { + if strings.ContainsRune(s, '\n') { + return false + } + + if strings.HasSuffix(s, ".yaml") || strings.HasSuffix(s, ".yml") { + return true + } + + return strings.ContainsRune(s, '/') || strings.ContainsRune(s, os.PathSeparator) +} + +// parseConsoleDeposit parses the workflow "deposit" param as a USD amount. +// "auto" (the keyring default) and empty values are rejected: the Console +// API needs an explicit USD deposit. +func parseConsoleDeposit(s string) (float64, error) { + s = strings.TrimSpace(s) + if s == "" || s == depositAuto { + return 0, fmt.Errorf("console-api contexts require an explicit deposit in USD: pass --deposit with an amount of at least %.2f (e.g. --deposit 5)", console.MinDepositUSD) + } + + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, fmt.Errorf("invalid deposit %q: console-api contexts take the deposit as a plain USD amount (e.g. --deposit 5)", s) + } + + if v < console.MinDepositUSD { + return 0, fmt.Errorf("deposit %s USD is below the Console minimum of %.2f USD", s, console.MinDepositUSD) + } + + return v, nil +} diff --git a/internal/workflow/adapters/console_test.go b/internal/workflow/adapters/console_test.go new file mode 100644 index 00000000..2726a793 --- /dev/null +++ b/internal/workflow/adapters/console_test.go @@ -0,0 +1,492 @@ +package adapters + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "pkg.akt.dev/akt/internal/console" + "pkg.akt.dev/akt/internal/workflow/steps" +) + +const testConsoleCtx = "test-ctx" + +// newConsoleClient wires an httptest server into a console client backed +// consoleChainClient with a temp manifest root. +func newConsoleClient(t *testing.T, handler http.HandlerFunc) (steps.ChainClient, string) { + t.Helper() + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + root := t.TempDir() + + return NewConsoleChainClient(console.New(srv.URL, "test-key"), nil, root, testConsoleCtx), root +} + +// decodeEnvelope decodes a {"data": ...} request body. +func decodeEnvelope(t *testing.T, r *http.Request) map[string]any { + t.Helper() + + var body struct { + Data map[string]any `json:"data"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + + return body.Data +} + +func writeTestSDL(t *testing.T, content string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "deploy.yaml") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write SDL: %v", err) + } + + return path +} + +func TestConsoleCreateDeployment(t *testing.T) { + const sdlText = "services:\n web:\n image: nginx\n" + sdlPath := writeTestSDL(t, sdlText) + + var gotMethod, gotPath string + var gotData map[string]any + + c, root := newConsoleClient(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + gotData = decodeEnvelope(t, r) + + _, _ = w.Write([]byte(`{"data":{"dseq":"4242","manifest":"[{\"name\":\"web\"}]","signTx":{"code":0,"transactionHash":"HASH1","rawLog":""}}}`)) + }) + + res, err := c.BroadcastTx(context.Background(), msgCreateDeployment, map[string]string{ + "sdl": sdlPath, + "deposit": "5", + }) + if err != nil { + t.Fatalf("BroadcastTx: %v", err) + } + + if gotMethod != http.MethodPost || gotPath != "/v1/deployments" { + t.Errorf("request = %s %s, want POST /v1/deployments", gotMethod, gotPath) + } + if gotData["sdl"] != sdlText { + t.Errorf("sent sdl = %q, want file content %q", gotData["sdl"], sdlText) + } + if dep, ok := gotData["deposit"].(float64); !ok || dep != 5 { + t.Errorf("sent deposit = %v, want 5 USD", gotData["deposit"]) + } + + if res.TxHash != "HASH1" { + t.Errorf("TxHash = %q, want HASH1", res.TxHash) + } + + var data map[string]string + if err := json.Unmarshal(res.Data, &data); err != nil { + t.Fatalf("unmarshal result data: %v", err) + } + if data["dseq"] != "4242" { + t.Errorf("data dseq = %q, want string \"4242\"", data["dseq"]) + } + + // The manifest must be cached for the subsequent lease creation. + manifest, err := console.LoadManifest(root, testConsoleCtx, "4242") + if err != nil { + t.Fatalf("LoadManifest: %v", err) + } + if manifest != `[{"name":"web"}]` { + t.Errorf("cached manifest = %q", manifest) + } +} + +func TestConsoleCreateDeploymentRawSDL(t *testing.T) { + const sdlText = "services:\n raw:\n image: nginx\n" + + var gotSDL any + c, _ := newConsoleClient(t, func(w http.ResponseWriter, r *http.Request) { + gotSDL = decodeEnvelope(t, r)["sdl"] + _, _ = w.Write([]byte(`{"data":{"dseq":"1","manifest":"[]"}}`)) + }) + + // The sdl param is raw content, not a path: it must pass through as-is. + if _, err := c.BroadcastTx(context.Background(), msgCreateDeployment, map[string]string{ + "sdl": sdlText, + "deposit": "0.5", + }); err != nil { + t.Fatalf("BroadcastTx: %v", err) + } + + if gotSDL != sdlText { + t.Errorf("sent sdl = %q, want raw content %q", gotSDL, sdlText) + } +} + +func TestConsoleCreateDeploymentSignTxFailure(t *testing.T) { + // A Console 200 whose managed-wallet broadcast failed on chain + // (signTx.code != 0) is a failed step: treating it as success would + // leave deploy.yaml hanging in wait-for-bids until timeout with the + // rawLog dropped. + sdlPath := writeTestSDL(t, "services:\n web:\n image: nginx\n") + + c, _ := newConsoleClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"dseq":"4243","manifest":"[]","signTx":{"code":5,"transactionHash":"DEADBEEF","rawLog":"insufficient fees"}}}`)) + }) + + _, err := c.BroadcastTx(context.Background(), msgCreateDeployment, map[string]string{ + "sdl": sdlPath, + "deposit": "5", + }) + if err == nil { + t.Fatal("expected a non-zero signTx code to fail the step") + } + for _, want := range []string{"code 5", "insufficient fees", "DEADBEEF"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not surface %q", err.Error(), want) + } + } +} + +func TestConsoleSDLPathMissingIsError(t *testing.T) { + // A value that looks like a file path but does not exist is a typo, not + // raw SDL content: POSTing it as content would create a garbage + // deployment on the managed wallet. + c, _ := newConsoleClient(t, func(w http.ResponseWriter, _ *http.Request) { + t.Error("no request expected for a missing SDL file") + w.WriteHeader(http.StatusInternalServerError) + }) + + for _, param := range []string{ + "deply.yaml", // typo'd filename + "missing/deploy.yml", + filepath.Join(t.TempDir(), "nope.yaml"), + "some/dir/without/suffix", + } { + _, err := c.BroadcastTx(context.Background(), msgCreateDeployment, map[string]string{ + "sdl": param, + "deposit": "5", + }) + if err == nil { + t.Errorf("sdl %q: expected missing-file error, got success", param) + continue + } + if !strings.Contains(err.Error(), "does not exist") { + t.Errorf("sdl %q: error %q does not mention the missing file", param, err) + } + } +} + +func TestConsoleCreateDeploymentEmptyManifestNotCached(t *testing.T) { + // An empty manifest must not be cached (mirroring the CLI twin): + // lease create should fail with the clear no-cached-manifest error + // instead of sending an empty manifest to the provider. + c, root := newConsoleClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"dseq":"4244","manifest":""}}`)) + }) + + if _, err := c.BroadcastTx(context.Background(), msgCreateDeployment, map[string]string{ + "sdl": "services:\n web:\n image: nginx\n", + "deposit": "5", + }); err != nil { + t.Fatalf("BroadcastTx: %v", err) + } + + if _, err := console.LoadManifest(root, testConsoleCtx, "4244"); err == nil { + t.Fatal("empty manifest must not be cached") + } +} + +func TestConsoleCreateDeploymentDepositValidation(t *testing.T) { + c, _ := newConsoleClient(t, func(w http.ResponseWriter, _ *http.Request) { + t.Error("no request expected for invalid deposits") + w.WriteHeader(http.StatusInternalServerError) + }) + + tests := []struct { + deposit string + want string + }{ + {"auto", "USD"}, + {"", "USD"}, + {"auto", "--deposit"}, + {"5000000uakt", "USD"}, + {"0.1", "below the Console minimum"}, + } + + for _, tt := range tests { + _, err := c.BroadcastTx(context.Background(), msgCreateDeployment, map[string]string{ + "sdl": "services: {}", + "deposit": tt.deposit, + }) + if err == nil { + t.Errorf("deposit %q: expected error", tt.deposit) + continue + } + if !strings.Contains(err.Error(), tt.want) { + t.Errorf("deposit %q: error %q does not mention %q", tt.deposit, err, tt.want) + } + } +} + +func TestConsoleUpdateDeployment(t *testing.T) { + const sdlText = "services:\n web:\n image: nginx:1.27\n" + sdlPath := writeTestSDL(t, sdlText) + + var gotMethod, gotPath string + var gotData map[string]any + + c, _ := newConsoleClient(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + gotData = decodeEnvelope(t, r) + _, _ = w.Write([]byte(`{"data":{"deployment":{"id":{"owner":"akash1x","dseq":"777"},"state":"active"}}}`)) + }) + + res, err := c.BroadcastTx(context.Background(), msgUpdateDeployment, map[string]string{ + "sdl": sdlPath, + "dseq": "777", + }) + if err != nil { + t.Fatalf("BroadcastTx: %v", err) + } + + if gotMethod != http.MethodPut || gotPath != "/v1/deployments/777" { + t.Errorf("request = %s %s, want PUT /v1/deployments/777", gotMethod, gotPath) + } + if gotData["sdl"] != sdlText { + t.Errorf("sent sdl = %q, want file content", gotData["sdl"]) + } + + var data map[string]string + _ = json.Unmarshal(res.Data, &data) + if data["dseq"] != "777" { + t.Errorf("data dseq = %q, want \"777\"", data["dseq"]) + } +} + +func TestConsoleCloseDeployment(t *testing.T) { + var gotMethod, gotPath string + + c, _ := newConsoleClient(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + w.WriteHeader(http.StatusOK) + }) + + res, err := c.BroadcastTx(context.Background(), msgCloseDeployment, map[string]string{"dseq": "888"}) + if err != nil { + t.Fatalf("BroadcastTx: %v", err) + } + + if gotMethod != http.MethodDelete || gotPath != "/v1/deployments/888" { + t.Errorf("request = %s %s, want DELETE /v1/deployments/888", gotMethod, gotPath) + } + + var data map[string]string + _ = json.Unmarshal(res.Data, &data) + if data["dseq"] != "888" { + t.Errorf("data dseq = %q, want \"888\"", data["dseq"]) + } +} + +func TestConsoleCloseDeploymentAlreadyClosed(t *testing.T) { + c, _ := newConsoleClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) // maps to ErrAlreadyClosed + }) + + res, err := c.BroadcastTx(context.Background(), msgCloseDeployment, map[string]string{"dseq": "999"}) + if err != nil { + t.Fatalf("already-closed must be treated as success, got: %v", err) + } + + var data map[string]string + _ = json.Unmarshal(res.Data, &data) + if data["dseq"] != "999" { + t.Errorf("data dseq = %q, want \"999\"", data["dseq"]) + } +} + +func TestConsoleUnsupportedMsg(t *testing.T) { + c, _ := newConsoleClient(t, func(w http.ResponseWriter, _ *http.Request) { + t.Error("no request expected for unsupported messages") + w.WriteHeader(http.StatusInternalServerError) + }) + + _, err := c.BroadcastTx(context.Background(), "gov.MsgVote", nil) + if err == nil { + t.Fatal("expected unsupported-command error") + } + // SPEC §7.5 wording. + for _, want := range []string{`"gov.MsgVote"`, "is not supported with console-api auth", "auth-method: keyring"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not contain %q", err.Error(), want) + } + } +} + +func TestConsoleCreateLeaseUsesCachedManifest(t *testing.T) { + var gotPath string + var gotBody map[string]any + + c, root := newConsoleClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode lease body: %v", err) + } + _, _ = w.Write([]byte(`{"data":{"deployment":{"id":{"owner":"akash1x","dseq":"900"},"state":"active"}}}`)) + }) + + if err := console.SaveManifest(root, testConsoleCtx, "900", "[MANIFEST]"); err != nil { + t.Fatalf("SaveManifest: %v", err) + } + + res, err := c.BroadcastTx(context.Background(), msgCreateLease, map[string]string{ + "dseq": "900", + "gseq": "2", + "oseq": "3", + "provider": "akash1provider", + }) + if err != nil { + t.Fatalf("BroadcastTx: %v", err) + } + + if gotPath != "/v1/leases" { + t.Errorf("request path = %s, want /v1/leases", gotPath) + } + if gotBody["manifest"] != "[MANIFEST]" { + t.Errorf("sent manifest = %v, want cached manifest", gotBody["manifest"]) + } + + leases, ok := gotBody["leases"].([]any) + if !ok || len(leases) != 1 { + t.Fatalf("sent leases = %v, want exactly one", gotBody["leases"]) + } + lease := leases[0].(map[string]any) + if lease["dseq"] != "900" || lease["gseq"] != float64(2) || lease["oseq"] != float64(3) || lease["provider"] != "akash1provider" { + t.Errorf("lease request = %v", lease) + } + + var data map[string]string + _ = json.Unmarshal(res.Data, &data) + if data["dseq"] != "900" || data["gseq"] != "2" || data["oseq"] != "3" || data["provider"] != "akash1provider" { + t.Errorf("result data = %v", data) + } +} + +func TestConsoleCreateLeaseMissingManifest(t *testing.T) { + c, _ := newConsoleClient(t, func(w http.ResponseWriter, _ *http.Request) { + t.Error("no request expected when the manifest is missing") + w.WriteHeader(http.StatusInternalServerError) + }) + + _, err := c.BroadcastTx(context.Background(), msgCreateLease, map[string]string{ + "dseq": "901", + "provider": "akash1provider", + }) + if err == nil { + t.Fatal("expected missing-manifest error") + } + for _, want := range []string{"no cached manifest", "901", "akt deploy"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err.Error(), want) + } + } +} + +func TestConsoleQueryBidsFallback(t *testing.T) { + c, _ := newConsoleClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/bids" || r.URL.Query().Get("dseq") != "4242" { + t.Errorf("request = %s?%s, want /v1/bids?dseq=4242", r.URL.Path, r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"data":[{"bid":{"id":{"owner":"akash1x","dseq":"4242","gseq":1,"oseq":1,"provider":"akash1p"},"state":"open","price":{"denom":"uakt","amount":"10"}}}]}`)) + }) + + raw, err := c.Query(context.Background(), queryMarketBids, map[string]string{"dseq": "4242"}) + if err != nil { + t.Fatalf("Query: %v", err) + } + + // The response must use the chain query shape: a top-level "bids" + // array of {"bid": {...}} wrappers, so wait conditions and the prompt + // step behave identically to keyring auth. + var res struct { + Bids []struct { + Bid struct { + ID struct { + DSeq string `json:"dseq"` + Provider string `json:"provider"` + } `json:"id"` + State string `json:"state"` + } `json:"bid"` + } `json:"bids"` + } + if err := json.Unmarshal(raw, &res); err != nil { + t.Fatalf("unmarshal bids: %v", err) + } + if len(res.Bids) != 1 { + t.Fatalf("bids = %d, want 1", len(res.Bids)) + } + if res.Bids[0].Bid.ID.Provider != "akash1p" || res.Bids[0].Bid.ID.DSeq != "4242" { + t.Errorf("bid id = %+v", res.Bids[0].Bid.ID) + } +} + +func TestConsoleQueryUnsupportedPathWithoutChain(t *testing.T) { + c, _ := newConsoleClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + _, err := c.Query(context.Background(), queryMarketLeases, nil) + if err == nil { + t.Fatal("expected error for unsupported query path without chain access") + } + if !strings.Contains(err.Error(), "not supported with console-api auth") { + t.Errorf("error %q lacks the unsupported-query wording", err.Error()) + } +} + +// recordingChainClient records Query calls for delegation tests. +type recordingChainClient struct { + path string + params map[string]string +} + +func (r *recordingChainClient) BroadcastTx(context.Context, string, map[string]string) (*steps.TxResult, error) { + return nil, nil +} + +func (r *recordingChainClient) Query(_ context.Context, path string, params map[string]string) (json.RawMessage, error) { + r.path = path + r.params = params + + return json.RawMessage(`{"bids":[]}`), nil +} + +func TestConsoleQueryDelegatesToChain(t *testing.T) { + rec := &recordingChainClient{} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("console API must not be hit when a chain query client exists") + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + c := NewConsoleChainClient(console.New(srv.URL, "k"), rec, t.TempDir(), testConsoleCtx) + + raw, err := c.Query(context.Background(), queryMarketBids, map[string]string{"dseq": "1", "owner": "akash1x"}) + if err != nil { + t.Fatalf("Query: %v", err) + } + if rec.path != queryMarketBids || rec.params["owner"] != "akash1x" { + t.Errorf("delegated query = %q %v", rec.path, rec.params) + } + if string(raw) != `{"bids":[]}` { + t.Errorf("raw = %s, want the chain client's response", raw) + } +} diff --git a/internal/workflow/adapters/msgs.go b/internal/workflow/adapters/msgs.go new file mode 100644 index 00000000..ef7422f6 --- /dev/null +++ b/internal/workflow/adapters/msgs.go @@ -0,0 +1,225 @@ +package adapters + +import ( + "fmt" + "strconv" + + sdk "github.com/cosmos/cosmos-sdk/types" + + dv1 "pkg.akt.dev/go/node/deployment/v1" + dv1beta "pkg.akt.dev/go/node/deployment/v1beta4" + mv1 "pkg.akt.dev/go/node/market/v1" + mtypes "pkg.akt.dev/go/node/market/v1beta5" + depositv1 "pkg.akt.dev/go/node/types/deposit/v1" + "pkg.akt.dev/go/sdl" +) + +// buildCreateDeploymentMsg builds a MsgCreateDeployment from an SDL file, +// mirroring `akt tx deployment create`: group specs and version hash come +// from the SDL, the deployment ID is owner + dseq, and the deposit is +// attached as-is. +func buildCreateDeploymentMsg(owner sdk.AccAddress, sdlPath string, dseq uint64, dep depositv1.Deposit) (*dv1beta.MsgCreateDeployment, error) { + if sdlPath == "" { + return nil, fmt.Errorf("%s: required param %q missing", msgCreateDeployment, "sdl") + } + + if dseq == 0 { + return nil, fmt.Errorf("%s: dseq must be non-zero", msgCreateDeployment) + } + + sdlManifest, err := sdl.ReadFile(sdlPath) + if err != nil { + return nil, fmt.Errorf("read SDL file %q: %w", sdlPath, err) + } + + groups, err := sdlManifest.DeploymentGroups() + if err != nil { + return nil, err + } + + version, err := sdlManifest.Version() + if err != nil { + return nil, err + } + + msg := &dv1beta.MsgCreateDeployment{ + ID: dv1.DeploymentID{ + Owner: owner.String(), + DSeq: dseq, + }, + Hash: version, + Groups: make(dv1beta.GroupSpecs, 0, len(groups)), + Deposit: dep, + } + + msg.Groups = append(msg.Groups, groups...) + + if err := msg.ValidateBasic(); err != nil { + return nil, err + } + + return msg, nil +} + +// buildUpdateDeploymentMsg builds a MsgUpdateDeployment from the "sdl" and +// "dseq" params, mirroring `akt tx deployment update` (the version hash is +// recomputed from the SDL). It also returns the SDL's group specs so the +// caller can verify they match the existing on-chain deployment. +func buildUpdateDeploymentMsg(owner sdk.AccAddress, params map[string]string) (*dv1beta.MsgUpdateDeployment, dv1beta.GroupSpecs, error) { + sdlPath := params["sdl"] + if sdlPath == "" { + return nil, nil, fmt.Errorf("%s: required param %q missing", msgUpdateDeployment, "sdl") + } + + dseq, err := requiredUint64Param(params, "dseq") + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", msgUpdateDeployment, err) + } + + sdlManifest, err := sdl.ReadFile(sdlPath) + if err != nil { + return nil, nil, fmt.Errorf("read SDL file %q: %w", sdlPath, err) + } + + hash, err := sdlManifest.Version() + if err != nil { + return nil, nil, err + } + + groups, err := sdlManifest.DeploymentGroups() + if err != nil { + return nil, nil, err + } + + msg := &dv1beta.MsgUpdateDeployment{ + ID: dv1.DeploymentID{ + Owner: owner.String(), + DSeq: dseq, + }, + Hash: hash, + } + + return msg, groups, nil +} + +// buildCloseDeploymentMsg builds a MsgCloseDeployment from the "dseq" param. +func buildCloseDeploymentMsg(owner sdk.AccAddress, params map[string]string) (*dv1beta.MsgCloseDeployment, error) { + dseq, err := requiredUint64Param(params, "dseq") + if err != nil { + return nil, fmt.Errorf("%s: %w", msgCloseDeployment, err) + } + + return &dv1beta.MsgCloseDeployment{ + ID: dv1.DeploymentID{ + Owner: owner.String(), + DSeq: dseq, + }, + }, nil +} + +// buildCreateLeaseMsg builds a MsgCreateLease from the "dseq", "gseq", +// "oseq", and "provider" params. gseq and oseq default to 1 when absent; the +// bid owner is the client's from-address. +func buildCreateLeaseMsg(owner sdk.AccAddress, params map[string]string) (*mtypes.MsgCreateLease, error) { + dseq, err := requiredUint64Param(params, "dseq") + if err != nil { + return nil, fmt.Errorf("%s: %w", msgCreateLease, err) + } + + provider := params["provider"] + if provider == "" { + return nil, fmt.Errorf("%s: required param %q missing", msgCreateLease, "provider") + } + + gseq, err := uint32ParamWithDefault(params, "gseq", 1) + if err != nil { + return nil, fmt.Errorf("%s: %w", msgCreateLease, err) + } + + oseq, err := uint32ParamWithDefault(params, "oseq", 1) + if err != nil { + return nil, fmt.Errorf("%s: %w", msgCreateLease, err) + } + + msg := &mtypes.MsgCreateLease{ + BidID: mv1.BidID{ + Owner: owner.String(), + DSeq: dseq, + GSeq: gseq, + OSeq: oseq, + Provider: provider, + }, + } + + if err := msg.ValidateBasic(); err != nil { + return nil, err + } + + return msg, nil +} + +// depositFromString parses a coin string (e.g. "5000000uakt") into a deposit +// with the default sources used by the CLI deposit flags (grant, balance). +func depositFromString(s string) (depositv1.Deposit, error) { + coin, err := sdk.ParseCoinNormalized(s) + if err != nil { + return depositv1.Deposit{}, fmt.Errorf("parse deposit %q: %w", s, err) + } + + return depositv1.Deposit{ + Amount: coin, + Sources: defaultDepositSources(), + }, nil +} + +// defaultDepositSources mirrors the default of the CLI --deposit-sources +// flag: grant first, then balance. +func defaultDepositSources() depositv1.Sources { + return depositv1.Sources{depositv1.SourceGrant, depositv1.SourceBalance} +} + +// requiredUint64Param parses a required unsigned integer param. +func requiredUint64Param(params map[string]string, key string) (uint64, error) { + v := params[key] + if v == "" { + return 0, fmt.Errorf("required param %q missing", key) + } + + n, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse param %q: %w", key, err) + } + + return n, nil +} + +// optionalUint64Param parses an optional unsigned integer param, returning 0 +// when absent. +func optionalUint64Param(params map[string]string, key string) (uint64, error) { + if params[key] == "" { + return 0, nil + } + + return requiredUint64Param(params, key) +} + +// uint32ParamWithDefault parses an optional 32-bit unsigned integer param. +func uint32ParamWithDefault(params map[string]string, key string, def uint32) (uint32, error) { + v := params[key] + if v == "" { + return def, nil + } + + n, err := strconv.ParseUint(v, 10, 32) + if err != nil { + return 0, fmt.Errorf("parse param %q: %w", key, err) + } + + return uint32(n), nil +} + +// optionalUint32Param parses an optional 32-bit unsigned integer param, +// returning 0 (no filter) when absent. +func optionalUint32Param(params map[string]string, key string) (uint32, error) { + return uint32ParamWithDefault(params, key, 0) +} diff --git a/internal/workflow/adapters/provider.go b/internal/workflow/adapters/provider.go new file mode 100644 index 00000000..1444af82 --- /dev/null +++ b/internal/workflow/adapters/provider.go @@ -0,0 +1,171 @@ +package adapters + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + sdkclient "github.com/cosmos/cosmos-sdk/client" + sdk "github.com/cosmos/cosmos-sdk/types" + + aktprovider "pkg.akt.dev/akt/internal/provider" + "pkg.akt.dev/akt/internal/workflow/steps" + mv1 "pkg.akt.dev/go/node/market/v1" + ptypes "pkg.akt.dev/go/node/provider/v1beta4" + rest "pkg.akt.dev/go/provider/client" + "pkg.akt.dev/go/sdl" +) + +// Default group and order sequence used when the workflow only tracks a +// deployment sequence. +const ( + defaultGSeq uint32 = 1 + defaultOSeq uint32 = 1 +) + +// providerClient adapts the provider gateway REST client to the workflow +// steps.ProviderClient interface. The gateway client is constructed per call +// because the target provider is supplied per call; its gateway URL is +// resolved from the provider's on-chain record (HostURI). +type providerClient struct { + cctx sdkclient.Context + authType string +} + +// NewProviderClient creates a workflow provider client. authType is "jwt" +// (default when empty) or "mtls", matching internal/provider.NewGatewayClient. +func NewProviderClient(cctx sdkclient.Context, authType string) steps.ProviderClient { + return &providerClient{ + cctx: cctx, + authType: authType, + } +} + +// SendManifest parses the SDL into a manifest and submits it to the provider +// for the given deployment, mirroring `akt provider send-manifest`. The sdl +// argument may be either raw SDL content or a path to an SDL file (the +// builtin workflows pass the sdl-file path through the provider step). +func (p *providerClient) SendManifest(ctx context.Context, provider string, dseq uint64, sdlData []byte) error { + if dseq == 0 { + return fmt.Errorf("send manifest: dseq is required") + } + + sdlManifest, err := readSDL(sdlData) + if err != nil { + return err + } + + mani, err := sdlManifest.Manifest() + if err != nil { + return fmt.Errorf("build manifest from SDL: %w", err) + } + + cl, err := p.gatewayClient(ctx, provider) + if err != nil { + return err + } + + if err := cl.SubmitManifest(ctx, dseq, mani); err != nil { + return fmt.Errorf("submit manifest: %w", err) + } + + return nil +} + +// LeaseStatus queries the live status of a lease from the provider gateway +// and returns it as JSON. The lease is identified by the client's +// from-address as owner and defaults gseq/oseq to 1. +func (p *providerClient) LeaseStatus(ctx context.Context, provider string, dseq uint64) (json.RawMessage, error) { + if dseq == 0 { + return nil, fmt.Errorf("lease status: dseq is required") + } + + cl, err := p.gatewayClient(ctx, provider) + if err != nil { + return nil, err + } + + lid := mv1.LeaseID{ + Owner: p.cctx.GetFromAddress().String(), + DSeq: dseq, + GSeq: defaultGSeq, + OSeq: defaultOSeq, + Provider: provider, + } + + status, err := cl.LeaseStatus(ctx, lid) + if err != nil { + return nil, fmt.Errorf("query lease status: %w", err) + } + + return json.Marshal(status) +} + +// gatewayClient constructs a provider gateway REST client for the given +// provider address, resolving the gateway URL from the provider's on-chain +// record. This mirrors gatewayClientFromCmd in internal/cli/provider, except +// the URL lookup is done on chain instead of via a --provider-url flag. +func (p *providerClient) gatewayClient(ctx context.Context, provider string) (rest.Client, error) { + if _, err := sdk.AccAddressFromBech32(provider); err != nil { + return nil, fmt.Errorf("invalid provider address %q: %w", provider, err) + } + + hostURI, err := p.providerHostURI(ctx, provider) + if err != nil { + return nil, err + } + + cl, err := aktprovider.NewGatewayClient( + ctx, + p.cctx, + p.cctx.GetFromAddress(), + hostURI, + p.authType, + p.cctx.Keyring, + ) + if err != nil { + return nil, fmt.Errorf("create provider gateway client: %w", err) + } + + return cl, nil +} + +// providerHostURI queries the provider's on-chain record for its gateway URL. +func (p *providerClient) providerHostURI(ctx context.Context, provider string) (string, error) { + res, err := ptypes.NewQueryClient(p.cctx).Provider(ctx, &ptypes.QueryProviderRequest{Owner: provider}) + if err != nil { + return "", fmt.Errorf("query provider %s: %w", provider, err) + } + + if res.Provider.HostURI == "" { + return "", fmt.Errorf("provider %s has no host URI on chain", provider) + } + + return res.Provider.HostURI, nil +} + +// readSDL interprets data as either a path to an SDL file or raw SDL content. +func readSDL(data []byte) (sdl.SDL, error) { + s := strings.TrimSpace(string(data)) + if s == "" { + return nil, fmt.Errorf("empty SDL") + } + + if info, err := os.Stat(s); err == nil && info.Mode().IsRegular() { + obj, err := sdl.ReadFile(s) + if err != nil { + return nil, fmt.Errorf("read SDL file %q: %w", s, err) + } + + return obj, nil + } + + obj, err := sdl.Read(data) + if err != nil { + return nil, fmt.Errorf("parse SDL: %w", err) + } + + return obj, nil +} diff --git a/internal/workflow/adapters/provider_test.go b/internal/workflow/adapters/provider_test.go new file mode 100644 index 00000000..5af3c66c --- /dev/null +++ b/internal/workflow/adapters/provider_test.go @@ -0,0 +1,66 @@ +package adapters + +import ( + "context" + "os" + "testing" +) + +func TestReadSDLFromPath(t *testing.T) { + obj, err := readSDL([]byte(testSDLPath)) + if err != nil { + t.Fatalf("readSDL(path): %v", err) + } + + mani, err := obj.Manifest() + if err != nil { + t.Fatalf("Manifest: %v", err) + } + if len(mani) != 1 { + t.Fatalf("manifest groups = %d, want 1", len(mani)) + } + if len(mani[0].Services) != 1 || mani[0].Services[0].Name != "web" { + t.Errorf("manifest services = %+v, want single web service", mani[0].Services) + } +} + +func TestReadSDLFromContent(t *testing.T) { + content, err := os.ReadFile(testSDLPath) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + + obj, err := readSDL(content) + if err != nil { + t.Fatalf("readSDL(content): %v", err) + } + + if _, err := obj.Manifest(); err != nil { + t.Fatalf("Manifest: %v", err) + } +} + +func TestReadSDLErrors(t *testing.T) { + if _, err := readSDL(nil); err == nil { + t.Error("expected error for empty SDL") + } + if _, err := readSDL([]byte(" \n\t")); err == nil { + t.Error("expected error for blank SDL") + } +} + +func TestSendManifestRequiresDSeq(t *testing.T) { + p := NewProviderClient(testClientContext(), "") + + if err := p.SendManifest(context.Background(), testProviderAddr().String(), 0, []byte(testSDLPath)); err == nil { + t.Error("expected error for zero dseq") + } +} + +func TestLeaseStatusRequiresDSeq(t *testing.T) { + p := NewProviderClient(testClientContext(), "") + + if _, err := p.LeaseStatus(context.Background(), testProviderAddr().String(), 0); err == nil { + t.Error("expected error for zero dseq") + } +} diff --git a/internal/workflow/adapters/testdata/deployment.yaml b/internal/workflow/adapters/testdata/deployment.yaml new file mode 100644 index 00000000..448a8262 --- /dev/null +++ b/internal/workflow/adapters/testdata/deployment.yaml @@ -0,0 +1,32 @@ +--- +version: "2.0" +services: + web: + image: nginx + expose: + - port: 80 + to: + - global: true +profiles: + compute: + web: + resources: + cpu: + units: "100m" + memory: + size: "128Mi" + storage: + size: "1Gi" + placement: + westcoast: + attributes: + region: us-west + pricing: + web: + denom: uakt + amount: 50 +deployment: + web: + westcoast: + profile: web + count: 1 diff --git a/internal/workflow/builtin/deploy.yaml b/internal/workflow/builtin/deploy.yaml index a0137f08..b9ccc7a9 100644 --- a/internal/workflow/builtin/deploy.yaml +++ b/internal/workflow/builtin/deploy.yaml @@ -10,7 +10,7 @@ params: deposit: type: string default: "auto" - description: Initial deposit amount (auto = chain minimum or SDL-specified) + description: "Initial deposit: 5usd or $5 (USD, console-api contexts), 5000000uakt (coin, keyring contexts), auto = chain minimum (keyring)" bid-timeout: type: duration default: "5m" @@ -42,22 +42,26 @@ steps: - name: select-bid type: prompt mode: "{{ index .Params \"bid-select\" }}" - data: "{{ (index .Steps \"wait-for-bids\").bids }}" + data: "{{ toJson (index .Steps \"wait-for-bids\").bids }}" display: - columns: [provider, price, audited] + columns: [provider, price] + on-error: abort - name: create-lease type: tx msg: market.MsgCreateLease params: - bid-id: "{{ (index .Steps \"select-bid\").bid.id }}" + dseq: "{{ (index .Steps \"create-deployment\").dseq }}" + gseq: "{{ (index .Steps \"select-bid\").gseq }}" + oseq: "{{ (index .Steps \"select-bid\").oseq }}" + provider: "{{ (index .Steps \"select-bid\").provider }}" on-error: abort - name: send-manifest type: provider action: send-manifest params: - provider: "{{ (index .Steps \"select-bid\").bid.provider }}" + provider: "{{ (index .Steps \"select-bid\").provider }}" dseq: "{{ (index .Steps \"create-deployment\").dseq }}" sdl: "{{ index .Params \"sdl-file\" }}" retry: diff --git a/internal/workflow/steps/output.go b/internal/workflow/steps/output.go index 9cda6b83..a0d9545f 100644 --- a/internal/workflow/steps/output.go +++ b/internal/workflow/steps/output.go @@ -3,14 +3,21 @@ package steps import ( "context" "fmt" + "io" "os" "time" "pkg.akt.dev/akt/internal/workflow" ) -// OutputExecutor renders a Go template to stdout. -type OutputExecutor struct{} +// OutputExecutor renders a Go template and writes it to Out (stdout by +// default). The rendered text is also recorded in the step result's Output +// under "text" so callers running in structured output modes (e.g. JSONL) +// can redirect Out and still access the rendered content. +type OutputExecutor struct { + // Out is the destination for rendered templates. Nil means os.Stdout. + Out io.Writer +} func (e *OutputExecutor) Type() workflow.StepType { return workflow.StepOutput } @@ -28,12 +35,18 @@ func (e *OutputExecutor) Execute(ctx context.Context, step workflow.StepDef, sta }, fmt.Errorf("render output template: %w", err) } - fmt.Fprint(os.Stdout, rendered) + out := e.Out + if out == nil { + out = os.Stdout + } + + fmt.Fprint(out, rendered) return &workflow.StepResult{ Name: step.Name, Type: step.Type, Status: "success", + Output: map[string]any{"text": rendered}, Duration: time.Since(start), }, nil } diff --git a/internal/workflow/steps/prompt.go b/internal/workflow/steps/prompt.go index 2e15e68e..d9724568 100644 --- a/internal/workflow/steps/prompt.go +++ b/internal/workflow/steps/prompt.go @@ -84,7 +84,13 @@ func (e *PromptExecutor) Execute(ctx context.Context, step workflow.StepDef, sta }, fmt.Errorf("no item selected") } + // Expose the full selected item plus its flattened identity fields + // (dseq/gseq/oseq/provider/price) so downstream steps can reference + // discrete values, e.g. {{ (index .Steps "select-bid").provider }}. outputs := map[string]any{"selected": selected} + for k, v := range bidIdentity(selected) { + outputs[k] = v + } return &workflow.StepResult{ Name: step.Name, @@ -95,17 +101,89 @@ func (e *PromptExecutor) Execute(ctx context.Context, step workflow.StepDef, sta }, nil } +// bidIdentity extracts the identifying fields of a bid-like item as flat, +// stringly-typed values. It understands the chain query shape +// {"bid":{"id":{owner,dseq,gseq,oseq,provider},"price":{...}}}, the flatter +// {"id":{...}} shape, and items carrying the fields at the top level. +// Numbers are normalized to plain decimal strings so templates never render +// scientific notation. +func bidIdentity(item map[string]any) map[string]any { + inner := item + if b, ok := item["bid"].(map[string]any); ok { + inner = b + } + + id := inner + if m, ok := inner["id"].(map[string]any); ok { + id = m + } + + out := make(map[string]any) + for _, k := range []string{"owner", "dseq", "gseq", "oseq", "provider"} { + if v, ok := id[k]; ok { + out[k] = normalizeScalar(v) + } + } + + if p, ok := inner["price"].(map[string]any); ok { + amount, _ := normalizeScalar(p["amount"]).(string) + denom, _ := p["denom"].(string) + out["price"] = strings.TrimSpace(amount + denom) + } + + return out +} + +// bidPrice returns the numeric price amount of a bid-like item, if present. +func bidPrice(item map[string]any) (float64, bool) { + inner := item + if b, ok := item["bid"].(map[string]any); ok { + inner = b + } + + p, ok := inner["price"].(map[string]any) + if !ok { + return 0, false + } + + switch a := p["amount"].(type) { + case float64: + return a, true + case string: + if f, err := strconv.ParseFloat(a, 64); err == nil { + return f, true + } + } + + return 0, false +} + +// normalizeScalar renders JSON scalars as template-friendly values: float64 +// becomes a plain decimal string (no exponent), everything else passes +// through unchanged. +func normalizeScalar(v any) any { + if f, ok := v.(float64); ok { + return strconv.FormatFloat(f, 'f', -1, 64) + } + + return v +} + func interactiveSelect(items []map[string]any, columns []string) (map[string]any, error) { if len(items) == 0 { return nil, fmt.Errorf("no items") } - // Display items. + // Display items. Column values resolve from the item's top level first, + // then from its flattened bid identity (provider, price, dseq, ...). for i, item := range items { + identity := bidIdentity(item) parts := make([]string, 0, len(columns)) for _, col := range columns { if v, ok := item[col]; ok { parts = append(parts, fmt.Sprintf("%v", v)) + } else if v, ok := identity[col]; ok { + parts = append(parts, fmt.Sprintf("%v", v)) } } @@ -126,19 +204,36 @@ func interactiveSelect(items []map[string]any, columns []string) (map[string]any return items[n-1], nil } +// selectCheapest returns the item with the lowest price amount. Items +// without a parseable price rank last; if no item has a price, the first +// item is returned. func selectCheapest(items []map[string]any) map[string]any { if len(items) == 0 { return nil } - // Simple: return first item. Real implementation would compare price fields. - // TODO: implement proper price comparison when price types are defined. - return items[0] + best := items[0] + bestPrice, bestOK := bidPrice(items[0]) + + for _, item := range items[1:] { + price, ok := bidPrice(item) + if !ok { + continue + } + + if !bestOK || price < bestPrice { + best = item + bestPrice = price + bestOK = true + } + } + + return best } func selectByProvider(items []map[string]any, addr string) map[string]any { for _, item := range items { - if p, ok := item["provider"]; ok && fmt.Sprintf("%v", p) == addr { + if p, ok := bidIdentity(item)["provider"]; ok && fmt.Sprintf("%v", p) == addr { return item } } diff --git a/internal/workflow/steps/prompt_test.go b/internal/workflow/steps/prompt_test.go new file mode 100644 index 00000000..3f0ee401 --- /dev/null +++ b/internal/workflow/steps/prompt_test.go @@ -0,0 +1,152 @@ +package steps + +import ( + "context" + "encoding/json" + "testing" + + "pkg.akt.dev/akt/internal/workflow" +) + +// bidsQueryJSON mimics the chain (and console fallback) market.bids query +// response shape: a top-level "bids" array of {"bid": {...}} wrappers. +const bidsQueryJSON = `{ + "bids": [ + {"bid": {"id": {"owner": "akash1x", "dseq": "4242", "gseq": 1, "oseq": 1, "provider": "akash1exp"}, "state": "open", "price": {"denom": "uakt", "amount": "25"}}}, + {"bid": {"id": {"owner": "akash1x", "dseq": "4242", "gseq": 1, "oseq": 2, "provider": "akash1cheap"}, "state": "open", "price": {"denom": "uakt", "amount": "10"}}} + ], + "pagination": {"total": 2} +}` + +// bidsState builds a run state as it looks after the deploy workflow's +// wait-for-bids step: the raw query result exposed as step outputs. +func bidsState(t *testing.T) *workflow.RunState { + t.Helper() + + state := workflow.NewRunState("wf-1", "deploy", "akash1x", map[string]any{"bid-select": "cheapest"}) + + var m map[string]any + if err := json.Unmarshal([]byte(bidsQueryJSON), &m); err != nil { + t.Fatalf("unmarshal bids fixture: %v", err) + } + + state.SetStepResult("wait-for-bids", &workflow.StepResult{ + Name: "wait-for-bids", + Status: "success", + Output: m, + RawResult: json.RawMessage(bidsQueryJSON), + }) + + return state +} + +// TestPromptCheapestSelectsLowestPriceAndExposesIdentity runs the prompt +// executor with the exact mode/data templates used by builtin/deploy.yaml +// and verifies the outputs feed the create-lease step's discrete params. +func TestPromptCheapestSelectsLowestPriceAndExposesIdentity(t *testing.T) { + state := bidsState(t) + + step := workflow.StepDef{ + Name: "select-bid", + Type: workflow.StepPrompt, + Mode: `{{ index .Params "bid-select" }}`, + Data: `{{ toJson (index .Steps "wait-for-bids").bids }}`, + } + + res, err := (&PromptExecutor{}).Execute(context.Background(), step, state) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Status != "success" { + t.Fatalf("status = %q, want success", res.Status) + } + + state.SetStepResult("select-bid", res) + + // The create-lease step templates from builtin/deploy.yaml must resolve + // to the cheapest bid's discrete id fields. + for tmpl, want := range map[string]string{ + `{{ (index .Steps "select-bid").provider }}`: "akash1cheap", + `{{ (index .Steps "select-bid").gseq }}`: "1", + `{{ (index .Steps "select-bid").oseq }}`: "2", + `{{ (index .Steps "select-bid").dseq }}`: "4242", + `{{ (index .Steps "select-bid").price }}`: "10uakt", + } { + got, err := workflow.ResolveTemplate(tmpl, state) + if err != nil { + t.Errorf("ResolveTemplate(%q): %v", tmpl, err) + continue + } + if got != want { + t.Errorf("ResolveTemplate(%q) = %q, want %q", tmpl, got, want) + } + } + + if _, ok := res.Output["selected"].(map[string]any); !ok { + t.Errorf("selected output missing or wrong type: %T", res.Output["selected"]) + } +} + +// TestPromptProviderModeMatchesNestedShape verifies provider= mode +// matches against the nested bid id, not a top-level field. +func TestPromptProviderModeMatchesNestedShape(t *testing.T) { + state := bidsState(t) + + step := workflow.StepDef{ + Name: "select-bid", + Type: workflow.StepPrompt, + Mode: "provider=akash1exp", + Data: `{{ toJson (index .Steps "wait-for-bids").bids }}`, + } + + res, err := (&PromptExecutor{}).Execute(context.Background(), step, state) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Output["provider"] != "akash1exp" { + t.Errorf("provider output = %v, want akash1exp", res.Output["provider"]) + } +} + +// TestPromptNoItems verifies the executor fails cleanly with no bids. +func TestPromptNoItems(t *testing.T) { + state := workflow.NewRunState("wf-1", "deploy", "akash1x", nil) + state.SetStepResult("wait-for-bids", &workflow.StepResult{ + Name: "wait-for-bids", + Status: "success", + Output: map[string]any{"bids": []any{}}, + }) + + step := workflow.StepDef{ + Name: "select-bid", + Type: workflow.StepPrompt, + Mode: "cheapest", + Data: `{{ toJson (index .Steps "wait-for-bids").bids }}`, + } + + if _, err := (&PromptExecutor{}).Execute(context.Background(), step, state); err == nil { + t.Fatal("expected error for empty bid list") + } +} + +func TestSelectCheapest(t *testing.T) { + items := []map[string]any{ + {"bid": map[string]any{"price": map[string]any{"amount": "30", "denom": "uakt"}, "id": map[string]any{"provider": "p1"}}}, + {"bid": map[string]any{"id": map[string]any{"provider": "no-price"}}}, + {"bid": map[string]any{"price": map[string]any{"amount": float64(7), "denom": "uakt"}, "id": map[string]any{"provider": "p3"}}}, + } + + got := selectCheapest(items) + if bidIdentity(got)["provider"] != "p3" { + t.Errorf("selectCheapest picked %v, want provider p3", bidIdentity(got)) + } + + // No prices at all: fall back to the first item. + noPrices := []map[string]any{ + {"bid": map[string]any{"id": map[string]any{"provider": "a"}}}, + {"bid": map[string]any{"id": map[string]any{"provider": "b"}}}, + } + if bidIdentity(selectCheapest(noPrices))["provider"] != "a" { + t.Error("selectCheapest without prices must return the first item") + } +} diff --git a/internal/workflow/steps/tx.go b/internal/workflow/steps/tx.go index 066da92d..3b13e3f7 100644 --- a/internal/workflow/steps/tx.go +++ b/internal/workflow/steps/tx.go @@ -2,7 +2,6 @@ package steps import ( "context" - "encoding/json" "fmt" "time" @@ -77,13 +76,3 @@ func (e *TxExecutor) Execute(ctx context.Context, step workflow.StepDef, state * Duration: time.Since(start), }, nil } - -// Ensure the raw data can be marshalled for output extraction. -func txResultToJSON(data json.RawMessage) map[string]any { - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - return nil - } - - return m -} diff --git a/internal/workflow/template.go b/internal/workflow/template.go index 6420a81d..5e45625a 100644 --- a/internal/workflow/template.go +++ b/internal/workflow/template.go @@ -8,6 +8,21 @@ import ( "text/template" ) +// templateFuncs returns the functions available to workflow templates in +// addition to the text/template builtins. +func templateFuncs() template.FuncMap { + return template.FuncMap{ + // toJson marshals a value to compact JSON. Steps that pass + // structured data to other steps (e.g. the bid list handed to a + // prompt step) must serialize it with toJson so the receiving step + // can parse it back instead of getting Go's fmt representation. + "toJson": func(v any) (string, error) { + b, err := json.Marshal(v) + return string(b), err + }, + } +} + // ResolveTemplate evaluates a Go template string against the workflow run state. // Templates use {{ .Params.key }}, {{ .Steps.name.key }}, {{ .Account }}, etc. func ResolveTemplate(tmpl string, state *RunState) (string, error) { @@ -20,7 +35,7 @@ func ResolveTemplate(tmpl string, state *RunState) (string, error) { return tmpl, nil } - t, err := template.New("").Option("missingkey=zero").Parse(tmpl) + t, err := template.New("").Funcs(templateFuncs()).Option("missingkey=zero").Parse(tmpl) if err != nil { return "", fmt.Errorf("parse template %q: %w", tmpl, err) } @@ -62,6 +77,9 @@ func ExtractOutputs(outputDefs map[string]string, raw json.RawMessage, state *Ru // If no output definitions, return the raw result as a generic map. var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { + //nolint:nilerr // with no output definitions there is nothing the + // caller asked for; a result that is not a JSON object simply + // yields no outputs rather than failing the workflow step. return nil, nil } @@ -179,7 +197,7 @@ func resolveWithResult(tmpl string, state *RunState, result any) (any, error) { data := buildTemplateData(state) data["Result"] = result - t, err := template.New("").Option("missingkey=zero").Parse(tmpl) + t, err := template.New("").Funcs(templateFuncs()).Option("missingkey=zero").Parse(tmpl) if err != nil { return nil, fmt.Errorf("parse template %q: %w", tmpl, err) } diff --git a/internal/workflow/template_extract_test.go b/internal/workflow/template_extract_test.go new file mode 100644 index 00000000..b3340400 --- /dev/null +++ b/internal/workflow/template_extract_test.go @@ -0,0 +1,397 @@ +package workflow_test + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + wf "pkg.akt.dev/akt/internal/workflow" +) + +func newState() *wf.RunState { + return wf.NewRunState("wf-1", "deploy", "akash1owner", map[string]any{ + "dseq": "12345", + "deposit": "5usd", + }) +} + +// TestExtractOutputsNamedTemplates covers the templated-output path used by tx +// and query steps to hand values to later steps: each output template is +// evaluated against .Result (the step's raw JSON) plus the run state. +func TestExtractOutputsNamedTemplates(t *testing.T) { + raw := json.RawMessage(`{"deployment":{"id":{"dseq":"9911","owner":"akash1owner"}},"code":0}`) + + outputs, err := wf.ExtractOutputs(map[string]string{ + "dseq": "{{ .Result.deployment.id.dseq }}", + "owner": "{{ .Result.deployment.id.owner }}", + "account": "{{ .Account }}", + "literal": "no-template-here", + }, raw, newState()) + if err != nil { + t.Fatalf("ExtractOutputs: %v", err) + } + + want := map[string]string{ + "dseq": "9911", + "owner": "akash1owner", + "account": "akash1owner", + "literal": "no-template-here", + } + + for k, v := range want { + if got := outputs[k]; got != v { + t.Errorf("output %q = %v, want %q", k, got, v) + } + } +} + +// TestExtractOutputsWithoutDefinitionsReturnsWholeResult covers the fallback +// branch: a step with no declared outputs exposes the entire decoded result, +// so a later step can reach fields nobody thought to name. +func TestExtractOutputsWithoutDefinitionsReturnsWholeResult(t *testing.T) { + outputs, err := wf.ExtractOutputs(nil, json.RawMessage(`{"a":1,"b":"two"}`), newState()) + if err != nil { + t.Fatalf("ExtractOutputs: %v", err) + } + + if outputs["b"] != "two" { + t.Errorf("outputs = %v, want the decoded result", outputs) + } + + // A non-object result has nothing to expose; that must be nil, not an error + // that aborts an otherwise successful step. + outputs, err = wf.ExtractOutputs(nil, json.RawMessage(`["not","an","object"]`), newState()) + if err != nil { + t.Fatalf("a non-object result must not error: %v", err) + } + if outputs != nil { + t.Errorf("outputs = %v, want nil for a non-object result", outputs) + } +} + +// TestExtractOutputsNilsUnresolvableEntries pins the per-output error handling: +// one bad template must not lose the outputs that did resolve. The bad entry +// becomes nil so a downstream {{ }} reference renders empty rather than +// silently reusing a stale value. +func TestExtractOutputsNilsUnresolvableEntries(t *testing.T) { + outputs, err := wf.ExtractOutputs(map[string]string{ + "good": "{{ .Result.ok }}", + "bad": "{{ .Result.ok ", // unterminated action: parse error + }, json.RawMessage(`{"ok":"yes"}`), newState()) + if err != nil { + t.Fatalf("ExtractOutputs: %v", err) + } + + if outputs["good"] != "yes" { + t.Errorf("good output = %v, want yes", outputs["good"]) + } + if v, present := outputs["bad"]; !present || v != nil { + t.Errorf("bad output = %v (present=%v), want a nil entry", v, present) + } +} + +// TestParseList covers the helper prompt steps use to turn a previous step's +// JSON output back into selectable rows (the bid list, most importantly). +func TestParseList(t *testing.T) { + list, err := wf.ParseList(` [{"provider":"akash1a","price":"100"},{"provider":"akash1b"}] `) + if err != nil { + t.Fatalf("ParseList: %v", err) + } + if len(list) != 2 || list[0]["provider"] != "akash1a" { + t.Errorf("list = %v", list) + } + + for name, in := range map[string]string{ + "empty": "", + "whitespace": " ", + "not json": "bids: none", + "json object": `{"provider":"akash1a"}`, + } { + if _, err := wf.ParseList(in); err == nil { + t.Errorf("%s: expected an error for %q", name, in) + } + } +} + +// TestToJsonTemplateFunc covers the toJson template function. Steps that pass +// structured data onward (the bid list handed to a prompt step) must serialize +// it as JSON; without toJson the receiving step would get Go's fmt rendering +// of a map, which ParseList cannot read back. +func TestToJsonTemplateFunc(t *testing.T) { + state := newState() + state.SetStepResult("bids", &wf.StepResult{ + Name: "bids", + Status: "success", + Output: map[string]any{ + "bids": []map[string]any{{"provider": "akash1a"}}, + }, + }) + + rendered, err := wf.ResolveTemplate("{{ toJson .Steps.bids.bids }}", state) + if err != nil { + t.Fatalf("ResolveTemplate: %v", err) + } + + list, err := wf.ParseList(rendered) + if err != nil { + t.Fatalf("toJson output must round-trip through ParseList, got %q: %v", rendered, err) + } + if len(list) != 1 || list[0]["provider"] != "akash1a" { + t.Errorf("round-tripped list = %v", list) + } +} + +// TestResolveTemplateReportsBadTemplates covers the parse and execute error +// branches. A workflow author's typo must abort the run with a message naming +// the template, not render "" into a transaction parameter. +func TestResolveTemplateReportsBadTemplates(t *testing.T) { + if _, err := wf.ResolveTemplate("{{ .Params.dseq ", newState()); err == nil { + t.Error("an unterminated action must be a parse error") + } else if !strings.Contains(err.Error(), "parse template") { + t.Errorf("unexpected error: %v", err) + } + + if _, err := wf.ResolveTemplate(`{{ toJson . | printf "%s" }}{{ fail }}`, newState()); err == nil { + t.Error("an unknown function must be an error") + } +} + +// TestResolveParamsPropagatesTemplateErrors pins that a failing parameter +// aborts resolution and names the offending key — resolved params become tx +// message fields, so a partially-resolved map must never be returned. +func TestResolveParamsPropagatesTemplateErrors(t *testing.T) { + out, err := wf.ResolveParams(map[string]string{ + "ok": "{{ .Params.dseq }}", + "bad": "{{ .Params.dseq ", + }, newState()) + if err == nil { + t.Fatal("a failing param template must abort resolution") + } + if out != nil { + t.Errorf("no partial map may be returned, got %v", out) + } + if !strings.Contains(err.Error(), `resolve param "bad"`) { + t.Errorf("error should name the failing key, got %q", err) + } + + // A nil/empty map is returned untouched (no allocation, no error). + if out, err := wf.ResolveParams(nil, newState()); err != nil || out != nil { + t.Errorf("empty params = (%v, %v), want (nil, nil)", out, err) + } +} + +// TestEvalConditionFalsyForms pins every value the engine treats as "condition +// not met". These gate whether a step runs at all, so an unrecognized falsy +// form would execute a step the workflow author meant to skip. +func TestEvalConditionFalsyForms(t *testing.T) { + state := newState() + state.SetStepResult("q", &wf.StepResult{ + Name: "q", + Status: "success", + Output: map[string]any{"count": 0, "name": ""}, + }) + + cases := []struct { + cond string + want bool + }{ + {"", true}, // no condition: always run + {"literal-true", true}, // non-template, non-empty + {"false", false}, // literal false + {"{{ .Steps.q.count }}", false}, // renders "0" + {"{{ .Steps.q.name }}", false}, // renders "" + {"{{ .Steps.q.missing }}", false}, // missingkey=zero -> "" + {"{{ .Steps.q._status }}", true}, // "success" + {"{{ eq .Account `akash1owner` }}", true}, + {"{{ eq .Account `nobody` }}", false}, + } + + for _, c := range cases { + got, err := wf.EvalCondition(c.cond, nil, state) + if err != nil { + t.Errorf("EvalCondition(%q): %v", c.cond, err) + continue + } + if got != c.want { + t.Errorf("EvalCondition(%q) = %v, want %v", c.cond, got, c.want) + } + } +} + +// TestEvalConditionReadsStepResult covers the .Result binding: conditions are +// evaluated against the step's raw JSON, which is how `wait` steps decide +// whether to keep polling. +func TestEvalConditionReadsStepResult(t *testing.T) { + raw := json.RawMessage(`{"bids":[{"provider":"akash1a"}]}`) + + got, err := wf.EvalCondition("{{ gt (len .Result.bids) 0 }}", raw, newState()) + if err != nil { + t.Fatalf("EvalCondition: %v", err) + } + if !got { + t.Error("a non-empty bid list should satisfy the condition") + } + + got, err = wf.EvalCondition("{{ gt (len .Result.bids) 0 }}", json.RawMessage(`{"bids":[]}`), newState()) + if err != nil { + t.Fatalf("EvalCondition: %v", err) + } + if got { + t.Error("an empty bid list must not satisfy the condition") + } +} + +// TestEvalConditionSurfacesTemplateErrors pins that a broken condition aborts +// rather than defaulting to "run the step". +func TestEvalConditionSurfacesTemplateErrors(t *testing.T) { + got, err := wf.EvalCondition("{{ .Result.bids ", nil, newState()) + if err == nil { + t.Fatal("a broken condition must be an error") + } + if got { + t.Error("a failing condition must not evaluate to true") + } +} + +// TestStepOutputMissingKeys covers RunState.StepOutput's two nil branches: +// an unknown step and a step that produced no outputs. +func TestStepOutputMissingKeys(t *testing.T) { + state := newState() + state.SetStepResult("nooutput", &wf.StepResult{Name: "nooutput", Status: "success"}) + + if got := state.StepOutput("ghost", "dseq"); got != nil { + t.Errorf("unknown step = %v, want nil", got) + } + if got := state.StepOutput("nooutput", "dseq"); got != nil { + t.Errorf("step without outputs = %v, want nil", got) + } +} + +// failingRegistry hands back an executor that always fails, so the engine's +// error handling can be exercised without a chain. +type failingRegistry struct { + calls *int +} + +func (r *failingRegistry) Get(t wf.StepType) (wf.StepExecutor, error) { + return &failingExecutor{stepType: t, calls: r.calls}, nil +} + +type failingExecutor struct { + stepType wf.StepType + calls *int +} + +func (e *failingExecutor) Type() wf.StepType { return e.stepType } + +func (e *failingExecutor) Execute(_ context.Context, step wf.StepDef, _ *wf.RunState) (*wf.StepResult, error) { + *e.calls++ + + return &wf.StepResult{ + Name: step.Name, + Type: step.Type, + Status: "failed", + Error: "boom", + }, errors.New("boom") +} + +// TestEngineAbortsOnStepFailure pins the default on-error behavior. A tx +// workflow that kept going after a failed broadcast would create a lease for a +// deployment that does not exist. +func TestEngineAbortsOnStepFailure(t *testing.T) { + calls := 0 + def := &wf.WorkflowDef{ + Name: "test", + Steps: []wf.StepDef{ + {Name: "first", Type: wf.StepTx}, + {Name: "second", Type: wf.StepTx}, + }, + } + + engine := wf.NewEngine(&failingRegistry{calls: &calls}, nil) + + state, err := engine.Run(context.Background(), def, "akash1owner", nil) + if err == nil { + t.Fatal("a failed step must abort the workflow") + } + if !strings.Contains(err.Error(), `step "first" failed`) { + t.Errorf("error should name the failing step, got %q", err) + } + if calls != 1 { + t.Errorf("the second step must not run, executor calls = %d", calls) + } + if state.Steps["second"] != nil { + t.Error("no result may be recorded for the un-run step") + } +} + +// TestEngineContinuesOnErrorWhenAsked covers the on-error: continue branch, +// which workflows use for best-effort steps (e.g. caching a manifest). +func TestEngineContinuesOnErrorWhenAsked(t *testing.T) { + calls := 0 + def := &wf.WorkflowDef{ + Name: "test", + Steps: []wf.StepDef{ + {Name: "first", Type: wf.StepTx, OnError: wf.OnErrorContinue}, + {Name: "second", Type: wf.StepTx, OnError: wf.OnErrorSkip}, + }, + } + + engine := wf.NewEngine(&failingRegistry{calls: &calls}, nil) + + state, err := engine.Run(context.Background(), def, "akash1owner", nil) + if err != nil { + t.Fatalf("on-error continue/skip must not abort: %v", err) + } + if calls != 2 { + t.Errorf("both steps should have run, executor calls = %d", calls) + } + if state.Steps["first"].Status != "failed" { + t.Errorf("the failure must still be recorded, got %+v", state.Steps["first"]) + } +} + +// TestEngineRetriesStep covers the retry loop: a step with retry.max must be +// attempted that many times before the workflow gives up. +func TestEngineRetriesStep(t *testing.T) { + calls := 0 + def := &wf.WorkflowDef{ + Name: "test", + Steps: []wf.StepDef{ + {Name: "flaky", Type: wf.StepQuery, Retry: &wf.RetryDef{Max: 3, Delay: "1ms"}}, + }, + } + + engine := wf.NewEngine(&failingRegistry{calls: &calls}, nil) + + if _, err := engine.Run(context.Background(), def, "akash1owner", nil); err == nil { + t.Fatal("an always-failing step must eventually abort") + } + if calls != 3 { + t.Errorf("executor calls = %d, want 3 (retry.max)", calls) + } +} + +// TestEngineStopsOnCancelledContext pins that Ctrl-C between steps stops the +// workflow instead of running the remaining transactions. +func TestEngineStopsOnCancelledContext(t *testing.T) { + calls := 0 + def := &wf.WorkflowDef{ + Name: "test", + Steps: []wf.StepDef{{Name: "first", Type: wf.StepTx}}, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + engine := wf.NewEngine(&failingRegistry{calls: &calls}, nil) + + if _, err := engine.Run(ctx, def, "akash1owner", nil); !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } + if calls != 0 { + t.Errorf("no step may run after cancellation, calls = %d", calls) + } +} diff --git a/make/releasing.mk b/make/releasing.mk index 5cb6e460..b70e5e0e 100644 --- a/make/releasing.mk +++ b/make/releasing.mk @@ -1,35 +1,137 @@ +# Release tooling. +# +# goreleaser is run from the goreleaser-cross container image rather than as a +# local binary: the darwin targets need osxcross and the linux targets need the +# gnu cross toolchains, and we do not want either as a developer prerequisite. +# Docker is therefore required for every release-* target below. + +# goreleaser-cross publishes one image per Go patch release, but not for every +# patch -- there is no v1.26.1 image even though go.mod pins go 1.26.1 -- so the +# tag is pinned here instead of being derived from GOTOOLCHAIN_SEMVER. Bump it +# together with the go directive in go.mod, after checking the tag exists: +# docker manifest inspect ghcr.io/goreleaser/goreleaser-cross:vX.Y.Z +GORELEASER_CROSS_VERSION ?= v1.26.2 +GORELEASER_IMAGE := ghcr.io/goreleaser/goreleaser-cross:$(GORELEASER_CROSS_VERSION) + +# The cross image is published for linux/amd64 only; be explicit so arm64 hosts +# emulate instead of failing to find a manifest. +GORELEASER_PLATFORM ?= linux/amd64 + GORELEASER_VERBOSE ?= false -GORELEASER_IMAGE := ghcr.io/goreleaser/goreleaser-cross:$(GOTOOLCHAIN_SEMVER) -GORELEASER_RELEASE ?= false GORELEASER_MOUNT_CONFIG ?= false -GORELEASER_SKIP := $(subst $(COMMA),$(SPACE),$(GORELEASER_SKIP)) -RELEASE_DOCKER_IMAGE ?= ghcr.io/akash-network/node + +# Extra comma-separated goreleaser steps to skip, on top of whatever each +# target already skips, e.g. `make release-dry-run GORELEASER_SKIP=nfpm`. +GORELEASER_SKIP ?= + +# Where the repo is mounted inside the container. Keeping it under /go/src with +# the canonical import path keeps GOPATH-shaped tooling happy. GORELEASER_MOD_MOUNT ?= $(shell cat $(AKT_ROOT)/.github/repo | tr -d '\n') +GORELEASER_WORKDIR := /go/src/$(GORELEASER_MOD_MOUNT) -RELEASE_DOCKER_IMAGE ?= ghcr.io/akash-network/node +# Container image path akt would publish to. Nothing consumes this yet -- +# .goreleaser.yaml has no `dockers:` section because akt is a client binary, +# not a node -- but this is the path to use when one is added. +RELEASE_DOCKER_IMAGE ?= ghcr.io/ovrclk/akt GORELEASER_GOWORK := $(GOWORK) ifneq ($(GOWORK), off) - GORELEASER_GOWORK := /go/src/$(GORELEASER_MOD_MOUNT)/go.work + GORELEASER_GOWORK := $(GORELEASER_WORKDIR)/go.work endif -ifneq ($(GORELEASER_RELEASE),true) - ifeq (,$(findstring publish,$(GORELEASER_SKIP))) - GORELEASER_SKIP += publish - endif +GORELEASER_ARGS := - GITHUB_TOKEN= +ifeq ($(GORELEASER_VERBOSE),true) + GORELEASER_ARGS += --verbose endif ifneq (,$(GORELEASER_SKIP)) - GORELEASER_SKIP := --skip=$(subst $(SPACE),$(COMMA),$(strip $(GORELEASER_SKIP))) + GORELEASER_ARGS += --skip=$(GORELEASER_SKIP) endif +# The image entrypoint runs `docker login ghcr.io` whenever GITHUB_TOKEN is set +# and /root/.docker/config.json is absent, and it runs under `set -e`, so that +# login failing kills the release before goreleaser even starts. It does fail: +# the workflow token is scoped to contents only and akt publishes no images. +# Handing the container a docker config that already exists skips that whole +# block. GORELEASER_MOUNT_CONFIG=true swaps in the real one for anyone who does +# need to push images. +GORELEASER_DOCKER_CONFIG := $(AKT_DEVCACHE)/goreleaser-docker + +$(GORELEASER_DOCKER_CONFIG)/config.json: + @mkdir -p $(@D) + @echo '{}' > $@ + +# Named volumes keep the module and build caches between runs without writing +# root-owned files into the developer's GOPATH. +GORELEASER_DOCKER_ARGS := \ + --rm \ + --platform $(GORELEASER_PLATFORM) \ + -e GOWORK=$(GORELEASER_GOWORK) \ + -v akt-goreleaser-gomod:/go/pkg/mod \ + -v akt-goreleaser-gobuild:/root/.cache/go-build \ + -v $(AKT_ROOT):$(GORELEASER_WORKDIR) \ + -w $(GORELEASER_WORKDIR) + ifeq ($(GORELEASER_MOUNT_CONFIG),true) - GORELEASER_IMAGE := -v $(HOME)/.docker/config.json:/root/.docker/config.json $(GORELEASER_IMAGE) + GORELEASER_DOCKER_ARGS += -v $(HOME)/.docker/config.json:/root/.docker/config.json +else + GORELEASER_DOCKER_ARGS += -v $(GORELEASER_DOCKER_CONFIG):/root/.docker endif +# The image entrypoint is goreleaser itself, so arguments are appended directly. +GORELEASER := docker run $(GORELEASER_DOCKER_ARGS) $(GORELEASER_IMAGE) + +# The Go module for libwasmvm ships only shared objects (.so/.dylib) that are +# linked with an rpath into the module cache, which is useless in a released +# binary. The static archives that the `muslc` (linux) and `static_wasm` +# (darwin) build tags link against are published on the wasmvm release page +# instead, so fetch them into .cache/lib -- that is the directory every +# `-extldflags "-L./.cache/lib ..."` in .goreleaser.yaml points at. +WASMVM_VERSION = $(shell go list -m -f '{{ .Version }}' github.com/CosmWasm/wasmvm/v3) +WASMVM_LIBS := libwasmvm_muslc.x86_64.a libwasmvm_muslc.aarch64.a libwasmvmstatic_darwin.a +RELEASE_LIBS := $(patsubst %,$(AKT_DEVCACHE_LIB)/%,$(WASMVM_LIBS)) + +# More specific than the catch-all $(AKT_DEVCACHE_LIB)/% rule in setup-cache.mk, +# so make prefers this one for the libwasmvm archives. +$(AKT_DEVCACHE_LIB)/libwasmvm%: | $(AKT_DEVCACHE) + @echo "fetching $(@F) ($(WASMVM_VERSION)) ..." + curl -sSfL -o $@ \ + https://github.com/CosmWasm/wasmvm/releases/download/$(WASMVM_VERSION)/$(@F) + +.PHONY: release-libs +release-libs: $(RELEASE_LIBS) + +# Validate .goreleaser.yaml. Cheap, needs no build. +.PHONY: release-check +release-check: $(GORELEASER_DOCKER_CONFIG)/config.json + $(GORELEASER) check $(GORELEASER_ARGS) + +# Full cross-compile with a synthetic version, no tag and no publishing. +# Artifacts land in .cache/goreleaser. +.PHONY: release-snapshot +release-snapshot: release-libs $(GORELEASER_DOCKER_CONFIG)/config.json + $(GORELEASER) release --clean --snapshot --skip=publish,validate $(GORELEASER_ARGS) + +# Same as `release` but stops short of uploading. Needs a tag reachable from +# HEAD; `--skip=validate` allows running it from a dirty tree. +.PHONY: release-dry-run +release-dry-run: release-libs $(GORELEASER_DOCKER_CONFIG)/config.json + $(GORELEASER) release --clean --skip=publish,validate $(GORELEASER_ARGS) + +# The real thing: builds the matrix and uploads to the GitHub release for the +# current tag. Driven by .github/workflows/release.yml on `v*` tag pushes; +# running it by hand needs a GITHUB_TOKEN with contents:write. The token is +# passed by name so it never lands in the echoed command line. +.PHONY: release +release: release-libs $(GORELEASER_DOCKER_CONFIG)/config.json + @if [ -z "$${GITHUB_TOKEN}" ]; then \ + echo "GITHUB_TOKEN is required to publish a release"; \ + exit 1; \ + fi + docker run $(GORELEASER_DOCKER_ARGS) -e GITHUB_TOKEN $(GORELEASER_IMAGE) release --clean $(GORELEASER_ARGS) + .PHONY: bins bins: $(AKT) diff --git a/make/testing.mk b/make/testing.mk new file mode 100644 index 00000000..f00da8ab --- /dev/null +++ b/make/testing.mk @@ -0,0 +1,100 @@ +# Test coverage. +# +# ./e2e is excluded from every coverage target below: it drives a built `akt` +# binary as a subprocess, so the coverage counters live in that child process +# and including the package only adds a 0%-covered entry. Run `make test-e2e` +# for that suite. +# +# Three package sets are reported, narrowest last: +# +# COVER_PACKAGES every non-e2e package -- the honest repo-wide figure. +# +# COVER_OWN_PACKAGES everything except internal/cli/chain/..., which is +# clean-copied from akash-network/chain-sdk and +# maintained by re-copying (DESIGN.md 5.1). Tests +# written against that tree would be clobbered by the +# next re-copy, and at ~7.9k statements it is over a +# third of the repo, so a repo-wide threshold would +# mostly measure code akt does not own. +# +# COVER_CORE_PACKAGES the akt-authored packages that carry the risk: +# money handling, credentials, capability gating, +# the action log, the transport translation layer, +# and the command surfaces built on them. This is the +# set the threshold applies to. + +COVER_DIR := $(AKT_DEVCACHE)/coverage +COVER_PROFILE := $(COVER_DIR)/coverage.out +COVER_OWN_PROFILE := $(COVER_DIR)/coverage-own.out +COVER_CORE_PROFILE := $(COVER_DIR)/coverage-core.out +COVER_HTML := $(COVER_DIR)/coverage.html +COVER_FUNC := $(COVER_DIR)/coverage-func.txt + +# Minimum statement coverage for COVER_CORE_PACKAGES, in percent. Raise it as +# coverage improves; never lower it to make a build pass. +COVER_THRESHOLD ?= 65 + +COVER_PACKAGES = $(shell $(GO) list ./... | grep -v '/e2e') +COVER_OWN_PACKAGES = $(shell $(GO) list ./... | grep -v '/e2e' | grep -v '/internal/cli/chain') + +COVER_CORE_PACKAGES := \ + $(GO_MOD_NAME)/internal/actionlog \ + $(GO_MOD_NAME)/internal/bootstrap \ + $(GO_MOD_NAME)/internal/capability \ + $(GO_MOD_NAME)/internal/cli/console \ + $(GO_MOD_NAME)/internal/cli/context \ + $(GO_MOD_NAME)/internal/cli/provider \ + $(GO_MOD_NAME)/internal/cli/sdl \ + $(GO_MOD_NAME)/internal/cli/workflow \ + $(GO_MOD_NAME)/internal/console \ + $(GO_MOD_NAME)/internal/context \ + $(GO_MOD_NAME)/internal/output/pretty \ + $(GO_MOD_NAME)/internal/transport \ + $(GO_MOD_NAME)/internal/workflow + +$(COVER_DIR): + mkdir -p $@ + +# Full unit-test run with a coverage profile, plus a per-function report and a +# browsable HTML report. Everything lands under .cache (gitignored). +.PHONY: test-coverage +test-coverage: $(COVER_DIR) + $(GO_TEST) -covermode=atomic -coverprofile=$(COVER_PROFILE) $(COVER_PACKAGES) + $(GO) tool cover -func=$(COVER_PROFILE) > $(COVER_FUNC) + $(GO) tool cover -html=$(COVER_PROFILE) -o $(COVER_HTML) + @echo + @tail -1 $(COVER_FUNC) + @echo "per-function report: $(COVER_FUNC)" + @echo "html report: $(COVER_HTML)" + +# Repo minus the clean-copied chain-sdk tree. +.PHONY: test-coverage-own +test-coverage-own: $(COVER_DIR) + $(GO_TEST) -covermode=atomic -coverprofile=$(COVER_OWN_PROFILE) $(COVER_OWN_PACKAGES) + @echo + @$(GO) tool cover -func=$(COVER_OWN_PROFILE) | tail -1 + +# The risk-carrying core. This is the number the threshold is applied to. +.PHONY: test-coverage-core +test-coverage-core: $(COVER_DIR) + $(GO_TEST) -covermode=atomic -coverprofile=$(COVER_CORE_PROFILE) $(COVER_CORE_PACKAGES) + @echo + @$(GO) tool cover -func=$(COVER_CORE_PROFILE) | tail -1 + +# Gate: fail when the core packages fall below COVER_THRESHOLD. +# This is the target CI should run. +.PHONY: test-coverage-check +test-coverage-check: test-coverage-core + @total=$$($(GO) tool cover -func=$(COVER_CORE_PROFILE) | tail -1 | awk '{print $$NF}' | tr -d '%'); \ + echo "core coverage: $${total}% (threshold $(COVER_THRESHOLD)%)"; \ + awk -v t="$${total}" -v min="$(COVER_THRESHOLD)" \ + 'BEGIN { if (t+0 < min+0) { printf "coverage %.1f%% is below the %s%% threshold\n", t, min; exit 1 } }' + +# Open the HTML report in a browser. +.PHONY: test-coverage-html +test-coverage-html: test-coverage + $(GO) tool cover -html=$(COVER_PROFILE) + +.PHONY: test-coverage-clean +test-coverage-clean: + rm -rf $(COVER_DIR)