diff --git a/README.md b/README.md index b3def2c0..06f608a2 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Prior to running the tests locally, ensure you have the following environmental For example, here they are set using the currently active controller: ```shell -CONTROLLER=$(juju whoami | yq .Controller) +export CONTROLLER=$(juju whoami | yq .Controller) export JUJU_CONTROLLER_ADDRESSES="$(juju show-controller | yq '.['$CONTROLLER']'.details.\"api-endpoints\" | tr -d "[]' "|tr -d '"'|tr -d '\n')" export JUJU_USERNAME="$(cat ~/.local/share/juju/accounts.yaml | yq .controllers.$CONTROLLER.user|tr -d '"')" export JUJU_PASSWORD="$(cat ~/.local/share/juju/accounts.yaml | yq .controllers.$CONTROLLER.password|tr -d '"')" diff --git a/internal/juju/integrations.go b/internal/juju/integrations.go index 0e606fc8..0657fb62 100644 --- a/internal/juju/integrations.go +++ b/internal/juju/integrations.go @@ -3,16 +3,27 @@ package juju import ( + "context" "fmt" "strings" "time" + "github.com/juju/errors" "github.com/juju/juju/api" apiapplication "github.com/juju/juju/api/client/application" apiclient "github.com/juju/juju/api/client/client" "github.com/juju/juju/rpc/params" ) +const ( + // IntegrationQueryTick defines the time to wait between ticks + // when querying the API + IntegrationApiTickWait = time.Second * 5 + // IntegrationAppAvailableTimeout indicates the time to wait + // for applications to be available before integrating them + IntegrationAppAvailableTimeout = time.Second * 60 +) + type integrationsClient struct { ConnectionFactory } @@ -30,6 +41,7 @@ type Offer struct { type IntegrationInput struct { ModelUUID string + Apps []string Endpoints []string ViaCIDRs string } @@ -69,6 +81,15 @@ func (c integrationsClient) CreateIntegration(input *IntegrationInput) (*CreateI client := apiapplication.NewClient(conn) defer client.Close() + // wait for the apps to be available + ctx, cancel := context.WithTimeout(context.Background(), IntegrationAppAvailableTimeout) + defer cancel() + + err = WaitForAppsAvailable(ctx, client, input.Apps, IntegrationApiTickWait) + if err != nil { + return nil, errors.New("the applications were not available to be integrated") + } + listViaCIDRs := splitCommaDelimitedList(input.ViaCIDRs) response, err := client.AddRelation( input.Endpoints, diff --git a/internal/juju/offers.go b/internal/juju/offers.go index e79038a2..7d023129 100644 --- a/internal/juju/offers.go +++ b/internal/juju/offers.go @@ -1,10 +1,13 @@ package juju import ( + "context" + "errors" "fmt" "strings" "time" + "github.com/juju/juju/api/client/application" apiapplication "github.com/juju/juju/api/client/application" "github.com/juju/juju/api/client/applicationoffers" apiclient "github.com/juju/juju/api/client/client" @@ -13,6 +16,15 @@ import ( "github.com/juju/names/v4" ) +const ( + // OfferAppAvailableTimeout is the time to wait for an app to be available + // before creating an offer. + OfferAppAvailableTimeout = time.Second * 60 + // OfferApiTickWait is the time to wait between consecutive requests + // to the API + OfferApiTickWait = time.Second * 5 +) + type offersClient struct { ConnectionFactory } @@ -82,6 +94,24 @@ func (c offersClient) CreateOffer(input *CreateOfferInput) (*CreateOfferResponse offerName = input.ApplicationName } + // connect to the corresponding model + modelConn, err := c.GetConnection(&input.ModelUUID) + if err != nil { + return nil, append(errs, err) + } + defer modelConn.Close() + applicationClient := application.NewClient(modelConn) + defer applicationClient.Close() + + // wait for the app to be available + ctx, cancel := context.WithTimeout(context.Background(), OfferAppAvailableTimeout) + defer cancel() + + err = WaitForAppsAvailable(ctx, applicationClient, []string{input.ApplicationName}, OfferApiTickWait) + if err != nil { + return nil, append(errs, errors.New("the application was not available to be offered")) + } + result, err := client.Offer(input.ModelUUID, input.ApplicationName, []string{input.Endpoint}, "admin", offerName, "") if err != nil { return nil, append(errs, err) @@ -215,7 +245,7 @@ func parseModelFromURL(url string) (result string, success bool) { return result, true } -//This function allows the integration resource to consume the offers managed by the offer resource +// This function allows the integration resource to consume the offers managed by the offer resource func (c offersClient) ConsumeRemoteOffer(input *ConsumeRemoteOfferInput) (*ConsumeRemoteOfferResponse, error) { modelConn, err := c.GetConnection(&input.ModelUUID) if err != nil { @@ -282,7 +312,7 @@ func (c offersClient) ConsumeRemoteOffer(input *ConsumeRemoteOfferInput) (*Consu return &response, nil } -//This function allows the integration resource to destroy the offers managed by the offer resource +// This function allows the integration resource to destroy the offers managed by the offer resource func (c offersClient) RemoveRemoteOffer(input *RemoveRemoteOfferInput) []error { var errors []error conn, err := c.GetConnection(&input.ModelUUID) diff --git a/internal/juju/utils.go b/internal/juju/utils.go index 3a9dd11a..5b3f4dc7 100644 --- a/internal/juju/utils.go +++ b/internal/juju/utils.go @@ -1,14 +1,18 @@ package juju import ( + "context" "errors" "fmt" "os/exec" "strings" "sync" + "time" "encoding/json" + apiapplication "github.com/juju/juju/api/client/application" + "github.com/juju/names/v4" "github.com/rs/zerolog/log" ) @@ -109,3 +113,44 @@ func populateControllerConfig() { log.Debug().Str("localProviderConfig", fmt.Sprintf("%#v", localProviderConfig)).Msg("local provider config was set") } + +// WaitForAppAvailable blocks the execution flow and waits until all the +// application names can be queried before the context is done. The +// tickTime param indicates the frequency used to query the API. +func WaitForAppsAvailable(ctx context.Context, client *apiapplication.Client, appsName []string, tickTime time.Duration) error { + if len(appsName) == 0 { + return nil + } + // build app tags for these apps + tags := make([]names.ApplicationTag, len(appsName)) + for i, n := range appsName { + tags[i] = names.NewApplicationTag(n) + } + + tick := time.NewTicker(tickTime) + for { + select { + case <-tick.C: + returned, err := client.ApplicationsInfo(tags) + // if there is no error and we get as many app infos as + // requested apps, we can assume the apps are available + if err != nil { + return err + } + totalAvailable := 0 + for _, entry := range returned { + // there's no info available yet + if entry.Result == nil { + continue + } + totalAvailable++ + } + // All the entries were available + if totalAvailable == len(appsName) { + return nil + } + case <-ctx.Done(): + return errors.New("the context was done waiting for apps") + } + } +} diff --git a/internal/provider/resource_application_test.go b/internal/provider/resource_application_test.go index 84ba2d01..0dbf3d5e 100644 --- a/internal/provider/resource_application_test.go +++ b/internal/provider/resource_application_test.go @@ -202,4 +202,4 @@ resource "juju_application" "subordinate" { } } `, modelName, constraints) -} \ No newline at end of file +} diff --git a/internal/provider/resource_integration.go b/internal/provider/resource_integration.go index 686f4044..b51ed19e 100644 --- a/internal/provider/resource_integration.go +++ b/internal/provider/resource_integration.go @@ -80,7 +80,7 @@ func resourceIntegrationCreate(ctx context.Context, d *schema.ResourceData, meta } apps := d.Get("application").(*schema.Set).List() - endpoints, offerURL, err := parseEndpoints(apps) + endpoints, offerURL, appNames, err := parseEndpoints(apps) if err != nil { return diag.FromErr(err) } @@ -101,10 +101,10 @@ func resourceIntegrationCreate(ctx context.Context, d *schema.ResourceData, meta if offerResponse.SAASName != "" { endpoints = append(endpoints, offerResponse.SAASName) } - viaCIDRs := d.Get("via").(string) response, err := client.Integrations.CreateIntegration(&juju.IntegrationInput{ ModelUUID: modelUUID, + Apps: appNames, Endpoints: endpoints, ViaCIDRs: viaCIDRs, }) @@ -178,11 +178,11 @@ func resourceIntegrationUpdate(ctx context.Context, d *schema.ResourceData, meta if d.HasChange("application") { old, new = d.GetChange("application") - oldEndpoints, oldOfferURL, err = parseEndpoints(old.(*schema.Set).List()) + oldEndpoints, oldOfferURL, _, err = parseEndpoints(old.(*schema.Set).List()) if err != nil { return diag.FromErr(err) } - endpoints, offerURL, err = parseEndpoints(new.(*schema.Set).List()) + endpoints, offerURL, _, err = parseEndpoints(new.(*schema.Set).List()) if err != nil { return diag.FromErr(err) } @@ -253,7 +253,7 @@ func resourceIntegrationDelete(ctx context.Context, d *schema.ResourceData, meta } apps := d.Get("application").(*schema.Set).List() - endpoints, offer, err := parseEndpoints(apps) + endpoints, offer, _, err := parseEndpoints(apps) if err != nil { return diag.FromErr(err) } @@ -308,10 +308,10 @@ func generateID(modelName string, apps []juju.Application) string { // This function can be used to parse the terraform data into usable juju endpoints // it also does some sanity checks on inputs and returns user friendly errors -func parseEndpoints(apps []interface{}) (endpoints []string, offer *string, err error) { +func parseEndpoints(apps []interface{}) (endpoints []string, offer *string, appNames []string, err error) { for _, app := range apps { if app == nil { - return nil, nil, fmt.Errorf("you must provide a non-empty name for each application in an integration") + return nil, nil, nil, fmt.Errorf("you must provide a non-empty name for each application in an integration") } a := app.(map[string]interface{}) name := a["name"].(string) @@ -319,15 +319,15 @@ func parseEndpoints(apps []interface{}) (endpoints []string, offer *string, err endpoint := a["endpoint"].(string) if name == "" && offerURL == "" { - return nil, nil, fmt.Errorf("you must provide one of \"name\" or \"offer_url\"") + return nil, nil, nil, fmt.Errorf("you must provide one of \"name\" or \"offer_url\"") } if name != "" && offerURL != "" { - return nil, nil, fmt.Errorf("you must only provider one of \"name\" or \"offer_url\" and not both") + return nil, nil, nil, fmt.Errorf("you must only provider one of \"name\" or \"offer_url\" and not both") } if offerURL != "" && endpoint != "" { - return nil, nil, fmt.Errorf("\"offer_url\" cannot be provided with \"endpoint\"") + return nil, nil, nil, fmt.Errorf("\"offer_url\" cannot be provided with \"endpoint\"") } //Here we check if the endpoint is empty and pass just the application name, this allows juju to attempt to infer endpoints @@ -342,9 +342,13 @@ func parseEndpoints(apps []interface{}) (endpoints []string, offer *string, err } else { endpoints = append(endpoints, fmt.Sprintf("%v:%v", name, endpoint)) } + // If there is no appname and this is not an offer, we have an app name + if name != "" && offerURL == "" { + appNames = append(appNames, name) + } } - return endpoints, offer, nil + return endpoints, offer, appNames, nil } func parseApplications(apps []juju.Application) []map[string]interface{} { diff --git a/internal/provider/resource_integration_test.go b/internal/provider/resource_integration_test.go index 7091120a..8475a370 100644 --- a/internal/provider/resource_integration_test.go +++ b/internal/provider/resource_integration_test.go @@ -21,9 +21,9 @@ func TestAcc_ResourceIntegration(t *testing.T) { Config: testAccResourceIntegration(modelName, "two"), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_integration.this", "model", modelName), - resource.TestCheckResourceAttr("juju_integration.this", "id", fmt.Sprintf("%v:%v:%v", modelName, "two:db", "one:db")), + resource.TestCheckResourceAttr("juju_integration.this", "id", fmt.Sprintf("%v:%v:%v", modelName, "two:db-admin", "one:backend-db-admin")), resource.TestCheckResourceAttr("juju_integration.this", "application.#", "2"), - resource.TestCheckTypeSetElemNestedAttrs("juju_integration.this", "application.*", map[string]string{"name": "one", "endpoint": "db"}), + resource.TestCheckTypeSetElemNestedAttrs("juju_integration.this", "application.*", map[string]string{"name": "one", "endpoint": "backend-db-admin"}), ), }, { @@ -32,12 +32,11 @@ func TestAcc_ResourceIntegration(t *testing.T) { ResourceName: "juju_integration.this", }, { - Config: testAccResourceIntegration(modelName, "three"), + Config: testAccResourceIntegration(modelName, "two"), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_integration.this", "model", modelName), - resource.TestCheckResourceAttr("juju_integration.this", "id", fmt.Sprintf("%v:%v:%v", modelName, "three:db", "one:db")), + resource.TestCheckResourceAttr("juju_integration.this", "id", fmt.Sprintf("%v:%v:%v", modelName, "two:db-admin", "one:backend-db-admin")), resource.TestCheckResourceAttr("juju_integration.this", "application.#", "2"), - resource.TestCheckTypeSetElemNestedAttrs("juju_integration.this", "application.*", map[string]string{"name": "three", "endpoint": "db"}), ), }, }, @@ -59,7 +58,7 @@ resource "juju_application" "one" { name = "one" charm { - name = "hello-juju" + name = "pgbouncer" series = "focal" } } @@ -74,26 +73,17 @@ resource "juju_application" "two" { } } -resource "juju_application" "three" { - model = juju_model.this.name - name = "three" - - charm { - name = "postgresql" - series = "focal" - } -} - resource "juju_integration" "this" { model = juju_model.this.name application { - name = juju_application.one.name + name = juju_application.%s.name + endpoint = "db-admin" } application { - name = juju_application.%s.name - endpoint = "db" + name = juju_application.one.name + endpoint = "backend-db-admin" } } `, modelName, integrationName) @@ -102,6 +92,8 @@ resource "juju_integration" "this" { func TestAcc_ResourceIntegrationWithViaCIDRs(t *testing.T) { srcModelName := acctest.RandomWithPrefix("tf-test-integration") dstModelName := acctest.RandomWithPrefix("tf-test-integration-dst") + // srcModelName := "modela" + // dstModelName := "modelb" via := "127.0.0.1/32,127.0.0.3/32" resource.Test(t, resource.TestCase{ @@ -112,63 +104,67 @@ func TestAcc_ResourceIntegrationWithViaCIDRs(t *testing.T) { { Config: testAccResourceIntegrationWithVia(srcModelName, dstModelName, via), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_integration.this", "model", srcModelName), - resource.TestCheckResourceAttr("juju_integration.this", "id", fmt.Sprintf("%v:%v:%v", srcModelName, "that:db", "this:db")), - resource.TestCheckResourceAttr("juju_integration.this", "application.#", "2"), - resource.TestCheckTypeSetElemNestedAttrs("juju_integration.this", "application.*", map[string]string{"name": "this", "endpoint": "db"}), - resource.TestCheckResourceAttr("juju_integration.this", "via", via), + resource.TestCheckResourceAttr("juju_integration.a", "model", srcModelName), + resource.TestCheckResourceAttr("juju_integration.a", "id", fmt.Sprintf("%v:%v:%v", srcModelName, "a:db-admin", "b:backend-db-admin")), + resource.TestCheckResourceAttr("juju_integration.a", "application.#", "2"), + resource.TestCheckTypeSetElemNestedAttrs("juju_integration.a", "application.*", map[string]string{"name": "a", "endpoint": "db-admin"}), + resource.TestCheckResourceAttr("juju_integration.a", "via", via), ), }, }, }) } +// testAccResourceIntegrationWithVia generates a plan where a +// postgresql:db-admin relates to a pgbouncer:backend-db-admin using +// and offer of pgbouncer. func testAccResourceIntegrationWithVia(srcModelName string, dstModelName string, viaCIDRs string) string { return fmt.Sprintf(` -resource "juju_model" "this" { - name = %q -} - -resource "juju_model" "that" { +resource "juju_model" "a" { name = %q } -resource "juju_application" "this" { - model = juju_model.this.name - name = "this" +resource "juju_application" "a" { + model = juju_model.a.name + name = "a" charm { - name = "hello-juju" + name = "postgresql" series = "focal" } } -resource "juju_application" "that" { - model = juju_model.that.name - name = "that" +resource "juju_model" "b" { + name = %q +} +resource "juju_application" "b" { + model = juju_model.b.name + name = "b" + charm { - name = "postgresql" + name = "pgbouncer" series = "focal" } } -resource "juju_offer" "that" { - model = juju_model.that.name - application_name = juju_application.that.name - endpoint = "db" +resource "juju_offer" "b" { + model = juju_model.b.name + application_name = juju_application.b.name + endpoint = "backend-db-admin" } -resource "juju_integration" "this" { - model = juju_model.this.name +resource "juju_integration" "a" { + model = juju_model.a.name via = %q application { - name = juju_application.this.name + name = juju_application.a.name + endpoint = "db-admin" } - + application { - offer_url = juju_offer.that.url + offer_url = juju_offer.b.url } } `, srcModelName, dstModelName, viaCIDRs)