Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 124 additions & 4 deletions build/rofl/provider/manifest.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package provider

import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"maps"
"net/url"
"os"
"path/filepath"
"slices"
"strings"

ethCommon "github.com/ethereum/go-ethereum/common"
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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
Expand All @@ -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)
Expand Down
183 changes: 183 additions & 0 deletions build/rofl/provider/manifest_test.go
Original file line number Diff line number Diff line change
@@ -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},
}))
}
Loading
Loading