Files

496 lines
14 KiB
Go

// Copyright (C) 2022, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package subnetcmd
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/go-git/go-git/v5"
"github.com/spf13/cobra"
"go.uber.org/zap"
"github.com/luxfi/cli/v2/pkg/lpmintegration"
"github.com/luxfi/cli/v2/pkg/binutils"
"github.com/luxfi/cli/v2/pkg/constants"
"github.com/luxfi/cli/v2/pkg/models"
"github.com/luxfi/cli/v2/pkg/prompts"
"github.com/luxfi/cli/v2/pkg/subnet"
"github.com/luxfi/cli/v2/pkg/utils"
"github.com/luxfi/cli/v2/pkg/ux"
"github.com/luxfi/ids"
"github.com/luxfi/node/v2/v2/version"
"gopkg.in/yaml.v3"
)
var (
alias string
repoURL string
vmDescPath string
subnetDescPath string
noRepoPath string
errSubnetNotDeployed = errors.New(
"only subnets which have already been deployed to either testnet (testnet) or mainnet can be published")
)
type newPublisherFunc func(string, string, string) subnet.Publisher
// lux subnet publish
func newPublishCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "publish [subnetName]",
Short: "Publish the subnet's VM to a repository",
Long: `The subnet publish command publishes the Subnet's VM to a repository.`,
SilenceUsage: true,
RunE: publish,
Args: cobra.ExactArgs(1),
}
cmd.Flags().StringVar(&alias, "alias", "",
"We publish to a remote repo, but identify the repo locally under a user-provided alias (e.g. myrepo).")
cmd.Flags().StringVar(&repoURL, "repo-url", "", "The URL of the repo where we are publishing")
cmd.Flags().StringVar(&vmDescPath, "vm-file-path", "",
"Path to the VM description file. If not given, a prompting sequence will be initiated.")
cmd.Flags().StringVar(&subnetDescPath, "subnet-file-path", "",
"Path to the Subnet description file. If not given, a prompting sequence will be initiated.")
cmd.Flags().StringVar(&noRepoPath, "no-repo-path", "",
"Do not let the tool manage file publishing, but have it only generate the files and put them in the location given by this flag.")
cmd.Flags().BoolVar(&forceWrite, forceFlag, false,
"If true, ignores if the subnet has been published in the past, and attempts a forced publish.")
return cmd
}
func publish(_ *cobra.Command, args []string) error {
chains, err := validateSubnetNameAndGetChains(args)
if err != nil {
return err
}
subnetName := chains[0]
sc, err := app.LoadSidecar(subnetName)
if err != nil {
return err
}
if !isReadyToPublish(&sc) {
return errSubnetNotDeployed
}
return doPublish(&sc, subnetName, subnet.NewPublisher)
}
// isReadyToPublish currently means if deployed to testnet and/or main
func isReadyToPublish(sc *models.Sidecar) bool {
if sc.Networks[models.Testnet.String()].SubnetID != ids.Empty &&
sc.Networks[models.Testnet.String()].BlockchainID != ids.Empty {
return true
}
if sc.Networks[models.Mainnet.String()].SubnetID != ids.Empty &&
sc.Networks[models.Mainnet.String()].BlockchainID != ids.Empty {
return true
}
return false
}
func doPublish(sc *models.Sidecar, subnetName string, publisherCreateFunc newPublisherFunc) (err error) {
reposDir := app.GetReposDir()
// iterate the reposDir to check what repos already exist locally
// if nothing is found, prompt the user for an alias for a new repo
if err = getAlias(reposDir); err != nil {
return err
}
// get the URL for the repo
if err = getRepoURL(reposDir); err != nil {
return err
}
var (
tsubnet *lpmintegration.Subnet
vm *lpmintegration.VM
)
if !forceWrite && noRepoPath == "" {
// if forceWrite is present, we don't need to check if it has been previously published, we just do
published, err := isAlreadyPublished(subnetName)
if err != nil {
return err
}
if published {
ux.Logger.PrintToUser(
"It appears this subnet has already been published, while no force flag has been detected.")
return errors.New("aborted")
}
}
if subnetDescPath == "" {
tsubnet, err = getSubnetInfo(sc)
} else {
tsubnet = new(lpmintegration.Subnet)
err = loadYAMLFile(subnetDescPath, tsubnet)
}
if err != nil {
return err
}
ux.Logger.PrintToUser("Nice! We got the subnet info. Let's now get the VM details")
if vmDescPath == "" {
vm, err = getVMInfo(sc)
} else {
vm = new(lpmintegration.VM)
err = loadYAMLFile(vmDescPath, vm)
}
if err != nil {
return err
}
// TODO: Publishing exactly 1 subnet and 1 VM in this iteration
tsubnet.VMs = []string{vm.Alias}
subnetYAML, err := yaml.Marshal(tsubnet)
if err != nil {
return err
}
vmYAML, err := yaml.Marshal(vm)
if err != nil {
return err
}
if noRepoPath != "" {
ux.Logger.PrintToUser(
"Writing the file specs to the provided directory at: %s", noRepoPath)
// the directory does not exist
if _, err := os.Stat(noRepoPath); err != nil {
if err := os.MkdirAll(noRepoPath, constants.DefaultPerms755); err != nil {
return fmt.Errorf(
"attempted to create the given --no-repo-path directory at %s, but failed: %w", noRepoPath, err)
}
ux.Logger.PrintToUser(
"The given --no-repo-path at %s did not exist; created it with permissions %o", noRepoPath, constants.DefaultPerms755)
}
subnetFile := filepath.Join(noRepoPath, constants.SubnetDir, subnetName+constants.YAMLSuffix)
vmFile := filepath.Join(noRepoPath, constants.VMDir, vm.Alias+constants.YAMLSuffix)
if !forceWrite {
// do not automatically overwrite
if _, err := os.Stat(subnetFile); err == nil {
return fmt.Errorf(
"a file with the name %s already exists. If you wish to overwrite, provide the %s flag", subnetFile, forceFlag)
}
if _, err := os.Stat(vmFile); err == nil {
return fmt.Errorf(
"a file with the name %s already exists. If you wish to overwrite, provide the %s flag", vmFile, forceFlag)
}
}
if err := os.WriteFile(subnetFile, subnetYAML, constants.DefaultPerms755); err != nil {
return fmt.Errorf("failed creating the subnet description YAML file: %w", err)
}
if err := os.WriteFile(vmFile, vmYAML, constants.DefaultPerms755); err != nil {
return fmt.Errorf("failed creating the subnet description YAML file: %w", err)
}
ux.Logger.PrintToUser("YAML files written successfully to %s", noRepoPath)
return nil
}
ux.Logger.PrintToUser("Thanks! We got all the bits and pieces. Trying to publish on the provided repo...")
publisher := publisherCreateFunc(reposDir, repoURL, alias)
repo, err := publisher.GetRepo()
if err != nil {
return err
}
// TODO: if not published? New commit? Etc...
if err = publisher.Publish(repo, subnetName, vm.Alias, subnetYAML, vmYAML); err != nil {
return err
}
ux.Logger.PrintToUser("Successfully published")
return nil
}
// current simplistic approach:
// just search any folder names `subnetName` inside the reposDir's `subnets` folder
func isAlreadyPublished(subnetName string) (bool, error) {
reposDir := app.GetReposDir()
found := false
if err := filepath.WalkDir(reposDir, func(path string, d fs.DirEntry, err error) error {
if err == nil {
if filepath.Base(path) == constants.VMDir {
return filepath.SkipDir
}
if !d.IsDir() && d.Name() == subnetName {
found = true
}
}
return nil
}); err != nil {
return false, err
}
return found, nil
}
// iterate the reposDir to check what repos already exist locally
// if nothing is found, prompt the user for an alias for a new repo
func getAlias(reposDir string) error {
// have any aliases already been defined?
if alias == "" {
matches, err := os.ReadDir(reposDir)
if err != nil {
return err
}
if len(matches) == 0 {
// no aliases yet; just ask for a new one
alias, err = getNewAlias()
if err != nil {
return err
}
} else {
// there are already aliases, ask how to proceed
options := []string{"Provide a new alias", "Pick from list"}
choice, err := app.Prompt.CaptureList(
"Don't know which repo to publish to. How would you like to proceed?", options)
if err != nil {
return err
}
if choice == options[0] {
// user chose to provide a new alias
alias, err = getNewAlias()
if err != nil {
return err
}
// double-check: actually this path exists...
if _, err := os.Stat(filepath.Join(reposDir, alias)); err == nil {
ux.Logger.PrintToUser(
"The repository with the given alias already exists locally. You may have already published this subnet there (the other explanation is that a different subnet has been published there).")
yes, err := app.Prompt.CaptureYesNo("Do you wish to continue?")
if err != nil {
return err
}
if !yes {
ux.Logger.PrintToUser("User canceled, nothing got published.")
return nil
}
}
} else {
aliases := make([]string, len(matches))
for i, a := range matches {
aliases[i] = a.Name()
}
alias, err = app.Prompt.CaptureList("Pick an alias", aliases)
if err != nil {
return err
}
}
}
}
return nil
}
// ask for a new alias
func getNewAlias() (string, error) {
return app.Prompt.CaptureString("Provide an alias for the repository we are going to use")
}
// TODO -- do we want to modify global [repoURL]?
func getRepoURL(reposDir string) error {
if repoURL != "" {
return nil
}
path := filepath.Join(reposDir, alias)
repo, err := git.PlainOpen(path)
if err != nil {
app.Log.Debug(
"opening repo failed - alias might have not been created yet, so ignore", zap.String("alias", alias), zap.Error(err))
repoURL, err = app.Prompt.CaptureString("Provide the repository URL")
return err
}
// there is a repo already for this alias, let's try to figure out the remote URL from there
conf, err := repo.Config()
if err != nil {
// TODO Would we really want to abort here?
return err
}
remotes := make([]string, len(conf.Remotes))
i := 0
for _, r := range conf.Remotes {
// NOTE: supporting only one remote for now
remotes[i] = r.URLs[0]
i++
}
repoURL, err = app.Prompt.CaptureList("Which is the remote URL for this repo?", remotes)
if err != nil {
// should never happen
return err
}
return nil
}
// loadYAMLFile loads a YAML file from disk into a concrete types.Definition object
// using generics. It's role really is solely to verify that the YAML content is valid.
func loadYAMLFile[T any](path string, defType T) error {
fileBytes, err := os.ReadFile(path)
if err != nil {
return err
}
return yaml.Unmarshal(fileBytes, &defType)
}
func getSubnetInfo(sc *models.Sidecar) (*lpmintegration.Subnet, error) {
_, err := app.Prompt.CaptureStringAllowEmpty("What is the homepage of the Subnet project?")
if err != nil {
return nil, err
}
desc, err := app.Prompt.CaptureStringAllowEmpty("Provide a free-text description of the Subnet")
if err != nil {
return nil, err
}
_, canceled, err := prompts.CaptureListDecision(
app.Prompt,
"Who are the maintainers of the Subnet?",
app.Prompt.CaptureEmail,
"Provide a maintainer",
"Maintainer",
"",
)
if err != nil {
return nil, err
}
if canceled {
ux.Logger.PrintToUser("Publishing aborted")
return nil, errors.New("canceled by user")
}
subnet := &lpmintegration.Subnet{
ID: sc.Networks[models.Testnet.String()].SubnetID.String(),
Alias: sc.Name,
Description: desc,
VMs: []string{sc.Subnet},
}
return subnet, nil
}
func getVMInfo(sc *models.Sidecar) (*lpmintegration.VM, error) {
var (
vmID, desc, url, sha string
canceled bool
ver *version.Semantic
err error
)
switch {
case sc.VM == models.CustomVM:
vmID, err = app.Prompt.CaptureStringAllowEmpty("What is the ID of this VM?")
if err != nil {
return nil, err
}
desc, err = app.Prompt.CaptureStringAllowEmpty("Provide a description for this VM")
if err != nil {
return nil, err
}
_, canceled, err = prompts.CaptureListDecision(
app.Prompt,
"Who are the maintainers of the VM?",
app.Prompt.CaptureEmail,
"Provide a maintainer",
"Maintainer",
"",
)
if err != nil {
return nil, err
}
if canceled {
ux.Logger.PrintToUser("Publishing aborted")
return nil, errors.New("canceled by user")
}
url, err = app.Prompt.CaptureStringAllowEmpty(
"Tell us the URL to download the source. Needs to be a fixed version, not `latest`.")
if err != nil {
return nil, err
}
sha, err = app.Prompt.CaptureStringAllowEmpty(
"For integrity checks, provide the sha256 commit for the used version")
if err != nil {
return nil, err
}
strVer, err := app.Prompt.CaptureVersion(
"This is the last question! What is the version being used? Use semantic versioning (v1.2.3)")
if err != nil {
return nil, err
}
ver, err = version.Parse(strVer)
if err != nil {
return nil, err
}
case sc.VM == models.EVM:
vmID = models.EVM
dl := binutils.NewEVMDownloader()
desc = "Lux EVM is a simplified version of Geth VM (C-Chain). It implements the Ethereum Virtual Machine and supports Solidity smart contracts as well as most other Ethereum client functionality"
_, ver, url, sha, err = getInfoForKnownVMs(
sc.VMVersion,
constants.EVMRepoName,
app.GetEVMBinDir(),
constants.EVMBin,
dl,
)
default:
return nil, fmt.Errorf("unexpected error: unsupported VM type: %s", sc.VM)
}
if err != nil {
return nil, err
}
_, err = app.Prompt.CaptureStringAllowEmpty(
"What scripts needs to run to install this VM? Needs to be an executable command to build the VM")
if err != nil {
return nil, err
}
_, err = app.Prompt.CaptureStringAllowEmpty(
"What is the binary path? (This is the output of the build command)")
if err != nil {
return nil, err
}
vm := &lpmintegration.VM{
ID: vmID,
Alias: sc.Networks["Testnet"].BlockchainID.String(), // TODO: Do we have to query for this? Or write to sidecar on create?
Description: desc,
URL: url,
Checksum: sha,
Version: ver.String(),
}
return vm, nil
}
func getInfoForKnownVMs(
strVer, repoName, vmBinDir, vmBin string,
dl binutils.GithubDownloader,
) ([]string, *version.Semantic, string, string, error) {
maintrs := []string{constants.LuxMaintainers}
binPath := filepath.Join(vmBinDir, repoName+"-"+strVer, vmBin)
sha, err := utils.GetSHA256FromDisk(binPath)
if err != nil {
return nil, nil, "", "", err
}
ver, err := version.Parse(strVer)
if err != nil {
return nil, nil, "", "", err
}
inst := binutils.NewInstaller()
url, _, err := dl.GetDownloadURL(strVer, inst)
if err != nil {
return nil, nil, "", "", err
}
return maintrs, ver, url, sha, nil
}