Skip to content

Commit 438199d

Browse files
authored
Merge pull request #10 from Phala-Network/feat/app-instances-state
2 parents 52585a4 + c57a662 commit 438199d

7 files changed

Lines changed: 233 additions & 1 deletion

File tree

docs/resources/app.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ resource "phala_app" "web" {
4747
- `phala_app` is the main lifecycle resource for Phala Cloud deployments.
4848
- `replicas` scales one app definition horizontally across multiple CVMs.
4949
- `docker_compose`, runtime visibility flags, and encrypted environment updates are applied across the app.
50+
- `instances` exposes the current per-CVM member view without introducing backend-native ordinals.
5051
- `wait_for_ready = true` waits until the app reports running replicas before returning.
5152
- `ssh_authorized_keys`, `storage_fs`, placement fields, and deterministic identity inputs can affect replacement behavior; check the schema details below before changing them in-place.
5253

@@ -92,7 +93,25 @@ resource "phala_app" "web" {
9293
- `cvm_ids` (List of String) Identifiers of CVMs currently attached to this app.
9394
- `endpoint` (String) Primary public endpoint URL.
9495
- `id` (String) Terraform ID (same as app_id).
96+
- `instances` (Attributes List) Computed per-instance view of CVMs currently attached to this app. (see [below for nested schema](#nestedatt--instances))
9597
- `primary_cvm_id` (String) Primary CVM identifier used for app-level patch operations.
9698
- `status` (String) Current CVM status.
9799

100+
<a id="nestedatt--instances"></a>
101+
### Nested Schema for `instances`
102+
103+
Read-Only:
104+
105+
- `app_id` (String)
106+
- `created_at` (String)
107+
- `endpoint` (String)
108+
- `id` (String)
109+
- `instance_id` (String)
110+
- `instance_type` (String)
111+
- `name` (String)
112+
- `region` (String)
113+
- `status` (String)
114+
- `vm_uuid` (String)
115+
116+
98117

examples/smoke/outputs.tf

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ output "app_cvm_ids" {
3030
value = var.create_resources ? phala_app.smoke[0].cvm_ids : null
3131
}
3232

33+
output "app_instances" {
34+
value = var.create_resources ? phala_app.smoke[0].instances : null
35+
}
36+
37+
output "app_instance_vm_uuids" {
38+
value = var.create_resources ? [for instance in phala_app.smoke[0].instances : instance.vm_uuid] : null
39+
}
40+
41+
output "app_instance_ids" {
42+
value = var.create_resources ? [for instance in phala_app.smoke[0].instances : instance.instance_id] : null
43+
}
44+
3345
output "consumer_app_id" {
3446
value = var.create_resources && var.create_consumer_app ? phala_app.consumer[0].app_id : null
3547
}

internal/provider/cvm_helpers.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type cvmAPIResponse struct {
2727
ID json.RawMessage `json:"id"`
2828
Name string `json:"name"`
2929
Status string `json:"status"`
30+
CreatedAt string `json:"created_at"`
3031
InProgress bool `json:"in_progress"`
3132
Listed *bool `json:"listed"`
3233
AppID string `json:"app_id"`

internal/provider/resource_app.go

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111
"time"
1212

13+
"github.com/hashicorp/terraform-plugin-framework/attr"
1314
"github.com/hashicorp/terraform-plugin-framework/diag"
1415
"github.com/hashicorp/terraform-plugin-framework/path"
1516
"github.com/hashicorp/terraform-plugin-framework/resource"
@@ -60,9 +61,42 @@ type appResourceModel struct {
6061
Status types.String `tfsdk:"status"`
6162
PrimaryCVMID types.String `tfsdk:"primary_cvm_id"`
6263
CVMIDs types.List `tfsdk:"cvm_ids"`
64+
Instances types.List `tfsdk:"instances"`
6365
Endpoint types.String `tfsdk:"endpoint"`
6466
}
6567

68+
type appInstanceModel struct {
69+
ID types.String `tfsdk:"id"`
70+
AppID types.String `tfsdk:"app_id"`
71+
Name types.String `tfsdk:"name"`
72+
VMUUID types.String `tfsdk:"vm_uuid"`
73+
InstanceID types.String `tfsdk:"instance_id"`
74+
Status types.String `tfsdk:"status"`
75+
Region types.String `tfsdk:"region"`
76+
InstanceType types.String `tfsdk:"instance_type"`
77+
Endpoint types.String `tfsdk:"endpoint"`
78+
CreatedAt types.String `tfsdk:"created_at"`
79+
}
80+
81+
func appInstanceAttrTypes() map[string]attr.Type {
82+
return map[string]attr.Type{
83+
"id": types.StringType,
84+
"app_id": types.StringType,
85+
"name": types.StringType,
86+
"vm_uuid": types.StringType,
87+
"instance_id": types.StringType,
88+
"status": types.StringType,
89+
"region": types.StringType,
90+
"instance_type": types.StringType,
91+
"endpoint": types.StringType,
92+
"created_at": types.StringType,
93+
}
94+
}
95+
96+
func appInstanceObjectType() types.ObjectType {
97+
return types.ObjectType{AttrTypes: appInstanceAttrTypes()}
98+
}
99+
66100
type appAPIResponse struct {
67101
ID json.RawMessage `json:"id"`
68102
Name string `json:"name"`
@@ -121,6 +155,24 @@ func (r *appResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *
121155
ElementType: types.StringType,
122156
MarkdownDescription: "Identifiers of CVMs currently attached to this app.",
123157
}
158+
attrs["instances"] = schema.ListNestedAttribute{
159+
Computed: true,
160+
MarkdownDescription: "Computed per-instance view of CVMs currently attached to this app.",
161+
NestedObject: schema.NestedAttributeObject{
162+
Attributes: map[string]schema.Attribute{
163+
"id": schema.StringAttribute{Computed: true},
164+
"app_id": schema.StringAttribute{Computed: true},
165+
"name": schema.StringAttribute{Computed: true},
166+
"vm_uuid": schema.StringAttribute{Computed: true},
167+
"instance_id": schema.StringAttribute{Computed: true},
168+
"status": schema.StringAttribute{Computed: true},
169+
"region": schema.StringAttribute{Computed: true},
170+
"instance_type": schema.StringAttribute{Computed: true},
171+
"endpoint": schema.StringAttribute{Computed: true},
172+
"created_at": schema.StringAttribute{Computed: true},
173+
},
174+
},
175+
}
124176
resp.Schema = schema.Schema{
125177
MarkdownDescription: "Manages a Phala Cloud App (app_id + shared compose/env + replica count).",
126178
Attributes: attrs,
@@ -882,6 +934,7 @@ func (r *appResource) populateState(
882934
state.Status = types.StringNull()
883935
state.Endpoint = types.StringNull()
884936
state.PrimaryCVMID = types.StringNull()
937+
state.Instances = types.ListNull(appInstanceObjectType())
885938
emptyIDs, listDiags := types.ListValueFrom(ctx, types.StringType, []string{})
886939
diags.Append(listDiags...)
887940
if !diags.HasError() {
@@ -917,7 +970,7 @@ func (r *appResource) populateState(
917970
if primary.Resource != nil && primary.Resource.DiskInGB != nil {
918971
state.DiskSize = types.Int64Value(*primary.Resource.DiskInGB)
919972
}
920-
if region := primary.region(); region != "" {
973+
if region := primary.region(); region != "" && !state.Region.IsNull() && !state.Region.IsUnknown() {
921974
state.Region = types.StringValue(region)
922975
}
923976
if image := primary.osImageName(); image != "" {
@@ -963,6 +1016,7 @@ func (r *appResource) populateState(
9631016
if len(cvms) == 0 {
9641017
if state.Replicas.IsNull() || state.Replicas.IsUnknown() || state.Replicas.ValueInt64() <= 0 {
9651018
state.Replicas = types.Int64Value(0)
1019+
state.Instances = types.ListNull(appInstanceObjectType())
9661020
emptyIDs, listDiags := types.ListValueFrom(ctx, types.StringType, []string{})
9671021
diags.Append(listDiags...)
9681022
if !diags.HasError() {
@@ -983,6 +1037,11 @@ func (r *appResource) populateState(
9831037
if !diags.HasError() {
9841038
state.CVMIDs = listValue
9851039
}
1040+
instancesValue, instanceDiags := buildAppInstances(ctx, cvms)
1041+
diags.Append(instanceDiags...)
1042+
if !diags.HasError() {
1043+
state.Instances = instancesValue
1044+
}
9861045

9871046
return diags
9881047
}
@@ -1168,6 +1227,53 @@ func orderedReplicaIDs(cvms []cvmAPIResponse, preferred string) []string {
11681227
return ids
11691228
}
11701229

1230+
func buildAppInstances(ctx context.Context, cvms []cvmAPIResponse) (types.List, diag.Diagnostics) {
1231+
var diags diag.Diagnostics
1232+
ordered := append([]cvmAPIResponse(nil), cvms...)
1233+
sort.SliceStable(ordered, func(i, j int) bool {
1234+
leftCreated := strings.TrimSpace(ordered[i].CreatedAt)
1235+
rightCreated := strings.TrimSpace(ordered[j].CreatedAt)
1236+
if leftCreated != rightCreated {
1237+
if leftCreated == "" {
1238+
return false
1239+
}
1240+
if rightCreated == "" {
1241+
return true
1242+
}
1243+
return leftCreated < rightCreated
1244+
}
1245+
1246+
leftID := selectReplicaIdentifier(ordered[i])
1247+
rightID := selectReplicaIdentifier(ordered[j])
1248+
if leftID != rightID {
1249+
return leftID < rightID
1250+
}
1251+
return ordered[i].InstanceID < ordered[j].InstanceID
1252+
})
1253+
1254+
out := make([]appInstanceModel, 0, len(ordered))
1255+
for _, cvm := range ordered {
1256+
out = append(out, appInstanceModel{
1257+
ID: nullableString(selectReplicaIdentifier(cvm)),
1258+
AppID: nullableString(cvm.AppID),
1259+
Name: nullableString(cvm.Name),
1260+
VMUUID: nullableString(cvm.VMUUID),
1261+
InstanceID: nullableString(cvm.InstanceID),
1262+
Status: nullableString(cvm.Status),
1263+
Region: nullableString(cvm.region()),
1264+
InstanceType: nullableString(cvm.instanceType()),
1265+
Endpoint: nullableString(cvm.endpoint()),
1266+
CreatedAt: nullableString(cvm.CreatedAt),
1267+
})
1268+
}
1269+
value, valueDiags := types.ListValueFrom(ctx, appInstanceObjectType(), out)
1270+
diags.Append(valueDiags...)
1271+
if diags.HasError() {
1272+
return types.ListNull(appInstanceObjectType()), diags
1273+
}
1274+
return value, diags
1275+
}
1276+
11711277
func (r *appResource) patchTextAcrossReplicas(
11721278
ctx context.Context,
11731279
cvms []cvmAPIResponse,

internal/provider/resource_app_env_update_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,10 +284,26 @@ func envUpdateAppObjectType() tftypes.Object {
284284
"status": tftypes.String,
285285
"primary_cvm_id": tftypes.String,
286286
"cvm_ids": tftypes.List{ElementType: tftypes.String},
287+
"instances": tftypes.List{ElementType: envUpdateInstanceObjectType()},
287288
"endpoint": tftypes.String,
288289
}}
289290
}
290291

292+
func envUpdateInstanceObjectType() tftypes.Object {
293+
return tftypes.Object{AttributeTypes: map[string]tftypes.Type{
294+
"id": tftypes.String,
295+
"app_id": tftypes.String,
296+
"name": tftypes.String,
297+
"vm_uuid": tftypes.String,
298+
"instance_id": tftypes.String,
299+
"status": tftypes.String,
300+
"region": tftypes.String,
301+
"instance_type": tftypes.String,
302+
"endpoint": tftypes.String,
303+
"created_at": tftypes.String,
304+
}}
305+
}
306+
291307
func envUpdateConfigValue(t *testing.T, imageVal string) tftypes.Value {
292308
t.Helper()
293309
objType := envUpdateAppObjectType()
@@ -327,6 +343,7 @@ func envUpdateConfigValue(t *testing.T, imageVal string) tftypes.Value {
327343
"status": tftypes.NewValue(tftypes.String, nil),
328344
"primary_cvm_id": tftypes.NewValue(tftypes.String, nil),
329345
"cvm_ids": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil),
346+
"instances": tftypes.NewValue(tftypes.List{ElementType: envUpdateInstanceObjectType()}, nil),
330347
"endpoint": tftypes.NewValue(tftypes.String, nil),
331348
})
332349
}
@@ -370,6 +387,7 @@ func envUpdateProposedCreate(t *testing.T, imageVal string) tftypes.Value {
370387
"status": tftypes.NewValue(tftypes.String, tftypes.UnknownValue),
371388
"primary_cvm_id": tftypes.NewValue(tftypes.String, tftypes.UnknownValue),
372389
"cvm_ids": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, tftypes.UnknownValue),
390+
"instances": tftypes.NewValue(tftypes.List{ElementType: envUpdateInstanceObjectType()}, tftypes.UnknownValue),
373391
"endpoint": tftypes.NewValue(tftypes.String, tftypes.UnknownValue),
374392
})
375393
}

internal/provider/resource_app_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,81 @@ func TestAppResourcePopulateStatePreservesReplicaDerivedFieldsWithoutFreshCVMs(t
206206
}
207207
}
208208

209+
func TestAppResourcePopulateStateBuildsComputedInstances(t *testing.T) {
210+
ctx := context.Background()
211+
state := appResourceModel{
212+
ID: types.StringValue("app_test"),
213+
AppID: types.StringValue("app_test"),
214+
Replicas: types.Int64Null(),
215+
DockerCompose: types.StringValue("services:\n app:\n"),
216+
}
217+
app := &appAPIResponse{
218+
AppID: "app_test",
219+
Name: "demo",
220+
}
221+
cvms := []cvmAPIResponse{
222+
{
223+
VMUUID: "vm-b",
224+
InstanceID: "inst-b",
225+
AppID: "app_test",
226+
Name: "demo-b",
227+
Status: "running",
228+
CreatedAt: "2026-05-02T11:00:00Z",
229+
InstanceType: "tdx.small",
230+
NodeInfo: &struct {
231+
Region string `json:"region"`
232+
}{Region: "us-west-1"},
233+
Endpoints: []struct {
234+
App string `json:"app"`
235+
}{{App: "https://b.example"}},
236+
},
237+
{
238+
VMUUID: "vm-a",
239+
InstanceID: "inst-a",
240+
AppID: "app_test",
241+
Name: "demo-a",
242+
Status: "running",
243+
CreatedAt: "2026-05-02T10:00:00Z",
244+
InstanceType: "tdx.small",
245+
NodeInfo: &struct {
246+
Region string `json:"region"`
247+
}{Region: "us-west-1"},
248+
Endpoints: []struct {
249+
App string `json:"app"`
250+
}{{App: "https://a.example"}},
251+
},
252+
}
253+
254+
resource := &appResource{}
255+
diags := resource.populateState(ctx, &state, app, cvms)
256+
if diags.HasError() {
257+
t.Fatalf("unexpected diagnostics: %v", diags)
258+
}
259+
if state.Instances.IsNull() || state.Instances.IsUnknown() {
260+
t.Fatalf("expected concrete instances list, got %#v", state.Instances)
261+
}
262+
var instances []appInstanceModel
263+
diags = state.Instances.ElementsAs(ctx, &instances, false)
264+
if diags.HasError() {
265+
t.Fatalf("decode instances: %v", diags)
266+
}
267+
if len(instances) != 2 {
268+
t.Fatalf("expected 2 instances, got %d", len(instances))
269+
}
270+
if got := instances[0].VMUUID.ValueString(); got != "vm-a" {
271+
t.Fatalf("expected instances sorted by created_at, got first vm_uuid %q", got)
272+
}
273+
if got := instances[0].InstanceID.ValueString(); got != "inst-a" {
274+
t.Fatalf("unexpected first instance_id: %q", got)
275+
}
276+
if got := instances[0].Endpoint.ValueString(); got != "https://a.example" {
277+
t.Fatalf("unexpected first endpoint: %q", got)
278+
}
279+
if got := instances[1].VMUUID.ValueString(); got != "vm-b" {
280+
t.Fatalf("unexpected second vm_uuid: %q", got)
281+
}
282+
}
283+
209284
func TestAppResourceWaitForAppReadyFailsFastOnStoppedReplica(t *testing.T) {
210285
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
211286
switch {

templates/resources/app.md.tmpl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ description: |-
1818
- `phala_app` is the main lifecycle resource for Phala Cloud deployments.
1919
- `replicas` scales one app definition horizontally across multiple CVMs.
2020
- `docker_compose`, runtime visibility flags, and encrypted environment updates are applied across the app.
21+
- `instances` exposes the current per-CVM member view without introducing backend-native ordinals.
2122
- `wait_for_ready = true` waits until the app reports running replicas before returning.
2223
- `ssh_authorized_keys`, `storage_fs`, placement fields, and deterministic identity inputs can affect replacement behavior; check the schema details below before changing them in-place.
2324

0 commit comments

Comments
 (0)