From 9801a0be6e00379df520063852781f2439626b91 Mon Sep 17 00:00:00 2001 From: Joao Luna <7607329+cloud-j-luna@users.noreply.github.com> Date: Wed, 17 Aug 2022 21:15:57 +0100 Subject: [PATCH] Version 0.0.4 (#17) --- CHANGELOG.md | 20 +++ Makefile | 6 +- README.md | 6 + akash/client/bid.go | 2 - akash/client/cli/cli.go | 4 +- akash/client/cli/cmd.go | 4 + akash/client/cli/groupings.go | 2 +- akash/client/deployment.go | 6 +- akash/client/provider.go | 20 --- akash/client/provider_test.go | 29 ---- akash/client/types/bids.go | 42 +++++- .../{lease-status.go => lease_status.go} | 0 akash/extensions/comparable_extensions.go | 56 ++++++++ .../extensions/comparable_extensions_test.go | 51 +++++++ akash/extensions/string_extensions.go | 26 ++++ akash/extensions/string_extensions_test.go | 18 +++ akash/file.go | 3 +- akash/filtering/bid_filtering.go | 60 +++++++++ akash/filtering/bid_filtering_test.go | 96 ++++++++++++++ akash/provider.go | 20 +-- akash/provider_test.go | 116 ++++++++++++++++ akash/resource_deployment.go | 125 +++++++++++++++--- docs/resources/deployment.md | 13 ++ examples/main.tf | 6 +- examples/resources/deployment/resource.tf | 4 + 25 files changed, 640 insertions(+), 95 deletions(-) delete mode 100644 akash/client/provider_test.go rename akash/client/types/{lease-status.go => lease_status.go} (100%) create mode 100644 akash/extensions/comparable_extensions.go create mode 100644 akash/extensions/comparable_extensions_test.go create mode 100644 akash/extensions/string_extensions.go create mode 100644 akash/extensions/string_extensions_test.go create mode 100644 akash/filtering/bid_filtering.go create mode 100644 akash/filtering/bid_filtering_test.go create mode 100644 akash/provider_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..f0d9057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.0.4] - 2022-08-17 +### Added +- Introduce provider filters with `enforce` and `providers` filters +### Changed +- Make temporary deployment file location cross-platform +### Fixed +- `net` field in provider configuration had wrong default value +- Bug where cheapest bids were not being selected +- Issue where gas adjustment was not enough on deployment update +### Development +- More unit tests +- Implemented several string utilities including `CointainsAny` and `FindAll` functions diff --git a/Makefile b/Makefile index 2ecc761..13811e6 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ HOSTNAME=joaoluna.com NAMESPACE=cloud NAME=akash BINARY=terraform-provider-${NAME} -VERSION=0.0.3 +VERSION=0.0.4 OS_ARCH=darwin_arm64 default: install @@ -31,8 +31,8 @@ install: build mv ${BINARY} ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}/${OS_ARCH} test: - go test -i $(TEST) || exit 1 - echo $(TEST) | xargs -t -n4 go test $(TESTARGS) -timeout=30s -parallel=4 + go test -i $(TEST) || exit 1 + echo $(TEST) | xargs -t -n4 go test $(TESTARGS) -timeout=30s -parallel=4 --cover testacc: TF_ACC=1 go test $(TEST) -v $(TESTARGS) -timeout 120m diff --git a/README.md b/README.md index 1cbee58..122380c 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,12 @@ cd examples && terraform init && terraform apply --auto-approve ./bin/akash provider lease-status --home ~/.akash --dseq --provider ``` +### Get logs + +```shell +./bin/akash provider lease-logs --dseq --provider --from "$AKASH_KEY_NAME" +``` + ## Troubleshooting ### `Error: error unmarshalling: invalid character '<' looking for beginning of value` diff --git a/akash/client/bid.go b/akash/client/bid.go index ed6cb79..4aced22 100644 --- a/akash/client/bid.go +++ b/akash/client/bid.go @@ -12,7 +12,6 @@ func (ak *AkashClient) GetBids(seqs Seqs, timeout time.Duration) (types.Bids, er bids := types.Bids{} for timeout > 0 && len(bids) <= 0 { startTime := time.Now() - // Check bids on deployments and choose one currentBids, err := queryBidList(ak, seqs) if err != nil { tflog.Error(ak.ctx, "Failed to query bid list") @@ -22,7 +21,6 @@ func (ak *AkashClient) GetBids(seqs Seqs, timeout time.Duration) (types.Bids, er } tflog.Debug(ak.ctx, fmt.Sprintf("Received %d bids", len(bids))) bids = currentBids - time.Sleep(time.Second) timeout -= time.Since(startTime) } diff --git a/akash/client/cli/cli.go b/akash/client/cli/cli.go index a8f510b..30d18f6 100644 --- a/akash/client/cli/cli.go +++ b/akash/client/cli/cli.go @@ -121,8 +121,8 @@ func (c AkashCommand) SetFrom(key string) AkashCommand { func (c AkashCommand) GasAuto() AkashCommand { return c.append("--gas=auto") } -func (c AkashCommand) SetGasAdjustment() AkashCommand { - return c.append("--gas-adjustment=1.15") +func (c AkashCommand) SetGasAdjustment(adjustment float32) AkashCommand { + return c.append(fmt.Sprintf("--gas-adjustment=%2f", adjustment)) } func (c AkashCommand) SetGasPrices() AkashCommand { diff --git a/akash/client/cli/cmd.go b/akash/client/cli/cmd.go index 184155c..fb98f15 100644 --- a/akash/client/cli/cmd.go +++ b/akash/client/cli/cmd.go @@ -34,6 +34,8 @@ func (c AkashCommand) Raw() ([]byte, error) { return nil, errors.New(errb.String()) } + tflog.Trace(c.ctx, fmt.Sprintf("Output: %s", out)) + return out, nil } @@ -54,6 +56,8 @@ func (c AkashCommand) DecodeJson(v any) error { return errors.New(errb.String()) } + tflog.Trace(c.ctx, fmt.Sprintf("Output: %s", out)) + err = json.NewDecoder(strings.NewReader(string(out))).Decode(v) if err != nil { tflog.Debug(c.ctx, fmt.Sprintf("Error while unmarshalling command output")) diff --git a/akash/client/cli/groupings.go b/akash/client/cli/groupings.go index 83529aa..84ef9a4 100644 --- a/akash/client/cli/groupings.go +++ b/akash/client/cli/groupings.go @@ -1,7 +1,7 @@ package cli func (c AkashCommand) DefaultGas() AkashCommand { - return c.GasAuto().SetGasAdjustment().SetGasPrices().SetSignMode("amino-json") + return c.GasAuto().SetGasAdjustment(1.15).SetGasPrices().SetSignMode("amino-json") } func (c AkashCommand) SetSeqs(dseq string, gseq string, oseq string) AkashCommand { diff --git a/akash/client/deployment.go b/akash/client/deployment.go index abe1910..8d70b5e 100644 --- a/akash/client/deployment.go +++ b/akash/client/deployment.go @@ -89,7 +89,7 @@ func (ak *AkashClient) GetDeployment(dseq string, owner string) (map[string]inte func (ak *AkashClient) CreateDeployment(manifestLocation string) (Seqs, error) { - tflog.Debug(ak.ctx, "Creating deployment") + tflog.Info(ak.ctx, "Creating deployment") // Create deployment using the file created with the SDL attributes, err := transactionCreateDeployment(ak, manifestLocation) if err != nil { @@ -102,7 +102,7 @@ func (ak *AkashClient) CreateDeployment(manifestLocation string) (Seqs, error) { gseq, _ := attributes.Get("gseq") oseq, _ := attributes.Get("oseq") - tflog.Info(ak.ctx, "Deployment created with DSEQ="+dseq+" GSEQ="+gseq+" OSEQ="+oseq) + tflog.Info(ak.ctx, fmt.Sprintf("Deployment created with DSEQ=%s GSEQ=%s OSEQ=%s", dseq, gseq, oseq)) return Seqs{dseq, gseq, oseq}, nil } @@ -145,7 +145,7 @@ func (ak *AkashClient) UpdateDeployment(dseq string, manifestLocation string) er cmd := cli.AkashCli(ak).Tx().Deployment().Update().Manifest(manifestLocation). SetDseq(dseq).SetFrom(ak.Config.KeyName).SetNode(ak.Config.Node). SetKeyringBackend(ak.Config.KeyringBackend).SetChainId(ak.Config.ChainId). - DefaultGas().AutoAccept().OutputJson() + GasAuto().SetGasAdjustment(1.5).SetGasPrices().SetSignMode("amino-json").AutoAccept().OutputJson() out, err := cmd.Raw() if err != nil { diff --git a/akash/client/provider.go b/akash/client/provider.go index d14ad99..46e0770 100644 --- a/akash/client/provider.go +++ b/akash/client/provider.go @@ -1,7 +1,6 @@ package client import ( - "errors" "fmt" "github.com/hashicorp/terraform-plugin-log/tflog" "terraform-provider-akash/akash/client/cli" @@ -39,22 +38,3 @@ func (ak *AkashClient) GetLeaseStatus(seqs Seqs, provider string) (*types.LeaseS return &leaseStatus, nil } - -func (ak *AkashClient) FindCheapest(bids types.Bids) (string, error) { - if len(bids) == 0 { - tflog.Error(ak.ctx, "Empty bid slice") - return "", errors.New("empty bid slice") - } - - var cheapestBid *types.Bid = nil - - tflog.Info(ak.ctx, "Finding cheapest bid") - - for _, bid := range bids { - if cheapestBid == nil || cheapestBid != nil && bid.Amount < cheapestBid.Amount { - cheapestBid = &bid - } - } - - return cheapestBid.Id.Provider, nil -} diff --git a/akash/client/provider_test.go b/akash/client/provider_test.go deleted file mode 100644 index e529b53..0000000 --- a/akash/client/provider_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package client_test - -import ( - "context" - "terraform-provider-akash/akash/client" - "terraform-provider-akash/akash/client/types" - "testing" -) - -func TestFindCheapestReturnsErrorOnEmptyBidsList(t *testing.T) { - akash := client.New(context.TODO(), client.AkashConfiguration{}) - provider, err := akash.FindCheapest(types.Bids{}) - expectedError := "empty bid slice" - - if provider != "" { - t.Logf("SetProvider should be empty string, is \"%s\" instead", provider) - t.Fail() - } - - if err == nil { - t.Logf("Should have returned an error, returned nil instead") - t.Fail() - } - - if err.Error() != expectedError { - t.Logf("Error should be \"%s\", is \"%s\" instead", expectedError, err.Error()) - t.Fail() - } -} diff --git a/akash/client/types/bids.go b/akash/client/types/bids.go index a3c9a24..df83acb 100644 --- a/akash/client/types/bids.go +++ b/akash/client/types/bids.go @@ -11,10 +11,48 @@ type BidWrapper struct { type Bids []Bid type Bid struct { - Id BidId `json:"bid_id"` - Amount int32 `json:"amount"` + Id BidId `json:"bid_id"` + Price BidPrice `json:"price"` } type BidId struct { Provider string `json:"provider"` } + +type BidPrice struct { + Amount float32 `json:"amount,string"` +} + +func (b Bids) GetProviderAddresses() []string { + addresses := make([]string, 0, len(b)) + + for _, bid := range b { + addresses = append(addresses, bid.Id.Provider) + } + + return addresses +} + +func (b Bids) FindByProvider(provider string) Bid { + for _, bid := range b { + if bid.Id.Provider == provider { + return bid + } + } + + return Bid{} +} + +// FindAllByProviders finds all the Bid structures that have any of the given providers. +// It returns a slice of all the Bid structures where the providers were found. +func (b Bids) FindAllByProviders(providers []string) Bids { + bids := make(Bids, 0) + + for _, provider := range providers { + if bid := b.FindByProvider(provider); bid != (Bid{}) { + bids = append(bids, bid) + } + } + + return bids +} diff --git a/akash/client/types/lease-status.go b/akash/client/types/lease_status.go similarity index 100% rename from akash/client/types/lease-status.go rename to akash/client/types/lease_status.go diff --git a/akash/extensions/comparable_extensions.go b/akash/extensions/comparable_extensions.go new file mode 100644 index 0000000..c87fd8f --- /dev/null +++ b/akash/extensions/comparable_extensions.go @@ -0,0 +1,56 @@ +package extensions + +func Union[T comparable](a []T, b []T) []T { + ch := make(chan T, len(b)) + + for _, bElem := range b { + go func(t T) { + if Contains(a, t) { + ch <- t + } else { + var zeroValue T + ch <- zeroValue + } + }(bElem) + } + + finds := make([]T, 0) + var zeroValue T + + for range b { + s := <-ch + if s != zeroValue { + finds = append(finds, s) + } + } + + return finds +} + +func Contains[T comparable](s []T, e T) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + +func ContainsAny[T comparable](a []T, b []T) bool { + ch := make(chan bool, len(b)) + + for _, str := range b { + go func(s T) { + ch <- Contains(a, s) + }(str) + } + + for range b { + contains := <-ch + if contains { + return true + } + } + + return false +} diff --git a/akash/extensions/comparable_extensions_test.go b/akash/extensions/comparable_extensions_test.go new file mode 100644 index 0000000..4088b21 --- /dev/null +++ b/akash/extensions/comparable_extensions_test.go @@ -0,0 +1,51 @@ +package extensions + +import "testing" + +func TestContains(t *testing.T) { + testSlice := []string{"A", "B", "C", "D", "test", "F"} + + t.Run("should return true if slice contains value", func(t *testing.T) { + if !Contains(testSlice, "test") { + t.Errorf("Expected slice to contain %s", testSlice) + } + }) + + t.Run("should return false if slice contains value", func(t *testing.T) { + if Contains(testSlice, "Non-existent test") { + t.Errorf("Expected slice not to contain %s", testSlice) + } + }) +} + +func TestContainsAny(t *testing.T) { + testSlice := []string{"A", "B", "C", "D", "test", "F"} + + t.Run("should return true if slice contains any value", func(t *testing.T) { + if !ContainsAny(testSlice, []string{"yes", "test", "hello", "there"}) { + t.Errorf("Expected slice to contain %s", testSlice) + } + }) + + t.Run("should return false if slice contains any value", func(t *testing.T) { + if ContainsAny(testSlice, []string{"Non-existent test"}) { + t.Errorf("Expected slice not to contain %s", testSlice) + } + }) +} + +func TestUnion(t *testing.T) { + type Test struct{ x string } + testSlice := []Test{{"A"}, {"B"}, {"C"}, {"D"}, {"test"}, {"F"}} + + t.Run("should return a slice containing finds", func(t *testing.T) { + finds := Union(testSlice, []Test{{"yes"}, {"test"}, {"A"}, {"there"}}) + if len(finds) != 2 { + t.Errorf("Expected to find %d but found %d (%v)", 2, len(finds), finds) + } + + if !Contains(testSlice, finds[0]) || !Contains(testSlice, finds[1]) { + t.Fail() + } + }) +} diff --git a/akash/extensions/string_extensions.go b/akash/extensions/string_extensions.go new file mode 100644 index 0000000..aade733 --- /dev/null +++ b/akash/extensions/string_extensions.go @@ -0,0 +1,26 @@ +package extensions + +func FindAll(strings []string, e []string) []string { + ch := make(chan string, len(e)) + + for _, str := range e { + go func(s string) { + if Contains(strings, s) { + ch <- s + } else { + ch <- "" + } + }(str) + } + + finds := make([]string, 0) + + for range e { + s := <-ch + if s != "" { + finds = append(finds, s) + } + } + + return finds +} diff --git a/akash/extensions/string_extensions_test.go b/akash/extensions/string_extensions_test.go new file mode 100644 index 0000000..17d08eb --- /dev/null +++ b/akash/extensions/string_extensions_test.go @@ -0,0 +1,18 @@ +package extensions + +import "testing" + +func TestFindAll(t *testing.T) { + testSlice := []string{"A", "B", "C", "D", "test", "F"} + + t.Run("should return a slice containing finds", func(t *testing.T) { + finds := FindAll(testSlice, []string{"yes", "test", "A", "there"}) + if len(finds) != 2 { + t.Errorf("Expected to find %d but found %d (%v)", 2, len(finds), finds) + } + + if !Contains(testSlice, finds[0]) || !Contains(testSlice, finds[1]) { + t.Fail() + } + }) +} diff --git a/akash/file.go b/akash/file.go index 5257a3d..598c171 100644 --- a/akash/file.go +++ b/akash/file.go @@ -5,12 +5,13 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-log/tflog" "io/ioutil" + "os" "time" ) func CreateTemporaryDeploymentFile(ctx context.Context, sdl string) (string, error) { timestamp := time.Now().UnixNano() - filename := fmt.Sprintf("/var/tmp/deployment-%d.yaml", timestamp) + filename := fmt.Sprintf("%sdeployment-%d.yaml", os.TempDir(), timestamp) tflog.Debug(ctx, fmt.Sprintf("Creating temporary deployment file %s", filename)) err := ioutil.WriteFile(filename, []byte(sdl), 0666) diff --git a/akash/filtering/bid_filtering.go b/akash/filtering/bid_filtering.go new file mode 100644 index 0000000..f4db844 --- /dev/null +++ b/akash/filtering/bid_filtering.go @@ -0,0 +1,60 @@ +package filtering + +import ( + "errors" + "terraform-provider-akash/akash/client/types" +) + +type FilterPipeline struct { + source types.Bids + pipeline []FilterPipe +} + +type FilterPipe func(types.Bids) (types.Bids, error) + +func NewFilterPipeline(source types.Bids) *FilterPipeline { + return &FilterPipeline{source: source, pipeline: make([]FilterPipe, 0)} +} + +func (fp *FilterPipeline) Pipe(filter FilterPipe) *FilterPipeline { + fp.pipeline = append(fp.pipeline, filter) + return fp +} + +func (fp *FilterPipeline) Reduce(reducer func(types.Bids) (types.Bid, error)) (types.Bid, error) { + result, err := fp.Execute() + if err != nil { + return types.Bid{}, err + } + + return reducer(result) +} + +func (fp *FilterPipeline) Execute() (types.Bids, error) { + buffer := fp.source + for _, pipe := range fp.pipeline { + result, err := pipe(buffer) + if err != nil { + return nil, err + } + buffer = result + } + + return buffer, nil +} + +func Cheapest(bids types.Bids) (types.Bid, error) { + if len(bids) == 0 { + return types.Bid{}, errors.New("empty bid slice") + } + + var cheapestBid types.Bid + + for _, bid := range bids { + if cheapestBid == (types.Bid{}) || cheapestBid != (types.Bid{}) && bid.Price.Amount < cheapestBid.Price.Amount { + cheapestBid = bid + } + } + + return cheapestBid, nil +} diff --git a/akash/filtering/bid_filtering_test.go b/akash/filtering/bid_filtering_test.go new file mode 100644 index 0000000..1c1e169 --- /dev/null +++ b/akash/filtering/bid_filtering_test.go @@ -0,0 +1,96 @@ +package filtering + +import ( + "errors" + "terraform-provider-akash/akash/client/types" + "testing" +) + +func TestFilterPipeline_Execute(t *testing.T) { + pipeline := NewFilterPipeline(types.Bids{ + types.Bid{Id: types.BidId{Provider: "test1"}, Price: types.BidPrice{Amount: 42}}, + types.Bid{Id: types.BidId{Provider: "test2"}, Price: types.BidPrice{Amount: 24}}, + types.Bid{Id: types.BidId{Provider: "test3"}, Price: types.BidPrice{Amount: 37}}, + }) + + t.Run("should return the expected bids", func(t *testing.T) { + result, _ := pipeline.Pipe(func(bids types.Bids) (types.Bids, error) { + newBids := make(types.Bids, 0) + for _, bid := range bids { + if bid.Price.Amount < 40 { + newBids = append(newBids, bid) + } + } + return newBids, nil + }).Execute() + + if len(result) != 2 { + t.Errorf("Expected bids to have size %d after pipeline run, has size %d instead", 2, len(result)) + } + + if result[0] != pipeline.source[1] || result[1] != pipeline.source[2] { + t.Errorf("Wrong result from pipeline %+v", result) + } + }) +} + +func TestFilterPipeline_Cheapest(t *testing.T) { + + t.Run("should return the cheapest bid with pipes", func(t *testing.T) { + pipeline := NewFilterPipeline(types.Bids{ + types.Bid{Id: types.BidId{Provider: "test1"}, Price: types.BidPrice{Amount: 42}}, + types.Bid{Id: types.BidId{Provider: "test2"}, Price: types.BidPrice{Amount: 24}}, + types.Bid{Id: types.BidId{Provider: "test3"}, Price: types.BidPrice{Amount: 37}}, + }) + + result, _ := pipeline.Pipe(func(bids types.Bids) (types.Bids, error) { + newBids := make(types.Bids, 0) + for _, bid := range bids { + if bid.Price.Amount > 30 { + newBids = append(newBids, bid) + } + } + return newBids, nil + }).Reduce(Cheapest) + + if result != pipeline.source[2] { + t.Errorf("Expected result to be %+v, got %+v instead", pipeline.source[2], result) + } + }) + + t.Run("should return the cheapest bid without pipes", func(t *testing.T) { + pipeline := NewFilterPipeline(types.Bids{ + types.Bid{Id: types.BidId{Provider: "test1"}, Price: types.BidPrice{Amount: 42}}, + types.Bid{Id: types.BidId{Provider: "test2"}, Price: types.BidPrice{Amount: 24}}, + types.Bid{Id: types.BidId{Provider: "test3"}, Price: types.BidPrice{Amount: 37}}, + }) + + result, _ := pipeline.Reduce(Cheapest) + + t.Logf("Values in pipeline: %+v", pipeline.source) + + if result != pipeline.source[1] { + t.Errorf("Expected result to be %+v, got %+v instead", pipeline.source[1], result) + } + }) + + t.Run("should return error if pipe fails", func(t *testing.T) { + pipeline := NewFilterPipeline(types.Bids{ + types.Bid{Id: types.BidId{Provider: "test1"}, Price: types.BidPrice{Amount: 42}}, + types.Bid{Id: types.BidId{Provider: "test2"}, Price: types.BidPrice{Amount: 24}}, + types.Bid{Id: types.BidId{Provider: "test3"}, Price: types.BidPrice{Amount: 37}}, + }) + + _, err := pipeline.Pipe(func(bids types.Bids) (types.Bids, error) { + return types.Bids{}, errors.New("failed") + }).Reduce(Cheapest) + + if err == nil { + t.Errorf("Expected and error") + } + + if err.Error() != "failed" { + t.Errorf("Expected error to be \"failed\", got %s instead", err.Error()) + } + }) +} diff --git a/akash/provider.go b/akash/provider.go index 18bc13f..cb549f7 100644 --- a/akash/provider.go +++ b/akash/provider.go @@ -24,42 +24,42 @@ const Path = "path" func Provider() *schema.Provider { return &schema.Provider{ Schema: map[string]*schema.Schema{ - KeyName: &schema.Schema{ + KeyName: { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("AKASH_KEY_NAME", ""), }, - KeyringBackend: &schema.Schema{ + KeyringBackend: { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("AKASH_KEYRING_BACKEND", "os"), }, - AccountAddress: &schema.Schema{ + AccountAddress: { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("AKASH_ACCOUNT_ADDRESS", ""), }, - Net: &schema.Schema{ + Net: { Type: schema.TypeString, Optional: true, - DefaultFunc: schema.EnvDefaultFunc("AKASH_NET", "akash"), + DefaultFunc: schema.EnvDefaultFunc("AKASH_NET", "mainnet"), }, - ChainVersion: &schema.Schema{ + ChainVersion: { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("AKASH_VERSION", ""), }, - ChainId: &schema.Schema{ + ChainId: { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("AKASH_CHAIN_ID", ""), }, - Node: &schema.Schema{ + Node: { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("AKASH_NODE", ""), }, - Home: &schema.Schema{ + Home: { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("AKASH_HOME", func() string { @@ -67,7 +67,7 @@ func Provider() *schema.Provider { return homeDir + "/.akash" }()), }, - Path: &schema.Schema{ + Path: { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("AKASH_PATH", "akash"), diff --git a/akash/provider_test.go b/akash/provider_test.go new file mode 100644 index 0000000..74afae6 --- /dev/null +++ b/akash/provider_test.go @@ -0,0 +1,116 @@ +package akash + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "os" + "testing" +) + +var testAccProviders map[string]*schema.Provider +var testAccProvider *schema.Provider + +func init() { + testAccProvider = Provider() + testAccProviders = map[string]*schema.Provider{ + "hashicups": testAccProvider, + } +} + +func TestProvider(t *testing.T) { + if err := Provider().InternalValidate(); err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProviderValidate(t *testing.T) { + provider := Provider() + + diags := provider.Validate(&terraform.ResourceConfig{Config: map[string]interface{}{ + KeyName: "test", + KeyringBackend: "test", + AccountAddress: "test", + ChainId: "test", + ChainVersion: "1", + Net: "test", + Node: "test", + Home: "test", + Path: "test", + }}) + + if diags.HasError() { + t.Errorf("Did not expect error. Got: %+v", diags) + } +} + +func TestProviderConfigure(t *testing.T) { + provider := Provider() + config := terraform.ResourceConfig{Config: map[string]interface{}{ + KeyName: "test", + KeyringBackend: "test", + AccountAddress: "test", + ChainId: "test", + ChainVersion: "1", + Net: "test", + Node: "test", + Home: "test", + Path: "test", + }} + + os.Clearenv() + + t.Run("should configure with valid configuration", func(t *testing.T) { + testConfig := config + diags := provider.Configure(context.TODO(), &testConfig) + + if diags.HasError() { + t.Errorf("Did not expect error. Got: %+v", diags) + } + }) + + testCases := []struct { + requiredField string + }{ + {KeyName}, + {AccountAddress}, + {ChainVersion}, + {ChainId}, + {Node}, + } + + for _, testCase := range testCases { + t.Run(fmt.Sprintf("should fail with empty %s", testCase.requiredField), func(t *testing.T) { + testConfig := config + + testConfig.Config[testCase.requiredField] = "" + diags := provider.Configure(context.TODO(), &testConfig) + + if !diags.HasError() { + t.Errorf("Expected an error but got nothing") + } + + if len(diags) != 1 { + t.Logf("Errors: %+v", diags) + t.Errorf("Expected 1 error, got %d", len(diags)) + } + }) + + t.Run(fmt.Sprintf("should fail with unset %s", testCase.requiredField), func(t *testing.T) { + testConfig := config + + delete(testConfig.Config, testCase.requiredField) + diags := provider.Configure(context.TODO(), &testConfig) + + if !diags.HasError() { + t.Errorf("Expected an error but got nothing") + } + + if len(diags) != 1 { + t.Logf("Errors: %+v", diags) + t.Errorf("Expected 1 error, got %d", len(diags)) + } + }) + } +} diff --git a/akash/resource_deployment.go b/akash/resource_deployment.go index 0f06dfa..a9ae8d3 100644 --- a/akash/resource_deployment.go +++ b/akash/resource_deployment.go @@ -8,6 +8,8 @@ import ( "strings" "terraform-provider-akash/akash/client" "terraform-provider-akash/akash/client/types" + "terraform-provider-akash/akash/extensions" + "terraform-provider-akash/akash/filtering" "time" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -73,6 +75,25 @@ func resourceDeployment() *schema.Resource { Type: schema.TypeString, Computed: true, }, + "provider_filters": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "providers": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "enforce": { + Type: schema.TypeBool, + Optional: true, + }, + }, + }, + }, + "services": &schema.Schema{ Type: schema.TypeList, Computed: true, @@ -115,23 +136,35 @@ func resourceDeploymentCreate(ctx context.Context, d *schema.ResourceData, m int return diagnostics } - provider := selectProvider(ctx, akash, bids) + provider, err := selectProvider(ctx, d, bids) + if err != nil { + if err := akash.DeleteDeployment(seqs.Dseq, akash.Config.AccountAddress); err != nil { + return diag.FromErr(err) + } + + return diag.FromErr(err) + } if diagnostics := createLease(ctx, akash, seqs, provider); diagnostics != nil { + tflog.Warn(ctx, "Could not create lease, deleting deployment") err := akash.DeleteDeployment(seqs.Dseq, akash.Config.AccountAddress) if err != nil { return diag.FromErr(err) } return diagnostics } + if diagnostics := sendManifest(ctx, akash, seqs, provider, manifestLocation); diagnostics != nil { + tflog.Warn(ctx, "Could not send manifest, deleting deployment") err := akash.DeleteDeployment(seqs.Dseq, akash.Config.AccountAddress) if err != nil { return diag.FromErr(err) } return diagnostics } + tflog.Info(ctx, "Setting created state") if diagnostics := setCreatedState(d, akash.Config.AccountAddress, seqs, provider); diagnostics != nil { + tflog.Warn(ctx, "Could not set state to created, deleting deployment") err := akash.DeleteDeployment(seqs.Dseq, akash.Config.AccountAddress) if err != nil { return diag.FromErr(err) @@ -145,7 +178,7 @@ func resourceDeploymentCreate(ctx context.Context, d *schema.ResourceData, m int } func queryBids(ctx context.Context, akash *client.AkashClient, seqs client.Seqs) (types.Bids, diag.Diagnostics) { - tflog.Debug(ctx, "Querying available bids") + tflog.Info(ctx, "Querying available bids") bids, err := akash.GetBids(seqs, time.Minute) if err != nil { return nil, diag.FromErr(err) @@ -153,18 +186,17 @@ func queryBids(ctx context.Context, akash *client.AkashClient, seqs client.Seqs) if len(bids) == 0 { return nil, diag.FromErr(errors.New("no bids on deployment")) } - tflog.Info(ctx, fmt.Sprintf("Received %d bids in the deployment", len(bids))) + tflog.Info(ctx, fmt.Sprintf("Received %d bids", len(bids))) return bids, nil } func sendManifest(ctx context.Context, akash *client.AkashClient, seqs client.Seqs, provider string, manifestLocation string) diag.Diagnostics { - tflog.Info(ctx, "Sending the manifest") - // Send the manifest - res, err := akash.SendManifest(seqs.Dseq, provider, manifestLocation) + tflog.Info(ctx, fmt.Sprintf("Sending manifest %s to %s", manifestLocation, provider)) + _, err := akash.SendManifest(seqs.Dseq, provider, manifestLocation) if err != nil { + tflog.Error(ctx, "Error sending manifest") return diag.FromErr(err) } - tflog.Debug(ctx, fmt.Sprintf("Result: %s", res)) return nil } @@ -190,23 +222,65 @@ func setCreatedState(d *schema.ResourceData, address string, seqs client.Seqs, p return nil } -func selectProvider(ctx context.Context, akash *client.AkashClient, bids types.Bids) string { - // Select the provider - provider, err := akash.FindCheapest(bids) +// This function gets all the configured filters and applies them. In the end it selects the cheapest provider. +func selectProvider(ctx context.Context, d *schema.ResourceData, bids types.Bids) (string, error) { + filterPipeline := filtering.NewFilterPipeline(bids) + + if f, ok := d.GetOk("provider_filters"); ok { + tflog.Info(ctx, "Filters provided") + + filters := f.([]interface{}) + filter := filters[0].(map[string]interface{}) + if !ok { + return "", errors.New("at least one field is expected inside filters") + } + + bidsProviders := bids.GetProviderAddresses() + + uncastProviders, ok := filter["providers"].([]interface{}) + if !ok { + tflog.Debug(ctx, fmt.Sprintf("Could not convert: %+v\n", filter["providers"])) + return "", errors.New("could not get 'providers' filter") + } + + preferredProviders := make([]string, len(uncastProviders)) + for _, uncastProvider := range uncastProviders { + preferredProviders = append(preferredProviders, uncastProvider.(string)) + } + + if extensions.ContainsAny(bidsProviders, preferredProviders) { + tflog.Info(ctx, "Accepting preferred provider's bid") + // Add pipe to get bids of preferred providers + filterPipeline.Pipe(func(bids types.Bids) (types.Bids, error) { + return bids.FindAllByProviders(preferredProviders), nil + }) + } else { + tflog.Warn(ctx, "Preferred provider did not bid") + if enforced, ok := filter["enforce"]; ok && enforced.(bool) { + tflog.Warn(ctx, "Could not find the preferred provider, deleting deployment") + return "", errors.New("preferred providers did not bid") + } else { + tflog.Warn(ctx, "Not enforcing filters, selecting another provider") + } + } + } else { + tflog.Info(ctx, "Filters were not provided") + } + + bid, err := filterPipeline.Reduce(filtering.Cheapest) if err != nil { - diag.FromErr(err) - return "" + return "", err } - tflog.Debug(ctx, fmt.Sprintf("Selected provider %s", provider)) - return provider + tflog.Info(ctx, fmt.Sprintf("Selected %s for %fuakt", bid.Id.Provider, bid.Price.Amount)) + return bid.Id.Provider, nil } func createLease(ctx context.Context, akash *client.AkashClient, seqs client.Seqs, provider string) diag.Diagnostics { tflog.Info(ctx, "Creating lease") - // Create a lease lease, err := akash.CreateLease(seqs, provider) if err != nil { + tflog.Error(ctx, "Failed creating the lease") return diag.FromErr(err) } tflog.Debug(ctx, fmt.Sprintf("Lease return: %s", lease)) @@ -254,6 +328,16 @@ func resourceDeploymentRead(ctx context.Context, d *schema.ResourceData, m inter return diag.FromErr(err) } + services := extractServicesFromLeaseStatus(*leaseStatus) + + if err := d.Set("services", services); err != nil { + return diag.FromErr(err) + } + + return diags +} + +func extractServicesFromLeaseStatus(leaseStatus types.LeaseStatus) []interface{} { var services []interface{} for key, value := range leaseStatus.Services { @@ -263,12 +347,7 @@ func resourceDeploymentRead(ctx context.Context, d *schema.ResourceData, m inter services = append(services, service) } - - if err := d.Set("services", services); err != nil { - return diag.FromErr(err) - } - - return diags + return services } func resourceDeploymentUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { @@ -301,6 +380,10 @@ func resourceDeploymentUpdate(ctx context.Context, d *schema.ResourceData, m int } } + if d.HasChange("provider_filters") { + tflog.Warn(ctx, "Ignoring filters on resource update") + } + return resourceDeploymentRead(ctx, d, m) } diff --git a/docs/resources/deployment.md b/docs/resources/deployment.md index dc02a79..7ce42cd 100644 --- a/docs/resources/deployment.md +++ b/docs/resources/deployment.md @@ -7,13 +7,26 @@ The deployment resource allows you to create a deployment on the Akash Network. ```terraform resource "akash_deployment" "my_deployment" { sdl = file("sdl.yaml") + provider_filters { + providers = ["akash..."] + enforce = false + } } ``` ## Argument Reference +The following arguments are required: + - `sdl` - (Required) The SDL configuration of the deployment. +The following arguments are optional: + +### provider_filters + +- `providers` - (Optional) The list of provider addresses we want to deploy on. +- `enforce` - (Optional) Whether to enforce the filters or to ignore them in case no bid/provider matches the filters. + ## Attributes Reference In addition to all the arguments above, the following attributes are exported. diff --git a/examples/main.tf b/examples/main.tf index a8734cc..7f8e879 100644 --- a/examples/main.tf +++ b/examples/main.tf @@ -5,7 +5,7 @@ terraform { required_providers { akash = { - version = "0.0.3" + version = "0.0.4" source = "cloud-j-luna/akash" } } @@ -16,6 +16,10 @@ provider "akash" { resource "akash_deployment" "my_deployment" { sdl = file("./wordpress.yaml") + provider_filters { + providers = ["akash123"] + enforce = false + } } output "deployment_id" { diff --git a/examples/resources/deployment/resource.tf b/examples/resources/deployment/resource.tf index d819182..ca1656b 100644 --- a/examples/resources/deployment/resource.tf +++ b/examples/resources/deployment/resource.tf @@ -1,3 +1,7 @@ resource "akash_deployment" "my_deployment" { sdl = file("./path/to/file.yaml") + provider_filters { + providers = ["provider0address1"] + enforce = false + } } \ No newline at end of file