1 Star 0 Fork 0

13683679291/fabric

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
deploy.go 20.67 KB
一键复制 编辑 原始数据 按行查看 历史
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
/*
Copyright IBM Corp. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0
*/
package nwo
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"os/exec"
"strconv"
"strings"
"github.com/golang/protobuf/proto"
"github.com/hyperledger/fabric-protos-go/common"
"github.com/hyperledger/fabric-protos-go/peer/lifecycle"
"github.com/hyperledger/fabric/common/util"
"github.com/hyperledger/fabric/integration/nwo/commands"
"github.com/hyperledger/fabric/protoutil"
"github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/gbytes"
"github.com/onsi/gomega/gexec"
. "github.com/onsi/gomega/gstruct"
)
type Chaincode struct {
Name string
Version string
Path string
Ctor string
Policy string // only used for legacy lifecycle. For new lifecycle use SignaturePolicy
Lang string
CollectionsConfig string // optional
PackageFile string
PackageID string // if unspecified, chaincode won't be executable. Can use SetPackageIDFromPackageFile() to set.
CodeFiles map[string]string // map from paths on the filesystem to code.tar.gz paths
Sequence string
EndorsementPlugin string
ValidationPlugin string
InitRequired bool
Label string
SignaturePolicy string
ChannelConfigPolicy string
}
func (c *Chaincode) SetPackageIDFromPackageFile() {
fileBytes, err := ioutil.ReadFile(c.PackageFile)
Expect(err).NotTo(HaveOccurred())
hashStr := fmt.Sprintf("%x", util.ComputeSHA256(fileBytes))
c.PackageID = c.Label + ":" + hashStr
}
// DeployChaincode is a helper that will install chaincode to all peers that
// are connected to the specified channel, approve the chaincode on one of the
// peers of each organization in the network, commit the chaincode definition
// on the channel using one of the peers, and wait for the chaincode commit to
// complete on all of the peers. It uses the _lifecycle implementation.
// NOTE V2_0 capabilities must be enabled for this functionality to work.
func DeployChaincode(n *Network, channel string, orderer *Orderer, chaincode Chaincode, peers ...*Peer) {
if len(peers) == 0 {
peers = n.PeersWithChannel(channel)
}
if len(peers) == 0 {
return
}
PackageAndInstallChaincode(n, chaincode, peers...)
// approve for each org
ApproveChaincodeForMyOrg(n, channel, orderer, chaincode, peers...)
// commit definition
CheckCommitReadinessUntilReady(n, channel, chaincode, n.PeerOrgs(), peers...)
CommitChaincode(n, channel, orderer, chaincode, peers[0], peers...)
// init the chaincode, if required
if chaincode.InitRequired {
InitChaincode(n, channel, orderer, chaincode, peers...)
}
}
// DeployChaincodeLegacy is a helper that will install chaincode to all peers
// that are connected to the specified channel, instantiate the chaincode on
// one of the peers, and wait for the instantiation to complete on all of the
// peers. It uses the legacy lifecycle (lscc) implementation.
//
// NOTE: This helper should not be used to deploy the same chaincode on
// multiple channels as the install will fail on subsequent calls. Instead,
// simply use InstantiateChaincode().
func DeployChaincodeLegacy(n *Network, channel string, orderer *Orderer, chaincode Chaincode, peers ...*Peer) {
if len(peers) == 0 {
peers = n.PeersWithChannel(channel)
}
if len(peers) == 0 {
return
}
// create temp file for chaincode package if not provided
if chaincode.PackageFile == "" {
tempFile, err := ioutil.TempFile("", "chaincode-package")
Expect(err).NotTo(HaveOccurred())
tempFile.Close()
defer os.Remove(tempFile.Name())
chaincode.PackageFile = tempFile.Name()
}
// only create chaincode package if it doesn't already exist
if fi, err := os.Stat(chaincode.PackageFile); os.IsNotExist(err) || fi.Size() == 0 {
PackageChaincodeLegacy(n, chaincode, peers[0])
}
// install on all peers
InstallChaincodeLegacy(n, chaincode, peers...)
// instantiate on the first peer
InstantiateChaincodeLegacy(n, channel, orderer, chaincode, peers[0], peers...)
}
func PackageAndInstallChaincode(n *Network, chaincode Chaincode, peers ...*Peer) {
// create temp file for chaincode package if not provided
if chaincode.PackageFile == "" {
tempFile, err := ioutil.TempFile("", "chaincode-package")
Expect(err).NotTo(HaveOccurred())
tempFile.Close()
defer os.Remove(tempFile.Name())
chaincode.PackageFile = tempFile.Name()
}
// only create chaincode package if it doesn't already exist
if _, err := os.Stat(chaincode.PackageFile); os.IsNotExist(err) {
switch chaincode.Lang {
case "binary":
PackageChaincodeBinary(chaincode)
default:
PackageChaincode(n, chaincode, peers[0])
}
}
// install on all peers
InstallChaincode(n, chaincode, peers...)
}
func PackageChaincode(n *Network, chaincode Chaincode, peer *Peer) {
sess, err := n.PeerAdminSession(peer, commands.ChaincodePackage{
Path: chaincode.Path,
Lang: chaincode.Lang,
Label: chaincode.Label,
OutputFile: chaincode.PackageFile,
ClientAuth: n.ClientAuthRequired,
})
Expect(err).NotTo(HaveOccurred())
Eventually(sess, n.EventuallyTimeout).Should(gexec.Exit(0))
}
func PackageChaincodeLegacy(n *Network, chaincode Chaincode, peer *Peer) {
sess, err := n.PeerAdminSession(peer, commands.ChaincodePackageLegacy{
Name: chaincode.Name,
Version: chaincode.Version,
Path: chaincode.Path,
Lang: chaincode.Lang,
OutputFile: chaincode.PackageFile,
ClientAuth: n.ClientAuthRequired,
})
Expect(err).NotTo(HaveOccurred())
Eventually(sess, n.EventuallyTimeout).Should(gexec.Exit(0))
}
func InstallChaincode(n *Network, chaincode Chaincode, peers ...*Peer) {
// Ensure 'jq' exists in path, because we need it to build chaincode
if _, err := exec.LookPath("jq"); err != nil {
ginkgo.Fail("'jq' is needed to build chaincode but it wasn't found in the PATH")
}
if chaincode.PackageID == "" {
chaincode.SetPackageIDFromPackageFile()
}
for _, p := range peers {
sess, err := n.PeerAdminSession(p, commands.ChaincodeInstall{
PackageFile: chaincode.PackageFile,
ClientAuth: n.ClientAuthRequired,
})
ExpectWithOffset(1, err).NotTo(HaveOccurred())
EventuallyWithOffset(1, sess, n.EventuallyTimeout).Should(gexec.Exit())
EnsureInstalled(n, chaincode.Label, chaincode.PackageID, p)
}
}
func InstallChaincodeLegacy(n *Network, chaincode Chaincode, peers ...*Peer) {
// Ensure 'jq' exists in path, because we need it to build chaincode
if _, err := exec.LookPath("jq"); err != nil {
ginkgo.Fail("'jq' is needed to build chaincode but it wasn't found in the PATH")
}
for _, p := range peers {
sess, err := n.PeerAdminSession(p, commands.ChaincodeInstallLegacy{
Name: chaincode.Name,
Version: chaincode.Version,
Path: chaincode.Path,
Lang: chaincode.Lang,
PackageFile: chaincode.PackageFile,
ClientAuth: n.ClientAuthRequired,
})
Expect(err).NotTo(HaveOccurred())
Eventually(sess, n.EventuallyTimeout).Should(gexec.Exit(0))
sess, err = n.PeerAdminSession(p, commands.ChaincodeListInstalledLegacy{
ClientAuth: n.ClientAuthRequired,
})
Expect(err).NotTo(HaveOccurred())
Eventually(sess, n.EventuallyTimeout).Should(gexec.Exit(0))
Expect(sess).To(gbytes.Say(fmt.Sprintf("Name: %s, Version: %s,", chaincode.Name, chaincode.Version)))
}
}
func ApproveChaincodeForMyOrg(n *Network, channel string, orderer *Orderer, chaincode Chaincode, peers ...*Peer) {
if chaincode.PackageID == "" {
chaincode.SetPackageIDFromPackageFile()
}
// used to ensure we only approve once per org
approvedOrgs := map[string]bool{}
for _, p := range peers {
if _, ok := approvedOrgs[p.Organization]; !ok {
sess, err := n.PeerAdminSession(p, commands.ChaincodeApproveForMyOrg{
ChannelID: channel,
Orderer: n.OrdererAddress(orderer, ListenPort),
Name: chaincode.Name,
Version: chaincode.Version,
PackageID: chaincode.PackageID,
Sequence: chaincode.Sequence,
EndorsementPlugin: chaincode.EndorsementPlugin,
ValidationPlugin: chaincode.ValidationPlugin,
SignaturePolicy: chaincode.SignaturePolicy,
ChannelConfigPolicy: chaincode.ChannelConfigPolicy,
InitRequired: chaincode.InitRequired,
CollectionsConfig: chaincode.CollectionsConfig,
ClientAuth: n.ClientAuthRequired,
})
Expect(err).NotTo(HaveOccurred())
Eventually(sess, n.EventuallyTimeout).Should(gexec.Exit(0))
approvedOrgs[p.Organization] = true
Eventually(sess.Err, n.EventuallyTimeout).Should(gbytes.Say(`\Qcommitted with status (VALID)\E`))
}
}
}
func CheckCommitReadinessUntilReady(n *Network, channel string, chaincode Chaincode, checkOrgs []*Organization, peers ...*Peer) {
for _, p := range peers {
keys := Keys{}
for _, org := range checkOrgs {
keys[org.MSPID] = BeTrue()
}
Eventually(checkCommitReadiness(n, p, channel, chaincode), n.EventuallyTimeout).Should(MatchKeys(IgnoreExtras, keys))
}
}
func CommitChaincode(n *Network, channel string, orderer *Orderer, chaincode Chaincode, peer *Peer, checkPeers ...*Peer) {
// commit using one peer per org
commitOrgs := map[string]bool{}
var peerAddresses []string
for _, p := range checkPeers {
if exists := commitOrgs[p.Organization]; !exists {
peerAddresses = append(peerAddresses, n.PeerAddress(p, ListenPort))
commitOrgs[p.Organization] = true
}
}
sess, err := n.PeerAdminSession(peer, commands.ChaincodeCommit{
ChannelID: channel,
Orderer: n.OrdererAddress(orderer, ListenPort),
Name: chaincode.Name,
Version: chaincode.Version,
Sequence: chaincode.Sequence,
EndorsementPlugin: chaincode.EndorsementPlugin,
ValidationPlugin: chaincode.ValidationPlugin,
SignaturePolicy: chaincode.SignaturePolicy,
ChannelConfigPolicy: chaincode.ChannelConfigPolicy,
InitRequired: chaincode.InitRequired,
CollectionsConfig: chaincode.CollectionsConfig,
PeerAddresses: peerAddresses,
ClientAuth: n.ClientAuthRequired,
})
Expect(err).NotTo(HaveOccurred())
Eventually(sess, n.EventuallyTimeout).Should(gexec.Exit(0))
for i := 0; i < len(peerAddresses); i++ {
Eventually(sess.Err, n.EventuallyTimeout).Should(gbytes.Say(`\Qcommitted with status (VALID)\E`))
}
checkOrgs := []*Organization{}
for org := range commitOrgs {
checkOrgs = append(checkOrgs, n.Organization(org))
}
EnsureChaincodeCommitted(n, channel, chaincode.Name, chaincode.Version, chaincode.Sequence, checkOrgs, checkPeers...)
}
// EnsureChaincodeCommitted polls each supplied peer until the chaincode definition
// has been committed to the peer's ledger.
func EnsureChaincodeCommitted(n *Network, channel, name, version, sequence string, checkOrgs []*Organization, peers ...*Peer) {
for _, p := range peers {
sequenceInt, err := strconv.ParseInt(sequence, 10, 64)
Expect(err).NotTo(HaveOccurred())
approvedKeys := Keys{}
for _, org := range checkOrgs {
approvedKeys[org.MSPID] = BeTrue()
}
Eventually(listCommitted(n, p, channel, name), n.EventuallyTimeout).Should(
MatchFields(IgnoreExtras, Fields{
"Version": Equal(version),
"Sequence": Equal(sequenceInt),
"Approvals": MatchKeys(IgnoreExtras, approvedKeys),
}),
)
}
}
func InitChaincode(n *Network, channel string, orderer *Orderer, chaincode Chaincode, peers ...*Peer) {
// init using one peer per org
initOrgs := map[string]bool{}
var peerAddresses []string
for _, p := range peers {
if exists := initOrgs[p.Organization]; !exists {
peerAddresses = append(peerAddresses, n.PeerAddress(p, ListenPort))
initOrgs[p.Organization] = true
}
}
sess, err := n.PeerUserSession(peers[0], "User1", commands.ChaincodeInvoke{
ChannelID: channel,
Orderer: n.OrdererAddress(orderer, ListenPort),
Name: chaincode.Name,
Ctor: chaincode.Ctor,
PeerAddresses: peerAddresses,
WaitForEvent: true,
IsInit: true,
ClientAuth: n.ClientAuthRequired,
})
Expect(err).NotTo(HaveOccurred())
Eventually(sess, n.EventuallyTimeout).Should(gexec.Exit(0))
for i := 0; i < len(peerAddresses); i++ {
Eventually(sess.Err, n.EventuallyTimeout).Should(gbytes.Say(`\Qcommitted with status (VALID)\E`))
}
Expect(sess.Err).To(gbytes.Say("Chaincode invoke successful. result: status:200"))
}
func InstantiateChaincodeLegacy(n *Network, channel string, orderer *Orderer, chaincode Chaincode, peer *Peer, checkPeers ...*Peer) {
sess, err := n.PeerAdminSession(peer, commands.ChaincodeInstantiateLegacy{
ChannelID: channel,
Orderer: n.OrdererAddress(orderer, ListenPort),
Name: chaincode.Name,
Version: chaincode.Version,
Ctor: chaincode.Ctor,
Policy: chaincode.Policy,
Lang: chaincode.Lang,
CollectionsConfig: chaincode.CollectionsConfig,
ClientAuth: n.ClientAuthRequired,
})
Expect(err).NotTo(HaveOccurred())
Eventually(sess, n.EventuallyTimeout).Should(gexec.Exit(0))
EnsureInstantiatedLegacy(n, channel, chaincode.Name, chaincode.Version, checkPeers...)
}
func EnsureInstantiatedLegacy(n *Network, channel, name, version string, peers ...*Peer) {
for _, p := range peers {
Eventually(listInstantiatedLegacy(n, p, channel), n.EventuallyTimeout).Should(
gbytes.Say(fmt.Sprintf("Name: %s, Version: %s,", name, version)),
)
}
}
func UpgradeChaincodeLegacy(n *Network, channel string, orderer *Orderer, chaincode Chaincode, peers ...*Peer) {
if len(peers) == 0 {
peers = n.PeersWithChannel(channel)
}
if len(peers) == 0 {
return
}
// install on all peers
InstallChaincodeLegacy(n, chaincode, peers...)
// upgrade from the first peer
sess, err := n.PeerAdminSession(peers[0], commands.ChaincodeUpgradeLegacy{
ChannelID: channel,
Orderer: n.OrdererAddress(orderer, ListenPort),
Name: chaincode.Name,
Version: chaincode.Version,
Ctor: chaincode.Ctor,
Policy: chaincode.Policy,
CollectionsConfig: chaincode.CollectionsConfig,
ClientAuth: n.ClientAuthRequired,
})
Expect(err).NotTo(HaveOccurred())
Eventually(sess, n.EventuallyTimeout).Should(gexec.Exit(0))
EnsureInstantiatedLegacy(n, channel, chaincode.Name, chaincode.Version, peers...)
}
func EnsureInstalled(n *Network, label, packageID string, peers ...*Peer) {
for _, p := range peers {
Eventually(QueryInstalled(n, p), n.EventuallyTimeout).Should(
ContainElement(MatchFields(IgnoreExtras,
Fields{
"Label": Equal(label),
"PackageId": Equal(packageID),
},
)),
)
}
}
func QueryInstalledReferences(n *Network, channel, label, packageID string, checkPeer *Peer, nameVersions ...[]string) {
chaincodes := make([]*lifecycle.QueryInstalledChaincodesResult_Chaincode, len(nameVersions))
for i, nameVersion := range nameVersions {
chaincodes[i] = &lifecycle.QueryInstalledChaincodesResult_Chaincode{
Name: nameVersion[0],
Version: nameVersion[1],
}
}
Expect(QueryInstalled(n, checkPeer)()).To(
ContainElement(MatchFields(IgnoreExtras,
Fields{
"Label": Equal(label),
"PackageId": Equal(packageID),
"References": HaveKeyWithValue(channel, PointTo(MatchFields(IgnoreExtras,
Fields{
"Chaincodes": ConsistOf(chaincodes),
},
))),
},
)),
)
}
func QueryInstalledNoReferences(n *Network, channel, label, packageID string, checkPeer *Peer) {
}
type queryInstalledOutput struct {
InstalledChaincodes []lifecycle.QueryInstalledChaincodesResult_InstalledChaincode `json:"installed_chaincodes"`
}
func QueryInstalled(n *Network, peer *Peer) func() []lifecycle.QueryInstalledChaincodesResult_InstalledChaincode {
return func() []lifecycle.QueryInstalledChaincodesResult_InstalledChaincode {
sess, err := n.PeerAdminSession(peer, commands.ChaincodeQueryInstalled{
ClientAuth: n.ClientAuthRequired,
})
Expect(err).NotTo(HaveOccurred())
Eventually(sess, n.EventuallyTimeout).Should(gexec.Exit(0))
output := &queryInstalledOutput{}
err = json.Unmarshal(sess.Out.Contents(), output)
Expect(err).NotTo(HaveOccurred())
return output.InstalledChaincodes
}
}
type checkCommitReadinessOutput struct {
Approvals map[string]bool `json:"approvals"`
}
func checkCommitReadiness(n *Network, peer *Peer, channel string, chaincode Chaincode) func() map[string]bool {
return func() map[string]bool {
sess, err := n.PeerAdminSession(peer, commands.ChaincodeCheckCommitReadiness{
ChannelID: channel,
Name: chaincode.Name,
Version: chaincode.Version,
Sequence: chaincode.Sequence,
EndorsementPlugin: chaincode.EndorsementPlugin,
ValidationPlugin: chaincode.ValidationPlugin,
SignaturePolicy: chaincode.SignaturePolicy,
ChannelConfigPolicy: chaincode.ChannelConfigPolicy,
InitRequired: chaincode.InitRequired,
CollectionsConfig: chaincode.CollectionsConfig,
ClientAuth: n.ClientAuthRequired,
})
Expect(err).NotTo(HaveOccurred())
Eventually(sess, n.EventuallyTimeout).Should(gexec.Exit(0))
output := &checkCommitReadinessOutput{}
err = json.Unmarshal(sess.Out.Contents(), output)
Expect(err).NotTo(HaveOccurred())
return output.Approvals
}
}
type queryCommittedOutput struct {
Sequence int64 `json:"sequence"`
Version string `json:"version"`
Approvals map[string]bool `json:"approvals"`
}
// listCommitted returns the result of the queryCommitted command.
// If the command fails for any reason (e.g. namespace not defined
// or a database access issue), it will return an empty output object.
func listCommitted(n *Network, peer *Peer, channel, name string) func() queryCommittedOutput {
return func() queryCommittedOutput {
sess, err := n.PeerAdminSession(peer, commands.ChaincodeListCommitted{
ChannelID: channel,
Name: name,
ClientAuth: n.ClientAuthRequired,
})
Expect(err).NotTo(HaveOccurred())
Eventually(sess, n.EventuallyTimeout).Should(gexec.Exit())
output := &queryCommittedOutput{}
if sess.ExitCode() == 1 {
// don't try to unmarshal the output as JSON if the query failed
return *output
}
err = json.Unmarshal(sess.Out.Contents(), output)
Expect(err).NotTo(HaveOccurred())
return *output
}
}
func listInstantiatedLegacy(n *Network, peer *Peer, channel string) func() *gbytes.Buffer {
return func() *gbytes.Buffer {
sess, err := n.PeerAdminSession(peer, commands.ChaincodeListInstantiatedLegacy{
ChannelID: channel,
ClientAuth: n.ClientAuthRequired,
})
Expect(err).NotTo(HaveOccurred())
Eventually(sess, n.EventuallyTimeout).Should(gexec.Exit(0))
return sess.Buffer()
}
}
// EnableCapabilities enables a specific capabilities flag for a running network.
// It generates the config update using the first peer, signs the configuration
// with the subsequent peers, and then submits the config update using the
// first peer.
func EnableCapabilities(network *Network, channel, capabilitiesGroup, capabilitiesVersion string, orderer *Orderer, peers ...*Peer) {
if len(peers) == 0 {
return
}
config := GetConfig(network, peers[0], orderer, channel)
updatedConfig := proto.Clone(config).(*common.Config)
updatedConfig.ChannelGroup.Groups[capabilitiesGroup].Values["Capabilities"] = &common.ConfigValue{
ModPolicy: "Admins",
Value: protoutil.MarshalOrPanic(
&common.Capabilities{
Capabilities: map[string]*common.Capability{
capabilitiesVersion: {},
},
},
),
}
UpdateConfig(network, orderer, channel, config, updatedConfig, false, peers[0], peers...)
}
// WaitUntilEqualLedgerHeight waits until all specified peers have the
// provided ledger height on a channel
func WaitUntilEqualLedgerHeight(n *Network, channel string, height int, peers ...*Peer) {
for _, peer := range peers {
Eventually(func() int {
return GetLedgerHeight(n, peer, channel)
}, n.EventuallyTimeout).Should(Equal(height))
}
}
// GetLedgerHeight returns the current ledger height for a peer on
// a channel
func GetLedgerHeight(n *Network, peer *Peer, channel string) int {
sess, err := n.PeerUserSession(peer, "User1", commands.ChannelInfo{
ChannelID: channel,
ClientAuth: n.ClientAuthRequired,
})
Expect(err).NotTo(HaveOccurred())
Eventually(sess, n.EventuallyTimeout).Should(gexec.Exit())
if sess.ExitCode() == 1 {
// if org is not yet member of channel, peer will return error
return -1
}
channelInfoStr := strings.TrimPrefix(string(sess.Buffer().Contents()[:]), "Blockchain info:")
var channelInfo = common.BlockchainInfo{}
json.Unmarshal([]byte(channelInfoStr), &channelInfo)
return int(channelInfo.Height)
}
// GetMaxLedgerHeight returns the maximum ledger height for the
// peers on a channel
func GetMaxLedgerHeight(n *Network, channel string, peers ...*Peer) int {
var maxHeight int
for _, peer := range peers {
peerHeight := GetLedgerHeight(n, peer, channel)
if peerHeight > maxHeight {
maxHeight = peerHeight
}
}
return maxHeight
}
马建仓 AI 助手
尝试更多
代码解读
代码找茬
代码优化
1
https://gitee.com/mmcro/fabric.git
git@gitee.com:mmcro/fabric.git
mmcro
fabric
fabric
v2.1.1

搜索帮助