mukan-ignite/ignite/services/chain/serve.go
Mukan Erkin Törük c32551b6f7
Some checks failed
Docs Deploy / build_and_deploy (push) Has been cancelled
Generate Docs / cli (push) Has been cancelled
Generate Config Doc / cli (push) Has been cancelled
Go formatting / go-formatting (push) Has been cancelled
Check links / markdown-link-check (push) Has been cancelled
Integration / pre-test (push) Has been cancelled
Integration / test on (push) Has been cancelled
Integration / status (push) Has been cancelled
Lint / Lint Go code (push) Has been cancelled
Test / test (ubuntu-latest) (push) Has been cancelled
refactor: replace all github.com upstream refs with git.cw.tr/mukan-network
2026-05-11 03:36:24 +03:00

693 lines
18 KiB
Go

package chain
import (
"context"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/otiai10/copy"
"golang.org/x/sync/errgroup"
"git.cw.tr/mukan-network/mukan-ignite/ignite/config"
chainconfig "git.cw.tr/mukan-network/mukan-ignite/ignite/config/chain"
"git.cw.tr/mukan-network/mukan-ignite/ignite/config/chain/defaults"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/cache"
chaincmdrunner "git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/chaincmd/runner"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/cliui/colors"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/cliui/icons"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/cliui/view/accountview"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/cliui/view/errorview"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/cosmosfaucet"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/dirchange"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/errors"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/events"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/localfs"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/xexec"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/xfilepath"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/xhttp"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/xurl"
)
const (
// EvtGroupPath is the group to use for path related events.
EvtGroupPath = "path"
// exportedGenesis is the name of the exported genesis file for a chain.
exportedGenesis = "exported_genesis.json"
// sourceChecksumKey is the cache key for the checksum to detect source modification.
sourceChecksumKey = "source_checksum"
// binaryChecksumKey is the cache key for the checksum to detect binary modification.
binaryChecksumKey = "binary_checksum"
// configChecksumKey is the cache key for containing the checksum to detect config modification.
configChecksumKey = "config_checksum"
// serveDirchangeCacheNamespace is the name of the cache namespace for detecting changes in directories.
serveDirchangeCacheNamespace = "serve.dirchange"
)
var (
// ignoredExts holds a list of ignored files from watching.
ignoredExts = []string{"pb.go", "pb.gw.go"}
// starportSavePath is the place where chain exported genesis are saved.
starportSavePath = xfilepath.Join(
config.DirPath,
xfilepath.Path("local-chains"),
)
)
type serveOptions struct {
forceReset bool
resetOnce bool
skipProto bool
skipBuild bool
quitOnFail bool
generateClients bool
buildTags []string
}
func newServeOption() serveOptions {
return serveOptions{
forceReset: false,
resetOnce: false,
}
}
// ServeOption provides options for the serve command.
type ServeOption func(*serveOptions)
// ServeForceReset allows to force reset of the state when the chain is served and on every source change.
func ServeForceReset() ServeOption {
return func(c *serveOptions) {
c.forceReset = true
}
}
// ServeResetOnce allows to reset of the state when the chain is served once.
func ServeResetOnce() ServeOption {
return func(c *serveOptions) {
c.resetOnce = true
}
}
// QuitOnFail exits the serve immediately if an error occurs.
func QuitOnFail() ServeOption {
return func(c *serveOptions) {
c.quitOnFail = true
}
}
// GenerateClients enables client code generation.
func GenerateClients() ServeOption {
return func(c *serveOptions) {
c.generateClients = true
}
}
// ServeSkipProto allows to serve the app without generate Go from proto.
func ServeSkipProto() ServeOption {
return func(c *serveOptions) {
c.skipProto = true
}
}
// ServeSkipBuild allows to serve the app without rebuilding it.
// It looks up the binary in the PATH.
func ServeSkipBuild() ServeOption {
return func(c *serveOptions) {
c.skipBuild = true
}
}
// BuildTags set the build tags for the go build.
func BuildTags(buildTags ...string) ServeOption {
return func(c *serveOptions) {
c.buildTags = buildTags
}
}
// Serve serves an app.
func (c *Chain) Serve(ctx context.Context, cacheStorage cache.Storage, options ...ServeOption) error {
serveOptions := newServeOption()
// apply the options
for _, apply := range options {
apply(&serveOptions)
}
// initial checks and setup.
if err := c.setup(); err != nil {
return err
}
// make sure that config.yml exists
if c.options.ConfigFile != "" {
if _, err := os.Stat(c.options.ConfigFile); err != nil {
return err
}
} else if _, err := chainconfig.LocateDefault(c.app.Path); err != nil {
return err
}
// start serving components.
g, ctx := errgroup.WithContext(ctx)
// blockchain node routine
g.Go(func() error {
c.refreshServe()
for {
if ctx.Err() != nil {
return ctx.Err()
}
select {
case <-ctx.Done():
return ctx.Err()
case <-c.serveRefresher:
commands, err := c.Commands(ctx)
if err != nil {
return err
}
var (
serveCtx context.Context
buildErr *CannotBuildAppError
startErr *CannotStartAppError
validationErr *chainconfig.ValidationError
)
serveCtx, c.serveCancel = context.WithCancel(ctx)
// determine if the chain should reset the state
shouldReset := serveOptions.forceReset || serveOptions.resetOnce
// serve the app.
err = c.serve(
serveCtx,
cacheStorage,
serveOptions.buildTags,
shouldReset,
serveOptions.skipProto,
serveOptions.skipBuild,
serveOptions.generateClients,
)
serveOptions.resetOnce = false
switch {
case err == nil:
case errors.Is(err, context.Canceled):
// If the app has been served, we save the genesis state
if c.served {
c.served = false
c.ev.Send("Saving genesis state...", events.ProgressStart())
// If serve has been stopped, save the genesis state
if err := c.saveChainState(context.TODO(), commands); err != nil {
c.ev.SendError(err, events.ProgressFinish())
return err
}
genesisPath, err := c.exportedGenesisPath()
if err != nil {
return err
}
// Inform where the genesis file is saved without using
// progress update to keep the event text in the terminal.
c.ev.Send(
fmt.Sprintf("Genesis state saved in %s", genesisPath),
events.Icon(icons.CD),
)
}
case errors.As(err, &validationErr):
if serveOptions.quitOnFail {
return err
}
// Change error message to add a link to the configuration docs
err = errors.Errorf("%w\nsee: https://github.com/ignite/cli#configure", err)
c.ev.SendView(errorview.NewError(err), events.ProgressFinish(), events.Group(events.GroupError))
case errors.As(err, &buildErr):
if serveOptions.quitOnFail {
return err
}
c.ev.SendView(errorview.NewError(err), events.ProgressFinish(), events.Group(events.GroupError))
case errors.As(err, &startErr):
// Parse returned error logs
parsedErr := startErr.ParseStartError()
// If empty, we cannot recognized the error
// Therefore, the error may be caused by a new logic that is not compatible with the old app state
// We suggest the user to eventually reset the app state
if parsedErr == "" {
info := colors.Info(
"Blockchain failed to start.\n",
"If the new code is no longer compatible with the saved\n",
"state, you can reset the database by launching:\n",
"For more verbose logging, add -v to the command.",
)
command := colors.SprintFunc(colors.White)("ignite chain serve --reset-once -v")
return errors.Errorf("cannot run %s\n\n%s\n%s", startErr.AppName, info, command)
}
// return the clear parsed error
return errors.New(parsedErr)
default:
return err
}
}
}
})
// routine to watch back-end
g.Go(func() error {
return c.watchAppBackend(ctx)
})
return g.Wait()
}
func (c *Chain) setup() error {
c.ev.Sendf("Cosmos SDK's version is: %s\n", colors.Info(c.Version))
return c.checkSystem()
}
// checkSystem checks if developer's work environment comply must to have
// dependencies and pre-conditions.
func (c *Chain) checkSystem() error {
// check if Go has installed.
if !xexec.IsCommandAvailable("go") {
return errors.New("Please, check that Go language is installed correctly in $PATH. See https://golang.org/doc/install")
}
return nil
}
func (c *Chain) refreshServe() {
if c.serveCancel != nil {
c.serveCancel()
}
// send event changes detected
c.serveRefresher <- struct{}{}
}
func (c *Chain) watchAppBackend(ctx context.Context) error {
watchPaths := appBackendSourceWatchPaths(defaults.ProtoDir)
if c.ConfigPath() != "" {
conf, err := c.Config()
if err != nil {
return err
}
watchPaths = append(appBackendSourceWatchPaths(conf.Build.Proto.Path), c.ConfigPath())
}
return localfs.Watch(
ctx,
watchPaths,
localfs.WatcherWorkdir(c.app.Path),
localfs.WatcherOnChange(c.refreshServe),
localfs.WatcherIgnoreHidden(),
localfs.WatcherIgnoreFolders(),
localfs.WatcherIgnoreExt(ignoredExts...),
)
}
// serve performs the operations to serve the blockchain: build, init and start.
// If the chain is already initialized and the file weren't changed, the app is directly started.
// If the files changed, the state is imported.
func (c *Chain) serve(
ctx context.Context,
cacheStorage cache.Storage,
buildTags []string,
forceReset, skipProto, skipBuild, generateClients bool,
) error {
conf, err := c.Config()
if err != nil {
return &CannotBuildAppError{err}
}
sourceWatchPaths := appBackendSourceWatchPaths(conf.Build.Proto.Path)
commands, err := c.Commands(ctx)
if err != nil {
return err
}
// isInit determines if the app is initialized
var isInit bool
dirCache := cache.New[[]byte](cacheStorage, serveDirchangeCacheNamespace)
// determine if the app must reset the state
// if the state must be reset, then we consider the chain as being not initialized
isInit, err = c.IsInitialized()
if err != nil {
return err
}
if isInit {
configModified := false
if c.ConfigPath() != "" {
configModified, err = dirchange.HasDirChecksumChanged(dirCache, configChecksumKey, c.app.Path, c.ConfigPath())
if err != nil {
return err
}
}
if forceReset || configModified {
// if forceReset is set, we consider the app as being not initialized
c.ev.Send("Resetting the app state...", events.ProgressUpdate())
isInit = false
}
}
// check if source has been modified since last serve
// if the state must not be reset but the source has changed, we rebuild the chain and import the exported state
sourceModified, err := dirchange.HasDirChecksumChanged(dirCache, sourceChecksumKey, c.app.Path, sourceWatchPaths...)
if err != nil {
return err
}
// we also consider the binary in the checksum to ensure the binary has not been changed by a third party
var binaryModified bool
binaryName, err := c.Binary()
if err != nil {
return err
}
binaryPath, err := xexec.ResolveAbsPath(binaryName)
if err != nil {
if !errors.Is(err, exec.ErrNotFound) {
return err
}
binaryModified = true
} else {
binaryModified, err = dirchange.HasDirChecksumChanged(dirCache, binaryChecksumKey, "", binaryPath)
if err != nil {
return err
}
}
appModified := sourceModified || binaryModified
// check if exported genesis exists
exportGenesisExists := true
exportedGenesisPath, err := c.exportedGenesisPath()
if err != nil {
return err
}
if _, err := os.Stat(exportedGenesisPath); os.IsNotExist(err) {
exportGenesisExists = false
} else if err != nil {
return err
}
// build phase
// if the app is not initialized or the source/binary has been modified
// and if the --skip-build flag is not set
if skipBuild {
c.ev.SendInfo("Skip building activated. Binary won't be rebuilt, nor refresh on changes")
}
if (!isInit || appModified) && !skipBuild {
// build the blockchain app
if err := c.build(ctx, cacheStorage, buildTags, "", skipProto, generateClients, true); err != nil {
return err
}
}
// init phase
initApp := !isInit || (appModified && !exportGenesisExists)
//nolint:gocritic
if initApp {
c.ev.Send("Initializing the app...", events.ProgressUpdate())
if err := c.Init(ctx, InitArgsAll); err != nil {
return err
}
} else if appModified {
// if the chain is already initialized but the source has been modified
// we reset the chain database and import the genesis state
c.ev.Send("Existent genesis detected, restoring the database...", events.ProgressUpdate())
if err := commands.UnsafeReset(ctx); err != nil {
return err
}
if err := c.importChainState(); err != nil {
return err
}
} else {
c.ev.Send("Restarting existing app...", events.ProgressUpdate())
}
// save checksums
if c.ConfigPath() != "" {
if err := dirchange.SaveDirChecksum(dirCache, configChecksumKey, c.app.Path, c.ConfigPath()); err != nil {
return err
}
}
if err := dirchange.SaveDirChecksum(dirCache, sourceChecksumKey, c.app.Path, sourceWatchPaths...); err != nil {
return err
}
if err := dirchange.SaveDirChecksum(dirCache, binaryChecksumKey, "", binaryPath); err != nil {
return err
}
// Display existing accounts if they were not initialized.
// Note that chain init displays accounts when the app is initialized.
if !initApp {
accounts, err := commands.ListAccounts(ctx)
if err != nil {
return err
}
var view accountview.Accounts
for _, a := range accounts {
view = view.Append(accountview.NewAccount(a.Name, a.Address))
}
c.ev.SendView(view, events.ProgressFinish())
}
// start the blockchain
return c.start(ctx, conf)
}
func (c *Chain) start(ctx context.Context, cfg *chainconfig.Config) error {
commands, err := c.Commands(ctx)
if err != nil {
return err
}
g, ctx := errgroup.WithContext(ctx)
// start the blockchain.
g.Go(func() error { return c.Start(ctx, commands, cfg) })
// start the faucet if enabled.
faucet, err := c.Faucet(ctx)
isFaucetEnabled := !errors.Is(err, ErrFaucetIsNotEnabled)
if isFaucetEnabled {
if errors.Is(err, ErrFaucetAccountDoesNotExist) {
return &CannotBuildAppError{errors.Wrap(err, "faucet account doesn't exist")}
}
if err != nil {
return err
}
g.Go(func() (err error) {
if err := c.runFaucetServer(ctx, faucet); err != nil {
return &CannotBuildAppError{err}
}
return nil
})
}
// set the app as being served
c.served = true
validator, err := chainconfig.FirstValidator(cfg)
if err != nil {
return err
}
servers, err := validator.GetServers()
if err != nil {
return err
}
// note: address format errors are handled by the
// error group, so they can be safely ignored here
rpcAddr, _ := xurl.HTTP(servers.RPC.Address)
apiAddr, _ := xurl.HTTP(servers.API.Address)
c.ev.Send(
fmt.Sprintf("Tendermint node: %s", rpcAddr),
events.Icon(icons.Earth),
events.ProgressFinish(),
)
c.ev.Send(
fmt.Sprintf("Blockchain API: %s", apiAddr),
events.Icon(icons.Earth),
)
if isFaucetEnabled {
faucetAddr, _ := xurl.HTTP(chainconfig.FaucetHost(cfg))
c.ev.Send(
fmt.Sprintf("Token faucet: %s", faucetAddr),
events.Icon(icons.Earth),
)
}
appHome, _ := c.Home()
appBin, _ := c.AbsBinaryPath()
c.ev.Send(
fmt.Sprintf("Data directory: %s", colors.Faint(appHome)),
events.Icon(icons.Bullet),
events.Group(EvtGroupPath),
)
c.ev.Send(
fmt.Sprintf("App binary: %s", colors.Faint(appBin)),
events.Icon(icons.Bullet),
events.Group(EvtGroupPath),
)
return g.Wait()
}
func (c *Chain) runFaucetServer(ctx context.Context, faucet cosmosfaucet.Faucet) error {
cfg, err := c.Config()
if err != nil {
return err
}
return xhttp.Serve(ctx, &http.Server{
Addr: chainconfig.FaucetHost(cfg),
Handler: faucet,
ReadHeaderTimeout: 5 * time.Second, // Set a reasonable timeout
})
}
// saveChainState runs the export command of the chain and store the exported genesis in the chain saved config.
func (c *Chain) saveChainState(ctx context.Context, commands chaincmdrunner.Runner) error {
genesisPath, err := c.exportedGenesisPath()
if err != nil {
return err
}
return commands.Export(ctx, genesisPath)
}
// importChainState imports the saved genesis in chain config to use it as the genesis.
func (c *Chain) importChainState() error {
exportGenesisPath, err := c.exportedGenesisPath()
if err != nil {
return err
}
genesisPath, err := c.GenesisPath()
if err != nil {
return err
}
return copy.Copy(exportGenesisPath, genesisPath)
}
// chainSavePath returns the path where the chain state is saved.
// Creates the path if it doesn't exist.
func (c *Chain) chainSavePath() (string, error) {
savePath, err := starportSavePath()
if err != nil {
return "", err
}
chainID, err := c.ID()
if err != nil {
return "", err
}
chainSavePath := filepath.Join(savePath, chainID)
// ensure the path exists
if err := os.MkdirAll(savePath, 0o700); err != nil && !os.IsExist(err) {
return "", err
}
return chainSavePath, nil
}
// exportedGenesisPath returns the path of the exported genesis file.
func (c *Chain) exportedGenesisPath() (string, error) {
savePath, err := c.chainSavePath()
if err != nil {
return "", err
}
return filepath.Join(savePath, exportedGenesis), nil
}
type CannotBuildAppError struct {
Err error
}
func (e *CannotBuildAppError) Error() string {
// TODO: Find at which point the error is wrapped twice
var buildErr *CannotBuildAppError
if errors.As(e.Err, &buildErr) {
return buildErr.Error()
}
return fmt.Sprintf("cannot build app:\n\n%s", e.Err)
}
func (e *CannotBuildAppError) Unwrap() error {
return e.Err
}
type CannotStartAppError struct {
AppName string
Err error
}
func (e *CannotStartAppError) Error() string {
return fmt.Sprintf("cannot run %sd start:\n%s", e.AppName, errors.Unwrap(e.Err))
}
func (e *CannotStartAppError) Unwrap() error {
return e.Err
}
// ParseStartError parses the error into a clear error string.
// The error logs from Cosmos SDK application are too extensive to be directly printed.
// If the error is not recognized, returns an empty string.
func (e *CannotStartAppError) ParseStartError() string {
errorLogs := errors.Unwrap(e.Err).Error()
switch {
case strings.Contains(errorLogs, "bind: address already in use"):
r := regexp.MustCompile(`listen .* bind: address already in use`)
return r.FindString(errorLogs)
case strings.Contains(errorLogs, "validator set is nil in genesis"):
return "Error: error during handshake: error on replay: validator set is nil in genesis and still empty after InitChain"
default:
return ""
}
}