diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc918f5..6b0087c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,9 +29,6 @@ jobs: go-version-file: go.mod - uses: GoCodeAlone/setup-wfctl@v1 with: - version: v0.62.0 - # workflow#762: scaffold carries type="scaffold" + TEMPLATE.* placeholders. - # Old `plugin validate --strict-contracts` (wfctl v0.20.1) rejects both; - # validate-contract (v0.62.0) checks scaffold-compatible contract surface. - - name: Validate plugin contract (scaffold-aware) + version: v0.63.1 + - name: Validate plugin contract run: wfctl plugin validate-contract . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7dbedb8..faf0ad7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,10 +42,10 @@ jobs: run: | RUNNER_ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') BIN=$(jq -r --arg arch "$RUNNER_ARCH" \ - '[.[] | select(.type=="Binary" and .goos=="linux" and .goarch==$arch and (.name|startswith("scaffold-workflow-plugin")))] | .[0].path // ""' \ + '[.[] | select(.type=="Binary" and .goos=="linux" and .goarch==$arch and (.name|startswith("workflow-plugin-crypto")))] | .[0].path // ""' \ dist/artifacts.json) if [ -z "$BIN" ] || [ "$BIN" = "null" ]; then - echo "::warning::No matching linux/$RUNNER_ARCH scaffold binary in dist/artifacts.json; skipping verify-capabilities" + echo "::warning::No matching linux/$RUNNER_ARCH workflow-plugin-crypto binary in dist/artifacts.json; skipping verify-capabilities" jq '.[] | {name, type, goos, goarch, path}' dist/artifacts.json exit 0 fi @@ -56,7 +56,5 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release edit "${{ github.ref_name }}" --draft=false --repo "${{ github.repository }}" - # NOTE: scaffold-workflow-plugin does NOT notify workflow-registry on release - # — it's a scaffold repo, NOT an installable plugin. The notify-workflow-registry - # job is intentionally omitted (and registry-side type-allowlist defense - # in wfctl plugin registry-sync rejects type:"scaffold" if it ever leaks in). + # Registry publication is intentionally separate from binary release until + # workflow-compute consumes provider catalog plugins from the registry. diff --git a/.github/workflows/scaffold-rename-test.yml b/.github/workflows/scaffold-rename-test.yml deleted file mode 100644 index a9c99cc..0000000 --- a/.github/workflows/scaffold-rename-test.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Scaffold rename test - -on: - push: - branches: [main] - pull_request: - -# Verifies scripts/rename-from-scaffold.sh produces a buildable plugin in -# both --mode iac and --mode non-iac. Includes a nested-fixture file to -# exercise the find-based bulk-sed (catches globstar regressions per -# workflow#762 plan C-P4 guard). -jobs: - rename-non-iac: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - name: Add nested fixture file - run: | - mkdir -p internal/nested/sub - cat > internal/nested/sub/test.go <<'EOF' - // Fixture file deeper than one level - exercises the rename script's - // find loop to verify imports in nested packages get rewritten. - package sub - import _ "github.com/GoCodeAlone/scaffold-workflow-plugin/internal" - EOF - - name: Rename to test-plugin (non-iac) + build - run: | - cp -r . /tmp/scaffold-copy - cd /tmp/scaffold-copy - bash scripts/rename-from-scaffold.sh test-plugin --mode non-iac - go build ./... - test -d cmd/workflow-plugin-test-plugin - test ! -d cmd/scaffold-workflow-plugin - test ! -d cmd/scaffold-workflow-plugin-iac - test "$(jq -r .type plugin.json)" = "external" - test "$(jq -r .name plugin.json)" = "workflow-plugin-test-plugin" - - rename-iac: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - name: Add nested fixture file - run: | - mkdir -p internal/nested/sub - cat > internal/nested/sub/test.go <<'EOF' - package sub - import _ "github.com/GoCodeAlone/scaffold-workflow-plugin/internal" - EOF - - name: Rename to test-plugin (iac) + build - run: | - cp -r . /tmp/scaffold-copy - cd /tmp/scaffold-copy - bash scripts/rename-from-scaffold.sh test-plugin --mode iac - go build ./... - test -d cmd/workflow-plugin-test-plugin - test ! -d cmd/scaffold-workflow-plugin - test ! -d cmd/scaffold-workflow-plugin-iac diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 346721e..6c764dd 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -6,9 +6,9 @@ before: - "sed -i.bak 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' plugin.json && rm -f plugin.json.bak" builds: - - id: scaffold-workflow-plugin - main: ./cmd/scaffold-workflow-plugin - binary: scaffold-workflow-plugin + - id: workflow-plugin-crypto + main: ./cmd/workflow-plugin-crypto + binary: workflow-plugin-crypto env: - CGO_ENABLED=0 goos: @@ -19,7 +19,7 @@ builds: - amd64 - arm64 ldflags: - - -s -w -X main.version={{.Version}} -X github.com/GoCodeAlone/scaffold-workflow-plugin/internal.Version={{.Version}} + - -s -w -X main.version={{.Version}} -X github.com/GoCodeAlone/workflow-plugin-crypto/internal.Version={{.Version}} archives: - formats: [tar.gz] diff --git a/CLAUDE.md b/CLAUDE.md index 7f19626..3f2fbc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,7 @@ -# CLAUDE.md — Workflow Plugin Template +# CLAUDE.md — workflow-plugin-crypto -External gRPC plugin for the GoCodeAlone/workflow engine. +External gRPC plugin for crypto network provider catalog metadata consumed by +Workflow and `workflow-compute`. ## Build & Test @@ -9,35 +10,21 @@ go build ./... go test ./... -v -race -count=1 ``` -## Cross-compile for deployment +## Cross-compile ```sh -GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" -o scaffold-workflow-plugin ./cmd/scaffold-workflow-plugin/ +GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" -o workflow-plugin-crypto ./cmd/workflow-plugin-crypto/ ``` ## Structure -- `cmd/scaffold-workflow-plugin/main.go` — Plugin entry point (calls `sdk.Serve`) -- `internal/plugin.go` — Plugin manifest, module factories, step factories -- `internal/` — All module and step implementations -- `plugin.json` — Capability manifest for the workflow registry -- `.goreleaser.yaml` — GoReleaser v2 config for cross-platform releases -- `.github/workflows/ci.yml` — CI on push/PR (build + test) -- `.github/workflows/release.yml` — Release on v* tag push (GoReleaser) - -## Adding a Module Type - -1. Create `internal/module_example.go` implementing the module -2. Register in `internal/plugin.go` ModuleFactories() -3. Add to `plugin.json` capabilities.moduleTypes -4. Add tests in `internal/module_example_test.go` - -## Adding a Step Type - -1. Create `internal/step_example.go` implementing the step -2. Register in `internal/plugin.go` StepFactories() -3. Add to `plugin.json` capabilities.stepTypes -4. Add tests in `internal/step_example_test.go` +- `cmd/workflow-plugin-crypto/main.go` — external plugin entrypoint +- `internal/plugin.go` — Workflow plugin manifest +- `catalog/crypto.go` — public `workflow-compute` provider catalog metadata +- `plugin.json` — registry-facing plugin manifest +- `.goreleaser.yaml` — GoReleaser v2 config for releases +- `.github/workflows/ci.yml` — build, test, vet, and plugin contract validation +- `.github/workflows/release.yml` — tagged release pipeline ## Releasing @@ -45,4 +32,3 @@ GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" -o scaffold-work git tag v0.1.0 git push origin v0.1.0 ``` -GoReleaser builds cross-platform binaries and creates a GitHub Release automatically. diff --git a/README.md b/README.md index 0022421..96ddfb5 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,36 @@ -# scaffold-workflow-plugin +# workflow-plugin-crypto -This is a SCAFFOLD repo. It is NOT an installable plugin. +Public Workflow plugin for crypto network provider catalog metadata used by +`workflow-compute`. The catalog package intentionally avoids private module +dependencies so this public plugin can build on public CI and be imported by +future public tooling. -Use it to create a new workflow plugin via GitHub's "Use this template" button. +The plugin currently owns shape-only provider contracts for: -(A future `wfctl plugin init --from-scaffold` subcommand is tracked at -[workflow#762](https://github.com/GoCodeAlone/workflow/issues/762) but -not yet implemented; use the GitHub UI path below.) +- BTC full node +- BCH full node +- Ethereum full node -## After creating your new repo from this template +Each profile declares the provider contract, network product shape, storage and +network guidance, treasury reward routing, and upstream-client image policy. +Mining, validator, and protocol-native reward roles are represented as deferred +role profiles until custody, slashing-risk, payout attribution, and evidence +contracts exist. -1. **Enable GitHub Actions**: Settings → Actions → "I understand my workflows, enable them". - New repos created from a template ship with workflows DISABLED by - default; you must enable them once before any release.yml run can - succeed. +## Build & Test -2. **Run the rename script**: - - ```bash - bash scripts/rename-from-scaffold.sh --mode [iac|non-iac] - ``` - - This: - - Picks the IaC or non-IaC main.go variant; deletes the other. - - Renames `cmd/scaffold-workflow-plugin*/` → `cmd/workflow-plugin-/`. - - Updates `go.mod` module path. - - Bulk-sed of `scaffold-workflow-plugin` → `workflow-plugin-` - across `.go`/`.yaml`/`.md`/`plugin.json` files. - - Resets `plugin.json.type` from `"scaffold"` to `"external"`; sets `.name`. - - Removes the rename script itself + scaffold-rename-test workflow. - -3. **Edit `plugin.json`**: replace `TEMPLATE.module` / `TEMPLATE.step` / - `TEMPLATE.resource` placeholder capabilities with your plugin's actual - types. Update `minEngineVersion` if you depend on a newer workflow. - -4. **Implement your plugin** in `internal/`: - - **non-IaC mode**: extend `internal/plugin.go`'s `NewPlugin()` with - real ModuleFactories / StepFactories / TriggerFactories. Delete - `internal/iacserver.go` (unused in non-IaC mode). - - **IaC mode**: replace `internal/iacserver.go`'s stub - `pb.UnimplementedIaCProviderRequiredServer` embed with your real - IaC provider implementation (Initialize, Plan, Destroy, etc.). - Delete `internal/plugin.go`'s NewPlugin (unused in IaC mode). - -5. **Commit + tag**: - - ```bash - git add -A && git commit -m "feat: initial plugin scaffold from scaffold-workflow-plugin" - git tag v0.1.0 && git push origin main v0.1.0 - ``` - - `release.yml`'s `wfctl plugin validate-contract --for-publish` gate - verifies your tag (must be release-grade semver `^v\d+\.\d+\.\d+$`) - and contract (capabilities populated, minEngineVersion set, main.go - wires `sdk.ResolveBuildVersion`, goreleaser ldflag present). - -## Modes - -- `--mode non-iac` (default): for module/step/trigger plugins that use - `sdk.Serve`. Suitable for MOST plugins. -- `--mode iac`: for IaC provider plugins that use `sdk.ServeIaCPlugin` - and satisfy `pb.IaCProviderRequiredServer`. Use ONLY if your plugin - provisions infrastructure (cloud resources, databases, etc.). - -## What's pre-baked in (workflow#758 + #762 compliance) - -- `plugin.json.version = "0.0.0"` sentinel (release tag injected at build - time via goreleaser). -- `internal/plugin.go`'s `var Version = "0.0.0"` (ldflag-injected at - release; surfaced through `sdk.ResolveBuildVersion`). -- `release.yml` pre-build + post-build `wfctl plugin validate-contract` - gates. -- No `sync-plugin-version.yml` (the discarded sync mechanism is not - shipped in scaffolds; goreleaser's `before:` hook rewrites - `plugin.json.version` from the tag at release time). -- `sdk.WithBuildVersion(sdk.ResolveBuildVersion(internal.Version))` - wired in main.go so the binary surfaces its release version through - `GetManifest` at runtime. -- `setup-wfctl@v1 with version: v0.62.0` pinned for the release pipeline. - -## Build & test (during plugin development) - -```bash +```sh go build ./... go test ./... -race -count=1 ``` -## Releasing +## Release -```bash -git tag v0.1.0 && git push origin v0.1.0 +```sh +git tag v0.1.0 +git push origin v0.1.0 ``` -`release.yml` runs `wfctl plugin validate-contract --for-publish`, -goreleaser builds cross-platform binaries, and the post-build gate -verifies the shipped tarball's `plugin.json` carries the tag. - -## References - -- Plugin release contract: [docs/PLUGIN_RELEASE_GATES.md](https://github.com/GoCodeAlone/workflow/blob/main/docs/PLUGIN_RELEASE_GATES.md) -- Plugin version discipline: [workflow#758](https://github.com/GoCodeAlone/workflow/issues/758) -- Registry sync subcommand: [workflow#762](https://github.com/GoCodeAlone/workflow/issues/762) +The release workflow validates `plugin.json`, builds cross-platform binaries +with GoReleaser, and verifies the runtime plugin manifest against the shipped +contract metadata. diff --git a/catalog/crypto.go b/catalog/crypto.go new file mode 100644 index 0000000..4f3f0d3 --- /dev/null +++ b/catalog/crypto.go @@ -0,0 +1,668 @@ +package catalog + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +const cryptoPluginID = "workflow-plugin-crypto" + +const ( + CryptoStorageArchiveFull = "archive-full" + CryptoNetworkProfilePublicChainPeer = "public-chain-peer" + CryptoRewardDestinationTreasury = "treasury" + CryptoProofModeOperationalEvidence = "operational-evidence" + CryptoProofModeMiningShare = "mining-share" + CryptoProofModeValidatorDuty = "validator-duty" + CryptoProofModeProtocolReward = "protocol-reward" + CryptoRoleFullNode = "full-node" + CryptoRoleMiner = "miner" + CryptoRoleValidator = "validator" + CryptoRoleProtocolReward = "protocol-reward" + CryptoRoleStatusSupported = "supported" + CryptoRoleStatusDeferred = "deferred" +) + +type CryptoRoleMetadata struct { + ID string `json:"id"` + ShapeOnly bool `json:"shape_only"` + ProtocolRewardsAssumed bool `json:"protocol_rewards_assumed"` + OperationalConformanceID string `json:"operational_conformance_id,omitempty"` +} + +type CryptoStorageMetadata struct { + Mode string `json:"mode"` + MinDiskBytes int64 `json:"min_disk_bytes"` + MinDiskDisplay string `json:"min_disk_display"` + RecommendedDiskBytes int64 `json:"recommended_disk_bytes"` + RecommendedDiskDisplay string `json:"recommended_disk_display"` + GrowthMarginBytes int64 `json:"growth_margin_bytes"` + GrowthMarginDisplay string `json:"growth_margin_display"` + DurableVolumeRequired bool `json:"durable_volume_required"` + PreserveOnUpdate bool `json:"preserve_on_update"` + PreserveOnUninstall bool `json:"preserve_on_uninstall"` + PruningSupported bool `json:"pruning_supported"` + SnapshotVerificationRequired bool `json:"snapshot_verification_required"` +} + +type CryptoNetworkMetadata struct { + ProfileID string `json:"profile_id"` + PeerPort int `json:"peer_port"` + AllowedPeerPorts []int `json:"allowed_peer_ports,omitempty"` + RequiresIngress bool `json:"requires_ingress"` + UsesDNSSeeds bool `json:"uses_dns_seeds"` + RPCPrivateOnly bool `json:"rpc_private_only"` + AuditRequired bool `json:"audit_required"` + MaxOutboundPeers int `json:"max_outbound_peers"` + MaxOutboundBytesPerSecond int64 `json:"max_outbound_bytes_per_second"` + KillClosesPeers bool `json:"kill_closes_peers"` +} + +type CryptoRewardMetadata struct { + ProtocolRewardDestination string `json:"protocol_reward_destination"` + TreasuryAccountID string `json:"treasury_account_id,omitempty"` + TreasuryWalletRef string `json:"treasury_wallet_ref,omitempty"` + ManagementFeeBasisPoints int `json:"management_fee_basis_points"` + DirectWorkerPayout bool `json:"direct_worker_payout"` + ProtocolRewardProofClaimed bool `json:"protocol_reward_proof_claimed"` +} + +type CryptoProofMetadata struct { + Mode string `json:"mode"` + ShapeOnly bool `json:"shape_only"` + ProtocolNativeRewardProof bool `json:"protocol_native_reward_proof"` + EvidenceRefs []string `json:"evidence_refs,omitempty"` +} + +type CryptoImageMetadata struct { + UpstreamClientName string `json:"upstream_client_name"` + DigestPinnedRequired bool `json:"digest_pinned_required"` + OperatorSuppliedRequired bool `json:"operator_supplied_required,omitempty"` + RecommendedImageRef string `json:"recommended_image_ref,omitempty"` + KnownImageRefs []string `json:"known_image_refs,omitempty"` +} + +type CryptoRoleProfile struct { + ID string `json:"id"` + Status string `json:"status"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + ProofMode string `json:"proof_mode"` + TreasuryRequired bool `json:"treasury_required"` + DirectWorkerPayout bool `json:"direct_worker_payout"` + ProductCreationSupported bool `json:"product_creation_supported"` + RequiresCustodyContract bool `json:"requires_custody_contract,omitempty"` + RequiresSlashingRiskContract bool `json:"requires_slashing_risk_contract,omitempty"` + RequiredEvidence []string `json:"required_evidence,omitempty"` + DeferredReason string `json:"deferred_reason,omitempty"` +} + +type CryptoProfile struct { + Chain string `json:"chain"` + ProductID string `json:"product_id"` + DisplayName string `json:"display_name"` + Purpose string `json:"purpose"` + PoolID string `json:"pool_id"` + ProviderID string `json:"provider_id"` + ContractID string `json:"contract_id"` + SchemaRef string `json:"schema_ref"` + SchemaDigest string `json:"schema_digest"` + ConfigRef string `json:"config_ref"` + SettlementNetwork string `json:"settlement_network"` + WalletRef string `json:"wallet_ref"` + MinDiskBytes int64 `json:"min_disk_bytes"` + MinMemoryBytes int64 `json:"min_memory_bytes"` + MinBandwidthMbps int64 `json:"min_bandwidth_mbps"` + Role CryptoRoleMetadata `json:"role"` + Storage CryptoStorageMetadata `json:"storage"` + Network CryptoNetworkMetadata `json:"network"` + Rewards CryptoRewardMetadata `json:"rewards"` + Proof CryptoProofMetadata `json:"proof"` + Image CryptoImageMetadata `json:"image"` +} + +type CryptoProviderManifestDocument struct { + ProtocolVersion string `json:"protocol_version"` + PluginID string `json:"plugin_id"` + Version string `json:"version"` + RoleProfiles []CryptoRoleProfile `json:"role_profiles"` + Profiles []CryptoProfile `json:"profiles"` +} + +func CryptoProviderManifest() CryptoProviderManifestDocument { + profiles := make([]CryptoProfile, 0, 3) + for _, chain := range []string{"btc", "bch", "ethereum"} { + profile, _ := CryptoNetworkProfile(chain) + profiles = append(profiles, profile) + } + return CryptoProviderManifestDocument{ + ProtocolVersion: Version, + PluginID: cryptoPluginID, + Version: "v1.0.0", + RoleProfiles: CryptoRoleProfiles(), + Profiles: profiles, + } +} + +func CryptoRoleProfiles() []CryptoRoleProfile { + return []CryptoRoleProfile{ + { + ID: CryptoRoleFullNode, + Status: CryptoRoleStatusSupported, + DisplayName: "Full node", + Description: "Runs a chain client that validates and serves chain data with durable storage and public peer networking.", + ProofMode: CryptoProofModeOperationalEvidence, + TreasuryRequired: true, + DirectWorkerPayout: false, + ProductCreationSupported: true, + RequiredEvidence: []string{ + "service health receipt", + "upstream client version evidence", + "durable data volume ref", + "public-chain peer policy evidence", + }, + }, + { + ID: CryptoRoleMiner, + Status: CryptoRoleStatusDeferred, + DisplayName: "Miner", + Description: "Runs mining or mining-pool work with pool/share evidence and treasury reward routing.", + ProofMode: CryptoProofModeMiningShare, + TreasuryRequired: true, + DirectWorkerPayout: false, + ProductCreationSupported: false, + RequiredEvidence: []string{ + "pool share or devnet block evidence", + "treasury reward address configuration", + "hashrate/resource budget evidence", + }, + DeferredReason: "mining pool, hardware eligibility, stale-share, and treasury-credit accounting contracts are not implemented", + }, + { + ID: CryptoRoleValidator, + Status: CryptoRoleStatusDeferred, + DisplayName: "Validator", + Description: "Performs validator duties only after custody, slashing-risk, uptime, and reward evidence contracts exist.", + ProofMode: CryptoProofModeValidatorDuty, + TreasuryRequired: true, + DirectWorkerPayout: false, + ProductCreationSupported: false, + RequiresCustodyContract: true, + RequiresSlashingRiskContract: true, + RequiredEvidence: []string{ + "validator duty attestation", + "slashing-risk controls", + "staking/custody authority refs", + "treasury credit evidence", + }, + DeferredReason: "validator custody, key-management, and slashing-risk contracts are not implemented", + }, + { + ID: CryptoRoleProtocolReward, + Status: CryptoRoleStatusDeferred, + DisplayName: "Protocol reward", + Description: "Captures chain-specific reward duties that are neither pure full-node service nor mining/validator work.", + ProofMode: CryptoProofModeProtocolReward, + TreasuryRequired: true, + DirectWorkerPayout: false, + ProductCreationSupported: false, + RequiredEvidence: []string{ + "chain-specific reward event evidence", + "treasury credit evidence", + "contribution attribution policy", + }, + DeferredReason: "chain-specific reward evidence and payout attribution contracts are not implemented", + }, + } +} + +func CryptoRoleProfileByID(roleID string) (CryptoRoleProfile, bool) { + roleID = strings.ToLower(strings.TrimSpace(roleID)) + if roleID == "" { + roleID = CryptoRoleFullNode + } + for _, role := range CryptoRoleProfiles() { + if role.ID == roleID { + return role, true + } + } + return CryptoRoleProfile{}, false +} + +func CryptoProviderManifestDigest() string { + return CanonicalHash(CryptoProviderManifest()) +} + +func (m CryptoProviderManifestDocument) Validate() error { + var errs []error + if m.ProtocolVersion != Version { + errs = append(errs, fmt.Errorf("protocol_version must be %q", Version)) + } + if m.PluginID != cryptoPluginID { + errs = append(errs, fmt.Errorf("plugin_id must be %q", cryptoPluginID)) + } + if strings.TrimSpace(m.Version) == "" || strings.ContainsAny(m.Version, " \t\r\n") { + errs = append(errs, errors.New("version is required")) + } + if len(m.Profiles) == 0 { + errs = append(errs, errors.New("profiles is required")) + } + if len(m.RoleProfiles) == 0 { + errs = append(errs, errors.New("role_profiles is required")) + } + seenRoles := map[string]struct{}{} + for i, role := range m.RoleProfiles { + if strings.TrimSpace(role.ID) == "" { + errs = append(errs, fmt.Errorf("role_profiles[%d].id is required", i)) + } + if _, ok := seenRoles[role.ID]; ok { + errs = append(errs, fmt.Errorf("role_profiles[%d].id %q is duplicated", i, role.ID)) + } + seenRoles[role.ID] = struct{}{} + if role.Status != CryptoRoleStatusSupported && role.Status != CryptoRoleStatusDeferred { + errs = append(errs, fmt.Errorf("role_profiles[%d].status is invalid", i)) + } + if strings.TrimSpace(role.ProofMode) == "" { + errs = append(errs, fmt.Errorf("role_profiles[%d].proof_mode is required", i)) + } + if !role.TreasuryRequired || role.DirectWorkerPayout { + errs = append(errs, fmt.Errorf("role_profiles[%d] must use treasury routing without direct worker payout", i)) + } + if role.Status == CryptoRoleStatusDeferred && role.ProductCreationSupported { + errs = append(errs, fmt.Errorf("role_profiles[%d] deferred role must not support product creation", i)) + } + if role.Status == CryptoRoleStatusDeferred && strings.TrimSpace(role.DeferredReason) == "" { + errs = append(errs, fmt.Errorf("role_profiles[%d].deferred_reason is required", i)) + } + } + seen := map[string]struct{}{} + for i, profile := range m.Profiles { + if strings.TrimSpace(profile.Chain) == "" { + errs = append(errs, fmt.Errorf("profiles[%d].chain is required", i)) + } + if _, ok := seen[profile.Chain]; ok { + errs = append(errs, fmt.Errorf("profiles[%d].chain %q is duplicated", i, profile.Chain)) + } + seen[profile.Chain] = struct{}{} + contract := profile.ProviderContract() + if err := contract.Validate(); err != nil { + errs = append(errs, fmt.Errorf("profiles[%d].provider_contract: %w", i, err)) + } + product := profile.NetworkProduct("public") + if err := product.Validate(); err != nil { + errs = append(errs, fmt.Errorf("profiles[%d].network_product: %w", i, err)) + } + if err := contract.SupportsProduct(product); err != nil { + errs = append(errs, fmt.Errorf("profiles[%d].contract_support: %w", i, err)) + } + if profile.Storage.Mode == "" || profile.Storage.MinDiskBytes <= 0 || profile.Storage.RecommendedDiskBytes < profile.Storage.MinDiskBytes { + errs = append(errs, fmt.Errorf("profiles[%d].storage is invalid", i)) + } + if profile.Storage.MinDiskDisplay == "" || profile.Storage.RecommendedDiskDisplay == "" || profile.Storage.GrowthMarginDisplay == "" { + errs = append(errs, fmt.Errorf("profiles[%d].storage display guidance is required", i)) + } + if profile.Network.ProfileID == "" || profile.Network.PeerPort <= 0 { + errs = append(errs, fmt.Errorf("profiles[%d].network is invalid", i)) + } + if profile.Rewards.ProtocolRewardDestination != CryptoRewardDestinationTreasury || profile.Rewards.DirectWorkerPayout || profile.Rewards.ProtocolRewardProofClaimed { + errs = append(errs, fmt.Errorf("profiles[%d].rewards must route through treasury without protocol proof claim", i)) + } + if profile.Rewards.TreasuryAccountID != profile.ProductID+"-treasury" || profile.Rewards.TreasuryWalletRef != profile.WalletRef || profile.Rewards.ManagementFeeBasisPoints < 0 { + errs = append(errs, fmt.Errorf("profiles[%d].rewards treasury policy is invalid", i)) + } + if profile.Proof.Mode != CryptoProofModeOperationalEvidence || !profile.Proof.ShapeOnly || profile.Proof.ProtocolNativeRewardProof { + errs = append(errs, fmt.Errorf("profiles[%d].proof must be operational evidence only", i)) + } + if !profile.Image.DigestPinnedRequired || profile.Image.UpstreamClientName == "" { + errs = append(errs, fmt.Errorf("profiles[%d].image is invalid", i)) + } + } + return errors.Join(errs...) +} + +func CryptoNetworkProfile(chain string) (CryptoProfile, bool) { + switch strings.ToLower(strings.TrimSpace(chain)) { + case "btc", "bitcoin": + return CryptoProfile{ + Chain: "btc", + ProductID: "btc-full-node", + DisplayName: "BTC Full Node", + Purpose: "Bitcoin full-node capacity with treasury settlement and participant attribution", + PoolID: "btc", + ProviderID: "btc-full-node", + ContractID: "crypto.btc-full-node.v1", + SchemaRef: "schema://providers/workflow-plugin-crypto/btc-full-node/v1", + SchemaDigest: cryptoSchemaDigest("btc"), + ConfigRef: "config://network-products/btc-full-node/btc-full-node", + SettlementNetwork: "bitcoin", + WalletRef: "wallet://btc-full-node/primary", + MinDiskBytes: 800000000000, + MinMemoryBytes: 8000000000, + MinBandwidthMbps: 50, + Role: cryptoFullNodeRole(), + Storage: cryptoFullNodeStorage(800000000000, 1000000000000, 200000000000), + Network: cryptoPublicChainNetwork(8333, 125, 50_000_000), + Rewards: cryptoTreasuryRewards("btc-full-node", "wallet://btc-full-node/primary"), + Proof: cryptoOperationalProof(), + Image: CryptoImageMetadata{ + UpstreamClientName: "bitcoind", + DigestPinnedRequired: true, + OperatorSuppliedRequired: true, + KnownImageRefs: []string{"bitcoin/bitcoin@sha256:"}, + }, + }, true + case "bch", "bitcoin-cash": + return CryptoProfile{ + Chain: "bch", + ProductID: "bch-full-node", + DisplayName: "BCH Full Node", + Purpose: "Bitcoin Cash full-node capacity with treasury settlement and participant attribution", + PoolID: "bch", + ProviderID: "bch-full-node", + ContractID: "crypto.bch-full-node.v1", + SchemaRef: "schema://providers/workflow-plugin-crypto/bch-full-node/v1", + SchemaDigest: cryptoSchemaDigest("bch"), + ConfigRef: "config://network-products/bch-full-node/bch-full-node", + SettlementNetwork: "bitcoin-cash", + WalletRef: "wallet://bch-full-node/primary", + MinDiskBytes: 800000000000, + MinMemoryBytes: 8000000000, + MinBandwidthMbps: 50, + Role: cryptoFullNodeRole(), + Storage: cryptoFullNodeStorage(800000000000, 1000000000000, 200000000000), + Network: cryptoPublicChainNetwork(8333, 125, 50_000_000), + Rewards: cryptoTreasuryRewards("bch-full-node", "wallet://bch-full-node/primary"), + Proof: cryptoOperationalProof(), + Image: CryptoImageMetadata{ + UpstreamClientName: "bitcoind", + DigestPinnedRequired: true, + OperatorSuppliedRequired: true, + KnownImageRefs: []string{"zquestz/bitcoin-cash-node@sha256:"}, + }, + }, true + case "ethereum", "eth": + return CryptoProfile{ + Chain: "ethereum", + ProductID: "ethereum-full-node", + DisplayName: "Ethereum Full Node", + Purpose: "Ethereum full-node capacity with treasury settlement and participant attribution", + PoolID: "ethereum", + ProviderID: "ethereum-full-node", + ContractID: "crypto.ethereum-full-node.v1", + SchemaRef: "schema://providers/workflow-plugin-crypto/ethereum-full-node/v1", + SchemaDigest: cryptoSchemaDigest("ethereum"), + ConfigRef: "config://network-products/ethereum-full-node/ethereum-full-node", + SettlementNetwork: "ethereum", + WalletRef: "wallet://ethereum-full-node/primary", + MinDiskBytes: 1200000000000, + MinMemoryBytes: 16000000000, + MinBandwidthMbps: 100, + Role: cryptoFullNodeRole(), + Storage: cryptoFullNodeStorage(1200000000000, 2000000000000, 500000000000), + Network: cryptoPublicChainNetwork(30303, 50, 100_000_000), + Rewards: cryptoTreasuryRewards("ethereum-full-node", "wallet://ethereum-full-node/primary"), + Proof: cryptoOperationalProof(), + Image: CryptoImageMetadata{ + UpstreamClientName: "geth", + DigestPinnedRequired: true, + RecommendedImageRef: "ethereum/client-go@sha256:", + KnownImageRefs: []string{"ethereum/client-go@sha256:"}, + }, + }, true + default: + return CryptoProfile{}, false + } +} + +func cryptoFullNodeRole() CryptoRoleMetadata { + return CryptoRoleMetadata{ + ID: "full-node", + ShapeOnly: true, + ProtocolRewardsAssumed: false, + OperationalConformanceID: "shape-only", + } +} + +func cryptoFullNodeStorage(minDisk, recommendedDisk, growthMargin int64) CryptoStorageMetadata { + return CryptoStorageMetadata{ + Mode: CryptoStorageArchiveFull, + MinDiskBytes: minDisk, + MinDiskDisplay: cryptoDecimalByteDisplay(minDisk), + RecommendedDiskBytes: recommendedDisk, + RecommendedDiskDisplay: cryptoDecimalByteDisplay(recommendedDisk), + GrowthMarginBytes: growthMargin, + GrowthMarginDisplay: cryptoDecimalByteDisplay(growthMargin), + DurableVolumeRequired: true, + PreserveOnUpdate: true, + PreserveOnUninstall: true, + PruningSupported: false, + SnapshotVerificationRequired: false, + } +} + +func cryptoDecimalByteDisplay(bytes int64) string { + const ( + gb = int64(1_000_000_000) + tb = int64(1_000_000_000_000) + ) + if bytes >= tb { + whole := bytes / tb + remainder := bytes % tb + if remainder == 0 { + return fmt.Sprintf("%d TB", whole) + } + return fmt.Sprintf("%d.%d TB", whole, remainder/(tb/10)) + } + if bytes >= gb { + whole := bytes / gb + remainder := bytes % gb + if remainder == 0 { + return fmt.Sprintf("%d GB", whole) + } + return fmt.Sprintf("%d.%d GB", whole, remainder/(gb/10)) + } + return fmt.Sprintf("%d bytes", bytes) +} + +func cryptoPublicChainNetwork(peerPort, maxOutboundPeers int, maxBytesPerSecond int64) CryptoNetworkMetadata { + return CryptoNetworkMetadata{ + ProfileID: CryptoNetworkProfilePublicChainPeer, + PeerPort: peerPort, + AllowedPeerPorts: []int{peerPort}, + RequiresIngress: true, + UsesDNSSeeds: true, + RPCPrivateOnly: true, + AuditRequired: true, + MaxOutboundPeers: maxOutboundPeers, + MaxOutboundBytesPerSecond: maxBytesPerSecond, + KillClosesPeers: true, + } +} + +func cryptoTreasuryRewards(productID, walletRef string) CryptoRewardMetadata { + return CryptoRewardMetadata{ + ProtocolRewardDestination: CryptoRewardDestinationTreasury, + TreasuryAccountID: productID + "-treasury", + TreasuryWalletRef: walletRef, + ManagementFeeBasisPoints: 1, + DirectWorkerPayout: false, + ProtocolRewardProofClaimed: false, + } +} + +func cryptoOperationalProof() CryptoProofMetadata { + return CryptoProofMetadata{ + Mode: CryptoProofModeOperationalEvidence, + ShapeOnly: true, + ProtocolNativeRewardProof: false, + EvidenceRefs: []string{ + "artifact://node-service/health-check", + "artifact://node-service/version-probe", + "artifact://node-service/resource-usage", + }, + } +} + +func cryptoSchemaDigest(chain string) string { + schema := `{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"properties":{"chain":{"const":"` + chain + `"},"network":{"type":"string"},"data_dir_ref":{"type":"string"},"rpc_secret_ref":{"type":"string"},"wallet_ref":{"type":"string"}}}` + sum := sha256.Sum256([]byte(schema)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func (p CryptoProfile) ProviderContract() ProviderContract { + runtime := DefaultProviderRuntimeProfile("node-service-sandboxed-container", ExecutionSandboxedContainer, ProofArtifactHash) + runtime.UpstreamClientConformance = UpstreamClientConformanceShapeOnly + return ProviderContract{ + ProtocolVersion: Version, + ID: p.ProviderID + "-v1", + DisplayName: p.DisplayName, + PluginID: cryptoPluginID, + ProviderID: p.ProviderID, + ContractID: p.ContractID, + Version: "v1.0.0", + ConfigSchemaRef: p.SchemaRef, + ConfigSchemaDigest: p.SchemaDigest, + OperatingModes: []NetworkOperatingMode{NetworkModeNodeService}, + WorkloadKinds: []string{"node-service"}, + ExecutorProviders: []string{"node-service-sandboxed-container"}, + ExecutionSecurityTiers: []ExecutionSecurityTier{ExecutionSandboxedContainer}, + ProofTiers: []ProofTier{ProofArtifactHash}, + NetworkModes: cryptoNodeNetworkModes(), + RuntimeContract: ProviderRuntimeContract{Profiles: []ProviderRuntimeProfile{ + runtime, + }}, + } +} + +func CryptoUpstreamClientRequirement(chain string) (ProviderUpstreamClientRequirement, bool) { + profile, ok := CryptoNetworkProfile(chain) + if !ok { + return ProviderUpstreamClientRequirement{}, false + } + runtime := DefaultProviderRuntimeProfile("node-service-sandboxed-container", ExecutionSandboxedContainer, ProofArtifactHash) + req := ProviderUpstreamClientRequirement{ + ProtocolVersion: Version, + PluginID: cryptoPluginID, + ProviderID: profile.ProviderID, + ContractID: profile.ContractID, + Version: "v1.0.0", + RuntimeProfileID: runtime.ID, + ConformanceProfile: "upstream-client-v1", + DefaultConformance: UpstreamClientConformanceShapeOnly, + RealClientConformance: UpstreamClientConformanceRealClient, + UpstreamClientName: "bitcoind", + VersionProbeCommand: []string{"bitcoind", "--version"}, + ImagePolicy: ProviderUpstreamImagePolicy{ + DigestPinnedImageRequired: true, + OperatorSuppliedImageRequired: true, + }, + RequiredEvidence: []string{ + "digest-pinned OCI image reference", + "artifact:// provider conformance evidence with sha256 digest", + "upstream-client-v1 version probe artifact", + }, + } + switch profile.Chain { + case "ethereum": + req.UpstreamClientName = "geth" + req.VersionProbeCommand = []string{"geth", "version"} + req.ImagePolicy.OperatorSuppliedImageRequired = false + req.ImagePolicy.RecommendedImageRef = "ethereum/client-go@sha256:" + req.ImagePolicy.KnownImageRefs = []string{"ethereum/client-go@sha256:"} + req.Notes = []string{ + "ethereum/client-go is the Geth image family; operators must pin and prove the digest they deploy", + "shape-only remains the default until real upstream-client-v1 evidence is attached to the provider contract", + } + case "bch": + req.ImagePolicy.KnownImageRefs = []string{"zquestz/bitcoin-cash-node@sha256:"} + req.Notes = []string{ + "Bitcoin Cash uses implementation-specific image families rather than a single wfcompute-owned canonical image", + "operators must choose a BCH implementation image, pin the digest, and prove the upstream client version before real-client promotion", + } + case "btc": + req.ImagePolicy.KnownImageRefs = []string{"bitcoin/bitcoin@sha256:"} + req.Notes = []string{ + "Bitcoin Core has no single canonical official OCI runtime image for wfcompute to assume", + "operators must supply a digest-pinned implementation image and prove the upstream client version before real-client promotion", + } + } + return req, true +} + +func (p CryptoProfile) NetworkProduct(orgID string) NetworkProduct { + if strings.TrimSpace(orgID) == "" { + orgID = "public" + } + return NetworkProduct{ + ProtocolVersion: Version, + ID: p.ProductID, + DisplayName: p.DisplayName, + Purpose: p.Purpose, + OperatingMode: NetworkModeNodeService, + OrgID: orgID, + PoolID: p.PoolID, + WorkloadKinds: []string{"node-service"}, + SecurityFloor: PlacementRequirements{ + ExecutorProvider: "node-service-sandboxed-container", + ExecutionSecurityTier: ExecutionSandboxedContainer, + ProofTier: ProofArtifactHash, + }, + SessionPolicy: SessionPolicy{WarmSeconds: 3600, MinRenewals: 1, MaxBatchRequests: 1}, + ProviderConfig: ProviderConfig{ + PluginID: cryptoPluginID, + ProviderID: p.ProviderID, + ContractID: p.ContractID, + Version: "v1.0.0", + ConfigRef: p.ConfigRef, + }, + NetworkModes: cryptoNodeNetworkModes(), + PlacementConstraints: PlacementConstraints{ + Chain: p.Chain, + Role: "full-node", + MinDiskBytes: p.MinDiskBytes, + MinMemoryBytes: p.MinMemoryBytes, + MinBandwidthMbps: p.MinBandwidthMbps, + RequiresIngress: true, + WalletRef: p.WalletRef, + StorageGuidance: StorageGuidance{ + Mode: p.Storage.Mode, + MinDiskBytes: p.Storage.MinDiskBytes, + MinDiskDisplay: p.Storage.MinDiskDisplay, + RecommendedDiskBytes: p.Storage.RecommendedDiskBytes, + RecommendedDiskDisplay: p.Storage.RecommendedDiskDisplay, + GrowthMarginBytes: p.Storage.GrowthMarginBytes, + GrowthMarginDisplay: p.Storage.GrowthMarginDisplay, + DurableVolumeRequired: p.Storage.DurableVolumeRequired, + PreserveOnUpdate: p.Storage.PreserveOnUpdate, + PreserveOnUninstall: p.Storage.PreserveOnUninstall, + PruningSupported: p.Storage.PruningSupported, + SnapshotVerificationRequired: p.Storage.SnapshotVerificationRequired, + }, + }, + RewardPolicy: "profit-share", + AbusePolicy: "crypto-node-v1", + SettlementAccountID: p.Rewards.TreasuryAccountID, + SettlementTarget: SettlementTarget{ + Kind: SettlementTargetTreasuryWallet, + AccountID: p.Rewards.TreasuryAccountID, + Network: p.SettlementNetwork, + WalletRef: p.Rewards.TreasuryWalletRef, + }, + CryptoRewardRouting: CryptoRewardRoutingPolicy{ + Network: p.SettlementNetwork, + TreasuryAccountID: p.Rewards.TreasuryAccountID, + TreasuryWalletRef: p.Rewards.TreasuryWalletRef, + CustodyMode: CryptoRewardCustodyTreasuryThenDistribute, + DistributionMode: CryptoRewardDistributionContributionShare, + ParticipantWalletSource: CryptoRewardParticipantAccountWallet, + ManagementFeeBps: 1, + }, + } +} + +func cryptoNodeNetworkModes() []NetworkMode { + return []NetworkMode{NetworkModeDirect, NetworkModeRelay} +} diff --git a/catalog/types.go b/catalog/types.go new file mode 100644 index 0000000..7d5146c --- /dev/null +++ b/catalog/types.go @@ -0,0 +1,447 @@ +package catalog + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" +) + +const Version = "compute.v1alpha1" + +type NetworkOperatingMode string + +const NetworkModeNodeService NetworkOperatingMode = "node-service" + +type RuntimeProfile string + +const RuntimeProfileServiceOCI RuntimeProfile = "service-oci-v1" + +type ContainerRuntimeTool string + +const ( + ContainerRuntimePodman ContainerRuntimeTool = "podman" + ContainerRuntimeDocker ContainerRuntimeTool = "docker" + ContainerRuntimeNerdctl ContainerRuntimeTool = "nerdctl" +) + +type RuntimePermission string + +const RuntimePermissionForbidden RuntimePermission = "forbidden" + +type UpstreamClientConformance string + +const ( + UpstreamClientConformanceShapeOnly UpstreamClientConformance = "shape-only" + UpstreamClientConformanceRealClient UpstreamClientConformance = "real-client" +) + +type ExecutionSecurityTier string + +const ExecutionSandboxedContainer ExecutionSecurityTier = "sandboxed-container" + +type ProofTier string + +const ProofArtifactHash ProofTier = "artifact-hash" + +type NetworkMode string + +const ( + NetworkModeDirect NetworkMode = "direct" + NetworkModeRelay NetworkMode = "relay" +) + +type PlacementRequirements struct { + ExecutorProvider string `json:"executor_provider,omitempty"` + ExecutionSecurityTier ExecutionSecurityTier `json:"execution_security_tier,omitempty"` + ProofTier ProofTier `json:"proof_tier,omitempty"` +} + +type SessionPolicy struct { + WarmSeconds int `json:"warm_seconds,omitempty"` + MinRenewals int `json:"min_renewals,omitempty"` + MaxBatchRequests int `json:"max_batch_requests,omitempty"` +} + +type ProviderConfig struct { + PluginID string `json:"plugin_id,omitempty"` + ProviderID string `json:"provider_id,omitempty"` + ContractID string `json:"contract_id,omitempty"` + Version string `json:"version,omitempty"` + ConfigRef string `json:"config_ref,omitempty"` +} + +type ProviderContract struct { + ProtocolVersion string `json:"protocol_version"` + ID string `json:"id"` + PluginID string `json:"plugin_id"` + ProviderID string `json:"provider_id"` + ContractID string `json:"contract_id"` + Version string `json:"version"` + DisplayName string `json:"display_name,omitempty"` + ConfigSchemaRef string `json:"config_schema_ref"` + ConfigSchemaDigest string `json:"config_schema_digest"` + OperatingModes []NetworkOperatingMode `json:"operating_modes"` + WorkloadKinds []string `json:"workload_kinds"` + ExecutorProviders []string `json:"executor_providers"` + ExecutionSecurityTiers []ExecutionSecurityTier `json:"execution_security_tiers"` + ProofTiers []ProofTier `json:"proof_tiers"` + NetworkModes []NetworkMode `json:"network_modes"` + RuntimeContract ProviderRuntimeContract `json:"runtime_contract"` +} + +func (c ProviderContract) Validate() error { + var errs []error + for _, field := range []struct { + name string + value string + }{ + {"protocol_version", c.ProtocolVersion}, + {"id", c.ID}, + {"plugin_id", c.PluginID}, + {"provider_id", c.ProviderID}, + {"contract_id", c.ContractID}, + {"version", c.Version}, + {"config_schema_ref", c.ConfigSchemaRef}, + {"config_schema_digest", c.ConfigSchemaDigest}, + } { + if strings.TrimSpace(field.value) == "" { + errs = append(errs, fmt.Errorf("%s is required", field.name)) + } + } + if c.ProtocolVersion != Version { + errs = append(errs, fmt.Errorf("protocol_version must be %q", Version)) + } + if len(c.OperatingModes) == 0 || len(c.WorkloadKinds) == 0 || len(c.ExecutorProviders) == 0 || len(c.ExecutionSecurityTiers) == 0 || len(c.ProofTiers) == 0 || len(c.NetworkModes) == 0 { + errs = append(errs, errors.New("provider contract capability lists are required")) + } + if len(c.RuntimeContract.Profiles) == 0 { + errs = append(errs, errors.New("runtime_contract.profiles is required")) + } + for i, profile := range c.RuntimeContract.Profiles { + if err := profile.Validate(); err != nil { + errs = append(errs, fmt.Errorf("runtime_contract.profiles[%d]: %w", i, err)) + } + } + return errors.Join(errs...) +} + +func (c ProviderContract) SupportsProduct(product NetworkProduct) error { + if c.PluginID != product.ProviderConfig.PluginID || + c.ProviderID != product.ProviderConfig.ProviderID || + c.ContractID != product.ProviderConfig.ContractID { + return errors.New("product provider config does not match contract") + } + if !contains(c.OperatingModes, product.OperatingMode) { + return fmt.Errorf("operating mode %q is unsupported", product.OperatingMode) + } + for _, kind := range product.WorkloadKinds { + if !contains(c.WorkloadKinds, kind) { + return fmt.Errorf("workload kind %q is unsupported", kind) + } + } + for _, mode := range product.NetworkModes { + if !contains(c.NetworkModes, mode) { + return fmt.Errorf("network mode %q is unsupported", mode) + } + } + return nil +} + +type ProviderRuntimeContract struct { + Profiles []ProviderRuntimeProfile `json:"profiles"` +} + +type ProviderRuntimeProfile struct { + ID string `json:"id"` + RuntimeProfile RuntimeProfile `json:"runtime_profile"` + ExecutorProvider string `json:"executor_provider"` + ExecutionSecurityTier ExecutionSecurityTier `json:"execution_security_tier"` + ProofTier ProofTier `json:"proof_tier"` + AllowedRuntimeTools []ContainerRuntimeTool `json:"allowed_runtime_tools,omitempty"` + ImageDigestRequired bool `json:"image_digest_required"` + RootFSDigestRequired bool `json:"rootfs_digest_required"` + AllowedMountRefs []string `json:"allowed_mount_refs,omitempty"` + WritablePaths []string `json:"writable_paths,omitempty"` + WritableRootFS RuntimePermission `json:"writable_rootfs"` + Privileged RuntimePermission `json:"privileged"` + HostNamespaces RuntimePermission `json:"host_namespaces"` + HostSocket RuntimePermission `json:"host_socket"` + SeccompDisable RuntimePermission `json:"seccomp_disable"` + NoNewPrivilegesDisable RuntimePermission `json:"no_new_privileges_disable"` + ConformanceProfiles []string `json:"conformance_profiles,omitempty"` + UpstreamClientConformance UpstreamClientConformance `json:"upstream_client_conformance,omitempty"` + HostWorkspaceSupported bool `json:"host_workspace_supported,omitempty"` +} + +func (p ProviderRuntimeProfile) Validate() error { + var errs []error + if p.ID == "" { + errs = append(errs, errors.New("id is required")) + } + if p.RuntimeProfile != RuntimeProfileServiceOCI { + errs = append(errs, fmt.Errorf("runtime_profile %q is unsupported", p.RuntimeProfile)) + } + if p.ExecutorProvider == "" { + errs = append(errs, errors.New("executor_provider is required")) + } + if p.ExecutionSecurityTier != ExecutionSandboxedContainer { + errs = append(errs, fmt.Errorf("execution_security_tier %q is unsupported", p.ExecutionSecurityTier)) + } + if p.ProofTier != ProofArtifactHash { + errs = append(errs, fmt.Errorf("proof_tier %q is unsupported", p.ProofTier)) + } + if !p.ImageDigestRequired || !p.RootFSDigestRequired { + errs = append(errs, errors.New("image and rootfs digests are required")) + } + for _, permission := range []struct { + name string + value RuntimePermission + }{ + {"writable_rootfs", p.WritableRootFS}, + {"privileged", p.Privileged}, + {"host_namespaces", p.HostNamespaces}, + {"host_socket", p.HostSocket}, + {"seccomp_disable", p.SeccompDisable}, + {"no_new_privileges_disable", p.NoNewPrivilegesDisable}, + } { + if permission.value != RuntimePermissionForbidden { + errs = append(errs, fmt.Errorf("%s must be forbidden", permission.name)) + } + } + return errors.Join(errs...) +} + +func DefaultProviderRuntimeProfile(executorProvider string, tier ExecutionSecurityTier, proof ProofTier) ProviderRuntimeProfile { + return ProviderRuntimeProfile{ + ID: executorProvider + "-" + string(tier) + "-" + string(proof) + "-runtime", + RuntimeProfile: RuntimeProfileServiceOCI, + ExecutorProvider: executorProvider, + ExecutionSecurityTier: tier, + ProofTier: proof, + AllowedRuntimeTools: []ContainerRuntimeTool{ContainerRuntimePodman, ContainerRuntimeDocker, ContainerRuntimeNerdctl}, + ImageDigestRequired: true, + RootFSDigestRequired: true, + AllowedMountRefs: []string{"workspace", "node-data"}, + WritablePaths: []string{"/tmp"}, + WritableRootFS: RuntimePermissionForbidden, + Privileged: RuntimePermissionForbidden, + HostNamespaces: RuntimePermissionForbidden, + HostSocket: RuntimePermissionForbidden, + SeccompDisable: RuntimePermissionForbidden, + NoNewPrivilegesDisable: RuntimePermissionForbidden, + ConformanceProfiles: []string{"service-oci-v1"}, + HostWorkspaceSupported: true, + UpstreamClientConformance: UpstreamClientConformanceShapeOnly, + } +} + +type NetworkProduct struct { + ProtocolVersion string `json:"protocol_version"` + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` + Purpose string `json:"purpose,omitempty"` + OperatingMode NetworkOperatingMode `json:"operating_mode"` + OrgID string `json:"org_id"` + PoolID string `json:"pool_id"` + WorkloadKinds []string `json:"workload_kinds"` + SecurityFloor PlacementRequirements `json:"security_floor"` + SessionPolicy SessionPolicy `json:"session_policy,omitzero"` + ProviderConfig ProviderConfig `json:"provider_config,omitzero"` + NetworkModes []NetworkMode `json:"network_modes"` + PlacementConstraints PlacementConstraints `json:"placement_constraints,omitzero"` + RewardPolicy string `json:"reward_policy"` + AbusePolicy string `json:"abuse_policy"` + SettlementAccountID string `json:"settlement_account_id,omitempty"` + SettlementTarget SettlementTarget `json:"settlement_target,omitzero"` + CryptoRewardRouting CryptoRewardRoutingPolicy `json:"crypto_reward_routing,omitzero"` +} + +func (p NetworkProduct) Validate() error { + var errs []error + for _, field := range []struct { + name string + value string + }{ + {"protocol_version", p.ProtocolVersion}, + {"id", p.ID}, + {"org_id", p.OrgID}, + {"pool_id", p.PoolID}, + {"reward_policy", p.RewardPolicy}, + {"abuse_policy", p.AbusePolicy}, + } { + if strings.TrimSpace(field.value) == "" { + errs = append(errs, fmt.Errorf("%s is required", field.name)) + } + } + if p.ProtocolVersion != Version { + errs = append(errs, fmt.Errorf("protocol_version must be %q", Version)) + } + if p.OperatingMode != NetworkModeNodeService { + errs = append(errs, fmt.Errorf("operating_mode %q is unsupported", p.OperatingMode)) + } + if len(p.WorkloadKinds) == 0 || len(p.NetworkModes) == 0 { + errs = append(errs, errors.New("workload_kinds and network_modes are required")) + } + if p.SecurityFloor.ExecutorProvider == "" || p.SecurityFloor.ExecutionSecurityTier == "" || p.SecurityFloor.ProofTier == "" { + errs = append(errs, errors.New("security_floor is required")) + } + if p.ProviderConfig.PluginID == "" || p.ProviderConfig.ProviderID == "" || p.ProviderConfig.ContractID == "" { + errs = append(errs, errors.New("provider_config identity is required")) + } + if p.PlacementConstraints.Chain == "" || p.PlacementConstraints.Role == "" || p.PlacementConstraints.MinDiskBytes <= 0 { + errs = append(errs, errors.New("placement_constraints chain, role, and min_disk_bytes are required")) + } + if p.SettlementTarget.Kind == "" || p.SettlementTarget.Network == "" || p.SettlementTarget.WalletRef == "" { + errs = append(errs, errors.New("settlement_target is required")) + } + return errors.Join(errs...) +} + +type PlacementConstraints struct { + Chain string `json:"chain,omitempty"` + Role string `json:"role,omitempty"` + MinDiskBytes int64 `json:"min_disk_bytes,omitempty"` + MinMemoryBytes int64 `json:"min_memory_bytes,omitempty"` + MinBandwidthMbps int64 `json:"min_bandwidth_mbps,omitempty"` + RequiresIngress bool `json:"requires_ingress,omitempty"` + RequiredCapabilities []string `json:"required_capabilities,omitempty"` + WalletRef string `json:"wallet_ref,omitempty"` + StorageGuidance StorageGuidance `json:"storage_guidance,omitzero"` +} + +type StorageGuidance struct { + Mode string `json:"mode,omitempty"` + MinDiskBytes int64 `json:"min_disk_bytes,omitempty"` + MinDiskDisplay string `json:"min_disk_display,omitempty"` + RecommendedDiskBytes int64 `json:"recommended_disk_bytes,omitempty"` + RecommendedDiskDisplay string `json:"recommended_disk_display,omitempty"` + GrowthMarginBytes int64 `json:"growth_margin_bytes,omitempty"` + GrowthMarginDisplay string `json:"growth_margin_display,omitempty"` + DurableVolumeRequired bool `json:"durable_volume_required,omitempty"` + PreserveOnUpdate bool `json:"preserve_on_update,omitempty"` + PreserveOnUninstall bool `json:"preserve_on_uninstall,omitempty"` + PruningSupported bool `json:"pruning_supported,omitempty"` + SnapshotVerificationRequired bool `json:"snapshot_verification_required,omitempty"` +} + +type SettlementTargetKind string + +const SettlementTargetTreasuryWallet SettlementTargetKind = "treasury_wallet" + +type SettlementTarget struct { + Kind SettlementTargetKind `json:"kind,omitempty"` + AccountID string `json:"account_id,omitempty"` + Network string `json:"network,omitempty"` + WalletRef string `json:"wallet_ref,omitempty"` +} + +type CryptoRewardCustodyMode string + +const CryptoRewardCustodyTreasuryThenDistribute CryptoRewardCustodyMode = "treasury_then_distribute" + +type CryptoRewardDistributionMode string + +const CryptoRewardDistributionContributionShare CryptoRewardDistributionMode = "contribution_share" + +type CryptoRewardParticipantWalletSource string + +const CryptoRewardParticipantAccountWallet CryptoRewardParticipantWalletSource = "account_wallet" + +type CryptoRewardRoutingPolicy struct { + Network string `json:"network,omitempty"` + TreasuryAccountID string `json:"treasury_account_id,omitempty"` + TreasuryWalletRef string `json:"treasury_wallet_ref,omitempty"` + CustodyMode CryptoRewardCustodyMode `json:"custody_mode,omitempty"` + DistributionMode CryptoRewardDistributionMode `json:"distribution_mode,omitempty"` + ParticipantWalletSource CryptoRewardParticipantWalletSource `json:"participant_wallet_source,omitempty"` + ManagementFeeBps int `json:"management_fee_bps,omitempty"` +} + +type ProviderUpstreamClientRequirement struct { + ProtocolVersion string `json:"protocol_version"` + PluginID string `json:"plugin_id"` + ProviderID string `json:"provider_id"` + ContractID string `json:"contract_id"` + Version string `json:"version"` + RuntimeProfileID string `json:"runtime_profile_id"` + ConformanceProfile string `json:"conformance_profile"` + DefaultConformance UpstreamClientConformance `json:"default_conformance"` + RealClientConformance UpstreamClientConformance `json:"real_client_conformance"` + UpstreamClientName string `json:"upstream_client_name"` + VersionProbeCommand []string `json:"version_probe_command,omitempty"` + ImagePolicy ProviderUpstreamImagePolicy `json:"image_policy"` + RequiredEvidence []string `json:"required_evidence,omitempty"` + Notes []string `json:"notes,omitempty"` +} + +func (r ProviderUpstreamClientRequirement) Validate() error { + var errs []error + for _, field := range []struct { + name string + value string + }{ + {"protocol_version", r.ProtocolVersion}, + {"plugin_id", r.PluginID}, + {"provider_id", r.ProviderID}, + {"contract_id", r.ContractID}, + {"version", r.Version}, + {"runtime_profile_id", r.RuntimeProfileID}, + {"conformance_profile", r.ConformanceProfile}, + {"upstream_client_name", r.UpstreamClientName}, + } { + if strings.TrimSpace(field.value) == "" { + errs = append(errs, fmt.Errorf("%s is required", field.name)) + } + } + if r.ProtocolVersion != Version { + errs = append(errs, fmt.Errorf("protocol_version must be %q", Version)) + } + if r.DefaultConformance != UpstreamClientConformanceShapeOnly { + errs = append(errs, errors.New("default_conformance must be shape-only")) + } + if r.RealClientConformance != UpstreamClientConformanceRealClient { + errs = append(errs, errors.New("real_client_conformance must be real-client")) + } + if r.ConformanceProfile != "upstream-client-v1" { + errs = append(errs, errors.New("conformance_profile must be upstream-client-v1")) + } + if err := r.ImagePolicy.Validate(); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) +} + +type ProviderUpstreamImagePolicy struct { + DigestPinnedImageRequired bool `json:"digest_pinned_image_required"` + OperatorSuppliedImageRequired bool `json:"operator_supplied_image_required,omitempty"` + RecommendedImageRef string `json:"recommended_image_ref,omitempty"` + KnownImageRefs []string `json:"known_image_refs,omitempty"` +} + +func (p ProviderUpstreamImagePolicy) Validate() error { + if !p.DigestPinnedImageRequired { + return errors.New("digest_pinned_image_required must be true") + } + return nil +} + +func CanonicalHash(value any) string { + data, err := json.Marshal(value) + if err != nil { + data = []byte(fmt.Sprintf("%v", value)) + } + sum := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func contains[T comparable](values []T, want T) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/cmd/scaffold-workflow-plugin-iac/main.go b/cmd/scaffold-workflow-plugin-iac/main.go deleted file mode 100644 index 2942e32..0000000 --- a/cmd/scaffold-workflow-plugin-iac/main.go +++ /dev/null @@ -1,23 +0,0 @@ -// Command scaffold-workflow-plugin-iac is the IaC variant of the workflow -// plugin scaffold. Use this entrypoint when the plugin provisions -// infrastructure (cloud resources, etc.) — it serves the typed -// pb.IaCProvider* surface required by wfctl infra apply/plan/destroy. -// -// Instantiators run `bash scripts/rename-from-scaffold.sh --mode iac` -// to copy this file to cmd/workflow-plugin-/main.go and delete -// the non-IaC variant (cmd/scaffold-workflow-plugin/). -// -// Non-IaC plugins use cmd/scaffold-workflow-plugin/main.go instead. The -// rename script's --mode flag selects which entrypoint survives. -package main - -import ( - "github.com/GoCodeAlone/scaffold-workflow-plugin/internal" - sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" -) - -func main() { - sdk.ServeIaCPlugin(internal.NewIaCServer(), sdk.IaCServeOptions{ - BuildVersion: sdk.ResolveBuildVersion(internal.Version), - }) -} diff --git a/cmd/scaffold-workflow-plugin/main.go b/cmd/scaffold-workflow-plugin/main.go deleted file mode 100644 index ed4c56b..0000000 --- a/cmd/scaffold-workflow-plugin/main.go +++ /dev/null @@ -1,19 +0,0 @@ -// Command scaffold-workflow-plugin is the NON-IaC variant of the workflow -// plugin scaffold. It runs as a subprocess and communicates with the host -// workflow engine via the go-plugin gRPC protocol. -// -// Instantiators run `bash scripts/rename-from-scaffold.sh --mode non-iac` -// to copy this file to cmd/workflow-plugin-/main.go and delete -// the IaC variant (cmd/scaffold-workflow-plugin-iac/). -package main - -import ( - "github.com/GoCodeAlone/scaffold-workflow-plugin/internal" - sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" -) - -func main() { - sdk.Serve(internal.NewPlugin(), - sdk.WithBuildVersion(sdk.ResolveBuildVersion(internal.Version)), - ) -} diff --git a/cmd/workflow-plugin-crypto/main.go b/cmd/workflow-plugin-crypto/main.go new file mode 100644 index 0000000..c4dc0ad --- /dev/null +++ b/cmd/workflow-plugin-crypto/main.go @@ -0,0 +1,14 @@ +// Command workflow-plugin-crypto runs as an external workflow plugin and +// exposes crypto network provider catalog metadata. +package main + +import ( + "github.com/GoCodeAlone/workflow-plugin-crypto/internal" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +func main() { + sdk.Serve(internal.NewPlugin(), + sdk.WithBuildVersion(sdk.ResolveBuildVersion(internal.Version)), + ) +} diff --git a/go.mod b/go.mod index 51e255f..a460782 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/GoCodeAlone/scaffold-workflow-plugin +module github.com/GoCodeAlone/workflow-plugin-crypto go 1.26.0 @@ -77,7 +77,7 @@ require ( github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.9.1 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect diff --git a/go.sum b/go.sum index 2b69e90..7584206 100644 --- a/go.sum +++ b/go.sum @@ -194,8 +194,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= -github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= diff --git a/internal/iacserver.go b/internal/iacserver.go deleted file mode 100644 index 2cb6b66..0000000 --- a/internal/iacserver.go +++ /dev/null @@ -1,30 +0,0 @@ -package internal - -import ( - pb "github.com/GoCodeAlone/workflow/plugin/external/proto" -) - -// IaCServer is the IaC-mode stub for the scaffold. Embeds -// pb.UnimplementedIaCProviderRequiredServer so all required RPCs (Initialize, -// Name, Version, Capabilities, Plan, Destroy, Status, Import, ResolveSizing, -// BootstrapStateBackend) return codes.Unimplemented by default. -// -// Instantiators using `bash scripts/rename-from-scaffold.sh --mode iac` -// replace this stub with their real IaC provider implementation. The -// rename script removes cmd/scaffold-workflow-plugin/ in IaC mode, so the -// non-IaC NewPlugin() entrypoint is gone — only the IaC server remains. -// -// To implement additional optional IaC contracts, embed the corresponding -// Unimplemented*Server type: -// - pb.UnimplementedIaCProviderServer -// - pb.UnimplementedIaCProviderLogCaptureServer -// - pb.UnimplementedIaCProviderFinalizerServer -type IaCServer struct { - pb.UnimplementedIaCProviderRequiredServer -} - -// NewIaCServer constructs the IaC-mode plugin server. Called from -// cmd/scaffold-workflow-plugin-iac/main.go. -func NewIaCServer() *IaCServer { - return &IaCServer{} -} diff --git a/internal/plugin.go b/internal/plugin.go index 9e2ec07..21a9953 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -1,72 +1,31 @@ -// Package internal implements the scaffold-workflow-plugin plugin. +// Package internal implements the workflow-plugin-crypto plugin. package internal import ( - "fmt" - sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" ) // Version is set at build time via -ldflags -// "-X github.com/GoCodeAlone/scaffold-workflow-plugin/internal.Version=X.Y.Z". +// "-X github.com/GoCodeAlone/workflow-plugin-crypto/internal.Version=X.Y.Z". // Default is a bare semver so plugin loaders that validate semver accept // unreleased dev builds; goreleaser overrides with the real release tag. var Version = "0.0.0" -// TEMPLATEPlugin implements sdk.PluginProvider and optionally -// sdk.ModuleProvider, sdk.StepProvider, sdk.TriggerProvider, etc. -type TEMPLATEPlugin struct{} +// CryptoPlugin exposes crypto network provider catalog metadata. +type CryptoPlugin struct{} // NewPlugin returns a new plugin instance. main.go calls sdk.Serve(NewPlugin()). func NewPlugin() sdk.PluginProvider { - return &TEMPLATEPlugin{} + return &CryptoPlugin{} } // Manifest returns the plugin metadata used by the workflow engine for // discovery and capability negotiation. -func (p *TEMPLATEPlugin) Manifest() sdk.PluginManifest { +func (p *CryptoPlugin) Manifest() sdk.PluginManifest { return sdk.PluginManifest{ - Name: "scaffold-workflow-plugin", + Name: "workflow-plugin-crypto", Version: Version, Author: "GoCodeAlone", - Description: "TEMPLATE plugin for the workflow engine", - } -} - -// ModuleTypes returns the module type names this plugin provides. -// Remove this method if the plugin does not provide any modules. -func (p *TEMPLATEPlugin) ModuleTypes() []string { - return []string{ - // "example.module_type", - } -} - -// CreateModule creates a module instance of the given type. -// Remove this method if the plugin does not provide any modules. -func (p *TEMPLATEPlugin) CreateModule(typeName, name string, config map[string]any) (sdk.ModuleInstance, error) { - switch typeName { - // case "example.module_type": - // return newExampleModule(name, config) - default: - return nil, fmt.Errorf("TEMPLATE: unknown module type %q", typeName) - } -} - -// StepTypes returns the step type names this plugin provides. -// Remove this method if the plugin does not provide any steps. -func (p *TEMPLATEPlugin) StepTypes() []string { - return []string{ - // "step.example_action", - } -} - -// CreateStep creates a step instance of the given type. -// Remove this method if the plugin does not provide any steps. -func (p *TEMPLATEPlugin) CreateStep(typeName, name string, config map[string]any) (sdk.StepInstance, error) { - switch typeName { - // case "step.example_action": - // return newExampleStep(name, config), nil - default: - return nil, fmt.Errorf("TEMPLATE: unknown step type %q", typeName) + Description: "Crypto network provider catalog plugin for workflow-compute.", } } diff --git a/internal/plugin_test.go b/internal/plugin_test.go index c05d370..ba2b8fc 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -1,9 +1,14 @@ package internal_test import ( + "encoding/json" + "os" + "slices" + "strings" "testing" - "github.com/GoCodeAlone/scaffold-workflow-plugin/internal" + "github.com/GoCodeAlone/workflow-plugin-crypto/catalog" + "github.com/GoCodeAlone/workflow-plugin-crypto/internal" sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" ) @@ -22,4 +27,126 @@ func TestManifest_HasRequiredFields(t *testing.T) { if m.Description == "" { t.Error("manifest Description is empty") } + if strings.Contains(m.Description, "TEMPLATE") || strings.Contains(strings.ToLower(m.Description), "scaffold") { + t.Fatalf("manifest still carries scaffold placeholder text: %q", m.Description) + } +} + +func TestPluginJSON_AdvertisesProviderCatalogOnly(t *testing.T) { + data, err := os.ReadFile("../plugin.json") + if err != nil { + t.Fatalf("read plugin.json: %v", err) + } + var manifest struct { + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Private bool `json:"private"` + Keywords []string `json:"keywords"` + Capabilities struct { + ConfigProvider bool `json:"configProvider"` + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + } `json:"capabilities"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse plugin.json: %v", err) + } + if manifest.Name != "workflow-plugin-crypto" || manifest.Type != "external" || manifest.Private { + t.Fatalf("unexpected plugin identity: %+v", manifest) + } + joined := strings.Join(append(manifest.Keywords, manifest.Description), " ") + if strings.Contains(joined, "TEMPLATE") || strings.Contains(strings.ToLower(joined), "scaffold") { + t.Fatalf("plugin.json still carries scaffold placeholder text: %s", joined) + } + if manifest.Capabilities.ConfigProvider || + len(manifest.Capabilities.ModuleTypes) != 0 || + len(manifest.Capabilities.StepTypes) != 0 || + len(manifest.Capabilities.TriggerTypes) != 0 { + t.Fatalf("crypto provider catalog should not advertise runtime capabilities: %+v", manifest.Capabilities) + } + if !slices.Contains(manifest.Keywords, "provider-catalog") || !slices.Contains(manifest.Keywords, "workflow-compute") { + t.Fatalf("provider-catalog keywords missing: %+v", manifest.Keywords) + } +} + +func TestCryptoProviderManifest_ValidatesStableCatalog(t *testing.T) { + manifest := catalog.CryptoProviderManifest() + if err := manifest.Validate(); err != nil { + t.Fatalf("crypto provider manifest invalid: %v", err) + } + if manifest.ProtocolVersion != catalog.Version || manifest.PluginID != "workflow-plugin-crypto" || manifest.Version != "v1.0.0" { + t.Fatalf("manifest identity: %+v", manifest) + } + if len(manifest.Profiles) != 3 || manifest.Profiles[0].Chain != "btc" || manifest.Profiles[1].Chain != "bch" || manifest.Profiles[2].Chain != "ethereum" { + t.Fatalf("manifest profiles are not stable btc/bch/ethereum order: %+v", manifest.Profiles) + } + if digest := catalog.CryptoProviderManifestDigest(); digest != "sha256:73faf36582844b9399686517b9cfd6ffe695fa47f14fedf87f4c02e74ec20b63" { + t.Fatalf("crypto provider manifest digest drifted: got %s", digest) + } +} + +func TestCryptoNetworkCatalog_BuildsStrictContractsAndProducts(t *testing.T) { + for _, tc := range []struct { + chain string + productID string + providerID string + contractID string + peerPort int + }{ + {chain: "btc", productID: "btc-full-node", providerID: "btc-full-node", contractID: "crypto.btc-full-node.v1", peerPort: 8333}, + {chain: "bch", productID: "bch-full-node", providerID: "bch-full-node", contractID: "crypto.bch-full-node.v1", peerPort: 8333}, + {chain: "ethereum", productID: "ethereum-full-node", providerID: "ethereum-full-node", contractID: "crypto.ethereum-full-node.v1", peerPort: 30303}, + } { + t.Run(tc.chain, func(t *testing.T) { + profile, ok := catalog.CryptoNetworkProfile(tc.chain) + if !ok { + t.Fatalf("missing profile %q", tc.chain) + } + contract := profile.ProviderContract() + if err := contract.Validate(); err != nil { + t.Fatalf("provider contract invalid: %v", err) + } + if contract.PluginID != "workflow-plugin-crypto" || contract.ProviderID != tc.providerID || contract.ContractID != tc.contractID { + t.Fatalf("contract identity: %+v", contract) + } + product := profile.NetworkProduct("public") + if err := product.Validate(); err != nil { + t.Fatalf("network product invalid: %v", err) + } + if product.ID != tc.productID || product.ProviderConfig.ProviderID != tc.providerID || product.ProviderConfig.ContractID != tc.contractID { + t.Fatalf("product identity/provider: %+v", product) + } + if product.PlacementConstraints.Chain != tc.chain || product.PlacementConstraints.Role != catalog.CryptoRoleFullNode || !product.PlacementConstraints.RequiresIngress { + t.Fatalf("placement constraints: %+v", product.PlacementConstraints) + } + if profile.Network.PeerPort != tc.peerPort || !profile.Network.RPCPrivateOnly || !profile.Network.AuditRequired { + t.Fatalf("public-chain network metadata: %+v", profile.Network) + } + if err := contract.SupportsProduct(product); err != nil { + t.Fatalf("contract should support product: %v", err) + } + }) + } +} + +func TestCryptoUpstreamRequirements_ValidateImagePolicy(t *testing.T) { + for _, chain := range []string{"btc", "bch", "ethereum"} { + t.Run(chain, func(t *testing.T) { + req, ok := catalog.CryptoUpstreamClientRequirement(chain) + if !ok { + t.Fatalf("missing upstream requirement %q", chain) + } + if err := req.Validate(); err != nil { + t.Fatalf("upstream requirement invalid: %v", err) + } + if req.PluginID != "workflow-plugin-crypto" || req.DefaultConformance != catalog.UpstreamClientConformanceShapeOnly { + t.Fatalf("upstream requirement identity/conformance: %+v", req) + } + if !req.ImagePolicy.DigestPinnedImageRequired { + t.Fatalf("upstream images must require digest pins: %+v", req.ImagePolicy) + } + }) + } } diff --git a/plugin.json b/plugin.json index 5f86b2b..301aa4f 100644 --- a/plugin.json +++ b/plugin.json @@ -1,24 +1,25 @@ { - "name": "scaffold-workflow-plugin", - "version": "0.0.0", - "description": "Scaffold repo for new workflow plugins. NOT an installable plugin — see README for the instantiation flow.", - "author": "GoCodeAlone", - "license": "MIT", - "type": "scaffold", - "tier": "community", - "private": false, - "minEngineVersion": "0.62.0", - "keywords": ["scaffold", "template"], - "homepage": "https://github.com/GoCodeAlone/scaffold-workflow-plugin", - "repository": "https://github.com/GoCodeAlone/scaffold-workflow-plugin", - "capabilities": { - "configProvider": false, - "moduleTypes": ["TEMPLATE.module"], - "stepTypes": ["TEMPLATE.step"], - "triggerTypes": [], - "iacProvider": { - "resourceTypes": ["TEMPLATE.resource"] - } - }, - "contracts": [] + "name": "workflow-plugin-crypto", + "version": "0.0.0", + "description": "Crypto network provider catalog plugin for workflow-compute.", + "author": "GoCodeAlone", + "license": "MIT", + "type": "external", + "tier": "community", + "private": false, + "minEngineVersion": "0.62.0", + "keywords": [ + "crypto", + "workflow-compute", + "provider-catalog" + ], + "homepage": "https://github.com/GoCodeAlone/workflow-plugin-crypto", + "repository": "https://github.com/GoCodeAlone/workflow-plugin-crypto", + "capabilities": { + "configProvider": false, + "moduleTypes": [], + "stepTypes": [], + "triggerTypes": [] + }, + "contracts": [] } diff --git a/scripts/rename-from-scaffold.sh b/scripts/rename-from-scaffold.sh deleted file mode 100755 index 6fcedbb..0000000 --- a/scripts/rename-from-scaffold.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# Usage: bash scripts/rename-from-scaffold.sh [--mode iac|non-iac] -# -# Renames scaffold-workflow-plugin internals to workflow-plugin-: -# 1. Picks the IaC or non-IaC main.go variant; deletes the other. -# 2. Renames cmd/scaffold-workflow-plugin*/ → cmd/workflow-plugin-/. -# 3. Updates go.mod module path. -# 4. Bulk sed across .go/.yaml/.yml/.md/.json files (find-based; safe with -# paths containing spaces; doesn't rely on bash globstar). -# 5. Resets plugin.json: type "scaffold" → "external"; name → workflow-plugin-. -# 6. Removes the rename script itself + scaffold-rename-test workflow. -# -# Requires: jq. -# -# Tested by .github/workflows/scaffold-rename-test.yml which runs this against -# a tmp copy in both --mode iac and --mode non-iac, then `go build ./...`. -set -euo pipefail - -NEW_NAME="${1:?Usage: rename-from-scaffold.sh [--mode iac|non-iac]}" -MODE="non-iac" -if [[ "${2:-}" == "--mode" ]]; then - MODE="${3:?Mode required}" -fi -case "$MODE" in - iac|non-iac) ;; - *) echo "Mode must be iac or non-iac" >&2; exit 1 ;; -esac - -if ! command -v jq >/dev/null 2>&1; then - echo "error: jq is required" >&2 - exit 1 -fi - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$REPO_ROOT" - -# 1+2. Pick main.go variant; delete the other; rename to workflow-plugin-. -if [[ "$MODE" == "iac" ]]; then - rm -rf cmd/scaffold-workflow-plugin - mv cmd/scaffold-workflow-plugin-iac "cmd/workflow-plugin-$NEW_NAME" -else - rm -rf cmd/scaffold-workflow-plugin-iac - mv cmd/scaffold-workflow-plugin "cmd/workflow-plugin-$NEW_NAME" -fi - -# 3. go.mod -go mod edit -module "github.com/GoCodeAlone/workflow-plugin-$NEW_NAME" - -# 4. Bulk sed via find (safe for paths with spaces; no globstar dependency). -find . \( -name '*.go' -o -name '*.yaml' -o -name '*.yml' -o -name '*.md' -o -name 'plugin.json' \) \ - -not -path './vendor/*' -not -path './_worktrees/*' -not -path './.git/*' -print0 \ - | while IFS= read -r -d '' f; do - sed -i.bak "s|scaffold-workflow-plugin|workflow-plugin-$NEW_NAME|g" "$f" - rm -f "$f.bak" - done - -# 5. plugin.json: reset type + name (jq-based; idempotent). -tmp="$(mktemp)" -jq --arg name "workflow-plugin-$NEW_NAME" '.type = "external" | .name = $name' plugin.json > "$tmp" -mv "$tmp" plugin.json - -# 6. Remove the rename script itself + scaffold-rename-test workflow. -rm -f scripts/rename-from-scaffold.sh -rm -f .github/workflows/scaffold-rename-test.yml - -echo "Renamed to workflow-plugin-$NEW_NAME ($MODE mode)." -echo "Next steps:" -echo " 1. Review changes: git status / git diff" -echo " 2. Edit plugin.json: replace TEMPLATE.* placeholders with real capabilities" -echo " 3. Commit: git add -A && git commit -m 'feat: initial plugin scaffold'" -echo " 4. Tag: git tag v0.1.0 && git push origin main v0.1.0"