diff --git a/.golangci.yml b/.golangci.yml index 72ba47ad..ea2460a9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -75,6 +75,9 @@ linters-settings: - gopkg.in/yaml.v3 - github.com/compose-spec/compose-go/v2 - github.com/github/go-spdx/v2 + - github.com/opencontainers/image-spec/specs-go/v1 + - oras.land/oras-go/v2 + - github.com/wI2L/jsondiff exhaustive: # Switch statements are to be considered exhaustive if a 'default' case is # present, even if all enum members aren't listed in the switch. diff --git a/build/cargo/cargo.go b/build/cargo/cargo.go index 8dec9de9..94950a1c 100644 --- a/build/cargo/cargo.go +++ b/build/cargo/cargo.go @@ -8,6 +8,8 @@ import ( "os/exec" "slices" "strings" + + "github.com/oasisprotocol/cli/build/env" ) // Metadata is the cargo package metadata. @@ -42,12 +44,15 @@ func (d *Dependency) HasFeature(feature string) bool { } // GetMetadata queries `cargo` for metadata of the package in the current working directory. -func GetMetadata() (*Metadata, error) { +func GetMetadata(env env.ExecEnv) (*Metadata, error) { cmd := exec.Command("cargo", "metadata", "--no-deps", "--format-version", "1") stdout, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("failed to initialize metadata process: %w", err) } + if err = env.WrapCommand(cmd); err != nil { + return nil, err + } if err = cmd.Start(); err != nil { return nil, fmt.Errorf("failed to start metadata process: %w", err) } @@ -105,8 +110,8 @@ func GetMetadata() (*Metadata, error) { } // Build builds a Rust program using `cargo` in the current working directory. -func Build(release bool, target string, features []string) (string, error) { - args := []string{"build"} +func Build(env env.ExecEnv, release bool, target string, features []string) (string, error) { + args := []string{"build", "--locked"} if release { args = append(args, "--release") } @@ -128,6 +133,9 @@ func Build(release bool, target string, features []string) (string, error) { var stderr bytes.Buffer cmd.Stderr = &stderr + if err = env.WrapCommand(cmd); err != nil { + return "", err + } if err = cmd.Start(); err != nil { return "", fmt.Errorf("failed to start build process: %w", err) } @@ -171,5 +179,9 @@ func Build(release bool, target string, features []string) (string, error) { if executable == "" { return "", fmt.Errorf("no executable generated") } + executable, err = env.PathFromEnv(executable) + if err != nil { + return "", fmt.Errorf("failed to map executable path: %w", err) + } return executable, nil } diff --git a/build/env/base.go b/build/env/base.go new file mode 100644 index 00000000..31698a09 --- /dev/null +++ b/build/env/base.go @@ -0,0 +1,47 @@ +package env + +import ( + "os" + "path/filepath" +) + +// GetBasedir finds the Git repository root directory and use that as base. If one cannot be found, +// uses the current working directory. +func GetBasedir() (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", err + } + + dir := getGitRoot(wd) + if dir != "" { + return dir, nil + } + return wd, nil +} + +func getGitRoot(dir string) string { + currentDir := dir + + for { + entries, err := os.ReadDir(currentDir) + if err != nil { + return "" + } + + for _, de := range entries { + if !de.IsDir() { + continue + } + if de.Name() == ".git" { + return currentDir + } + } + + parentDir := filepath.Dir(currentDir) + if parentDir == currentDir { + return "" + } + currentDir = parentDir + } +} diff --git a/build/env/env.go b/build/env/env.go new file mode 100644 index 00000000..bd2f6052 --- /dev/null +++ b/build/env/env.go @@ -0,0 +1,211 @@ +package env + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// ExecEnv is an execution environment. +type ExecEnv interface { + // WrapCommand modifies an existing `exec.Cmd` such that it runs in this environment. + WrapCommand(cmd *exec.Cmd) error + + // PathFromEnv converts the given path from inside the environment into a path outside the + // environment. + PathFromEnv(path string) (string, error) + + // PathToEnv converts the given path from outside the environment into a path inside the + // environment. + PathToEnv(path string) (string, error) + + // FixPermissions ensures that the user executing this process owns the file at the given path + // outside the environment. + FixPermissions(path string) error + + // HasBinary returns true iff the given binary name is available in this environment. + HasBinary(name string) bool + + // IsAvailable returns true iff the given execution environment is available. + IsAvailable() bool +} + +// NativeEnv is the native execution environment that executes all commands directly. +type NativeEnv struct{} + +// NewNativeEnv creates a new native execution environment. +func NewNativeEnv() *NativeEnv { + return &NativeEnv{} +} + +// WrapCommand implements ExecEnv. +func (ne *NativeEnv) WrapCommand(*exec.Cmd) error { + return nil +} + +// PathFromEnv implements ExecEnv. +func (ne *NativeEnv) PathFromEnv(path string) (string, error) { + return path, nil +} + +// PathToEnv implements ExecEnv. +func (ne *NativeEnv) PathToEnv(path string) (string, error) { + return path, nil +} + +// FixPermissions implements ExecEnv. +func (ne *NativeEnv) FixPermissions(string) error { + return nil +} + +// HasBinary implements ExecEnv. +func (ne *NativeEnv) HasBinary(name string) bool { + path, err := exec.LookPath(name) + return err == nil && path != "" +} + +// IsAvailable implements ExecEnv. +func (ne *NativeEnv) IsAvailable() bool { + return true +} + +// String returns a string representation of the execution environment. +func (ne *NativeEnv) String() string { + return "native environment" +} + +// DockerEnv is a Docker-based execution environment that executes all commands inside a Docker +// container using the configured image. +type DockerEnv struct { + image string + volumes map[string]string +} + +// NewDockerEnv creates a new Docker-based execution environment. +func NewDockerEnv(image, baseDir, dirMount string) *DockerEnv { + return &DockerEnv{ + image: image, + volumes: map[string]string{ + baseDir: dirMount, + }, + } +} + +// AddDirectory exposes a host directory to the container under the same path. +func (de *DockerEnv) AddDirectory(path string) { + de.volumes[path] = path +} + +// WrapCommand implements ExecEnv. +func (de *DockerEnv) WrapCommand(cmd *exec.Cmd) error { + origArgs := cmd.Args + + var err error + wd := cmd.Dir + if wd == "" { + wd, err = os.Getwd() + if err != nil { + return err + } + } + workDir, err := de.PathToEnv(wd) + if err != nil { + return fmt.Errorf("bad working directory: %w", err) + } + + var envArgs []string //nolint: prealloc + for _, envKV := range cmd.Env { + envArgs = append(envArgs, "--env", envKV) + } + // When no environment is set, copy over any OASIS_ and ROFL_ variables. + if len(cmd.Env) == 0 { + for _, envKV := range os.Environ() { + if !strings.HasPrefix(envKV, "OASIS_") && !strings.HasPrefix(envKV, "ROFL_") { + continue + } + envArgs = append(envArgs, "--env", envKV) + } + } + + cmd.Path, err = exec.LookPath("docker") + if err != nil { + return fmt.Errorf("failed to find 'docker': %w", err) + } + + cmd.Args = []string{ + "docker", "run", + "--rm", + "--platform", "linux/amd64", + "--workdir", workDir, + } + for hostDir, bindDir := range de.volumes { + cmd.Args = append(cmd.Args, "--volume", hostDir+":"+bindDir) + } + cmd.Args = append(cmd.Args, envArgs...) + cmd.Args = append(cmd.Args, de.image) + cmd.Args = append(cmd.Args, origArgs...) + + return nil +} + +// PathFromEnv implements ExecEnv. +func (de *DockerEnv) PathFromEnv(path string) (string, error) { + for hostDir, bindDir := range de.volumes { + if !strings.HasPrefix(path, bindDir) { + continue + } + relPath, err := filepath.Rel(bindDir, path) + if err != nil { + return "", fmt.Errorf("bad path: %w", err) + } + return filepath.Join(hostDir, relPath), nil + } + return "", fmt.Errorf("bad path '%s'", path) +} + +// PathToEnv implements ExecEnv. +func (de *DockerEnv) PathToEnv(path string) (string, error) { + for hostDir, bindDir := range de.volumes { + if !strings.HasPrefix(path, hostDir) { + continue + } + relPath, err := filepath.Rel(hostDir, path) + if err != nil { + return "", fmt.Errorf("bad path: %w", err) + } + return filepath.Join(bindDir, relPath), nil + } + return "", fmt.Errorf("bad path '%s'", path) +} + +// FixPermissions implements ExecEnv. +func (de *DockerEnv) FixPermissions(path string) error { + path, err := de.PathToEnv(path) + if err != nil { + return err + } + + cmd := exec.Command("chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), path) //nolint: gosec + if err = de.WrapCommand(cmd); err != nil { + return err + } + return cmd.Run() +} + +// HasBinary implements ExecEnv. +func (de *DockerEnv) HasBinary(string) bool { + return true +} + +// IsAvailable implements ExecEnv. +func (de *DockerEnv) IsAvailable() bool { + path, err := exec.LookPath("docker") + return err == nil && path != "" +} + +// String returns a string representation of the execution environment. +func (de *DockerEnv) String() string { + return "Docker" +} diff --git a/build/rofl/manifest.go b/build/rofl/manifest.go index 2fa6c9e2..e34834f5 100644 --- a/build/rofl/manifest.go +++ b/build/rofl/manifest.go @@ -243,6 +243,10 @@ func (m *Manifest) Save() error { // used in case no deployment is passed. const DefaultDeploymentName = "default" +// 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 { // AppID is the Bech32-encoded ROFL app ID. @@ -255,6 +259,8 @@ type Deployment struct { Admin string `yaml:"admin,omitempty" json:"admin,omitempty"` // Debug is a flag denoting whether this is a debuggable deployment. Debug bool `yaml:"debug,omitempty" json:"debug,omitempty"` + // OCIRepository is the optional OCI repository where one can push the ORC to. + OCIRepository string `yaml:"oci_repository,omitempty" json:"oci_repository,omitempty"` // TrustRoot is the optional trust root configuration. TrustRoot *TrustRootConfig `yaml:"trust_root,omitempty" json:"trust_root,omitempty"` // Policy is the ROFL app policy. @@ -263,9 +269,12 @@ type Deployment struct { Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"` // Secrets contains encrypted secrets. Secrets []*SecretConfig `yaml:"secrets,omitempty" json:"secrets,omitempty"` + + // Machines are the machines on which app replicas are deployed. + Machines map[string]*Machine `yaml:"machines,omitempty" json:"machines,omitempty"` } -// Validate validates the manifest for correctness. +// Validate validates the deployment for correctness. func (d *Deployment) Validate() error { if len(d.AppID) > 0 { var appID rofl.AppID @@ -284,6 +293,12 @@ func (d *Deployment) Validate() error { return fmt.Errorf("bad secret: %w", err) } } + + for name, machine := range d.Machines { + if err := machine.Validate(); err != nil { + return fmt.Errorf("bad machine '%s': %w", name, err) + } + } return nil } @@ -292,6 +307,27 @@ func (d *Deployment) HasAppID() bool { return len(d.AppID) > 0 } +// Machine is a hosted machine where a ROFL app is deployed. +type Machine struct { + // Provider is the address of the ROFL market provider to deploy to. + Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` + // Offer is the provider's offer identifier to provision. + Offer string `yaml:"offer,omitempty" json:"offer,omitempty"` + // ID is the identifier of the machine to deploy into. + ID string `yaml:"id,omitempty" json:"id,omitempty"` +} + +// Validate validates the machine for correctness. +func (m *Machine) Validate() error { + if m.Offer != "" && m.Provider == "" { + return fmt.Errorf("offer identifier cannot be specified without a provider") + } + if m.ID != "" && m.Provider == "" { + return fmt.Errorf("machine identifier cannot be specified without a provider") + } + return nil +} + // TrustRootConfig is the trust root configuration. type TrustRootConfig struct { // Height is the consensus layer block height where to take the trust root. @@ -359,11 +395,13 @@ func (e *StorageConfig) Validate() error { // ArtifactsConfig is the artifact location override configuration. type ArtifactsConfig struct { - // Firmware is the URI/path to the firmware artifact (empty to use default). + // Builder is the OCI reference to the builder container image. Empty to not use a builder. + Builder string `yaml:"builder,omitempty" json:"builder,omitempty"` + // Firmware is the URI/path to the firmware artifact. Firmware string `yaml:"firmware,omitempty" json:"firmware,omitempty"` - // Kernel is the URI/path to the kernel artifact (empty to use default). + // Kernel is the URI/path to the kernel artifact. Kernel string `yaml:"kernel,omitempty" json:"kernel,omitempty"` - // Stage2 is the URI/path to the stage 2 disk artifact (empty to use default). + // Stage2 is the URI/path to the stage 2 disk artifact. Stage2 string `yaml:"stage2,omitempty" json:"stage2,omitempty"` // Container is the container artifacts configuration. Container ContainerArtifactsConfig `yaml:"container,omitempty" json:"container,omitempty"` diff --git a/build/rofl/oci.go b/build/rofl/oci.go new file mode 100644 index 00000000..5eb7f858 --- /dev/null +++ b/build/rofl/oci.go @@ -0,0 +1,133 @@ +package rofl + +import ( + "context" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + + v1 "github.com/opencontainers/image-spec/specs-go/v1" + oras "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content/file" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials" + "oras.land/oras-go/v2/registry/remote/retry" + + "github.com/oasisprotocol/oasis-core/go/common/crypto/hash" + "github.com/oasisprotocol/oasis-core/go/runtime/bundle" +) + +const ( + ociTypeOrcConfig = "application/vnd.oasis.orc.config.v1+json" + ociTypeOrcLayer = "application/vnd.oasis.orc.layer.v1" + ociTypeOrcArtifact = "application/vnd.oasis.orc" +) + +// PushBundleToOciRepository pushes an ORC bundle to the given remote OCI repository. +// +// Returns the OCI manifest digest and the ORC manifest hash. +func PushBundleToOciRepository(bundleFn, dst string) (string, hash.Hash, error) { + ctx := context.Background() + + atoms := strings.Split(dst, ":") + if len(atoms) != 2 { + return "", hash.Hash{}, fmt.Errorf("malformed OCI repository reference (repo:tag required)") + } + dst = atoms[0] + tag := atoms[1] + + // Open the bundle. + bnd, err := bundle.Open(bundleFn) + if err != nil { + return "", hash.Hash{}, fmt.Errorf("failed to open bundle: %w", err) + } + defer bnd.Close() + + // Create a temporary file store to build the OCI layers. + tmpDir, err := os.MkdirTemp("", "oasis-orc2oci") + if err != nil { + return "", hash.Hash{}, fmt.Errorf("failed to create temporary directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + storeDir := filepath.Join(tmpDir, "oci") + store, err := file.New(storeDir) + if err != nil { + return "", hash.Hash{}, fmt.Errorf("failed to create temporary OCI store: %w", err) + } + defer store.Close() + + bundleDir := filepath.Join(tmpDir, "bundle") + if err = bnd.WriteExploded(bundleDir); err != nil { + return "", hash.Hash{}, fmt.Errorf("failed to explode bundle: %w", err) + } + + // Generate the config object from the manifest. + const manifestName = "META-INF/MANIFEST.MF" + configDsc, err := store.Add(ctx, manifestName, ociTypeOrcConfig, filepath.Join(bundleDir, manifestName)) + if err != nil { + return "", hash.Hash{}, fmt.Errorf("failed to add config object from manifest: %w", err) + } + + // Add other files as layers. + layers := make([]v1.Descriptor, 0, len(bnd.Data)-1) + fns := slices.Sorted(maps.Keys(bnd.Data)) // Ensure deterministic order. + for _, fn := range fns { + if fn == manifestName { + continue + } + + var layerDsc v1.Descriptor + layerDsc, err = store.Add(ctx, fn, ociTypeOrcLayer, filepath.Join(bundleDir, fn)) + if err != nil { + return "", hash.Hash{}, fmt.Errorf("failed to add OCI layer: %w", err) + } + + layers = append(layers, layerDsc) + } + + // Pack the OCI manifest. + opts := oras.PackManifestOptions{ + Layers: layers, + ConfigDescriptor: &configDsc, + ManifestAnnotations: map[string]string{ + // Use a fixed crated timestamp to avoid changing the manifest digest for no reason. + v1.AnnotationCreated: "2025-03-31T00:00:00Z", + }, + } + manifestDescriptor, err := oras.PackManifest(ctx, store, oras.PackManifestVersion1_1, ociTypeOrcArtifact, opts) + if err != nil { + return "", hash.Hash{}, fmt.Errorf("failed to pack OCI manifest: %w", err) + } + + // Tag the manifest. + if err = store.Tag(ctx, manifestDescriptor, tag); err != nil { + return "", hash.Hash{}, fmt.Errorf("failed to tag OCI manifest: %w", err) + } + + // Connect to remote repository. + repo, err := remote.NewRepository(dst) + if err != nil { + return "", hash.Hash{}, fmt.Errorf("failed to init remote OCI repository: %w", err) + } + creds, err := credentials.NewStoreFromDocker(credentials.StoreOptions{}) + if err != nil { + return "", hash.Hash{}, fmt.Errorf("failed to init OCI credential store: %w", err) + } + repo.Client = &auth.Client{ + Client: retry.DefaultClient, + Cache: auth.NewCache(), + Credential: credentials.Credential(creds), + } + + // Push to remote repository. + if _, err = oras.Copy(ctx, store, tag, repo, tag, oras.DefaultCopyOptions); err != nil { + return "", hash.Hash{}, fmt.Errorf("failed to push to remote OCI repository: %w", err) + } + + return manifestDescriptor.Digest.String(), bnd.Manifest.Hash(), nil +} diff --git a/build/rofl/provider/defaults.go b/build/rofl/provider/defaults.go new file mode 100644 index 00000000..414c1d08 --- /dev/null +++ b/build/rofl/provider/defaults.go @@ -0,0 +1,8 @@ +package provider + +// DefaultSchedulerApp contains the default scheduler app IDs for each network/paratime. +var DefaultSchedulerApp = map[string]map[string]string{ + "testnet": { + "sapphire": "rofl1qrqw99h0f7az3hwt2cl7yeew3wtz0fxunu7luyfg", + }, +} diff --git a/build/rofl/provider/manifest.go b/build/rofl/provider/manifest.go new file mode 100644 index 00000000..a473c0bb --- /dev/null +++ b/build/rofl/provider/manifest.go @@ -0,0 +1,407 @@ +package provider + +import ( + "encoding/hex" + "errors" + "fmt" + "maps" + "net/url" + "os" + "path/filepath" + "strings" + + ethCommon "github.com/ethereum/go-ethereum/common" + "gopkg.in/yaml.v3" + + "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" + "github.com/oasisprotocol/oasis-core/go/common/quantity" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/config" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/helpers" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/rofl" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/roflmarket" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/types" +) + +// ManifestFileNames are the manifest file names that are tried when loading the manifest. +var ManifestFileNames = []string{ + "rofl-provider.yaml", + "rofl-provider.yml", +} + +// Manifest is the manifest describing a provider. +type Manifest struct { + // Name is the optional name of the provider. + Name string `yaml:"name,omitempty" json:"name,omitempty"` + // Homepage is the optional homepage URL of the provider. + Homepage string `yaml:"homepage,omitempty" json:"homepage,omitempty"` + // Description is the optional description of the provider. + Description string `yaml:"description,omitempty" json:"description,omitempty"` + + // Network is the identifier of the network to deploy to. + Network string `yaml:"network" json:"network"` + // ParaTime is the identifier of the paratime to deploy to. + ParaTime string `yaml:"paratime" json:"paratime"` + // Provider is the identifier of the provider account. + Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` + + // Nodes are the public keys of nodes authorized to act on behalf of provider. + Nodes []signature.PublicKey `yaml:"nodes" json:"nodes"` + // ScheduperApp is the authorized scheduper app for this provider. + SchedulerApp rofl.AppID `yaml:"scheduler_app" json:"scheduler_app"` + // PaymentAddress is the payment address. + PaymentAddress string `yaml:"payment_address" json:"payment_address"` + // Offers is a list of offers available from this provider. + Offers []*Offer `yaml:"offers,omitempty" json:"offers,omitempty"` + // Metadata is arbitrary metadata (key-value pairs) assigned by the provider. + Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"` + + // sourceFn is the filename from which the manifest has been loaded. + sourceFn string +} + +// ManifestExists checks whether a manifest file exist. No attempt is made to load, parse or +// validate any of the found manifest files. +func ManifestExists() bool { + for _, fn := range ManifestFileNames { + _, err := os.Stat(fn) + switch { + case errors.Is(err, os.ErrNotExist): + continue + default: + return true + } + } + return false +} + +// LoadManifest attempts to find and load the ROFL app manifest from a local file. +func LoadManifest() (*Manifest, error) { + for _, fn := range ManifestFileNames { + f, err := os.Open(fn) + switch { + case err == nil: + case errors.Is(err, os.ErrNotExist): + continue + default: + return nil, fmt.Errorf("failed to load manifest from '%s': %w", fn, err) + } + + var m Manifest + dec := yaml.NewDecoder(f) + if err = dec.Decode(&m); err != nil { + f.Close() + return nil, fmt.Errorf("malformed manifest '%s': %w", fn, err) + } + if err = m.Validate(); err != nil { + f.Close() + return nil, fmt.Errorf("invalid manifest '%s': %w", fn, err) + } + m.sourceFn, _ = filepath.Abs(f.Name()) // Record source filename. + + f.Close() + return &m, nil + } + return nil, fmt.Errorf("no ROFL provider manifest found (tried: %s)", strings.Join(ManifestFileNames, ", ")) +} + +// Validate validates the provider manifest. +func (m *Manifest) Validate() error { + if _, err := url.Parse(m.Homepage); err != nil && m.Homepage != "" { + return fmt.Errorf("malformed homepage URL: %w", err) + } + + for idx, offer := range m.Offers { + if err := offer.Validate(); err != nil { + return fmt.Errorf("invalid offer %d: %w", idx, err) + } + } + return nil +} + +// providerMetadataPrefix is the prefix used for all provider metadata. +const providerMetadataPrefix = "net.oasis.provider." + +// GetMetadata derives metadata from the attributes defined in the manifest and combines it with +// the specified metadata. +func (m *Manifest) GetMetadata() map[string]string { + meta := make(map[string]string) + for _, md := range []struct { + name string + value string + }{ + {"name", m.Name}, + {"homepage", m.Homepage}, + {"description", m.Description}, + } { + if md.value == "" { + continue + } + meta[providerMetadataPrefix+md.name] = md.value + } + + maps.Copy(meta, m.Metadata) + return meta +} + +// SourceFileName returns the filename of the manifest file from which the manifest was loaded or +// an empty string in case the filename is not available. +func (m *Manifest) SourceFileName() string { + return m.sourceFn +} + +// Save serializes the manifest and writes it to the file returned by `SourceFileName`, overwriting +// any previous manifest. +// +// If no previous source filename is available, a default one is set. +func (m *Manifest) Save() error { + if m.sourceFn == "" { + m.sourceFn = ManifestFileNames[0] + } + + f, err := os.Create(m.sourceFn) + if err != nil { + return err + } + defer f.Close() + + enc := yaml.NewEncoder(f) + enc.SetIndent(2) + return enc.Encode(m) +} + +// Offer is a provider's offer. +type Offer struct { + // ID is the unique offer identifier used by the scheduler. + ID string `yaml:"id" json:"id"` + // Resources are the offered resources. + Resources Resources `yaml:"resources" json:"resources"` + // Payment is the payment for this offer. + Payment Payment `yaml:"payment" json:"payment"` + // Capacity is the amount of available instances. Setting this to zero will disallow + // provisioning of new instances for this offer. Each accepted instance will automatically + // decrement capacity. + Capacity uint64 `yaml:"capacity" json:"capacity"` + // Metadata is arbitrary metadata (key-value pairs) assigned by the provider. + Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"` +} + +// Validate validates the offer. +func (o *Offer) Validate() error { + if o.ID == "" { + return fmt.Errorf("missing offer identifier") + } + if err := o.Resources.Validate(); err != nil { + return fmt.Errorf("invalid resources: %w", err) + } + if err := o.Payment.Validate(); err != nil { + return fmt.Errorf("invalid payment specifier: %w", err) + } + return nil +} + +// schedulerMetadataPrefix is the prefix used for all scheduler metadata. +const schedulerMetadataPrefix = "net.oasis.scheduler." + +// SchedulerMetadataOfferKey is the metadata key used for the offer name. +const SchedulerMetadataOfferKey = schedulerMetadataPrefix + "offer" + +// GetMetadata derives metadata from the attributes defined in the offer and combines it with the +// specified metadata. +func (o *Offer) GetMetadata() map[string]string { + meta := make(map[string]string) + for _, md := range []struct { + name string + value string + }{ + {"offer", o.ID}, + } { + if md.value == "" { + continue + } + meta[schedulerMetadataPrefix+md.name] = md.value + } + + maps.Copy(meta, o.Metadata) + return meta +} + +// AsDescriptor returns the configuration as an on-chain descriptor. +func (o *Offer) AsDescriptor(pt *config.ParaTime) (*roflmarket.Offer, error) { + offer := roflmarket.Offer{ + Resources: *o.Resources.AsDescriptor(), + Capacity: o.Capacity, + Metadata: o.GetMetadata(), + } + + payment, err := o.Payment.AsDescriptor(pt) + if err != nil { + return nil, err + } + offer.Payment = *payment + + return &offer, nil +} + +const ( + TermKeyHour = "hourly" + TermKeyMonth = "monthly" + TermKeyYear = "yearly" +) + +// Payment is payment configuration for an offer. +type Payment struct { + Native *struct { + Denomination string `yaml:"denomination" json:"denomination"` + Terms map[string]string `yaml:"terms" json:"terms"` + } `yaml:"native,omitempty" json:"native,omitempty"` + + EvmContract *struct { + Address string `json:"address"` + Data string `json:"data"` + } `yaml:"evm,omitempty" json:"evm,omitempty"` +} + +// Validate validates the payment configuration. +func (p *Payment) Validate() error { + if p.Native != nil && p.EvmContract != nil { + return fmt.Errorf("only one payment method may be specified") + } + switch { + case p.Native != nil: + for term := range p.Native.Terms { + switch term { + case TermKeyHour, TermKeyMonth, TermKeyYear: + default: + return fmt.Errorf("invalid term: %s", term) + } + } + case p.EvmContract != nil: + if !ethCommon.IsHexAddress(p.EvmContract.Address) { + return fmt.Errorf("malformed Ethereum address: %s", p.EvmContract.Address) + } + _, err := hex.DecodeString(p.EvmContract.Data) + if err != nil { + return fmt.Errorf("malformed EVM contract data: %w", err) + } + default: + return fmt.Errorf("missing payment configuration") + } + return nil +} + +// AsDescriptor returns the configuration as an on-chain descriptor. +func (p *Payment) AsDescriptor(pt *config.ParaTime) (*roflmarket.Payment, error) { + var dsc roflmarket.Payment + switch { + case p.Native != nil: + dsc.Native = &roflmarket.NativePayment{ + Terms: make(map[roflmarket.Term]quantity.Quantity), + } + + for termCfg, amount := range p.Native.Terms { + var term roflmarket.Term + switch termCfg { + case TermKeyHour: + term = roflmarket.TermHour + case TermKeyMonth: + term = roflmarket.TermMonth + case TermKeyYear: + term = roflmarket.TermYear + default: + return nil, fmt.Errorf("invalid term: %s", termCfg) + } + + bu, err := helpers.ParseParaTimeDenomination(pt, amount, types.Denomination(p.Native.Denomination)) + if err != nil { + return nil, fmt.Errorf("invalid amount: %w", err) + } + + dsc.Native.Terms[term] = bu.Amount + dsc.Native.Denomination = bu.Denomination + } + case p.EvmContract != nil: + var addr [20]byte + copy(addr[:], ethCommon.HexToAddress(p.EvmContract.Address).Bytes()) + + data, err := hex.DecodeString(p.EvmContract.Data) + if err != nil { + return nil, fmt.Errorf("malformed EVM contract data: %w", err) + } + + dsc.EvmContract = &roflmarket.EvmContractPayment{ + Address: addr, + Data: data, + } + } + return &dsc, nil +} + +// Resources are describe the offered resources. +type Resources struct { + // TEE is the type of TEE hardware. + TEE string `yaml:"tee" json:"tee"` + // Memory is the amount of memory in megabytes. + Memory uint64 `yaml:"memory" json:"memory"` + // CPUCount is the amount of vCPUs. + CPUCount uint16 `yaml:"cpus" json:"cpus"` + // Storage is the amount of storage ine megabytes. + Storage uint64 `yaml:"storage" json:"storage"` + // GPU is the optional GPU resource. + GPU *GPUResource `yaml:"gpu,omitempty" json:"gpu,omitempty"` +} + +// Validate validates the resources. +func (r *Resources) Validate() error { + switch r.TEE { + case "sgx", "tdx": + default: + return fmt.Errorf("invalid TEE: %s", r.TEE) + } + + if r.GPU != nil { + err := r.GPU.Validate() + if err != nil { + return fmt.Errorf("invalid GPU resource: %w", err) + } + } + return nil +} + +// AsDescriptor returns the configuration as an on-chain descriptor. +func (r *Resources) AsDescriptor() *roflmarket.Resources { + dsc := roflmarket.Resources{ + Memory: r.Memory, + CPUCount: r.CPUCount, + Storage: r.Storage, + } + + switch r.TEE { + case "sgx": + dsc.TEE = roflmarket.TeeTypeSGX + case "tdx": + dsc.TEE = roflmarket.TeeTypeTDX + default: + } + + if r.GPU != nil { + dsc.GPU = &roflmarket.GPUResource{ + Model: r.GPU.Model, + Count: r.GPU.Count, + } + } + + return &dsc +} + +// GPUResource is the offered GPU resource. +type GPUResource struct { + // Model is the optional GPU model. + Model string `yaml:"model,omitempty" json:"model,omitempty"` + // Count is the number of GPUs requested. + Count uint8 `yaml:"count" json:"count"` +} + +// Validate validates the GPU resource. +func (g *GPUResource) Validate() error { + return nil +} diff --git a/build/rofl/scheduler/commands.go b/build/rofl/scheduler/commands.go new file mode 100644 index 00000000..8951b203 --- /dev/null +++ b/build/rofl/scheduler/commands.go @@ -0,0 +1,42 @@ +package scheduler + +import ( + "github.com/oasisprotocol/oasis-core/go/common/cbor" + + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/roflmarket" +) + +// Commands supported by the scheduler. +const ( + MethodDeploy = "Deploy" + MethodRestart = "Restart" + MethodTerminate = "Terminate" +) + +// Command is a command to be executed on a specific instance by the scheduler. +type Command struct { + // Method is the method name. + Method string `json:"method"` + // Args are the method arguments. + Args cbor.RawMessage `json:"args"` +} + +// DeployRequest is a deployment request. +type DeployRequest struct { + // Deployment is the deployment to deploy. + Deployment roflmarket.Deployment `json:"deployment"` + // WipeStorage is a flag indicating whether persistent storage should be wiped. + WipeStorage bool `json:"wipe_storage"` +} + +// RestartRequest is an instance restart request. +type RestartRequest struct { + // WipeStorage is a flag indicating whether persistent storage should be wiped. + WipeStorage bool `json:"wipe_storage"` +} + +// TerminateRequest is an instance termination request. +type TerminateRequest struct { + // WipeStorage is a flag indicating whether persistent storage should be wiped. + WipeStorage bool `json:"wipe_storage"` +} diff --git a/build/sgxs/sgxs.go b/build/sgxs/sgxs.go index fbfeb293..8627f622 100644 --- a/build/sgxs/sgxs.go +++ b/build/sgxs/sgxs.go @@ -4,12 +4,14 @@ package sgxs import ( "os/exec" "strconv" + + "github.com/oasisprotocol/cli/build/env" ) // Elf2Sgxs converts an ELF binary built for the SGX ABI into an SGXS binary. // // It requires the `ftxsgx-elf2sgxs` utility to be installed. -func Elf2Sgxs(elfSgxPath, sgxsPath string, heapSize, stackSize, threads uint64) error { +func Elf2Sgxs(buildEnv env.ExecEnv, elfSgxPath, sgxsPath string, heapSize, stackSize, threads uint64) error { args := []string{ elfSgxPath, "--heap-size", strconv.FormatUint(heapSize, 10), @@ -19,5 +21,8 @@ func Elf2Sgxs(elfSgxPath, sgxsPath string, heapSize, stackSize, threads uint64) } cmd := exec.Command("ftxsgx-elf2sgxs", args...) + if err := buildEnv.WrapCommand(cmd); err != nil { + return err + } return cmd.Run() } diff --git a/cmd/common/json.go b/cmd/common/json.go index fb125cec..684972b4 100644 --- a/cmd/common/json.go +++ b/cmd/common/json.go @@ -21,7 +21,12 @@ import ( // PrettyJSONMarshal returns pretty-printed JSON encoding of v. func PrettyJSONMarshal(v interface{}) ([]byte, error) { - formatted, err := json.MarshalIndent(v, "", " ") + return PrettyJSONMarshalIndent(v, "", " ") +} + +// PrettyJSONMarshal returns pretty-printed JSON encoding of v. +func PrettyJSONMarshalIndent(v interface{}, prefix, indent string) ([]byte, error) { + formatted, err := json.MarshalIndent(v, prefix, indent) if err != nil { return nil, fmt.Errorf("failed to marshal to pretty JSON: %w", err) } @@ -139,7 +144,7 @@ func PrettyPrint(npa *NPASelection, prefix string, blob interface{}) string { rtx.PrettyPrint(ctx, prefix, &pp) ret = pp.String() default: - pp, err := PrettyJSONMarshal(blob) + pp, err := PrettyJSONMarshalIndent(blob, "", prefix) cobra.CheckErr(err) out := string(pp) @@ -157,6 +162,7 @@ func PrettyPrint(npa *NPASelection, prefix string, blob interface{}) string { } ret += line + "\n" } + ret = strings.TrimRight(ret, "\n") } return ret diff --git a/cmd/common/transaction.go b/cmd/common/transaction.go index 9b025db3..28f43772 100644 --- a/cmd/common/transaction.go +++ b/cmd/common/transaction.go @@ -264,6 +264,9 @@ func PrepareParatimeTransaction(ctx context.Context, npa *NPASelection, account if err != nil { return 0, nil, "", fmt.Errorf("failed to estimate gas: %w", err) } + + // Inflate the estimate by 20% for good measure. + gas = (120 * gas) / 100 } // Compute fee. diff --git a/cmd/network/show.go b/cmd/network/show.go index 63e8da39..ab9e4c0a 100644 --- a/cmd/network/show.go +++ b/cmd/network/show.go @@ -433,6 +433,7 @@ func showParameters(ctx context.Context, npa *common.NPASelection, height int64, fmt.Printf("=== %s PARAMETERS ===\n", strings.ToUpper(name)) out := common.PrettyPrint(npa, " ", params) fmt.Printf("%s\n", out) + fmt.Println() } } diff --git a/cmd/paratime/show.go b/cmd/paratime/show.go index e79c68bd..8f8ce3ff 100644 --- a/cmd/paratime/show.go +++ b/cmd/paratime/show.go @@ -482,9 +482,12 @@ func showParameters(ctx context.Context, npa *common.NPASelection, round uint64, } } - stakeThresholds, err := rt.ROFL.StakeThresholds(ctx, round) + roflStakeThresholds, err := rt.ROFL.StakeThresholds(ctx, round) checkErr("ROFL StakeThresholds", err) + roflMarketStakeThresholds, err := rt.ROFLMarket.StakeThresholds(ctx, round) + checkErr("ROFL Market StakeThresholds", err) + doc := make(map[string]interface{}) doSection := func(name string, params interface{}) { @@ -497,7 +500,8 @@ func showParameters(ctx context.Context, npa *common.NPASelection, round uint64, } } - doSection("rofl", stakeThresholds) + doSection("rofl", roflStakeThresholds) + doSection("rofl market", roflMarketStakeThresholds) if common.OutputFormat() == common.FormatJSON { pp, err := json.MarshalIndent(doc, "", " ") diff --git a/cmd/rofl/build/artifacts.go b/cmd/rofl/build/artifacts.go index c0cc2b70..c9d138e2 100644 --- a/cmd/rofl/build/artifacts.go +++ b/cmd/rofl/build/artifacts.go @@ -20,6 +20,8 @@ import ( "github.com/spf13/cobra" "github.com/oasisprotocol/oasis-core/go/common/crypto/hash" + + "github.com/oasisprotocol/cli/build/env" ) const artifactCacheDir = "build_cache" @@ -255,8 +257,8 @@ func copyFile(src, dst string, mode os.FileMode) error { // ensureBinaryExists checks whether the given binary name exists in path and returns a nice error // message suggesting to install a given package if it doesn't. -func ensureBinaryExists(name, pkg string) error { - if path, err := exec.LookPath(name); err != nil || path == "" { +func ensureBinaryExists(buildEnv env.ExecEnv, name, pkg string) error { + if !buildEnv.HasBinary(name) { return fmt.Errorf("missing '%s' binary, please install %s or similar", name, pkg) } return nil @@ -266,9 +268,9 @@ func ensureBinaryExists(name, pkg string) error { // it. // // Returns the size of the created filesystem image in bytes. -func createSquashFs(fn, dir string) (int64, error) { +func createSquashFs(buildEnv env.ExecEnv, fn, dir string) (int64, error) { const mkSquashFsBin = "mksquashfs" - if err := ensureBinaryExists(mkSquashFsBin, "squashfs-tools"); err != nil { + if err := ensureBinaryExists(buildEnv, mkSquashFsBin, "squashfs-tools"); err != nil { return 0, err } @@ -288,6 +290,9 @@ func createSquashFs(fn, dir string) (int64, error) { var out strings.Builder cmd.Stderr = &out cmd.Stdout = &out + if err := buildEnv.WrapCommand(cmd); err != nil { + return 0, err + } if err := cmd.Run(); err != nil { return 0, fmt.Errorf("%w\n%s", err, out.String()) } @@ -301,10 +306,10 @@ func createSquashFs(fn, dir string) (int64, error) { } // createVerityHashTree creates the verity Merkle hash tree and returns the root hash. -func createVerityHashTree(fsFn, hashFn string) (string, error) { +func createVerityHashTree(buildEnv env.ExecEnv, fsFn, hashFn string) (string, error) { // Print a nicer error message in case veritysetup is missing. const veritysetupBin = "veritysetup" - if err := ensureBinaryExists(veritysetupBin, "cryptsetup-bin"); err != nil { + if err := ensureBinaryExists(buildEnv, veritysetupBin, "cryptsetup-bin"); err != nil { return "", err } @@ -335,10 +340,23 @@ func createVerityHashTree(fsFn, hashFn string) (string, error) { var out strings.Builder cmd.Stderr = &out cmd.Stdout = &out + if err = buildEnv.WrapCommand(cmd); err != nil { + return "", err + } if err = cmd.Run(); err != nil { return "", fmt.Errorf("%w\n%s", err, out.String()) } + if err = buildEnv.FixPermissions(fsFn); err != nil { + return "", err + } + if err = buildEnv.FixPermissions(hashFn); err != nil { + return "", err + } + if err = buildEnv.FixPermissions(rootHashFn); err != nil { + return "", err + } + data, err := os.ReadFile(rootHashFn) if err != nil { return "", fmt.Errorf("failed to read dm-verity root hash: %w", err) @@ -413,9 +431,9 @@ func appendEmptySpace(fn string, size uint64, align uint64) (uint64, error) { } // convertToQcow2 converts a raw image to qcow2 format. -func convertToQcow2(fn string) error { +func convertToQcow2(buildEnv env.ExecEnv, fn string) error { const qemuImgBin = "qemu-img" - if err := ensureBinaryExists(qemuImgBin, "qemu-utils"); err != nil { + if err := ensureBinaryExists(buildEnv, qemuImgBin, "qemu-utils"); err != nil { return err } @@ -432,6 +450,10 @@ func convertToQcow2(fn string) error { var out strings.Builder cmd.Stderr = &out cmd.Stdout = &out + + if err := buildEnv.WrapCommand(cmd); err != nil { + return err + } if err := cmd.Run(); err != nil { return fmt.Errorf("%w\n%s", err, out.String()) } diff --git a/cmd/rofl/build/build.go b/cmd/rofl/build/build.go index 11d8ad6e..87bb9284 100644 --- a/cmd/rofl/build/build.go +++ b/cmd/rofl/build/build.go @@ -16,6 +16,7 @@ import ( "github.com/oasisprotocol/oasis-core/go/runtime/bundle" "github.com/oasisprotocol/oasis-sdk/client-sdk/go/connection" + "github.com/oasisprotocol/cli/build/env" buildRofl "github.com/oasisprotocol/cli/build/rofl" "github.com/oasisprotocol/cli/cmd/common" roflCommon "github.com/oasisprotocol/cli/cmd/rofl/common" @@ -35,6 +36,7 @@ var ( noUpdate bool doVerify bool deploymentName string + noDocker bool Cmd = &cobra.Command{ Use: "build", @@ -73,6 +75,30 @@ var ( } defer os.RemoveAll(tmpDir) + var buildEnv env.ExecEnv + switch { + case manifest.Artifacts.Builder == "" || noDocker: + buildEnv = env.NewNativeEnv() + default: + var baseDir string + baseDir, err = env.GetBasedir() + if err != nil { + cobra.CheckErr(fmt.Sprintf("failed to determine base directory: %s", err)) + } + + dockerEnv := env.NewDockerEnv( + manifest.Artifacts.Builder, + baseDir, + "/src", + ) + dockerEnv.AddDirectory(tmpDir) + buildEnv = dockerEnv + } + + if !buildEnv.IsAvailable() { + cobra.CheckErr(fmt.Sprintf("Build environment '%s' is not available. Make sure it is installed.", buildEnv)) + } + bnd := &bundle.Bundle{ Manifest: &bundle.Manifest{ Name: deployment.AppID, @@ -97,14 +123,14 @@ var ( return } - sgxBuild(npa, manifest, deployment, bnd) + sgxBuild(buildEnv, npa, manifest, deployment, bnd) case buildRofl.TEETypeTDX: // TDX. switch manifest.Kind { case buildRofl.AppKindRaw: - err = tdxBuildRaw(tmpDir, npa, manifest, deployment, bnd) + err = tdxBuildRaw(buildEnv, tmpDir, npa, manifest, deployment, bnd) case buildRofl.AppKindContainer: - err = tdxBuildContainer(tmpDir, npa, manifest, deployment, bnd) + err = tdxBuildContainer(buildEnv, tmpDir, npa, manifest, deployment, bnd) } default: fmt.Printf("unsupported TEE kind: %s\n", manifest.TEE) @@ -316,6 +342,7 @@ func init() { buildFlags.BoolVar(&noUpdate, "no-update-manifest", false, "do not update the manifest") buildFlags.BoolVar(&doVerify, "verify", false, "verify build against manifest and on-chain state") buildFlags.StringVar(&deploymentName, "deployment", buildRofl.DefaultDeploymentName, "deployment name") + buildFlags.BoolVar(&noDocker, "no-docker", false, "do not use the Dockerized builder") // TODO: Remove when all the examples, demos and docs are updated. var dummy bool diff --git a/cmd/rofl/build/container.go b/cmd/rofl/build/container.go index 5eda6347..71912ecf 100644 --- a/cmd/rofl/build/container.go +++ b/cmd/rofl/build/container.go @@ -8,12 +8,14 @@ import ( "github.com/oasisprotocol/oasis-core/go/runtime/bundle" + "github.com/oasisprotocol/cli/build/env" buildRofl "github.com/oasisprotocol/cli/build/rofl" "github.com/oasisprotocol/cli/cmd/common" ) // tdxBuildContainer builds a TDX-based container ROFL app. func tdxBuildContainer( + buildEnv env.ExecEnv, tmpDir string, npa *common.NPASelection, manifest *buildRofl.Manifest, @@ -40,7 +42,7 @@ func tdxBuildContainer( // Use the pre-built container runtime. initPath := artifacts[artifactContainerRuntime] - stage2, err := tdxPrepareStage2(tmpDir, artifacts, initPath, map[string]string{ + stage2, err := tdxPrepareStage2(buildEnv, tmpDir, artifacts, initPath, map[string]string{ artifacts[artifactContainerCompose]: "etc/oasis/containers/compose.yaml", }) if err != nil { @@ -64,5 +66,5 @@ func tdxBuildContainer( fmt.Println("Creating ORC bundle...") - return tdxBundleComponent(manifest, artifacts, bnd, stage2, extraKernelOpts) + return tdxBundleComponent(buildEnv, manifest, artifacts, bnd, stage2, extraKernelOpts) } diff --git a/cmd/rofl/build/sgx.go b/cmd/rofl/build/sgx.go index d86b72df..10c32680 100644 --- a/cmd/rofl/build/sgx.go +++ b/cmd/rofl/build/sgx.go @@ -18,6 +18,7 @@ import ( "github.com/oasisprotocol/oasis-core/go/runtime/bundle/component" "github.com/oasisprotocol/cli/build/cargo" + "github.com/oasisprotocol/cli/build/env" buildRofl "github.com/oasisprotocol/cli/build/rofl" "github.com/oasisprotocol/cli/build/sgxs" "github.com/oasisprotocol/cli/cmd/common" @@ -26,6 +27,7 @@ import ( // sgxBuild builds an SGX-based "raw" ROFL app. func sgxBuild( + buildEnv env.ExecEnv, npa *common.NPASelection, manifest *buildRofl.Manifest, deployment *buildRofl.Deployment, @@ -37,14 +39,14 @@ func sgxBuild( // First build for the default target. fmt.Println("Building ELF binary...") - elfPath, err := cargo.Build(true, "x86_64-unknown-linux-gnu", features) + elfPath, err := cargo.Build(buildEnv, true, "x86_64-unknown-linux-gnu", features) if err != nil { cobra.CheckErr(fmt.Errorf("failed to build ELF binary: %w", err)) } // Then build for the SGX target. fmt.Println("Building SGXS binary...") - elfSgxPath, err := cargo.Build(true, "x86_64-fortanix-unknown-sgx", nil) + elfSgxPath, err := cargo.Build(buildEnv, true, "x86_64-fortanix-unknown-sgx", nil) if err != nil { cobra.CheckErr(fmt.Errorf("failed to build SGXS binary: %w", err)) } @@ -54,7 +56,7 @@ func sgxBuild( sgxStackSize := uint64(2 * 1024 * 1024) sgxsPath := fmt.Sprintf("%s.sgxs", elfSgxPath) - err = sgxs.Elf2Sgxs(elfSgxPath, sgxsPath, sgxHeapSize, sgxStackSize, sgxThreads) + err = sgxs.Elf2Sgxs(buildEnv, elfSgxPath, sgxsPath, sgxHeapSize, sgxStackSize, sgxThreads) if err != nil { cobra.CheckErr(fmt.Errorf("failed to generate SGXS binary: %w", err)) } diff --git a/cmd/rofl/build/tdx.go b/cmd/rofl/build/tdx.go index b1b225df..911bb1e3 100644 --- a/cmd/rofl/build/tdx.go +++ b/cmd/rofl/build/tdx.go @@ -13,6 +13,7 @@ import ( "github.com/oasisprotocol/oasis-core/go/runtime/bundle/component" "github.com/oasisprotocol/cli/build/cargo" + "github.com/oasisprotocol/cli/build/env" buildRofl "github.com/oasisprotocol/cli/build/rofl" "github.com/oasisprotocol/cli/cmd/common" roflCommon "github.com/oasisprotocol/cli/cmd/rofl/common" @@ -29,6 +30,7 @@ const ( // tdxBuildRaw builds a TDX-based "raw" ROFL app. func tdxBuildRaw( + buildEnv env.ExecEnv, tmpDir string, npa *common.NPASelection, manifest *buildRofl.Manifest, @@ -43,7 +45,7 @@ func tdxBuildRaw( tdxSetupBuildEnv(deployment, npa) // Obtain package metadata. - pkgMeta, err := cargo.GetMetadata() + pkgMeta, err := cargo.GetMetadata(buildEnv) if err != nil { return fmt.Errorf("failed to obtain package metadata: %w", err) } @@ -60,19 +62,19 @@ func tdxBuildRaw( } fmt.Println("Building runtime binary...") - initPath, err := cargo.Build(true, "", nil) + initPath, err := cargo.Build(buildEnv, true, "", nil) if err != nil { return fmt.Errorf("failed to build runtime binary: %w", err) } - stage2, err := tdxPrepareStage2(tmpDir, artifacts, initPath, nil) + stage2, err := tdxPrepareStage2(buildEnv, tmpDir, artifacts, initPath, nil) if err != nil { return err } fmt.Println("Creating ORC bundle...") - return tdxBundleComponent(manifest, artifacts, bnd, stage2, nil) + return tdxBundleComponent(buildEnv, manifest, artifacts, bnd, stage2, nil) } type artifact struct { @@ -124,7 +126,13 @@ type tdxStage2 struct { } // tdxPrepareStage2 prepares the stage 2 rootfs. -func tdxPrepareStage2(tmpDir string, artifacts map[string]string, initPath string, extraFiles map[string]string) (*tdxStage2, error) { +func tdxPrepareStage2( + buildEnv env.ExecEnv, + tmpDir string, + artifacts map[string]string, + initPath string, + extraFiles map[string]string, +) (*tdxStage2, error) { // Create temporary directory and unpack stage 2 template into it. fmt.Println("Preparing stage 2 root filesystem...") rootfsDir := filepath.Join(tmpDir, "rootfs") @@ -155,7 +163,7 @@ func tdxPrepareStage2(tmpDir string, artifacts map[string]string, initPath strin // Create the root filesystem. fmt.Println("Creating squashfs filesystem...") rootfsImage := filepath.Join(tmpDir, "rootfs.squashfs") - rootfsSize, err := createSquashFs(rootfsImage, rootfsDir) + rootfsSize, err := createSquashFs(buildEnv, rootfsImage, rootfsDir) if err != nil { return nil, fmt.Errorf("failed to create rootfs image: %w", err) } @@ -163,7 +171,7 @@ func tdxPrepareStage2(tmpDir string, artifacts map[string]string, initPath strin // Create dm-verity hash tree. fmt.Println("Creating dm-verity hash tree...") hashFile := filepath.Join(tmpDir, "rootfs.hash") - rootHash, err := createVerityHashTree(rootfsImage, hashFile) + rootHash, err := createVerityHashTree(buildEnv, rootfsImage, hashFile) if err != nil { return nil, fmt.Errorf("failed to create verity hash tree: %w", err) } @@ -182,6 +190,7 @@ func tdxPrepareStage2(tmpDir string, artifacts map[string]string, initPath strin // tdxBundleComponent adds the ROFL component to the given bundle. func tdxBundleComponent( + buildEnv env.ExecEnv, manifest *buildRofl.Manifest, artifacts map[string]string, bnd *bundle.Bundle, @@ -268,7 +277,7 @@ func tdxBundleComponent( } // Use qcow2 image format to support sparse files. - if err = convertToQcow2(stage2.fn); err != nil { + if err = convertToQcow2(buildEnv, stage2.fn); err != nil { return fmt.Errorf("failed to convert to qcow2 image: %w", err) } comp.TDX.Stage2Format = "qcow2" diff --git a/cmd/rofl/common/manifest.go b/cmd/rofl/common/manifest.go index d29094cc..17a45c91 100644 --- a/cmd/rofl/common/manifest.go +++ b/cmd/rofl/common/manifest.go @@ -44,6 +44,10 @@ func MaybeLoadManifestAndSetNPA(cfg *config.Config, npa *common.NPASelection, de 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) } diff --git a/cmd/rofl/common/term.go b/cmd/rofl/common/term.go new file mode 100644 index 00000000..1518ec7a --- /dev/null +++ b/cmd/rofl/common/term.go @@ -0,0 +1,33 @@ +package common + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/roflmarket" +) + +// Machine payment terms. +const ( + TermHour = "hour" + TermMonth = "month" + TermYear = "year" +) + +// ParseMachineTerm parses the given machine payment term. +// +// Terminates the process in case of errors. +func ParseMachineTerm(term string) roflmarket.Term { + switch term { + case TermHour: + return roflmarket.TermHour + case TermMonth: + return roflmarket.TermMonth + case TermYear: + return roflmarket.TermYear + default: + cobra.CheckErr(fmt.Sprintf("invalid machine payment term: %s", term)) + return 0 + } +} diff --git a/cmd/rofl/deploy.go b/cmd/rofl/deploy.go new file mode 100644 index 00000000..5dcb2a00 --- /dev/null +++ b/cmd/rofl/deploy.go @@ -0,0 +1,309 @@ +package rofl + +import ( + "context" + "errors" + "fmt" + "maps" + "os" + + "github.com/spf13/cobra" + flag "github.com/spf13/pflag" + + "github.com/oasisprotocol/oasis-core/go/common/cbor" + "github.com/oasisprotocol/oasis-core/go/common/sgx" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/client" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/connection" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/rofl" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/roflmarket" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/types" + + buildRofl "github.com/oasisprotocol/cli/build/rofl" + "github.com/oasisprotocol/cli/build/rofl/provider" + "github.com/oasisprotocol/cli/build/rofl/scheduler" + "github.com/oasisprotocol/cli/cmd/common" + roflCommon "github.com/oasisprotocol/cli/cmd/rofl/common" + cliConfig "github.com/oasisprotocol/cli/config" +) + +var ( + deployProvider string + deployOffer string + deployMachine string + deployTerm string + deployTermCount uint64 + deployForce bool + + deployCmd = &cobra.Command{ + Use: "deploy", + Short: "Deploy ROFL to a specified machine", + Args: cobra.NoArgs, + Run: func(_ *cobra.Command, _ []string) { + cfg := cliConfig.Global() + npa := common.GetNPASelection(cfg) + txCfg := common.GetTransactionConfig() + + if txCfg.Offline { + cobra.CheckErr("offline mode currently not supported") + } + + manifest, deployment := roflCommon.LoadManifestAndSetNPA(cfg, npa, deploymentName, &roflCommon.ManifestOptions{ + NeedAppID: true, + NeedAdmin: true, + }) + + var appID rofl.AppID + if err := appID.UnmarshalText([]byte(deployment.AppID)); err != nil { + cobra.CheckErr(fmt.Sprintf("malformed app id: %s", err)) + } + + ctx := context.Background() + conn, err := connection.Connect(ctx, npa.Network) + cobra.CheckErr(err) + + manifestEnclaves := make(map[sgx.EnclaveIdentity]struct{}) + for _, eid := range deployment.Policy.Enclaves { + manifestEnclaves[eid] = struct{}{} + } + + cfgEnclaves, err := roflCommon.GetRegisteredEnclaves(ctx, deployment.AppID, npa) + cobra.CheckErr(err) + + if !maps.Equal(manifestEnclaves, cfgEnclaves) && !deployForce { + // TODO: Generate and run Update TX automatically. + cobra.CheckErr("Local enclave identities DIFFER from on-chain enclave identities! Run `oasis rofl update` first") + } + + machine, ok := deployment.Machines[deployMachine] + if !ok { + machine = new(buildRofl.Machine) + if deployment.Machines == nil { + deployment.Machines = make(map[string]*buildRofl.Machine) + } + deployment.Machines[deployMachine] = machine + } + + switch machine.Provider { + case "": + // Not yet set, require the provider to be specified. + if deployProvider == "" { + cobra.CheckErr(fmt.Sprintf("Provider not configured for deployment '%s' machine '%s'. Please specify --provider.", deploymentName, deployMachine)) + } + + machine.Provider = deployProvider + default: + // Already set, require the provider to be omitted. + if deployProvider != "" { + cobra.CheckErr(fmt.Sprintf("Provider already configured for deployment '%s' machine '%s'. Omit --provider.", deploymentName, deployMachine)) + } + } + + // Resolve provider address. + providerAddr, _, err := common.ResolveLocalAccountOrAddress(npa.Network, machine.Provider) + if err != nil { + cobra.CheckErr(fmt.Sprintf("Invalid provider address: %s", err)) + } + + fmt.Printf("Using provider: %s (%s)\n", machine.Provider, providerAddr) + + // Parse machine payment term. + term := roflCommon.ParseMachineTerm(deployTerm) + if deployTermCount < 1 { + cobra.CheckErr("Number of terms must be at least 1.") + } + + // Push ORC to OCI repository. + if deployment.OCIRepository == "" { + // TODO: Support default OCI repository. + cobra.CheckErr(fmt.Sprintf("Missing OCI repository for deployment '%s'.", deploymentName)) + } + fmt.Printf("Pushing ROFL app to OCI repository '%s'...\n", deployment.OCIRepository) + + orcFilename := roflCommon.GetOrcFilename(manifest, deploymentName) + ociDigest, manifestHash, err := buildRofl.PushBundleToOciRepository(orcFilename, deployment.OCIRepository) + switch { + case err == nil: + case errors.Is(err, os.ErrNotExist): + cobra.CheckErr(fmt.Sprintf("ROFL app bundle '%s' not found. Run `oasis rofl build` first.", orcFilename)) + default: + cobra.CheckErr(fmt.Sprintf("Failed to push ROFL app to OCI repository: %s", err)) + } + + // Define the deployment. + machineDeployment := roflmarket.Deployment{ + AppID: appID, + ManifestHash: manifestHash, + Metadata: map[string]string{ + "net.oasis.deployment.orc.ref": fmt.Sprintf("%s@%s", deployment.OCIRepository, ociDigest), + }, + } + + switch machine.ID { + case "": + // When machine is not set, we need to obtain one. + fmt.Printf("No pre-existing machine configured, creating a new one...\n") + + if machine.Offer == "" && deployOffer == "" { + // Display all offers supported by the provider. + showProviderOffers(ctx, npa, conn, *providerAddr) + cobra.CheckErr(fmt.Sprintf("Offer not configured for deployment '%s' machine '%s'. Please specify --offer.", deploymentName, deployMachine)) + } + if deployOffer != "" { + machine.Offer = deployOffer + } + + // Resolve offer. + var offers []*roflmarket.Offer + offers, err = conn.Runtime(npa.ParaTime).ROFLMarket.Offers(ctx, client.RoundLatest, *providerAddr) + if err != nil { + cobra.CheckErr(fmt.Sprintf("Failed to query provider: %s", err)) + } + var offer *roflmarket.Offer + for _, of := range offers { + if of.Metadata[provider.SchedulerMetadataOfferKey] == machine.Offer { + offer = of + break + } + } + if offer == nil { + showProviderOffers(ctx, npa, conn, *providerAddr) + cobra.CheckErr(fmt.Sprintf("Offer '%s' not found for provider '%s'.\n", machine.Offer, providerAddr)) + return + } + + fmt.Printf("Taking offer: %s [%s]\n", machine.Offer, offer.ID) + + // Prepare transaction. + tx := roflmarket.NewInstanceCreateTx(nil, &roflmarket.InstanceCreate{ + Provider: *providerAddr, + Offer: offer.ID, + Deployment: &machineDeployment, + Term: term, + TermCount: deployTermCount, + }) + + acc := common.LoadAccount(cfg, npa.AccountName) + var sigTx, meta any + sigTx, meta, err = common.SignParaTimeTransaction(ctx, npa, acc, conn, tx, nil) + cobra.CheckErr(err) + + var machineID roflmarket.InstanceID + if !common.BroadcastOrExportTransaction(ctx, npa, conn, sigTx, meta, &machineID) { + return + } + + rawMachineID, _ := machineID.MarshalText() + cobra.CheckErr(err) + machine.ID = string(rawMachineID) + + fmt.Printf("Created machine: %s\n", machine.ID) + default: + // Deploy into existing machine. + var machineID roflmarket.InstanceID + if err = machineID.UnmarshalText([]byte(machine.ID)); err != nil { + cobra.CheckErr(fmt.Errorf("malformed machine ID: %w", err)) + } + + fmt.Printf("Deploying into existing machine: %s\n", machine.ID) + + // Sanity check the deployment. + var insDsc *roflmarket.Instance + insDsc, err = conn.Runtime(npa.ParaTime).ROFLMarket.Instance(ctx, client.RoundLatest, *providerAddr, machineID) + cobra.CheckErr(err) + if insDsc.Deployment != nil && insDsc.Deployment.AppID != machineDeployment.AppID && !deployForce { + fmt.Printf("Machine already contains a deployment of ROFL app '%s'.\n", insDsc.Deployment.AppID) + fmt.Printf("You are trying to replace it with ROFL app '%s'.\n", machineDeployment.AppID) + cobra.CheckErr("Refusing to change existing ROFL app. Use --force to override.") + } + + // Prepare transaction. + tx := roflmarket.NewInstanceExecuteCmdsTx(nil, &roflmarket.InstanceExecuteCmds{ + Provider: *providerAddr, + ID: machineID, + Cmds: [][]byte{cbor.Marshal(scheduler.Command{ + Method: scheduler.MethodDeploy, + Args: cbor.Marshal(scheduler.DeployRequest{ + Deployment: machineDeployment, + WipeStorage: false, + }), + })}, + }) + + acc := common.LoadAccount(cfg, npa.AccountName) + var sigTx, meta any + sigTx, meta, err = common.SignParaTimeTransaction(ctx, npa, acc, conn, tx, nil) + cobra.CheckErr(err) + + if !common.BroadcastOrExportTransaction(ctx, npa, conn, sigTx, meta, nil) { + return + } + } + + fmt.Printf("Deployment into machine scheduled.\n") + fmt.Printf("Use `oasis rofl machine show` to see status.\n") + + if err = manifest.Save(); err != nil { + cobra.CheckErr(fmt.Errorf("failed to update manifest: %w", err)) + } + }, + } +) + +func showProviderOffers(ctx context.Context, npa *common.NPASelection, conn connection.Connection, provider types.Address) { + offers, err := conn.Runtime(npa.ParaTime).ROFLMarket.Offers(ctx, client.RoundLatest, provider) + if err != nil { + return + } + + fmt.Println() + fmt.Printf("Offers available from the selected provider:\n") + for idx, offer := range offers { + showProviderOffer(offer) + if idx != len(offers)-1 { + fmt.Println() + } + } + fmt.Println() +} + +func showProviderOffer(offer *roflmarket.Offer) { + name, ok := offer.Metadata[provider.SchedulerMetadataOfferKey] + if !ok { + name = "" + } + + var tee string + switch offer.Resources.TEE { + case roflmarket.TeeTypeSGX: + tee = "sgx" + case roflmarket.TeeTypeTDX: + tee = "tdx" + default: + tee = "" + } + + fmt.Printf("- %s [%s]\n", name, offer.ID) + fmt.Printf(" TEE: %s | Memory: %d MiB | vCPUs: %d | Storage: %.2f GiB\n", + tee, + offer.Resources.Memory, + offer.Resources.CPUCount, + float64(offer.Resources.Storage)/1024., + ) + + // TODO: Show pricing. +} + +func init() { + providerFlags := flag.NewFlagSet("", flag.ContinueOnError) + providerFlags.StringVar(&deployProvider, "provider", "", "set the provider address") + providerFlags.StringVar(&deployOffer, "offer", "", "set the provider's offer identifier") + providerFlags.StringVar(&deployMachine, "machine", buildRofl.DefaultMachineName, "machine to deploy into") + providerFlags.StringVar(&deployTerm, "term", roflCommon.TermMonth, "term to pay for in advance") + providerFlags.Uint64Var(&deployTermCount, "term-count", 1, "number of terms to pay for in advance") + providerFlags.BoolVar(&deployForce, "force", false, "force deployment") + + deployCmd.Flags().AddFlagSet(common.SelectorFlags) + deployCmd.Flags().AddFlagSet(common.RuntimeTxFlags) + deployCmd.Flags().AddFlagSet(deploymentFlags) + deployCmd.Flags().AddFlagSet(providerFlags) +} diff --git a/cmd/rofl/machine/machine.go b/cmd/rofl/machine/machine.go new file mode 100644 index 00000000..8f75fb40 --- /dev/null +++ b/cmd/rofl/machine/machine.go @@ -0,0 +1,19 @@ +package machine + +import ( + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "machine", + Short: "ROFL machine management commands", + Aliases: []string{"i"}, +} + +func init() { + Cmd.AddCommand(showCmd) + Cmd.AddCommand(restartCmd) + Cmd.AddCommand(terminateCmd) + Cmd.AddCommand(cancelCmd) + Cmd.AddCommand(topUpCmd) +} diff --git a/cmd/rofl/machine/mgmt.go b/cmd/rofl/machine/mgmt.go new file mode 100644 index 00000000..049f1d12 --- /dev/null +++ b/cmd/rofl/machine/mgmt.go @@ -0,0 +1,444 @@ +package machine + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + flag "github.com/spf13/pflag" + + "github.com/oasisprotocol/oasis-core/go/common/cbor" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/client" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/connection" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/rofl" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/roflmarket" + + buildRofl "github.com/oasisprotocol/cli/build/rofl" + "github.com/oasisprotocol/cli/build/rofl/scheduler" + "github.com/oasisprotocol/cli/cmd/common" + roflCommon "github.com/oasisprotocol/cli/cmd/rofl/common" + cliConfig "github.com/oasisprotocol/cli/config" +) + +var ( + deploymentName string + wipeStorage bool + topUpTerm string + topUpTermCount uint64 + + showCmd = &cobra.Command{ + Use: "show []", + Short: "Show information about a machine", + Args: cobra.MaximumNArgs(1), + Run: func(_ *cobra.Command, args []string) { + cfg := cliConfig.Global() + npa := common.GetNPASelection(cfg) + + _, deployment := roflCommon.LoadManifestAndSetNPA(cfg, npa, deploymentName, &roflCommon.ManifestOptions{ + NeedAppID: true, + NeedAdmin: false, + }) + + machine, machineName, machineID := resolveMachine(args, deployment) + + // Establish connection with the target network. + ctx := context.Background() + conn, err := connection.Connect(ctx, npa.Network) + cobra.CheckErr(err) + + // Resolve provider address. + providerAddr, _, err := common.ResolveLocalAccountOrAddress(npa.Network, machine.Provider) + if err != nil { + cobra.CheckErr(fmt.Sprintf("Invalid provider address: %s", err)) + } + + insDsc, err := conn.Runtime(npa.ParaTime).ROFLMarket.Instance(ctx, client.RoundLatest, *providerAddr, machineID) + cobra.CheckErr(err) + + insCmds, err := conn.Runtime(npa.ParaTime).ROFLMarket.InstanceCommands(ctx, client.RoundLatest, *providerAddr, machineID) + cobra.CheckErr(err) + + fmt.Printf("Name: %s\n", machineName) + fmt.Printf("Provider: %s\n", insDsc.Provider) + fmt.Printf("ID: %s\n", insDsc.ID) + fmt.Printf("Offer: %s\n", insDsc.Offer) + fmt.Printf("Status: %s\n", insDsc.Status) + fmt.Printf("Creator: %s\n", insDsc.Creator) + fmt.Printf("Admin: %s\n", insDsc.Admin) + switch insDsc.NodeID { + case nil: + fmt.Printf("Node ID: \n") + default: + fmt.Printf("Node ID: %s\n", insDsc.NodeID) + } + + fmt.Printf("Created at: %s\n", time.Unix(int64(insDsc.CreatedAt), 0)) + fmt.Printf("Updated at: %s\n", time.Unix(int64(insDsc.UpdatedAt), 0)) + fmt.Printf("Paid until: %s\n", time.Unix(int64(insDsc.PaidUntil), 0)) + + if len(insDsc.Metadata) > 0 { + fmt.Printf("Metadata:\n") + for key, value := range insDsc.Metadata { + fmt.Printf(" %s: %s\n", key, value) + } + } + + fmt.Printf("Resources:\n") + + fmt.Printf(" TEE: ") + switch insDsc.Resources.TEE { + case roflmarket.TeeTypeSGX: + fmt.Printf("Intel SGX\n") + case roflmarket.TeeTypeTDX: + fmt.Printf("Intel TDX\n") + default: + fmt.Printf("[unknown: %d]\n", insDsc.Resources.TEE) + } + + fmt.Printf(" Memory: %d MiB\n", insDsc.Resources.Memory) + fmt.Printf(" vCPUs: %d\n", insDsc.Resources.CPUCount) + fmt.Printf(" Storage: %d MiB\n", insDsc.Resources.Storage) + if insDsc.Resources.GPU != nil { + fmt.Printf(" GPU:\n") + if insDsc.Resources.GPU.Model != "" { + fmt.Printf(" Model: %s\n", insDsc.Resources.GPU.Model) + } else { + fmt.Printf(" Model: \n") + } + fmt.Printf(" Count: %d\n", insDsc.Resources.GPU.Count) + } + + switch insDsc.Deployment { + default: + fmt.Printf("Deployment:\n") + fmt.Printf(" App ID: %s\n", insDsc.Deployment.AppID) + + if len(insDsc.Deployment.Metadata) > 0 { + fmt.Printf(" Metadata:\n") + for key, value := range insDsc.Deployment.Metadata { + fmt.Printf(" %s: %s\n", key, value) + } + } + case nil: + fmt.Printf("Deployment: \n") + } + + // Show commands. + fmt.Printf("Commands:\n") + if len(insCmds) > 0 { + for _, qc := range insCmds { + fmt.Printf(" - ID: %s\n", qc.ID) + + var cmd scheduler.Command + err := cbor.Unmarshal(qc.Cmd, &cmd) + switch err { + case nil: + // Decodable scheduler command. + fmt.Printf(" Method: %s\n", cmd.Method) + fmt.Printf(" Args:\n") + + switch cmd.Method { + case scheduler.MethodDeploy: + showCommandArgs(npa, cmd.Args, scheduler.DeployRequest{}) + case scheduler.MethodRestart: + showCommandArgs(npa, cmd.Args, scheduler.RestartRequest{}) + case scheduler.MethodTerminate: + showCommandArgs(npa, cmd.Args, scheduler.TerminateRequest{}) + default: + showCommandArgs(npa, cmd.Args, make(map[string]interface{})) + } + default: + // Unknown command format. + fmt.Printf(" \n", qc.Cmd) + } + } + } else { + fmt.Printf(" \n") + } + }, + } + + restartCmd = &cobra.Command{ + Use: "restart []", + Short: "Restart a machine", + Args: cobra.MaximumNArgs(1), + Run: func(_ *cobra.Command, args []string) { + queueCommand( + args, + scheduler.MethodRestart, + scheduler.RestartRequest{ + WipeStorage: wipeStorage, + }, + "Machine restart scheduled.", + ) + }, + } + + terminateCmd = &cobra.Command{ + Use: "terminate []", + Short: "Terminate a machine", + Args: cobra.MaximumNArgs(1), + Run: func(_ *cobra.Command, args []string) { + queueCommand( + args, + scheduler.MethodTerminate, + scheduler.TerminateRequest{ + WipeStorage: wipeStorage, + }, + "Machine termination scheduled.", + ) + }, + } + + cancelCmd = &cobra.Command{ + Use: "cancel []", + Short: "Cancel a machine", + Args: cobra.MaximumNArgs(1), + Run: func(_ *cobra.Command, args []string) { + cfg := cliConfig.Global() + npa := common.GetNPASelection(cfg) + txCfg := common.GetTransactionConfig() + + manifest, deployment := roflCommon.LoadManifestAndSetNPA(cfg, npa, deploymentName, &roflCommon.ManifestOptions{ + NeedAppID: true, + NeedAdmin: false, + }) + + machine, machineName, machineID := resolveMachine(args, deployment) + + // Resolve provider address. + providerAddr, _, err := common.ResolveLocalAccountOrAddress(npa.Network, machine.Provider) + if err != nil { + cobra.CheckErr(fmt.Sprintf("Invalid provider address: %s", err)) + } + + // When not in offline mode, connect to the given network endpoint. + ctx := context.Background() + var conn connection.Connection + if !txCfg.Offline { + conn, err = connection.Connect(ctx, npa.Network) + cobra.CheckErr(err) + } + + fmt.Printf("Using provider: %s (%s)\n", machine.Provider, providerAddr) + fmt.Printf("Canceling machine: %s [%s]\n", machineName, machine.ID) + fmt.Printf("WARNING: Canceling a machine will permanently destroy it!\n") + + // Prepare transaction. + tx := roflmarket.NewInstanceCancelTx(nil, &roflmarket.InstanceCancel{ + Provider: *providerAddr, + ID: machineID, + }) + + acc := common.LoadAccount(cfg, npa.AccountName) + sigTx, meta, err := common.SignParaTimeTransaction(ctx, npa, acc, conn, tx, nil) + cobra.CheckErr(err) + + if !common.BroadcastOrExportTransaction(ctx, npa, conn, sigTx, meta, nil) { + return + } + + fmt.Printf("Machine cancelled.\n") + + // Update manifest to clear the machine ID as it has been cancelled. + machine.ID = "" + + if err = manifest.Save(); err != nil { + cobra.CheckErr(fmt.Errorf("failed to update manifest: %w", err)) + } + }, + } + + topUpCmd = &cobra.Command{ + Use: "top-up []", + Short: "Top-up payment for a machine", + Args: cobra.MaximumNArgs(1), + Run: func(_ *cobra.Command, args []string) { + cfg := cliConfig.Global() + npa := common.GetNPASelection(cfg) + txCfg := common.GetTransactionConfig() + + _, deployment := roflCommon.LoadManifestAndSetNPA(cfg, npa, deploymentName, &roflCommon.ManifestOptions{ + NeedAppID: true, + NeedAdmin: false, + }) + + machine, machineName, machineID := resolveMachine(args, deployment) + + // Resolve provider address. + providerAddr, _, err := common.ResolveLocalAccountOrAddress(npa.Network, machine.Provider) + if err != nil { + cobra.CheckErr(fmt.Sprintf("Invalid provider address: %s", err)) + } + + // Parse machine payment term. + term := roflCommon.ParseMachineTerm(topUpTerm) + if topUpTermCount < 1 { + cobra.CheckErr("Number of terms must be at least 1.") + } + + // When not in offline mode, connect to the given network endpoint. + ctx := context.Background() + var conn connection.Connection + if !txCfg.Offline { + conn, err = connection.Connect(ctx, npa.Network) + cobra.CheckErr(err) + } + + fmt.Printf("Using provider: %s (%s)\n", machine.Provider, providerAddr) + fmt.Printf("Top-up machine: %s [%s]\n", machineName, machine.ID) + fmt.Printf("Top-up term: %d x %s\n", topUpTermCount, topUpTerm) + + // Prepare transaction. + tx := roflmarket.NewInstanceTopUpTx(nil, &roflmarket.InstanceTopUp{ + Provider: *providerAddr, + ID: machineID, + Term: term, + TermCount: topUpTermCount, + }) + + acc := common.LoadAccount(cfg, npa.AccountName) + sigTx, meta, err := common.SignParaTimeTransaction(ctx, npa, acc, conn, tx, nil) + cobra.CheckErr(err) + + if !common.BroadcastOrExportTransaction(ctx, npa, conn, sigTx, meta, nil) { + return + } + + fmt.Printf("Machine topped up.\n") + }, + } +) + +func resolveMachine(args []string, deployment *buildRofl.Deployment) (*buildRofl.Machine, string, roflmarket.InstanceID) { + machineName := buildRofl.DefaultMachineName + if len(args) > 0 { + machineName = args[0] + } + var appID rofl.AppID + if err := appID.UnmarshalText([]byte(deployment.AppID)); err != nil { + cobra.CheckErr(fmt.Errorf("malformed ROFL app ID: %w", err)) + } + + var machine *buildRofl.Machine + for name, mach := range deployment.Machines { + if name == machineName { + machine = mach + break + } + } + if machine == nil { + fmt.Println("The following machines are configured in the app manifest:") + for name := range deployment.Machines { + fmt.Printf(" - %s\n", name) + } + cobra.CheckErr(fmt.Errorf("machine '%s' not found in manifest", machineName)) + return nil, "", roflmarket.InstanceID{} + } + if machine.ID == "" { + cobra.CheckErr(fmt.Errorf("machine '%s' not yet deployed, use `oasis rofl deploy`", machineName)) + } + + var machineID roflmarket.InstanceID + if err := machineID.UnmarshalText([]byte(machine.ID)); err != nil { + cobra.CheckErr(fmt.Errorf("malformed machine ID: %w", err)) + } + + return machine, machineName, machineID +} + +func queueCommand(cliArgs []string, method string, args any, msgAfter string) { + cfg := cliConfig.Global() + npa := common.GetNPASelection(cfg) + txCfg := common.GetTransactionConfig() + + _, deployment := roflCommon.LoadManifestAndSetNPA(cfg, npa, deploymentName, &roflCommon.ManifestOptions{ + NeedAppID: true, + NeedAdmin: false, + }) + + machine, machineName, machineID := resolveMachine(cliArgs, deployment) + + // Resolve provider address. + providerAddr, _, err := common.ResolveLocalAccountOrAddress(npa.Network, machine.Provider) + if err != nil { + cobra.CheckErr(fmt.Sprintf("Invalid provider address: %s", err)) + } + + // When not in offline mode, connect to the given network endpoint. + ctx := context.Background() + var conn connection.Connection + if !txCfg.Offline { + conn, err = connection.Connect(ctx, npa.Network) + cobra.CheckErr(err) + } + + fmt.Printf("Using provider: %s (%s)\n", machine.Provider, providerAddr) + fmt.Printf("Machine: %s [%s]\n", machineName, machine.ID) + fmt.Printf("Command: %s\n", method) + fmt.Printf("Args:\n") + fmt.Println(common.PrettyPrint(npa, " ", args)) + + // Prepare transaction. + tx := roflmarket.NewInstanceExecuteCmdsTx(nil, &roflmarket.InstanceExecuteCmds{ + Provider: *providerAddr, + ID: machineID, + Cmds: [][]byte{cbor.Marshal(scheduler.Command{ + Method: method, + Args: cbor.Marshal(args), + })}, + }) + + acc := common.LoadAccount(cfg, npa.AccountName) + sigTx, meta, err := common.SignParaTimeTransaction(ctx, npa, acc, conn, tx, nil) + cobra.CheckErr(err) + + if !common.BroadcastOrExportTransaction(ctx, npa, conn, sigTx, meta, nil) { + return + } + + fmt.Println(msgAfter) + fmt.Printf("Use `oasis rofl machine show` to see status.\n") +} + +func showCommandArgs[V any](npa *common.NPASelection, raw []byte, args V) { + if err := cbor.Unmarshal(raw, &args); err != nil { + fmt.Printf(" %X\n", raw) + } else { + fmt.Println(common.PrettyPrint(npa, " ", args)) + } +} + +func init() { + deploymentFlags := flag.NewFlagSet("", flag.ContinueOnError) + deploymentFlags.StringVar(&deploymentName, "deployment", buildRofl.DefaultDeploymentName, "deployment name") + + wipeFlags := flag.NewFlagSet("", flag.ContinueOnError) + wipeFlags.BoolVar(&wipeStorage, "wipe-storage", false, "whether to wipe machine storage") + + topUpFlags := flag.NewFlagSet("", flag.ContinueOnError) + topUpFlags.StringVar(&topUpTerm, "term", roflCommon.TermMonth, "term to pay for in advance") + topUpFlags.Uint64Var(&topUpTermCount, "term-count", 1, "number of terms to pay for in advance") + + showCmd.Flags().AddFlagSet(common.SelectorFlags) + showCmd.Flags().AddFlagSet(deploymentFlags) + + restartCmd.Flags().AddFlagSet(common.SelectorFlags) + restartCmd.Flags().AddFlagSet(common.RuntimeTxFlags) + restartCmd.Flags().AddFlagSet(deploymentFlags) + restartCmd.Flags().AddFlagSet(wipeFlags) + + terminateCmd.Flags().AddFlagSet(common.SelectorFlags) + terminateCmd.Flags().AddFlagSet(common.RuntimeTxFlags) + terminateCmd.Flags().AddFlagSet(deploymentFlags) + terminateCmd.Flags().AddFlagSet(wipeFlags) + + cancelCmd.Flags().AddFlagSet(common.SelectorFlags) + cancelCmd.Flags().AddFlagSet(common.RuntimeTxFlags) + cancelCmd.Flags().AddFlagSet(deploymentFlags) + + topUpCmd.Flags().AddFlagSet(common.SelectorFlags) + topUpCmd.Flags().AddFlagSet(common.RuntimeTxFlags) + topUpCmd.Flags().AddFlagSet(deploymentFlags) +} diff --git a/cmd/rofl/mgmt.go b/cmd/rofl/mgmt.go index c21f7041..7798babc 100644 --- a/cmd/rofl/mgmt.go +++ b/cmd/rofl/mgmt.go @@ -5,14 +5,12 @@ import ( "encoding/json" "fmt" "io" - "maps" "os" "path/filepath" "github.com/spf13/cobra" flag "github.com/spf13/pflag" - "github.com/oasisprotocol/oasis-core/go/common/sgx" "github.com/oasisprotocol/oasis-core/go/common/sgx/pcs" "github.com/oasisprotocol/oasis-core/go/common/sgx/quote" consensus "github.com/oasisprotocol/oasis-core/go/consensus/api" @@ -34,7 +32,6 @@ var ( "cn": rofl.CreatorNonce, } - policyFn string scheme string adminAddress string pubName string @@ -43,6 +40,8 @@ var ( appKind string deploymentName string + deploymentFlags *flag.FlagSet + initCmd = &cobra.Command{ Use: "init [] [--tee TEE] [--kind KIND]", Short: "Initialize a ROFL app manifest", @@ -428,70 +427,23 @@ var ( fmt.Printf(" %s\n", string(policyJSON)) fmt.Println() - fmt.Printf("=== Instances ===\n") + fmt.Printf("=== Replicas ===\n") - appInstances, err := conn.Runtime(npa.ParaTime).ROFL.AppInstances(ctx, client.RoundLatest, appID) + replicas, err := conn.Runtime(npa.ParaTime).ROFL.AppInstances(ctx, client.RoundLatest, appID) cobra.CheckErr(err) - if len(appInstances) > 0 { - for _, ai := range appInstances { + if len(replicas) > 0 { + for _, ai := range replicas { fmt.Printf("- RAK: %s\n", ai.RAK) fmt.Printf(" Node ID: %s\n", ai.NodeID) fmt.Printf(" Expiration: %d\n", ai.Expiration) } } else { - fmt.Println("No registered app instances.") + fmt.Println("No registered replicas.") } }, } - deployCmd = &cobra.Command{ - Use: "deploy", - Short: "Deploy ROFL to a specified instance", - Args: cobra.NoArgs, - Run: func(_ *cobra.Command, _ []string) { - cfg := cliConfig.Global() - npa := common.GetNPASelection(cfg) - - manifest, deployment := roflCommon.LoadManifestAndSetNPA(cfg, npa, deploymentName, &roflCommon.ManifestOptions{ - NeedAppID: true, - NeedAdmin: true, - }) - - manifestEnclaves := make(map[sgx.EnclaveIdentity]struct{}) - for _, eid := range deployment.Policy.Enclaves { - manifestEnclaves[eid] = struct{}{} - } - - ctx := context.Background() - cfgEnclaves, err := roflCommon.GetRegisteredEnclaves(ctx, deployment.AppID, npa) - cobra.CheckErr(err) - - if !maps.Equal(manifestEnclaves, cfgEnclaves) { - // TODO: Generate and run Update TX automatically. - cobra.CheckErr("Local enclave identities DIFFER from on-chain enclave identities! Run `oasis rofl update` first") - } - - orcFilename := roflCommon.GetOrcFilename(manifest, deploymentName) - cfgSnippet := " runtime:\n" + - " paths:\n" + - " - /node/rofls/" + orcFilename + "\n" - fmt.Printf( - "To deploy your ROFL app, you can decide between one of the two options:\n"+ - "\nA. RUN YOUR OWN OASIS NODE\n\n"+ - " 1. Follow https://docs.oasis.io/node/run-your-node/paratime-client-node\n"+ - " and configure your TDX Oasis node\n"+ - " 2. Copy '%s' to your node, for example:\n\n"+ - " scp %s mynode.com:/node/rofls\n\n"+ - " 3. Add the following snippet to your Oasis node config.yml:\n\n%s\n"+ - " 4. Restart your node\n"+ - "\nB. DEPLOY YOUR ROFL TO THE OASIS PROVIDER\n\n"+ - " 1. Upload '%s' to a publicly accessible file server\n"+ - " 2. Reach out to us at https://oasis.io/discord #dev-central channel and we\n"+ - " will run your ROFL app on our TDX Oasis nodes\n", orcFilename, orcFilename, cfgSnippet, orcFilename) - }, - } - upgradeCmd = &cobra.Command{ Use: "upgrade", Short: "Upgrade all artifacts to their latest default versions", @@ -692,11 +644,10 @@ func detectOrCreateComposeFile() string { } func init() { - deploymentFlags := flag.NewFlagSet("", flag.ContinueOnError) + deploymentFlags = flag.NewFlagSet("", flag.ContinueOnError) deploymentFlags.StringVar(&deploymentName, "deployment", buildRofl.DefaultDeploymentName, "deployment name") updateFlags := flag.NewFlagSet("", flag.ContinueOnError) - updateFlags.StringVar(&policyFn, "policy", "", "set the ROFL application policy") updateFlags.StringVar(&adminAddress, "admin", "", "set the administrator address") updateCmd.Flags().AddFlagSet(deploymentFlags) diff --git a/cmd/rofl/provider/mgmt.go b/cmd/rofl/provider/mgmt.go new file mode 100644 index 00000000..228b8bfe --- /dev/null +++ b/cmd/rofl/provider/mgmt.go @@ -0,0 +1,441 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "reflect" + "strings" + + "github.com/spf13/cobra" + "github.com/wI2L/jsondiff" + + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/client" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/connection" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/rofl" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/roflmarket" + + "github.com/oasisprotocol/cli/build/rofl/provider" + "github.com/oasisprotocol/cli/cmd/common" + cliConfig "github.com/oasisprotocol/cli/config" +) + +var ( + initCmd = &cobra.Command{ + Use: "init", + Short: "Initialize a ROFL provider manifest", + Args: cobra.NoArgs, + Run: func(_ *cobra.Command, _ []string) { + cfg := cliConfig.Global() + npa := common.GetNPASelection(cfg) + + // Fail in case there is an existing manifest. + if provider.ManifestExists() { + cobra.CheckErr("refusing to overwrite existing manifest") + } + + var schedulerApp rofl.AppID + rawSchedulerApp, ok := provider.DefaultSchedulerApp[npa.NetworkName][npa.ParaTimeName] + if ok { + err := schedulerApp.UnmarshalText([]byte(rawSchedulerApp)) + cobra.CheckErr(err) + } + + fmt.Printf("Scheduler app: %s\n", schedulerApp) + + // Create a default manifest. + manifest := provider.Manifest{ + Network: npa.NetworkName, + ParaTime: npa.ParaTimeName, + Provider: npa.AccountName, + SchedulerApp: schedulerApp, + PaymentAddress: npa.AccountName, + } + err := manifest.Validate() + cobra.CheckErr(err) + + // Serialize manifest and write it to file. + err = manifest.Save() + if err != nil { + cobra.CheckErr(fmt.Errorf("failed to write manifest: %w", err)) + } + + fmt.Printf("Created manifest in '%s'.\n", manifest.SourceFileName()) + fmt.Printf("Edit the manifest to add desired configuration.\n") + fmt.Printf("Then run `oasis rofl provider create` to register your ROFL provider.\n") + }, + } + + createCmd = &cobra.Command{ + Use: "create", + Short: "Create a new ROFL provider", + Args: cobra.NoArgs, + Run: func(_ *cobra.Command, _ []string) { + cfg := cliConfig.Global() + npa := common.GetNPASelection(cfg) + txCfg := common.GetTransactionConfig() + + manifest := loadManifestAndSetNPA(cfg, npa) + + // When not in offline mode, connect to the given network endpoint. + ctx := context.Background() + var conn connection.Connection + if !txCfg.Offline { + var err error + conn, err = connection.Connect(ctx, npa.Network) + cobra.CheckErr(err) + } + + // Prepare provider create transaction. + create := roflmarket.ProviderCreate{ + Nodes: manifest.Nodes, + SchedulerApp: manifest.SchedulerApp, + Metadata: manifest.GetMetadata(), + } + + // Resolve payment address. + sdkAddr, ethAddr, err := common.ResolveLocalAccountOrAddress(npa.Network, manifest.PaymentAddress) + switch { + case err != nil: + cobra.CheckErr(fmt.Errorf("invalid payment address: %w", err)) + case ethAddr != nil: + var rawAddr [20]byte + copy(rawAddr[:], ethAddr.Bytes()) + create.PaymentAddress.Eth = &rawAddr + default: + create.PaymentAddress.Native = sdkAddr + } + + // Offers. + for idx, offerCfg := range manifest.Offers { + var offer *roflmarket.Offer + offer, err = offerCfg.AsDescriptor(npa.ParaTime) + if err != nil { + cobra.CheckErr(fmt.Errorf("bad offer configuration %d: %w", idx, err)) + } + + create.Offers = append(create.Offers, *offer) + } + + tx := roflmarket.NewProviderCreateTx(nil, &create) + acc := common.LoadAccount(cfg, npa.AccountName) + sigTx, meta, err := common.SignParaTimeTransaction(ctx, npa, acc, conn, tx, nil) + cobra.CheckErr(err) + + if !common.BroadcastOrExportTransaction(ctx, npa, conn, sigTx, meta, nil) { + return + } + }, + } + + updateCmd = &cobra.Command{ + Use: "update", + Short: "Update a ROFL provider", + Args: cobra.NoArgs, + Run: func(_ *cobra.Command, _ []string) { + cfg := cliConfig.Global() + npa := common.GetNPASelection(cfg) + txCfg := common.GetTransactionConfig() + + manifest := loadManifestAndSetNPA(cfg, npa) + + // When not in offline mode, connect to the given network endpoint. + ctx := context.Background() + var conn connection.Connection + if !txCfg.Offline { + var err error + conn, err = connection.Connect(ctx, npa.Network) + cobra.CheckErr(err) + } + + acc := common.LoadAccount(cfg, npa.AccountName) + + // Prepare provider update transaction. + update := roflmarket.ProviderUpdate{ + Provider: acc.Address(), + Nodes: manifest.Nodes, + SchedulerApp: manifest.SchedulerApp, + Metadata: manifest.GetMetadata(), + } + + // Resolve payment address. + sdkAddr, ethAddr, err := common.ResolveLocalAccountOrAddress(npa.Network, manifest.PaymentAddress) + switch { + case err != nil: + cobra.CheckErr(fmt.Errorf("invalid payment address: %w", err)) + case ethAddr != nil: + var rawAddr [20]byte + copy(rawAddr[:], ethAddr.Bytes()) + update.PaymentAddress.Eth = &rawAddr + default: + update.PaymentAddress.Native = sdkAddr + } + + tx := roflmarket.NewProviderUpdateTx(nil, &update) + sigTx, meta, err := common.SignParaTimeTransaction(ctx, npa, acc, conn, tx, nil) + cobra.CheckErr(err) + + if !common.BroadcastOrExportTransaction(ctx, npa, conn, sigTx, meta, nil) { + return + } + }, + } + + // TODO: Update offers. Diff and then add/update/remove. + updateOffersCmd = &cobra.Command{ + Use: "update-offers", + Short: "Update offers of a ROFL provider", + Args: cobra.NoArgs, + Run: func(_ *cobra.Command, _ []string) { + cfg := cliConfig.Global() + npa := common.GetNPASelection(cfg) + txCfg := common.GetTransactionConfig() + + if txCfg.Offline { + cobra.CheckErr("offline mode currently not supported") + } + + manifest := loadManifestAndSetNPA(cfg, npa) + + // When not in offline mode, connect to the given network endpoint. + ctx := context.Background() + var conn connection.Connection + if !txCfg.Offline { + var err error + conn, err = connection.Connect(ctx, npa.Network) + cobra.CheckErr(err) + } + + acc := common.LoadAccount(cfg, npa.AccountName) + + // Retrieve existing offers. + existingOffers, err := conn.Runtime(npa.ParaTime).ROFLMarket.Offers(ctx, client.RoundLatest, acc.Address()) + cobra.CheckErr(err) + existingOfferMap := make(map[string]*roflmarket.Offer) + for _, offer := range existingOffers { + offerID, ok := offer.Metadata[provider.SchedulerMetadataOfferKey] + if !ok { + fmt.Printf("WARNING: On-chain offer '%s' is missing '%s' metadata, ignoring.\n", offer.ID, provider.SchedulerMetadataOfferKey) + continue + } + + existingOfferMap[offerID] = offer + } + removeOffers := maps.Clone(existingOfferMap) + + // Determine what should be added, updated and/or removed. + var ( + add []roflmarket.Offer + update []roflmarket.Offer + remove []roflmarket.OfferID + ) + for _, offer := range manifest.Offers { + var offerDsc *roflmarket.Offer + offerDsc, err = offer.AsDescriptor(npa.ParaTime) + cobra.CheckErr(err) + + existingOffer, ok := existingOfferMap[offer.ID] + switch ok { + case true: + // Offer already exists, check if it needs to be updated. + offerDsc.ID = existingOffer.ID + if !reflect.DeepEqual(existingOffer, offerDsc) { + update = append(update, *offerDsc) + } + + delete(removeOffers, offer.ID) + case false: + // Offer does not yet exist and should be added. + add = append(add, *offerDsc) + } + + } + // Remove any offers that don't exist in the manifest. + for _, offer := range removeOffers { + remove = append(remove, offer.ID) + } + + // Show a summary of changes. + fmt.Printf("Going to perform the following updates:\n") + + fmt.Printf("Add offers:\n") + if len(add) > 0 { + for _, offer := range add { + fmt.Printf(" - %s\n", offer.Metadata[provider.SchedulerMetadataOfferKey]) + } + } else { + fmt.Printf(" \n") + } + + fmt.Printf("Update offers:\n") + if len(update) > 0 { + for _, offer := range update { + offerID := offer.Metadata[provider.SchedulerMetadataOfferKey] + fmt.Printf(" - %s\n", offerID) + + oldOffer, _ := json.Marshal(existingOfferMap[offerID]) + newOffer, _ := json.Marshal(offer) + + var patch jsondiff.Patch + patch, err = jsondiff.CompareJSON(oldOffer, newOffer) + if err == nil { + for _, p := range patch { + path := strings.ReplaceAll(p.Path, "/", ".") + newValue, _ := json.Marshal(p.Value) + + switch p.Type { + case jsondiff.OperationAdd: + fmt.Printf(" {add} %s = %s", path, newValue) + case jsondiff.OperationReplace: + fmt.Printf(" {replace} %s = %s", path, newValue) + case jsondiff.OperationRemove: + fmt.Printf(" {remove} %s", path) + default: + continue + } + fmt.Println() + } + } + } + } else { + fmt.Printf(" \n") + } + + fmt.Printf("Remove offers:\n") + if len(remove) > 0 { + for _, offerID := range remove { + fmt.Printf(" - %s\n", offerID) + } + } else { + fmt.Printf(" \n") + } + + if len(add) == 0 && len(update) == 0 && len(remove) == 0 { + fmt.Printf("Nothing to update.\n") + return + } + + // Prepare provider update offers transaction. + tx := roflmarket.NewProviderUpdateOffersTx(nil, &roflmarket.ProviderUpdateOffers{ + Provider: acc.Address(), + Add: add, + Update: update, + Remove: remove, + }) + + sigTx, meta, err := common.SignParaTimeTransaction(ctx, npa, acc, conn, tx, nil) + cobra.CheckErr(err) + + if !common.BroadcastOrExportTransaction(ctx, npa, conn, sigTx, meta, nil) { + return + } + }, + } + + removeCmd = &cobra.Command{ + Use: "remove", + Short: "Remove a ROFL provider", + Args: cobra.NoArgs, + Run: func(_ *cobra.Command, _ []string) { + cfg := cliConfig.Global() + npa := common.GetNPASelection(cfg) + txCfg := common.GetTransactionConfig() + + loadManifestAndSetNPA(cfg, npa) + + // When not in offline mode, connect to the given network endpoint. + ctx := context.Background() + var conn connection.Connection + if !txCfg.Offline { + var err error + conn, err = connection.Connect(ctx, npa.Network) + cobra.CheckErr(err) + } + + // Prepare provider remove transaction. + acc := common.LoadAccount(cfg, npa.AccountName) + tx := roflmarket.NewProviderRemoveTx(nil, &roflmarket.ProviderRemove{ + Provider: acc.Address(), + }) + + sigTx, meta, err := common.SignParaTimeTransaction(ctx, npa, acc, conn, tx, nil) + cobra.CheckErr(err) + + if !common.BroadcastOrExportTransaction(ctx, npa, conn, sigTx, meta, nil) { + return + } + }, + } +) + +// loadManifestAndSetNPA loads the ROFL provider manifest and reconfigures the +// network/paratime/account selection. +// +// In case there is an error in loading the manifest, it aborts the application. +func loadManifestAndSetNPA(cfg *cliConfig.Config, npa *common.NPASelection) *provider.Manifest { + manifest, err := maybeLoadManifestAndSetNPA(cfg, npa) + cobra.CheckErr(err) + return manifest +} + +// maybeLoadManifestAndSetNPA loads the ROFL provider 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) (*provider.Manifest, error) { + manifest, err := provider.LoadManifest() + if err != nil { + return nil, err + } + + switch manifest.Network { + case "": + if npa.Network == nil { + return nil, fmt.Errorf("no network selected") + } + default: + npa.Network = cfg.Networks.All[manifest.Network] + if npa.Network == nil { + return nil, fmt.Errorf("network '%s' does not exist", manifest.Network) + } + npa.NetworkName = manifest.Network + } + switch manifest.ParaTime { + case "": + npa.MustHaveParaTime() + default: + npa.ParaTime = npa.Network.ParaTimes.All[manifest.ParaTime] + if npa.ParaTime == nil { + return nil, fmt.Errorf("paratime '%s' does not exist", manifest.ParaTime) + } + npa.ParaTimeName = manifest.ParaTime + } + switch manifest.Provider { + case "": + default: + accCfg, err := common.LoadAccountConfig(cfg, manifest.Provider) + if err != nil { + return nil, err + } + npa.Account = accCfg + npa.AccountName = manifest.Provider + } + return manifest, nil +} + +func init() { + initCmd.Flags().AddFlagSet(common.SelectorFlags) + + createCmd.Flags().AddFlagSet(common.SelectorFlags) + createCmd.Flags().AddFlagSet(common.RuntimeTxFlags) + + updateCmd.Flags().AddFlagSet(common.SelectorFlags) + updateCmd.Flags().AddFlagSet(common.RuntimeTxFlags) + + updateOffersCmd.Flags().AddFlagSet(common.SelectorFlags) + updateOffersCmd.Flags().AddFlagSet(common.RuntimeTxFlags) + + removeCmd.Flags().AddFlagSet(common.SelectorFlags) + removeCmd.Flags().AddFlagSet(common.RuntimeTxFlags) +} diff --git a/cmd/rofl/provider/provider.go b/cmd/rofl/provider/provider.go new file mode 100644 index 00000000..9c696f9a --- /dev/null +++ b/cmd/rofl/provider/provider.go @@ -0,0 +1,19 @@ +package provider + +import ( + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "provider", + Short: "ROFL provider commands", + Aliases: []string{"p"}, +} + +func init() { + Cmd.AddCommand(initCmd) + Cmd.AddCommand(createCmd) + Cmd.AddCommand(updateCmd) + Cmd.AddCommand(updateOffersCmd) + Cmd.AddCommand(removeCmd) +} diff --git a/cmd/rofl/rofl.go b/cmd/rofl/rofl.go index 8ec47189..145e9bf6 100644 --- a/cmd/rofl/rofl.go +++ b/cmd/rofl/rofl.go @@ -4,6 +4,8 @@ import ( "github.com/spf13/cobra" "github.com/oasisprotocol/cli/cmd/rofl/build" + "github.com/oasisprotocol/cli/cmd/rofl/machine" + "github.com/oasisprotocol/cli/cmd/rofl/provider" ) var Cmd = &cobra.Command{ @@ -24,4 +26,6 @@ func init() { Cmd.AddCommand(identityCmd) Cmd.AddCommand(secretCmd) Cmd.AddCommand(upgradeCmd) + Cmd.AddCommand(provider.Cmd) + Cmd.AddCommand(machine.Cmd) } diff --git a/examples/account/delegate-paratime.y.out b/examples/account/delegate-paratime.y.out index 5b4a7148..39985fed 100644 --- a/examples/account/delegate-paratime.y.out +++ b/examples/account/delegate-paratime.y.out @@ -8,8 +8,8 @@ Authorized signer(s): 1. Bx6gOixnxy15tCs09ua5DcKyX9uo2Forb32O6Hyjoc8= (ed25519) Nonce: 0 Fee: - Amount: 0.0061312 TEST - Gas limit: 61312 + Amount: 0.0073574 TEST + Gas limit: 73574 (gas price: 0.0000001 TEST per gas unit) Network: testnet diff --git a/examples/account/deposit-eth.y.out b/examples/account/deposit-eth.y.out index 621aaadb..bce88418 100644 --- a/examples/account/deposit-eth.y.out +++ b/examples/account/deposit-eth.y.out @@ -9,7 +9,7 @@ Authorized signer(s): Nonce: 0 Fee: Amount: 0.0 TEST - Gas limit: 61310 + Gas limit: 73572 (gas price: 0.0 TEST per gas unit) Network: testnet diff --git a/examples/account/deposit-named.y.out b/examples/account/deposit-named.y.out index 414eccea..f7ca6720 100644 --- a/examples/account/deposit-named.y.out +++ b/examples/account/deposit-named.y.out @@ -9,7 +9,7 @@ Authorized signer(s): Nonce: 0 Fee: Amount: 0.0 TEST - Gas limit: 61310 + Gas limit: 73572 (gas price: 0.0 TEST per gas unit) Network: testnet diff --git a/examples/account/deposit-oasis.y.out b/examples/account/deposit-oasis.y.out index 3e6bac4e..8bb18c12 100644 --- a/examples/account/deposit-oasis.y.out +++ b/examples/account/deposit-oasis.y.out @@ -9,7 +9,7 @@ Authorized signer(s): Nonce: 0 Fee: Amount: 0.0 TEST - Gas limit: 61310 + Gas limit: 73572 (gas price: 0.0 TEST per gas unit) Network: testnet diff --git a/examples/account/deposit.y.out b/examples/account/deposit.y.out index 536a9564..c3217ad9 100644 --- a/examples/account/deposit.y.out +++ b/examples/account/deposit.y.out @@ -9,7 +9,7 @@ Authorized signer(s): Nonce: 0 Fee: Amount: 0.0 TEST - Gas limit: 61285 + Gas limit: 73542 (gas price: 0.0 TEST per gas unit) Network: testnet diff --git a/examples/account/transfer-eth.y.out b/examples/account/transfer-eth.y.out index c8f92efe..30a88cbd 100644 --- a/examples/account/transfer-eth.y.out +++ b/examples/account/transfer-eth.y.out @@ -8,8 +8,8 @@ Authorized signer(s): 1. cb+NHKt7JT4fumy0wQdkiBwO3P+DUh8ylozMpsu1xH4= (ed25519) Nonce: 0 Fee: - Amount: 0.000231 TEST - Gas limit: 2310 + Amount: 0.0002772 TEST + Gas limit: 2772 (gas price: 0.0000001 TEST per gas unit) Network: testnet diff --git a/examples/account/transfer-eth2.y.out b/examples/account/transfer-eth2.y.out index 2e8cbf4d..2659f6b0 100644 --- a/examples/account/transfer-eth2.y.out +++ b/examples/account/transfer-eth2.y.out @@ -8,8 +8,8 @@ Authorized signer(s): 1. A1ik9X/7X/eGSoSYOKSIJqM7pZ5It/gHbF+wraxi33u3 (secp256k1eth) Nonce: 0 Fee: - Amount: 0.0002316 TEST - Gas limit: 2316 + Amount: 0.0002779 TEST + Gas limit: 2779 (gas price: 0.0000001 TEST per gas unit) Network: testnet diff --git a/examples/account/transfer-named.y.out b/examples/account/transfer-named.y.out index 30e05c7f..b5ddaa5e 100644 --- a/examples/account/transfer-named.y.out +++ b/examples/account/transfer-named.y.out @@ -8,8 +8,8 @@ Authorized signer(s): 1. cb+NHKt7JT4fumy0wQdkiBwO3P+DUh8ylozMpsu1xH4= (ed25519) Nonce: 0 Fee: - Amount: 0.000231 TEST - Gas limit: 2310 + Amount: 0.0002772 TEST + Gas limit: 2772 (gas price: 0.0000001 TEST per gas unit) Network: testnet diff --git a/examples/account/transfer-subtract-fee.y.out b/examples/account/transfer-subtract-fee.y.out index 1e0baf35..974331a9 100644 --- a/examples/account/transfer-subtract-fee.y.out +++ b/examples/account/transfer-subtract-fee.y.out @@ -3,13 +3,13 @@ Format: plain Method: accounts.Transfer Body: To: test:dave (oasis1qrk58a6j2qn065m6p06jgjyt032f7qucy5wqeqpt) - Amount: 0.999769 TEST + Amount: 0.9997228 TEST Authorized signer(s): 1. cb+NHKt7JT4fumy0wQdkiBwO3P+DUh8ylozMpsu1xH4= (ed25519) Nonce: 0 Fee: - Amount: 0.000231 TEST - Gas limit: 2310 + Amount: 0.0002772 TEST + Gas limit: 2772 (gas price: 0.0000001 TEST per gas unit) Network: testnet diff --git a/examples/account/undelegate-paratime.y.out b/examples/account/undelegate-paratime.y.out index 0ae528a1..542e66a2 100644 --- a/examples/account/undelegate-paratime.y.out +++ b/examples/account/undelegate-paratime.y.out @@ -8,8 +8,8 @@ Authorized signer(s): 1. Bx6gOixnxy15tCs09ua5DcKyX9uo2Forb32O6Hyjoc8= (ed25519) Nonce: 0 Fee: - Amount: 0.012131 TEST - Gas limit: 121310 + Amount: 0.0145572 TEST + Gas limit: 145572 (gas price: 0.0000001 TEST per gas unit) Network: testnet diff --git a/examples/account/withdraw-named.y.out b/examples/account/withdraw-named.y.out index 931cf1b8..6f58ee58 100644 --- a/examples/account/withdraw-named.y.out +++ b/examples/account/withdraw-named.y.out @@ -8,8 +8,8 @@ Authorized signer(s): 1. Bx6gOixnxy15tCs09ua5DcKyX9uo2Forb32O6Hyjoc8= (ed25519) Nonce: 0 Fee: - Amount: 0.0061311 TEST - Gas limit: 61311 + Amount: 0.0073573 TEST + Gas limit: 73573 (gas price: 0.0000001 TEST per gas unit) Network: testnet diff --git a/examples/account/withdraw-oasis.y.out b/examples/account/withdraw-oasis.y.out index 66d8645b..a071af06 100644 --- a/examples/account/withdraw-oasis.y.out +++ b/examples/account/withdraw-oasis.y.out @@ -8,8 +8,8 @@ Authorized signer(s): 1. Bx6gOixnxy15tCs09ua5DcKyX9uo2Forb32O6Hyjoc8= (ed25519) Nonce: 0 Fee: - Amount: 0.0061311 TEST - Gas limit: 61311 + Amount: 0.0073573 TEST + Gas limit: 73573 (gas price: 0.0000001 TEST per gas unit) Network: testnet diff --git a/examples/account/withdraw.y.out b/examples/account/withdraw.y.out index 218bb48b..4bf14082 100644 --- a/examples/account/withdraw.y.out +++ b/examples/account/withdraw.y.out @@ -8,8 +8,8 @@ Authorized signer(s): 1. Bx6gOixnxy15tCs09ua5DcKyX9uo2Forb32O6Hyjoc8= (ed25519) Nonce: 0 Fee: - Amount: 0.0061286 TEST - Gas limit: 61286 + Amount: 0.0073543 TEST + Gas limit: 73543 (gas price: 0.0000001 TEST per gas unit) Network: testnet diff --git a/examples/addressbook/02-transfer.y.out b/examples/addressbook/02-transfer.y.out index 323bb82f..cfc6f13e 100644 --- a/examples/addressbook/02-transfer.y.out +++ b/examples/addressbook/02-transfer.y.out @@ -8,8 +8,8 @@ Authorized signer(s): 1. ArEjDxsPfDvfeLlity4mjGzy8E/nI4umiC8vYQh+eh/c (secp256k1eth) Nonce: 0 Fee: - Amount: 0.0002316 ROSE - Gas limit: 2316 + Amount: 0.0002779 ROSE + Gas limit: 2779 (gas price: 0.0000001 ROSE per gas unit) Network: mainnet diff --git a/examples/paratime-show/show-parameters.out b/examples/paratime-show/show-parameters.out index fd33b43a..f99cfb55 100644 --- a/examples/paratime-show/show-parameters.out +++ b/examples/paratime-show/show-parameters.out @@ -5,3 +5,8 @@ ParaTime: sapphire Stake thresholds: App create: 100.0 TEST + +=== ROFL MARKET PARAMETERS === + Stake thresholds: + Provider create: 100.0 TEST + diff --git a/go.mod b/go.mod index 105d20c6..781c8717 100644 --- a/go.mod +++ b/go.mod @@ -24,18 +24,21 @@ require ( github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7 github.com/oasisprotocol/metadata-registry-tools v0.0.0-20220406100644-7e9a2b991920 github.com/oasisprotocol/oasis-core/go v0.2500.0 - github.com/oasisprotocol/oasis-sdk/client-sdk/go v0.12.2 + github.com/oasisprotocol/oasis-sdk/client-sdk/go v0.13.2 github.com/olekukonko/tablewriter v0.0.5 + github.com/opencontainers/image-spec v1.1.0 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.10.0 github.com/tyler-smith/go-bip39 v1.1.0 + github.com/wI2L/jsondiff v0.6.1 github.com/zondax/ledger-go v1.0.0 golang.org/x/crypto v0.36.0 golang.org/x/sys v0.31.0 golang.org/x/text v0.23.0 gopkg.in/yaml.v3 v3.0.1 + oras.land/oras-go/v2 v2.5.0 ) require ( @@ -130,7 +133,7 @@ require ( github.com/rivo/uniseg v0.2.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sergi/go-diff v1.2.0 // indirect + github.com/sergi/go-diff v1.3.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.2.1 // indirect @@ -141,6 +144,10 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/supranational/blst v0.3.13 // indirect github.com/tidwall/btree v1.6.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect diff --git a/go.sum b/go.sum index 3f37b0ba..471fbd14 100644 --- a/go.sum +++ b/go.sum @@ -429,8 +429,8 @@ github.com/oasisprotocol/metadata-registry-tools v0.0.0-20220406100644-7e9a2b991 github.com/oasisprotocol/metadata-registry-tools v0.0.0-20220406100644-7e9a2b991920/go.mod h1:MKr/giwakLyCCjSWh0W9Pbaf7rDD1K96Wr57OhNoUK0= github.com/oasisprotocol/oasis-core/go v0.2500.0 h1:VK3bWrw+Z3Ptv1YcTHOqWCS8Io888imnYgd9aohFg4g= github.com/oasisprotocol/oasis-core/go v0.2500.0/go.mod h1:VGnmXqzTnSjM5Ik+nMyposdUQip9UsT0ebzMA3JfcjE= -github.com/oasisprotocol/oasis-sdk/client-sdk/go v0.12.2 h1:1bpgDUzGb5AVJtiYNp7UBGQ7LqTPtCW8qMKfJpsYU1M= -github.com/oasisprotocol/oasis-sdk/client-sdk/go v0.12.2/go.mod h1:cSeE0PjcOJiXFQKxUAvO/9B0PmraZQAlX6WPZN/UVoI= +github.com/oasisprotocol/oasis-sdk/client-sdk/go v0.13.2 h1:ZjLU1nKU3R1Hl231DIWwwbK4Zs8vLixsDUqcdjKxA2A= +github.com/oasisprotocol/oasis-sdk/client-sdk/go v0.13.2/go.mod h1:cSeE0PjcOJiXFQKxUAvO/9B0PmraZQAlX6WPZN/UVoI= github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= @@ -445,6 +445,8 @@ github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opencontainers/runtime-spec v1.2.0 h1:z97+pHb3uELt/yiAWD691HNHQIF07bE7dzrbT927iTk= github.com/opencontainers/runtime-spec v1.2.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= @@ -530,8 +532,8 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= -github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= @@ -577,12 +579,24 @@ github.com/supranational/blst v0.3.13 h1:AYeSxdOMacwu7FBmpfloBz5pbFXDmJL33RuwnKt github.com/supranational/blst v0.3.13/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/tidwall/btree v1.6.0 h1:LDZfKfQIBHGHWSwckhXI0RPSXzlo+KYdjK7FWSqOzzg= github.com/tidwall/btree v1.6.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= +github.com/wI2L/jsondiff v0.6.1 h1:ISZb9oNWbP64LHnu4AUhsMF5W0FIj5Ok3Krip9Shqpw= +github.com/wI2L/jsondiff v0.6.1/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -955,7 +969,6 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= @@ -975,6 +988,8 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= +oras.land/oras-go/v2 v2.5.0 h1:o8Me9kLY74Vp5uw07QXPiitjsw7qNXi8Twd+19Zf02c= +oras.land/oras-go/v2 v2.5.0/go.mod h1:z4eisnLP530vwIOUOJeBIj0aGI0L1C3d53atvCBqZHg= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=