From 7dc6b7d6ddaff5f1384566bec781d2d0f329ae65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C5=BEe=20Luzar?= Date: Wed, 5 Aug 2026 15:28:54 +0200 Subject: [PATCH 1/3] build/rofl/provider: Add on-chain offer access policy Adds the `allowed_creators`, `allowed_artifacts` and `private` offer fields to the provider manifest. They are stored on-chain as the `net.oasis.scheduler.offer.*` metadata keys read by the ROFL Scheduler, so the access policy can now be changed with `rofl provider update-offers` instead of editing the node config and restarting the machine. Account names from the wallet and the address book are resolved to addresses, artifact kinds and hashes are validated up front and list entries are sorted and deduplicated so that reordering them in the manifest does not produce a spurious offer update. --- build/rofl/provider/manifest.go | 128 ++++++++++++++++++- build/rofl/provider/manifest_test.go | 183 +++++++++++++++++++++++++++ cmd/rofl/provider/mgmt.go | 17 ++- 3 files changed, 322 insertions(+), 6 deletions(-) create mode 100644 build/rofl/provider/manifest_test.go 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/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. // From 86de5c90467ee9bb0f18890dd6704b132b046fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C5=BEe=20Luzar?= Date: Wed, 5 Aug 2026 15:29:34 +0200 Subject: [PATCH 2/3] cmd/rofl: Hide private offers from offer listings Offers marked as private in their on-chain metadata are now omitted from `rofl provider list --show-offers`, `rofl provider show` and `rofl deploy --show-offers`. The new `--all` (`-a`) flag includes them again and private offers are marked as such when listed. Automatic offer selection in `rofl deploy` skips private offers, but an offer explicitly requested via `--offer` is still resolved so that whitelisted users can rent one. --- cmd/rofl/common/flags.go | 9 +++++++++ cmd/rofl/common/offer.go | 28 ++++++++++++++++++++++++++++ cmd/rofl/deploy.go | 21 ++++++++++++++++----- cmd/rofl/provider/list.go | 12 ++++++++++-- cmd/rofl/provider/show.go | 4 ++++ 5 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 cmd/rofl/common/offer.go 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/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) } From a2c32dd9723f5061a3ab6632688a7b73ced88c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C5=BEe=20Luzar?= Date: Wed, 5 Aug 2026 15:29:38 +0200 Subject: [PATCH 3/3] docs/rofl: Document offer access policy Documents the `allowed_creators`, `allowed_artifacts` and `private` offer fields, the `--all` flag of the offer listings and extends the example provider manifest with an offer reserved for the provider's own team. --- docs/rofl.md | 62 ++++++++++++++++++++++++++++++-- examples/rofl/rofl-provider.yaml | 27 +++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) 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