diff --git a/build/rofl/manifest.go b/build/rofl/manifest.go index c5ad30bd..14d1f8ce 100644 --- a/build/rofl/manifest.go +++ b/build/rofl/manifest.go @@ -180,14 +180,21 @@ func (m *Manifest) Validate() error { return fmt.Errorf("bad resources config: %w", err) } + var defaultNames []string for name, d := range m.Deployments { if d == nil { return fmt.Errorf("bad deployment: %s", name) } + if d.Default { + defaultNames = append(defaultNames, name) + } if err := d.Validate(); err != nil { return fmt.Errorf("bad deployment '%s': %w", name, err) } } + if len(defaultNames) > 1 { + return fmt.Errorf("multiple deployments marked as default: %s", strings.Join(defaultNames, ", ")) + } return nil } @@ -250,16 +257,56 @@ func (m *Manifest) Save() error { return enc.Encode(m) } -// DefaultDeploymentName is the name of the default deployment that must always be defined and is -// used in case no deployment is passed. +// DefaultDeploymentName is the legacy name of the default deployment. It is used as a fallback +// when no deployment is explicitly marked as default. const DefaultDeploymentName = "default" +// DefaultDeployment returns the name of the default deployment. Resolution order: +// 1. Deployment explicitly marked as default (default: true). +// 2. Legacy fallback: deployment named "default". +// 3. If exactly one deployment exists, use it. +// +// Returns an empty string if no default can be determined. +func (m *Manifest) DefaultDeployment() string { + for name, d := range m.Deployments { + if d != nil && d.Default { + return name + } + } + if _, ok := m.Deployments[DefaultDeploymentName]; ok { + return DefaultDeploymentName + } + if len(m.Deployments) == 1 { + for name := range m.Deployments { + return name + } + } + return "" +} + +// SetDefaultDeployment sets the given deployment as the default, clearing the flag from all others. +func (m *Manifest) SetDefaultDeployment(name string) error { + d := m.Deployments[name] + if d == nil { + return fmt.Errorf("deployment '%s' does not exist", name) + } + for _, other := range m.Deployments { + if other != nil { + other.Default = false + } + } + d.Default = true + return nil +} + // DefaultMachineName is the name of the default machine into which the app is deployed when no // specific machine is passed. const DefaultMachineName = "default" // Deployment describes a single ROFL app deployment. type Deployment struct { + // Default indicates whether this is the default deployment. + Default bool `yaml:"default,omitempty" json:"default,omitempty"` // AppID is the Bech32-encoded ROFL app ID. AppID string `yaml:"app_id,omitempty" json:"app_id,omitempty"` // Network is the identifier of the network to deploy to. diff --git a/build/rofl/manifest_test.go b/build/rofl/manifest_test.go index 488f912d..e4966e19 100644 --- a/build/rofl/manifest_test.go +++ b/build/rofl/manifest_test.go @@ -83,6 +83,24 @@ func TestManifestValidation(t *testing.T) { err = m.Validate() require.NoError(err) + // Multiple defaults should be rejected. + m.Deployments["default"].Default = true + m.Deployments["testnet"] = &Deployment{ + Default: true, + Network: "testnet", + ParaTime: "sapphire", + } + err = m.Validate() + require.ErrorContains(err, "multiple deployments marked as default") + + // Single default is fine. + m.Deployments["default"].Default = false + err = m.Validate() + require.NoError(err) + + // Clean up for subsequent tests. + delete(m.Deployments, "testnet") + // Add ephemeral storage configuration. m.Resources.Storage = &StorageConfig{} err = m.Validate() @@ -297,3 +315,59 @@ func TestUpgradePossible(t *testing.T) { require.False((&ContainerArtifactsConfig{Runtime: "new"}).UpgradePossible(&containerLatest)) require.False((&ContainerArtifactsConfig{}).UpgradePossible(&containerLatest)) } + +func TestDefaultDeployment(t *testing.T) { + require := require.New(t) + + m := Manifest{} + + // No deployments -> empty. + require.Empty(m.DefaultDeployment()) + + // Single deployment -> use it regardless of name. + m.Deployments = map[string]*Deployment{ + "testnet": {Network: "testnet", ParaTime: "sapphire"}, + } + require.Equal("testnet", m.DefaultDeployment()) + + // Legacy "default" key is preferred over arbitrary single deployment when multiple exist. + m.Deployments["default"] = &Deployment{Network: "mainnet", ParaTime: "sapphire"} + require.Equal("default", m.DefaultDeployment()) + + // Explicit default flag takes precedence. + m.Deployments["testnet"].Default = true + require.Equal("testnet", m.DefaultDeployment()) + + // With only the "default" key and no flag, fallback works. + delete(m.Deployments, "testnet") + require.Equal("default", m.DefaultDeployment()) + + // Multiple deployments, none marked default, no "default" key -> empty. + m.Deployments = map[string]*Deployment{ + "testnet": {Network: "testnet", ParaTime: "sapphire"}, + "mainnet": {Network: "mainnet", ParaTime: "sapphire"}, + } + require.Empty(m.DefaultDeployment()) +} + +func TestSetDefaultDeployment(t *testing.T) { + require := require.New(t) + + m := Manifest{ + Deployments: map[string]*Deployment{ + "testnet": {Default: true, Network: "testnet", ParaTime: "sapphire"}, + "mainnet": {Network: "mainnet", ParaTime: "sapphire"}, + }, + } + + // Setting non-existent deployment fails. + err := m.SetDefaultDeployment("staging") + require.ErrorContains(err, "deployment 'staging' does not exist") + + // Setting mainnet as default clears testnet. + err = m.SetDefaultDeployment("mainnet") + require.NoError(err) + require.False(m.Deployments["testnet"].Default) + require.True(m.Deployments["mainnet"].Default) + require.Equal("mainnet", m.DefaultDeployment()) +} diff --git a/cmd/rofl/common/flags.go b/cmd/rofl/common/flags.go index c9b29bb6..6c9f83de 100644 --- a/cmd/rofl/common/flags.go +++ b/cmd/rofl/common/flags.go @@ -2,8 +2,6 @@ package common import ( flag "github.com/spf13/pflag" - - buildRofl "github.com/oasisprotocol/cli/build/rofl" ) var ( @@ -46,7 +44,7 @@ func init() { WipeFlags.BoolVar(&WipeStorage, "wipe-storage", false, "whether to wipe machine storage") DeploymentFlags = flag.NewFlagSet("", flag.ContinueOnError) - DeploymentFlags.StringVar(&DeploymentName, "deployment", buildRofl.DefaultDeploymentName, "deployment name") + DeploymentFlags.StringVar(&DeploymentName, "deployment", "", "deployment name") NoUpdateFlag = flag.NewFlagSet("", flag.ContinueOnError) NoUpdateFlag.BoolVar(&NoUpdate, "no-update-manifest", false, "do not update the manifest") diff --git a/cmd/rofl/common/manifest.go b/cmd/rofl/common/manifest.go index 4ed66148..1b96d89c 100644 --- a/cmd/rofl/common/manifest.go +++ b/cmd/rofl/common/manifest.go @@ -40,8 +40,9 @@ func LoadManifestAndSetNPA(opts *ManifestOptions) (*rofl.Manifest, *rofl.Deploym cfg := cliConfig.Global() npa := common.GetNPASelection(cfg) - manifest, d, err := MaybeLoadManifestAndSetNPA(cfg, npa, DeploymentName, opts) + manifest, resolvedName, d, err := MaybeLoadManifestAndSetNPA(cfg, npa, DeploymentName, opts) cobra.CheckErr(err) + DeploymentName = resolvedName if opts != nil && opts.NeedAppID && !d.HasAppID() { cobra.CheckErr(fmt.Errorf("deployment '%s' does not have an app ID set, maybe you need to run `oasis rofl create`", DeploymentName)) } @@ -51,34 +52,44 @@ func LoadManifestAndSetNPA(opts *ManifestOptions) (*rofl.Manifest, *rofl.Deploym // MaybeLoadManifestAndSetNPA loads the ROFL app manifest and reconfigures the // network/paratime/account selection. // -// In case there is an error in loading the manifest, it is returned. -func MaybeLoadManifestAndSetNPA(cfg *cliConfig.Config, npa *common.NPASelection, deployment string, opts *ManifestOptions) (*rofl.Manifest, *rofl.Deployment, error) { +// In case there is an error in loading the manifest, it is returned. The resolved deployment name +// is always returned alongside the deployment. +func MaybeLoadManifestAndSetNPA(cfg *cliConfig.Config, npa *common.NPASelection, deployment string, opts *ManifestOptions) (*rofl.Manifest, string, *rofl.Deployment, error) { manifest, err := rofl.LoadManifest() if err != nil { - return nil, nil, err + return nil, "", nil, err } // Warn if manifest was created with an older CLI version. checkToolingVersion(manifest) + // Resolve deployment name when not explicitly provided. + if deployment == "" { + deployment = manifest.DefaultDeployment() + } + if deployment == "" { + if len(manifest.Deployments) == 0 { + return nil, "", nil, fmt.Errorf("no deployments configured\nHint: use `oasis rofl create` to register a new ROFL app and create a deployment") + } + printAvailableDeployments(manifest) + return nil, "", nil, fmt.Errorf("no default deployment configured\nHint: use `oasis rofl set-default ` to set a default deployment") + } + d, ok := manifest.Deployments[deployment] if !ok { - fmt.Println("The following deployments are configured in the app manifest:") - for name := range manifest.Deployments { - fmt.Printf(" - %s\n", name) - } - return nil, nil, fmt.Errorf("deployment '%s' does not exist", deployment) + printAvailableDeployments(manifest) + return nil, "", nil, fmt.Errorf("deployment '%s' does not exist", deployment) } switch d.Network { case "": if npa.Network == nil { - return nil, nil, fmt.Errorf("no network selected") + return nil, "", nil, fmt.Errorf("no network selected") } default: npa.Network = cfg.Networks.All[d.Network] if npa.Network == nil { - return nil, nil, fmt.Errorf("network '%s' does not exist", d.Network) + return nil, "", nil, fmt.Errorf("network '%s' does not exist", d.Network) } npa.NetworkName = d.Network } @@ -88,7 +99,7 @@ func MaybeLoadManifestAndSetNPA(cfg *cliConfig.Config, npa *common.NPASelection, default: npa.ParaTime = npa.Network.ParaTimes.All[d.ParaTime] if npa.ParaTime == nil { - return nil, nil, fmt.Errorf("paratime '%s' does not exist", d.ParaTime) + return nil, "", nil, fmt.Errorf("paratime '%s' does not exist", d.ParaTime) } npa.ParaTimeName = d.ParaTime } @@ -104,12 +115,22 @@ func MaybeLoadManifestAndSetNPA(cfg *cliConfig.Config, npa *common.NPASelection, npa.Account = accCfg npa.AccountName = d.Admin case opts != nil && opts.NeedAdmin: - return nil, nil, err + return nil, "", nil, err default: // Admin account is not valid, but it is also not required, so do not override. } } - return manifest, d, nil + return manifest, deployment, d, nil +} + +func printAvailableDeployments(manifest *rofl.Manifest) { + if len(manifest.Deployments) == 0 { + return + } + fmt.Println("The following deployments are configured in the app manifest:") + for name := range manifest.Deployments { + fmt.Printf(" - %s\n", name) + } } // GetOrcFilename generates a filename based on the project name and deployment. diff --git a/cmd/rofl/mgmt.go b/cmd/rofl/mgmt.go index f7fa481a..e23a5053 100644 --- a/cmd/rofl/mgmt.go +++ b/cmd/rofl/mgmt.go @@ -211,22 +211,34 @@ var ( manifest, err := buildRofl.LoadManifest() cobra.CheckErr(err) + // Resolve deployment name: explicit flag > network name for new deployments. + deploymentName := roflCommon.DeploymentName + if deploymentName == "" { + deploymentName = npa.NetworkName + } + // Load or create a deployment. - deployment, ok := manifest.Deployments[roflCommon.DeploymentName] + deployment, ok := manifest.Deployments[deploymentName] switch ok { case true: if deployment.AppID != "" { - cobra.CheckErr(fmt.Errorf("ROFL app identifier already defined (%s) for deployment '%s', refusing to overwrite", deployment.AppID, roflCommon.DeploymentName)) + cobra.CheckErr(fmt.Errorf("ROFL app identifier already defined (%s) for deployment '%s', refusing to overwrite", deployment.AppID, deploymentName)) } // An existing deployment is defined, but without an AppID. Load everything else for // the deployment and proceed with creating a new app. + roflCommon.DeploymentName = deploymentName manifest, deployment, npa = roflCommon.LoadManifestAndSetNPA(&roflCommon.ManifestOptions{ NeedAppID: false, NeedAdmin: true, }) + + // Mark as default if no default is currently set (must be after reload). + if manifest.DefaultDeployment() == "" { + deployment.Default = true + } case false: - // No deployment defined, create a new default one. + // No deployment defined, create a new one named after the network. npa.MustHaveAccount() npa.MustHaveParaTime() if txCfg.Offline { @@ -283,10 +295,15 @@ var ( Hash: blk.Hash.Hex(), }, } + // Mark as default if this is the first deployment. + if len(manifest.Deployments) == 0 { + deployment.Default = true + } if manifest.Deployments == nil { manifest.Deployments = make(map[string]*buildRofl.Deployment) } - manifest.Deployments[roflCommon.DeploymentName] = deployment + manifest.Deployments[deploymentName] = deployment + roflCommon.DeploymentName = deploymentName } idScheme, ok := identifierSchemes[scheme] @@ -298,7 +315,7 @@ var ( tx := rofl.NewCreateTx(nil, &rofl.Create{ Policy: *deployment.Policy.AsDescriptor(), Scheme: idScheme, - Metadata: manifest.GetMetadata(roflCommon.DeploymentName), + Metadata: manifest.GetMetadata(deploymentName), }) acc := common.LoadAccount(cfg, npa.AccountName) diff --git a/cmd/rofl/rofl.go b/cmd/rofl/rofl.go index c5c1b1e0..10242e68 100644 --- a/cmd/rofl/rofl.go +++ b/cmd/rofl/rofl.go @@ -23,6 +23,7 @@ func init() { Cmd.AddCommand(removeCmd) Cmd.AddCommand(showCmd) Cmd.AddCommand(listCmd) + Cmd.AddCommand(setDefaultCmd) Cmd.AddCommand(trustRootCmd) Cmd.AddCommand(build.Cmd) Cmd.AddCommand(identityCmd) diff --git a/cmd/rofl/set_default.go b/cmd/rofl/set_default.go new file mode 100644 index 00000000..7752a20e --- /dev/null +++ b/cmd/rofl/set_default.go @@ -0,0 +1,29 @@ +package rofl + +import ( + "fmt" + + "github.com/spf13/cobra" + + buildRofl "github.com/oasisprotocol/cli/build/rofl" +) + +var setDefaultCmd = &cobra.Command{ + Use: "set-default ", + Short: "Sets the given deployment as the default deployment", + Args: cobra.ExactArgs(1), + Run: func(_ *cobra.Command, args []string) { + name := args[0] + + manifest, err := buildRofl.LoadManifest() + cobra.CheckErr(err) + + err = manifest.SetDefaultDeployment(name) + cobra.CheckErr(err) + + err = manifest.Save() + cobra.CheckErr(err) + + fmt.Printf("Set '%s' as the default deployment.\n", name) + }, +}