Skip to content

Commit 99f86f4

Browse files
Add support for App Platform (#497)
* Bump godo version. * Initial support for app service spec. * Update godo * Add support for static sites. * Refactor to share appSpecComponentBase * Add support for workers. * Fix expandAppDomainSpec * Add database support. * Add first set of acceptance tests. * Add test excercising envs. * Add worker test. * Add import test. * Add sweeper. * Add App data source. * Add documentation for the resource. * Add data source docs. * Update health_check attributes. * Use basic plan in acceptance tests. * Test upgrading an app from basic to professional. * Update waitForAppDeployment method. * Fix env docs. * Update digitalocean/datasource_digitalocean_app_test.go Co-authored-by: Cesar Garza <scotch.neat@live.com> * Simplify expand methods. * Fix typo in sweeper log message. Co-authored-by: Cesar Garza <scotch.neat@live.com>
1 parent 4c6b74b commit 99f86f4

18 files changed

Lines changed: 2420 additions & 148 deletions

digitalocean/app_spec.go

Lines changed: 846 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package digitalocean
2+
3+
import (
4+
"github.com/hashicorp/terraform-plugin-sdk/helper/schema"
5+
)
6+
7+
func dataSourceDigitalOceanApp() *schema.Resource {
8+
return &schema.Resource{
9+
Read: dataSourceDigitalOceanAppRead,
10+
Schema: map[string]*schema.Schema{
11+
"app_id": {
12+
Type: schema.TypeString,
13+
Required: true,
14+
},
15+
"spec": {
16+
Type: schema.TypeList,
17+
Computed: true,
18+
MaxItems: 1,
19+
Description: "A DigitalOcean App Platform Spec",
20+
Elem: &schema.Resource{
21+
Schema: appSpecSchema(),
22+
},
23+
},
24+
"default_ingress": {
25+
Type: schema.TypeString,
26+
Computed: true,
27+
Description: "The default URL to access the App",
28+
},
29+
"live_url": {
30+
Type: schema.TypeString,
31+
Computed: true,
32+
},
33+
"active_deployment_id": {
34+
Type: schema.TypeString,
35+
Computed: true,
36+
Description: "The ID the App's currently active deployment",
37+
},
38+
"updated_at": {
39+
Type: schema.TypeString,
40+
Computed: true,
41+
Description: "The date and time of when the App was last updated",
42+
},
43+
"created_at": {
44+
Type: schema.TypeString,
45+
Computed: true,
46+
Description: "The date and time of when the App was created",
47+
},
48+
},
49+
}
50+
}
51+
52+
func dataSourceDigitalOceanAppRead(d *schema.ResourceData, meta interface{}) error {
53+
d.SetId(d.Get("app_id").(string))
54+
55+
return resourceDigitalOceanAppRead(d, meta)
56+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package digitalocean
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/digitalocean/godo"
8+
"github.com/hashicorp/terraform-plugin-sdk/helper/resource"
9+
)
10+
11+
func TestAccDataSourceDigitalOceanApp_Basic(t *testing.T) {
12+
var app godo.App
13+
appName := randomTestName()
14+
appCreateConfig := fmt.Sprintf(testAccCheckDigitalOceanAppConfig_basic, appName)
15+
appDataConfig := fmt.Sprintf(testAccCheckDataSourceDigitalOceanAppConfig, appCreateConfig)
16+
17+
updatedAppCreateConfig := fmt.Sprintf(testAccCheckDigitalOceanAppConfig_addService, appName)
18+
updatedAppDataConfig := fmt.Sprintf(testAccCheckDataSourceDigitalOceanAppConfig, updatedAppCreateConfig)
19+
20+
resource.ParallelTest(t, resource.TestCase{
21+
PreCheck: func() { testAccPreCheck(t) },
22+
Providers: testAccProviders,
23+
Steps: []resource.TestStep{
24+
{
25+
Config: appCreateConfig,
26+
Check: resource.ComposeTestCheckFunc(
27+
testAccCheckDigitalOceanAppExists("digitalocean_app.foobar", &app),
28+
),
29+
},
30+
{
31+
Config: appDataConfig,
32+
Check: resource.ComposeTestCheckFunc(
33+
resource.TestCheckResourceAttr(
34+
"data.digitalocean_app.foobar", "spec.0.name", appName),
35+
resource.TestCheckResourceAttrPair("digitalocean_app.foobar", "default_ingress",
36+
"data.digitalocean_app.foobar", "default_ingress"),
37+
resource.TestCheckResourceAttrPair("digitalocean_app.foobar", "live_url",
38+
"data.digitalocean_app.foobar", "live_url"),
39+
resource.TestCheckResourceAttrPair("digitalocean_app.foobar", "active_deployment_id",
40+
"data.digitalocean_app.foobar", "active_deployment_id"),
41+
resource.TestCheckResourceAttrPair("digitalocean_app.foobar", "updated_at",
42+
"data.digitalocean_app.foobar", "updated_at"),
43+
resource.TestCheckResourceAttrPair("digitalocean_app.foobar", "created_at",
44+
"data.digitalocean_app.foobar", "created_at"),
45+
resource.TestCheckResourceAttr(
46+
"data.digitalocean_app.foobar", "spec.0.service.0.instance_count", "1"),
47+
resource.TestCheckResourceAttr(
48+
"data.digitalocean_app.foobar", "spec.0.service.0.instance_size_slug", "professional-xs"),
49+
resource.TestCheckResourceAttr(
50+
"data.digitalocean_app.foobar", "spec.0.service.0.routes.0.path", "/"),
51+
resource.TestCheckResourceAttr(
52+
"data.digitalocean_app.foobar", "spec.0.service.0.git.0.repo_clone_url",
53+
"https://github.com/digitalocean/sample-golang.git"),
54+
resource.TestCheckResourceAttr(
55+
"data.digitalocean_app.foobar", "spec.0.service.0.git.0.branch", "main"),
56+
),
57+
},
58+
{
59+
Config: updatedAppDataConfig,
60+
},
61+
{
62+
Config: updatedAppDataConfig,
63+
Check: resource.ComposeTestCheckFunc(
64+
resource.TestCheckResourceAttr(
65+
"data.digitalocean_app.foobar", "spec.0.service.0.name", "go-service"),
66+
resource.TestCheckResourceAttr(
67+
"data.digitalocean_app.foobar", "spec.0.service.0.routes.0.path", "/go"),
68+
resource.TestCheckResourceAttr(
69+
"data.digitalocean_app.foobar", "spec.0.service.1.name", "python-service"),
70+
resource.TestCheckResourceAttr(
71+
"data.digitalocean_app.foobar", "spec.0.service.1.routes.0.path", "/python"),
72+
),
73+
},
74+
},
75+
})
76+
}
77+
78+
const testAccCheckDataSourceDigitalOceanAppConfig = `
79+
%s
80+
81+
data "digitalocean_app" "foobar" {
82+
app_id = digitalocean_app.foobar.id
83+
}`
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package digitalocean
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/hashicorp/terraform-plugin-sdk/helper/resource"
8+
)
9+
10+
func TestAccDigitalOceanApp_importBasic(t *testing.T) {
11+
resourceName := "digitalocean_app.foobar"
12+
13+
resource.ParallelTest(t, resource.TestCase{
14+
PreCheck: func() { testAccPreCheck(t) },
15+
Providers: testAccProviders,
16+
CheckDestroy: testAccCheckDigitalOceanCertificateDestroy,
17+
Steps: []resource.TestStep{
18+
{
19+
Config: fmt.Sprintf(testAccCheckDigitalOceanAppConfig_basic, randomTestName()),
20+
},
21+
22+
{
23+
ResourceName: resourceName,
24+
ImportState: true,
25+
ImportStateVerify: true,
26+
},
27+
},
28+
})
29+
}

digitalocean/provider.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ func Provider() terraform.ResourceProvider {
4545
},
4646
DataSourcesMap: map[string]*schema.Resource{
4747
"digitalocean_account": dataSourceDigitalOceanAccount(),
48+
"digitalocean_app": dataSourceDigitalOceanApp(),
4849
"digitalocean_certificate": dataSourceDigitalOceanCertificate(),
4950
"digitalocean_container_registry": dataSourceDigitalOceanContainerRegistry(),
5051
"digitalocean_database_cluster": dataSourceDigitalOceanDatabaseCluster(),
@@ -78,6 +79,7 @@ func Provider() terraform.ResourceProvider {
7879
},
7980

8081
ResourcesMap: map[string]*schema.Resource{
82+
"digitalocean_app": resourceDigitalOceanApp(),
8183
"digitalocean_certificate": resourceDigitalOceanCertificate(),
8284
"digitalocean_container_registry": resourceDigitalOceanContainerRegistry(),
8385
"digitalocean_container_registry_docker_credentials": resourceDigitalOceanContainerRegistryDockerCredentials(),
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package digitalocean
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"time"
8+
9+
"github.com/digitalocean/godo"
10+
"github.com/hashicorp/terraform-plugin-sdk/helper/schema"
11+
)
12+
13+
func resourceDigitalOceanApp() *schema.Resource {
14+
return &schema.Resource{
15+
Create: resourceDigitalOceanAppCreate,
16+
Read: resourceDigitalOceanAppRead,
17+
Update: resourceDigitalOceanAppUpdate,
18+
Delete: resourceDigitalOceanAppDelete,
19+
Importer: &schema.ResourceImporter{
20+
State: schema.ImportStatePassthrough,
21+
},
22+
23+
Schema: map[string]*schema.Schema{
24+
"spec": {
25+
Type: schema.TypeList,
26+
Optional: true,
27+
MaxItems: 1,
28+
Description: "A DigitalOcean App Platform Spec",
29+
Elem: &schema.Resource{
30+
Schema: appSpecSchema(),
31+
},
32+
},
33+
34+
// Computed attributes
35+
"default_ingress": {
36+
Type: schema.TypeString,
37+
Computed: true,
38+
Description: "The default URL to access the App",
39+
},
40+
41+
"live_url": {
42+
Type: schema.TypeString,
43+
Computed: true,
44+
},
45+
46+
// TODO: The full Deployment should be a data source, not a resource
47+
// specify the app id for the active deployment, include a deployment
48+
// id for a specific one
49+
"active_deployment_id": {
50+
Type: schema.TypeString,
51+
Computed: true,
52+
Description: "The ID the App's currently active deployment",
53+
},
54+
55+
"updated_at": {
56+
Type: schema.TypeString,
57+
Computed: true,
58+
Description: "The date and time of when the App was last updated",
59+
},
60+
"created_at": {
61+
Type: schema.TypeString,
62+
Computed: true,
63+
Description: "The date and time of when the App was created",
64+
},
65+
},
66+
}
67+
}
68+
69+
func resourceDigitalOceanAppCreate(d *schema.ResourceData, meta interface{}) error {
70+
client := meta.(*CombinedConfig).godoClient()
71+
appCreateRequest := &godo.AppCreateRequest{}
72+
appCreateRequest.Spec = expandAppSpec(d.Get("spec").([]interface{}))
73+
74+
log.Printf("[DEBUG] App create request: %#v", appCreateRequest)
75+
app, _, err := client.Apps.Create(context.Background(), appCreateRequest)
76+
if err != nil {
77+
return fmt.Errorf("Error creating App: %s", err)
78+
}
79+
80+
d.SetId(app.ID)
81+
log.Printf("[DEBUG] Waiting for app (%s) deployment to become active", app.ID)
82+
83+
err = waitForAppDeployment(client, app.ID)
84+
if err != nil {
85+
return err
86+
}
87+
88+
log.Printf("[INFO] App created, ID: %s", d.Id())
89+
90+
return resourceDigitalOceanAppRead(d, meta)
91+
}
92+
93+
func resourceDigitalOceanAppRead(d *schema.ResourceData, meta interface{}) error {
94+
client := meta.(*CombinedConfig).godoClient()
95+
96+
app, resp, err := client.Apps.Get(context.Background(), d.Id())
97+
if err != nil {
98+
if resp != nil && resp.StatusCode == 404 {
99+
log.Printf("[DEBUG] App (%s) was not found - removing from state", d.Id())
100+
d.SetId("")
101+
return nil
102+
}
103+
return fmt.Errorf("Error reading App: %s", err)
104+
}
105+
106+
d.SetId(app.ID)
107+
d.Set("default_ingress", app.DefaultIngress)
108+
d.Set("live_url", app.LiveURL)
109+
d.Set("active_deployment_id", app.ActiveDeployment.ID)
110+
d.Set("updated_at", app.UpdatedAt.UTC().String())
111+
d.Set("created_at", app.CreatedAt.UTC().String())
112+
113+
if err := d.Set("spec", flattenAppSpec(app.Spec)); err != nil {
114+
return fmt.Errorf("[DEBUG] Error setting app spec: %#v", err)
115+
}
116+
117+
return err
118+
}
119+
120+
func resourceDigitalOceanAppUpdate(d *schema.ResourceData, meta interface{}) error {
121+
client := meta.(*CombinedConfig).godoClient()
122+
123+
if d.HasChange("spec") {
124+
appUpdateRequest := &godo.AppUpdateRequest{}
125+
appUpdateRequest.Spec = expandAppSpec(d.Get("spec").([]interface{}))
126+
127+
app, _, err := client.Apps.Update(context.Background(), d.Id(), appUpdateRequest)
128+
if err != nil {
129+
return fmt.Errorf("Error updating app (%s): %s", d.Id(), err)
130+
}
131+
132+
log.Printf("[DEBUG] Waiting for app (%s) deployment to become active", app.ID)
133+
err = waitForAppDeployment(client, app.ID)
134+
if err != nil {
135+
return err
136+
}
137+
138+
log.Printf("[INFO] Updated app (%s)", app.ID)
139+
}
140+
141+
return resourceDigitalOceanAppRead(d, meta)
142+
}
143+
144+
func resourceDigitalOceanAppDelete(d *schema.ResourceData, meta interface{}) error {
145+
client := meta.(*CombinedConfig).godoClient()
146+
147+
log.Printf("[INFO] Deleting App: %s", d.Id())
148+
_, err := client.Apps.Delete(context.Background(), d.Id())
149+
if err != nil {
150+
return fmt.Errorf("Error deletingApp: %s", err)
151+
}
152+
153+
d.SetId("")
154+
return nil
155+
}
156+
157+
func waitForAppDeployment(client *godo.Client, id string) error {
158+
tickerInterval := 10 //10s
159+
timeout := 1800 //1800s, 30min
160+
n := 0
161+
162+
var deploymentID string
163+
ticker := time.NewTicker(time.Duration(tickerInterval) * time.Second)
164+
for range ticker.C {
165+
if n*tickerInterval > timeout {
166+
ticker.Stop()
167+
break
168+
}
169+
170+
if deploymentID == "" {
171+
app, _, err := client.Apps.Get(context.Background(), id)
172+
if err != nil {
173+
return fmt.Errorf("Error trying to read app deployment state: %s", err)
174+
}
175+
176+
if app.InProgressDeployment != nil {
177+
deploymentID = app.InProgressDeployment.ID
178+
}
179+
180+
} else {
181+
deployment, _, err := client.Apps.GetDeployment(context.Background(), id, deploymentID)
182+
if err != nil {
183+
ticker.Stop()
184+
return fmt.Errorf("Error trying to read app deployment state: %s", err)
185+
}
186+
187+
allSuccessful := deployment.Progress.SuccessSteps == deployment.Progress.TotalSteps
188+
if allSuccessful {
189+
ticker.Stop()
190+
return nil
191+
}
192+
193+
if deployment.Progress.ErrorSteps > 0 {
194+
ticker.Stop()
195+
return fmt.Errorf("error deploying app (%s) (deployment ID: %s):\n%s", id, deployment.ID, godo.Stringify(deployment.Progress))
196+
}
197+
198+
log.Printf("[DEBUG] Waiting for app (%s) deployment (%s) to become active. Phase: %s (%d/%d)",
199+
id, deployment.ID, deployment.Phase, deployment.Progress.SuccessSteps, deployment.Progress.TotalSteps)
200+
}
201+
202+
n++
203+
}
204+
205+
return fmt.Errorf("timeout waiting to app (%s) deployment", id)
206+
}

0 commit comments

Comments
 (0)