diff --git a/build/rofl/provider/manifest.go b/build/rofl/provider/manifest.go index 409628c1..90f071ae 100644 --- a/build/rofl/provider/manifest.go +++ b/build/rofl/provider/manifest.go @@ -1,6 +1,7 @@ package provider import ( + "crypto/sha256" "encoding/hex" "errors" "fmt" @@ -8,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "slices" "strings" ethCommon "github.com/ethereum/go-ethereum/common" @@ -185,10 +187,22 @@ type Offer struct { // provisioning of new instances for this offer. Each accepted instance will automatically // decrement capacity. Capacity uint64 `yaml:"capacity" json:"capacity"` + // AllowedCreators is the list of accounts (names or addresses) allowed to rent machines from + // this offer. When empty, anyone can rent a machine. + AllowedCreators []string `yaml:"allowed_creators,omitempty" json:"allowed_creators,omitempty"` + // AllowedArtifacts is the map of artifact kind to the list of allowed SHA256 hashes of that + // artifact. When a kind is not present, any artifact of that kind is allowed. + AllowedArtifacts map[string][]string `yaml:"allowed_artifacts,omitempty" json:"allowed_artifacts,omitempty"` + // Private hides the offer from public offer listings. Note that this is only a listing hint + // and does not restrict who may rent the offer, use AllowedCreators for that. + Private bool `yaml:"private,omitempty" json:"private,omitempty"` // Metadata is arbitrary metadata (key-value pairs) assigned by the provider. Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"` } +// ArtifactKinds are the artifact kinds recognized by the scheduler in AllowedArtifacts. +var ArtifactKinds = []string{"firmware", "kernel", "initrd", "stage2"} + // Validate validates the offer. func (o *Offer) Validate() error { if o.ID == "" { @@ -200,24 +214,81 @@ func (o *Offer) Validate() error { if err := o.Payment.Validate(); err != nil { return fmt.Errorf("invalid payment specifier: %w", err) } + for _, creator := range o.AllowedCreators { + if strings.TrimSpace(creator) == "" { + return fmt.Errorf("malformed allowed creator: empty account") + } + if strings.Contains(creator, ",") { + return fmt.Errorf("malformed allowed creator '%s': must not contain a comma", creator) + } + } + for kind, hashes := range o.AllowedArtifacts { + if !slices.Contains(ArtifactKinds, kind) { + return fmt.Errorf("invalid allowed artifact kind '%s' (supported: %s)", kind, strings.Join(ArtifactKinds, ", ")) + } + for _, hash := range hashes { + if _, err := parseArtifactHash(hash); err != nil { + return fmt.Errorf("invalid allowed %s artifact: %w", kind, err) + } + } + } return nil } +// parseArtifactHash validates and normalizes a hex-encoded SHA256 artifact hash. +func parseArtifactHash(hash string) (string, error) { + raw, err := hex.DecodeString(strings.TrimSpace(hash)) + if err != nil { + return "", fmt.Errorf("malformed hash '%s': %w", hash, err) + } + if len(raw) != sha256.Size { + return "", fmt.Errorf("malformed hash '%s': expected %d bytes, got %d", hash, sha256.Size, len(raw)) + } + return hex.EncodeToString(raw), 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" +const ( + // SchedulerMetadataOfferAllowedCreatorsKey is the metadata key holding a comma-separated list + // of accounts allowed to rent machines from the offer. When absent or empty, anyone can rent + // a machine. + SchedulerMetadataOfferAllowedCreatorsKey = SchedulerMetadataOfferKey + ".allowed_creators" + + // SchedulerMetadataOfferAllowedArtifactsPrefix is the prefix of the metadata keys holding a + // comma-separated list of allowed SHA256 artifact hashes. The artifact kind is the suffix + // following the prefix (e.g. `net.oasis.scheduler.offer.allowed_artifacts.firmware`). When a + // key for a kind is absent, any artifact of that kind is allowed. + SchedulerMetadataOfferAllowedArtifactsPrefix = SchedulerMetadataOfferKey + ".allowed_artifacts." + + // SchedulerMetadataOfferPrivateKey is the metadata key hinting that the offer should be hidden + // from public offer listings. + SchedulerMetadataOfferPrivateKey = SchedulerMetadataOfferKey + ".private" + + // SchedulerMetadataValueTrue is the metadata value that enables a boolean flag such as + // SchedulerMetadataOfferPrivateKey. + SchedulerMetadataValueTrue = "1" +) + // NoteMetadataKey is the metadata key for offer-specific one-line notification such as a discount or a warning. const NoteMetadataKey = "net.oasis.note" // DescriptionMetadataKey is the metadata key for longer offer-specific description such as intended applications. const DescriptionMetadataKey = "net.oasis.description" +// AddressResolver resolves an account name or address into the corresponding account address. +type AddressResolver func(nameOrAddress string) (types.Address, error) + // GetMetadata derives metadata from the attributes defined in the offer and combines it with the // specified metadata. -func (o *Offer) GetMetadata() map[string]string { +// +// The given resolver is used to resolve the accounts in AllowedCreators. Any metadata explicitly +// specified in Metadata takes precedence over the derived one. +func (o *Offer) GetMetadata(resolve AddressResolver) (map[string]string, error) { meta := make(map[string]string) for _, md := range []struct { name string @@ -238,16 +309,65 @@ func (o *Offer) GetMetadata() map[string]string { meta[NoteMetadataKey] = o.Note } + if len(o.AllowedCreators) > 0 { + creators := make([]string, 0, len(o.AllowedCreators)) + for _, rawCreator := range o.AllowedCreators { + addr, err := resolve(strings.TrimSpace(rawCreator)) + if err != nil { + return nil, fmt.Errorf("invalid allowed creator '%s': %w", rawCreator, err) + } + creators = append(creators, addr.String()) + } + meta[SchedulerMetadataOfferAllowedCreatorsKey] = joinMetadataList(creators) + } + + for kind, rawHashes := range o.AllowedArtifacts { + hashes := make([]string, 0, len(rawHashes)) + for _, rawHash := range rawHashes { + hash, err := parseArtifactHash(rawHash) + if err != nil { + return nil, fmt.Errorf("invalid allowed %s artifact: %w", kind, err) + } + hashes = append(hashes, hash) + } + meta[SchedulerMetadataOfferAllowedArtifactsPrefix+kind] = joinMetadataList(hashes) + } + + if o.Private { + meta[SchedulerMetadataOfferPrivateKey] = SchedulerMetadataValueTrue + } + maps.Copy(meta, o.Metadata) - return meta + return meta, nil +} + +// joinMetadataList sorts and deduplicates the given items and serializes them into a +// comma-separated metadata value. +// +// The items are sorted so that reordering them in the manifest does not result in a spurious +// on-chain offer update. +func joinMetadataList(items []string) string { + slices.Sort(items) + return strings.Join(slices.Compact(items), ",") +} + +// IsOfferPrivate returns true iff the given on-chain offer is marked as private and should thus be +// hidden from public offer listings. +func IsOfferPrivate(offer *roflmarket.Offer) bool { + return offer.Metadata[SchedulerMetadataOfferPrivateKey] == SchedulerMetadataValueTrue } // AsDescriptor returns the configuration as an on-chain descriptor. -func (o *Offer) AsDescriptor(pt *config.ParaTime) (*roflmarket.Offer, error) { +func (o *Offer) AsDescriptor(pt *config.ParaTime, resolve AddressResolver) (*roflmarket.Offer, error) { + metadata, err := o.GetMetadata(resolve) + if err != nil { + return nil, err + } + offer := roflmarket.Offer{ Resources: *o.Resources.AsDescriptor(), Capacity: o.Capacity, - Metadata: o.GetMetadata(), + Metadata: metadata, } payment, err := o.Payment.AsDescriptor(pt) diff --git a/build/rofl/provider/manifest_test.go b/build/rofl/provider/manifest_test.go new file mode 100644 index 00000000..291c3d10 --- /dev/null +++ b/build/rofl/provider/manifest_test.go @@ -0,0 +1,183 @@ +package provider + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/roflmarket" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/types" +) + +// testAddresses maps the account names understood by testResolver to their addresses. +var testAddresses = map[string]string{ + "alice": "oasis1qrec770vrek0a9a5lcrv0zvt22504k68svq7kzve", + "bob": "oasis1qrydpazemvuwtnp3efm7vmfvg3tde044qg6cxwzx", +} + +// testResolver resolves the account names in testAddresses and any valid Oasis address. +func testResolver(nameOrAddress string) (types.Address, error) { + if addr, ok := testAddresses[nameOrAddress]; ok { + nameOrAddress = addr + } + + var addr types.Address + if err := addr.UnmarshalText([]byte(nameOrAddress)); err != nil { + return types.Address{}, fmt.Errorf("unsupported address format") + } + return addr, nil +} + +const ( + testHashA = "1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a" + testHashB = "2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b" +) + +func newTestOffer() *Offer { + return &Offer{ + ID: "small", + Resources: Resources{ + TEE: "tdx", + Memory: 1024, + CPUCount: 1, + Storage: 2048, + }, + Payment: Payment{ + Native: &NativePayment{ + Terms: map[string]string{TermKeyHour: "10"}, + }, + }, + Capacity: 1, + } +} + +func TestOfferGetMetadataAccessPolicy(t *testing.T) { + require := require.New(t) + + offer := newTestOffer() + offer.AllowedCreators = []string{"bob", testAddresses["alice"]} + offer.AllowedArtifacts = map[string][]string{ + "firmware": {testHashB, testHashA}, + "kernel": {testHashA}, + } + offer.Private = true + + meta, err := offer.GetMetadata(testResolver) + require.NoError(err) + + require.Equal("small", meta[SchedulerMetadataOfferKey]) + // Entries must be sorted so that reordering them in the manifest is not seen as a change. + require.Equal( + testAddresses["alice"]+","+testAddresses["bob"], + meta[SchedulerMetadataOfferAllowedCreatorsKey], + ) + require.Equal(testHashA+","+testHashB, meta[SchedulerMetadataOfferAllowedArtifactsPrefix+"firmware"]) + require.Equal(testHashA, meta[SchedulerMetadataOfferAllowedArtifactsPrefix+"kernel"]) + require.Equal(SchedulerMetadataValueTrue, meta[SchedulerMetadataOfferPrivateKey]) +} + +func TestOfferGetMetadataAccessPolicyOmitted(t *testing.T) { + require := require.New(t) + + meta, err := newTestOffer().GetMetadata(testResolver) + require.NoError(err) + + // Absent policy must not emit any keys, otherwise everyone's offers would be updated on-chain. + require.NotContains(meta, SchedulerMetadataOfferAllowedCreatorsKey) + require.NotContains(meta, SchedulerMetadataOfferAllowedArtifactsPrefix+"firmware") + require.NotContains(meta, SchedulerMetadataOfferPrivateKey) +} + +func TestOfferGetMetadataDeduplicatesCreators(t *testing.T) { + require := require.New(t) + + offer := newTestOffer() + // The same account, once by name and once by address. + offer.AllowedCreators = []string{"alice", testAddresses["alice"]} + + meta, err := offer.GetMetadata(testResolver) + require.NoError(err) + require.Equal(testAddresses["alice"], meta[SchedulerMetadataOfferAllowedCreatorsKey]) +} + +func TestOfferGetMetadataExplicitOverride(t *testing.T) { + require := require.New(t) + + offer := newTestOffer() + offer.Private = true + offer.Metadata = map[string]string{ + SchedulerMetadataOfferPrivateKey: "0", + SchedulerMetadataOfferAllowedArtifactsPrefix + "firmware": testHashA, + } + + meta, err := offer.GetMetadata(testResolver) + require.NoError(err) + require.Equal("0", meta[SchedulerMetadataOfferPrivateKey]) + require.Equal(testHashA, meta[SchedulerMetadataOfferAllowedArtifactsPrefix+"firmware"]) +} + +func TestOfferGetMetadataUnresolvableCreator(t *testing.T) { + offer := newTestOffer() + offer.AllowedCreators = []string{"nonexistent"} + + _, err := offer.GetMetadata(testResolver) + require.ErrorContains(t, err, "invalid allowed creator 'nonexistent'") +} + +func TestOfferValidateAccessPolicy(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*Offer) + errMsg string + }{ + {"Valid", func(o *Offer) { + o.AllowedCreators = []string{"alice"} + o.AllowedArtifacts = map[string][]string{"stage2": {testHashA}} + o.Private = true + }, ""}, + {"EmptyCreator", func(o *Offer) { + o.AllowedCreators = []string{" "} + }, "empty account"}, + {"CommaInCreator", func(o *Offer) { + o.AllowedCreators = []string{"alice,bob"} + }, "must not contain a comma"}, + {"UnknownArtifactKind", func(o *Offer) { + o.AllowedArtifacts = map[string][]string{"bios": {testHashA}} + }, "invalid allowed artifact kind 'bios'"}, + {"NonHexArtifactHash", func(o *Offer) { + o.AllowedArtifacts = map[string][]string{"initrd": {"not-a-hash"}} + }, "malformed hash"}, + {"ShortArtifactHash", func(o *Offer) { + o.AllowedArtifacts = map[string][]string{"initrd": {"1a2b"}} + }, "expected 32 bytes, got 2"}, + } { + t.Run(tc.name, func(t *testing.T) { + offer := newTestOffer() + tc.mutate(offer) + + err := offer.Validate() + if tc.errMsg == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tc.errMsg) + }) + } +} + +func TestIsOfferPrivate(t *testing.T) { + require := require.New(t) + + require.False(IsOfferPrivate(&roflmarket.Offer{})) + require.False(IsOfferPrivate(&roflmarket.Offer{ + Metadata: map[string]string{SchedulerMetadataOfferPrivateKey: "0"}, + })) + // Only the exact "1" value enables the flag, matching the scheduler. + require.False(IsOfferPrivate(&roflmarket.Offer{ + Metadata: map[string]string{SchedulerMetadataOfferPrivateKey: "true"}, + })) + require.True(IsOfferPrivate(&roflmarket.Offer{ + Metadata: map[string]string{SchedulerMetadataOfferPrivateKey: SchedulerMetadataValueTrue}, + })) +} diff --git a/cmd/rofl/common/flags.go b/cmd/rofl/common/flags.go index 6c9f83de..7d348b05 100644 --- a/cmd/rofl/common/flags.go +++ b/cmd/rofl/common/flags.go @@ -20,6 +20,9 @@ var ( // ShowOffersFlag is the flag for showing all provider offers. ShowOffersFlag *flag.FlagSet + // ShowPrivateOffersFlag is the flag for including private offers in offer listings. + ShowPrivateOffersFlag *flag.FlagSet + // DeploymentName is the name of the ROFL app deployment. DeploymentName string @@ -37,6 +40,9 @@ var ( // ShowOffers controls whether to display all offers for each provider. ShowOffers bool + + // ShowPrivateOffers controls whether private offers are included in offer listings. + ShowPrivateOffers bool ) func init() { @@ -55,4 +61,7 @@ func init() { ShowOffersFlag = flag.NewFlagSet("", flag.ContinueOnError) ShowOffersFlag.BoolVar(&ShowOffers, "show-offers", false, "show all offers for each provider") + + ShowPrivateOffersFlag = flag.NewFlagSet("", flag.ContinueOnError) + ShowPrivateOffersFlag.BoolVarP(&ShowPrivateOffers, "all", "a", false, "include private offers in offer listings") } diff --git a/cmd/rofl/common/offer.go b/cmd/rofl/common/offer.go new file mode 100644 index 00000000..adc634cb --- /dev/null +++ b/cmd/rofl/common/offer.go @@ -0,0 +1,28 @@ +package common + +import ( + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/roflmarket" + + "github.com/oasisprotocol/cli/build/rofl/provider" +) + +// FilterOffers removes the offers marked as private from the given listing, unless the user +// explicitly asked for them via --all. +func FilterOffers(offers []*roflmarket.Offer) []*roflmarket.Offer { + if ShowPrivateOffers { + return offers + } + return PublicOffers(offers) +} + +// PublicOffers returns only the offers that are not marked as private. +func PublicOffers(offers []*roflmarket.Offer) []*roflmarket.Offer { + public := make([]*roflmarket.Offer, 0, len(offers)) + for _, offer := range offers { + if provider.IsOfferPrivate(offer) { + continue + } + public = append(public, offer) + } + return public +} diff --git a/cmd/rofl/deploy.go b/cmd/rofl/deploy.go index 6a2ff5a6..de6dfcc6 100644 --- a/cmd/rofl/deploy.go +++ b/cmd/rofl/deploy.go @@ -140,7 +140,7 @@ var ( fmt.Println() fmt.Printf("Offers available from the selected provider:\n") - for _, offer := range offers { + for _, offer := range roflCommon.FilterOffers(offers) { roflProvider.ShowOfferSummary(npa, offer) } fmt.Println() @@ -190,16 +190,26 @@ var ( cobra.CheckErr(err) var offer *roflmarket.Offer for _, of := range offers { - if of.Metadata[provider.SchedulerMetadataOfferKey] == machine.Offer || machine.Offer == "" { + switch machine.Offer { + case "": + // No offer requested, automatically pick the first public one. + if provider.IsOfferPrivate(of) { + continue + } machine.Offer = of.Metadata[provider.SchedulerMetadataOfferKey] - offer = of - break + default: + // An explicitly requested offer may also be a private one. + if of.Metadata[provider.SchedulerMetadataOfferKey] != machine.Offer { + continue + } } + offer = of + break } if offer == nil { fmt.Println() fmt.Printf("Offers available from the selected provider:\n") - for _, of := range offers { + for _, of := range roflCommon.FilterOffers(offers) { roflProvider.ShowOfferSummary(npa, of) } fmt.Println() @@ -451,6 +461,7 @@ func init() { deployCmd.Flags().AddFlagSet(common.RuntimeTxFlags) deployCmd.Flags().AddFlagSet(providerFlags) deployCmd.Flags().AddFlagSet(roflCommon.ShowOffersFlag) + deployCmd.Flags().AddFlagSet(roflCommon.ShowPrivateOffersFlag) deployCmd.Flags().AddFlagSet(roflCommon.DeploymentFlags) deployCmd.Flags().AddFlagSet(roflCommon.WipeFlags) deployCmd.Flags().AddFlagSet(roflCommon.TermFlags) diff --git a/cmd/rofl/provider/list.go b/cmd/rofl/provider/list.go index 191ea222..28f06206 100644 --- a/cmd/rofl/provider/list.go +++ b/cmd/rofl/provider/list.go @@ -32,6 +32,7 @@ This command queries on-chain provider data and displays provider addresses, scheduler app IDs, node counts, and offer/instance counts. Use --show-offers to expand and display all offers for each provider. +Private offers are hidden by default, use --all to include them. Use --format json for machine-readable output.`, Args: cobra.NoArgs, Run: func(_ *cobra.Command, _ []string) { @@ -85,7 +86,7 @@ func outputJSON(ctx context.Context, npa *common.NPASelection, conn connection.C if err != nil { cobra.CheckErr(fmt.Errorf("failed to query offers for provider %s: %w", provider.Address, err)) } - pwo.Offers = offers + pwo.Offers = roflCommon.FilterOffers(offers) } output = append(output, pwo) @@ -138,6 +139,7 @@ func showProviderOffersExpanded(ctx context.Context, npa *common.NPASelection, c if err != nil { cobra.CheckErr(fmt.Errorf("failed to query offers for provider %s: %w", provider.Address, err)) } + offers = roflCommon.FilterOffers(offers) prettyAddr := common.PrettyAddress(provider.Address.String()) @@ -183,7 +185,12 @@ func ShowOfferSummary(npa *common.NPASelection, offer *roflmarket.Offer) { } } - fmt.Printf(" - %s [%s]\n", name, offer.ID) + var private string + if provider.IsOfferPrivate(offer) { + private = " (private)" + } + + fmt.Printf(" - %s [%s]%s\n", name, offer.ID, private) fmt.Printf(" TEE: %s | Memory: %d MiB | vCPUs: %d | Storage: %.2f GiB%s\n", tee, offer.Resources.Memory, @@ -221,6 +228,7 @@ func ShowOfferSummary(npa *common.NPASelection, offer *roflmarket.Offer) { func init() { listCmd.Flags().AddFlagSet(roflCommon.ShowOffersFlag) + listCmd.Flags().AddFlagSet(roflCommon.ShowPrivateOffersFlag) common.AddSelectorNPFlags(listCmd) listCmd.Flags().AddFlagSet(common.FormatFlag) } diff --git a/cmd/rofl/provider/mgmt.go b/cmd/rofl/provider/mgmt.go index d383fe27..63d96280 100644 --- a/cmd/rofl/provider/mgmt.go +++ b/cmd/rofl/provider/mgmt.go @@ -15,6 +15,7 @@ import ( "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" "github.com/oasisprotocol/cli/build/rofl/provider" "github.com/oasisprotocol/cli/cmd/common" @@ -127,7 +128,7 @@ var ( // Offers. for idx, offerCfg := range manifest.Offers { var offer *roflmarket.Offer - offer, err = offerCfg.AsDescriptor(npa.ParaTime) + offer, err = offerCfg.AsDescriptor(npa.ParaTime, addressResolver(npa)) if err != nil { cobra.CheckErr(fmt.Errorf("bad offer configuration %d: %w", idx, err)) } @@ -249,7 +250,7 @@ var ( ) for _, offer := range manifest.Offers { var offerDsc *roflmarket.Offer - offerDsc, err = offer.AsDescriptor(npa.ParaTime) + offerDsc, err = offer.AsDescriptor(npa.ParaTime, addressResolver(npa)) cobra.CheckErr(err) existingOffer, ok := existingOfferMap[offer.ID] @@ -386,6 +387,18 @@ var ( } ) +// addressResolver returns a resolver for account names and addresses used in the provider +// manifest, bound to the currently selected network. +func addressResolver(npa *common.NPASelection) provider.AddressResolver { + return func(nameOrAddress string) (types.Address, error) { + addr, _, err := common.ResolveLocalAccountOrAddress(npa.Network, nameOrAddress) + if err != nil { + return types.Address{}, err + } + return *addr, nil + } +} + // loadManifestAndSetNPA loads the ROFL provider manifest and reconfigures the // network/paratime/account selection. // diff --git a/cmd/rofl/provider/show.go b/cmd/rofl/provider/show.go index bd5df6b4..6ae2c6f2 100644 --- a/cmd/rofl/provider/show.go +++ b/cmd/rofl/provider/show.go @@ -16,6 +16,7 @@ import ( "github.com/oasisprotocol/oasis-sdk/client-sdk/go/types" "github.com/oasisprotocol/cli/cmd/common" + roflCommon "github.com/oasisprotocol/cli/cmd/rofl/common" cliConfig "github.com/oasisprotocol/cli/config" ) @@ -27,6 +28,7 @@ var showCmd = &cobra.Command{ This command queries on-chain provider data and displays all provider details including address, scheduler app, nodes, payment address, and all offers. +Private offers are hidden by default, use --all to include them. Use --format json for machine-readable output including provider metadata.`, Args: cobra.ExactArgs(1), Run: func(_ *cobra.Command, args []string) { @@ -56,6 +58,7 @@ Use --format json for machine-readable output including provider metadata.`, if err != nil { cobra.CheckErr(fmt.Errorf("failed to query offers for provider: %w", err)) } + offers = roflCommon.FilterOffers(offers) // Output format handling. if common.OutputFormat() == common.FormatJSON { @@ -155,5 +158,6 @@ func outputProviderText(npa *common.NPASelection, provider *roflmarket.Provider, func init() { common.AddSelectorNPFlags(showCmd) + showCmd.Flags().AddFlagSet(roflCommon.ShowPrivateOffersFlag) showCmd.Flags().AddFlagSet(common.FormatFlag) } diff --git a/docs/rofl.md b/docs/rofl.md index ef12bc1a..ab399693 100644 --- a/docs/rofl.md +++ b/docs/rofl.md @@ -268,8 +268,9 @@ offer: - `--provider
` specifies the provider to rent the machine from. On Sapphire Testnet, the Oasis-managed provider will be selected by default. - `--offer ` specifies the offer of the machine to rent. By default - it takes the most recent offer. Run `--show-offers` to list offers and - specifications. + it takes the most recent non-private offer. Run `--show-offers` to list offers + and specifications, optionally combined with `--all` to also list + [private](#provider-offer-access-policy) offers. - `--term ` specifies the base rent period. It takes the first available provider term by default. - `--term-count ` specifies the multiplier. Default is `1`. @@ -494,6 +495,57 @@ in your provider manifest file. To update your provider policies, run [`rofl provider update`](#provider-update) instead. +#### Restrict who can rent an offer {#provider-offer-access-policy} + +Sometimes you want an offer to be used only by yourself or your team. For +example, you can have market-priced offers that can be rented by anyone and +"internal" offers that are free of charge for your team. + +The access policy is defined per-offer in your provider manifest file and is +stored **on-chain** as part of the offer metadata. This means you can change it +by running [`rofl provider update-offers`](#provider-update-offers), without +having to reconfigure and restart your ROFL node. The ROFL Scheduler picks up +the new policy in the next round. + +The following offer fields are supported: + +- `allowed_creators` is the list of accounts allowed to rent machines from this + offer. Account names in your wallet, entries of your [address book] or plain + addresses can be used. If omitted or empty, anyone can rent a machine. +- `allowed_artifacts` maps the artifact kind (`firmware`, `kernel`, `initrd` or + `stage2`) to the list of allowed SHA256 hashes of that artifact. If a kind is + omitted, any artifact of that kind is allowed. +- `private` hides the offer from public offer listings when set to `true`. This + is only a listing hint and does **not** restrict who can rent the offer, + combine it with `allowed_creators` for that. + +For example: + +```yaml title="rofl-provider.yaml" +offers: + - id: internal_small + # ...resources, payment and capacity omitted... + allowed_creators: + - oasis1qrk58a6j2qn065m6p06jgjyt032f7qucy5wqeqpt + - oasis1qqnf0s9p8z79zfutszt0hwlh7w7jjrfqnq997mlw + private: true +``` + +These fields are a convenience wrapper around the `net.oasis.scheduler.offer.*` +offer metadata keys recognized by the ROFL Scheduler. You can also set the +corresponding keys in the offer's `metadata` section directly, in which case +they take precedence over the fields above. + +:::caution + +The ROFL Scheduler ignores malformed entries and only reports them as a warning +in its logs. Run `rofl provider show
--all` after updating your offers +to confirm that the policy is stored on-chain as intended. + +::: + +[address book]: ./addressbook.md + #### List ROFL providers {#provider-list} Use `rofl provider list` to display all ROFL providers registered on the @@ -511,6 +563,9 @@ To see detailed information about all offers from each provider, use the ![code shell](../examples/rofl/provider-list-show-offers.in.static) +Offers marked as [private](#provider-offer-access-policy) are omitted from the +listing. Pass `--all` (or `-a`) to include them. + #### Show ROFL provider details {#provider-show} Use `rofl provider show
` to display detailed information about a @@ -527,6 +582,9 @@ This command provides comprehensive information including: - Stake amount - Detailed information about all offers (resources, pricing terms, capacity) +Offers marked as [private](#provider-offer-access-policy) are omitted. Pass +`--all` (or `-a`) to include them. + Use `--format json` to get the full provider metadata in machine-readable format. diff --git a/examples/rofl/rofl-provider.yaml b/examples/rofl/rofl-provider.yaml index 749e7865..80f6bca2 100644 --- a/examples/rofl/rofl-provider.yaml +++ b/examples/rofl/rofl-provider.yaml @@ -24,4 +24,29 @@ offers: native: # Possible keys: native, evm terms: hourly: 10 # Possible keys: hourly, monthly, yearly - capacity: 50 # Max number of actively rented machines \ No newline at end of file + capacity: 50 # Max number of actively rented machines + - id: internal_small # An offer reserved for your own team + resources: + tee: tdx + memory: 4096 + cpus: 2 + storage: 20000 + payment: + native: + terms: + hourly: 0 + capacity: 10 + # Only these accounts can rent machines from this offer. Account names in + # your Oasis CLI, your address book or plain addresses can be used. If + # omitted, anyone can rent a machine. + allowed_creators: + - oasis1qrk58a6j2qn065m6p06jgjyt032f7qucy5wqeqpt + - oasis1qqnf0s9p8z79zfutszt0hwlh7w7jjrfqnq997mlw + # Allowed SHA256 hashes of the machine artifacts. Possible keys: firmware, + # kernel, initrd, stage2. If a key is omitted, any artifact of that kind is + # allowed. + allowed_artifacts: + firmware: + - 4f2b3c1d0e9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c + # Hide this offer from public offer listings. + private: true \ No newline at end of file