diff --git a/docs/resources/app.md b/docs/resources/app.md index ed00fcf..65fe899 100644 --- a/docs/resources/app.md +++ b/docs/resources/app.md @@ -47,6 +47,7 @@ resource "phala_app" "web" { - `phala_app` is the main lifecycle resource for Phala Cloud deployments. - `replicas` scales one app definition horizontally across multiple CVMs. - `docker_compose`, runtime visibility flags, and encrypted environment updates are applied across the app. +- `instances` exposes the current per-CVM member view without introducing backend-native ordinals. - `wait_for_ready = true` waits until the app reports running replicas before returning. - `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. @@ -92,7 +93,17 @@ resource "phala_app" "web" { - `cvm_ids` (List of String) Identifiers of CVMs currently attached to this app. - `endpoint` (String) Primary public endpoint URL. - `id` (String) Terraform ID (same as app_id). +- `instances` (Attributes List) Computed per-instance view of CVMs currently attached to this app. + - `app_id` (String) + - `created_at` (String) + - `endpoint` (String) + - `id` (String) + - `instance_id` (String) + - `instance_type` (String) + - `name` (String) + - `region` (String) + - `status` (String) + - `vm_uuid` (String) - `primary_cvm_id` (String) Primary CVM identifier used for app-level patch operations. - `status` (String) Current CVM status. - diff --git a/examples/smoke/outputs.tf b/examples/smoke/outputs.tf index d2686ff..3a77adf 100644 --- a/examples/smoke/outputs.tf +++ b/examples/smoke/outputs.tf @@ -30,6 +30,18 @@ output "app_cvm_ids" { value = var.create_resources ? phala_app.smoke[0].cvm_ids : null } +output "app_instances" { + value = var.create_resources ? phala_app.smoke[0].instances : null +} + +output "app_instance_vm_uuids" { + value = var.create_resources ? [for instance in phala_app.smoke[0].instances : instance.vm_uuid] : null +} + +output "app_instance_ids" { + value = var.create_resources ? [for instance in phala_app.smoke[0].instances : instance.instance_id] : null +} + output "consumer_app_id" { value = var.create_resources && var.create_consumer_app ? phala_app.consumer[0].app_id : null } diff --git a/internal/provider/cvm_helpers.go b/internal/provider/cvm_helpers.go index 7fa9467..84e1035 100644 --- a/internal/provider/cvm_helpers.go +++ b/internal/provider/cvm_helpers.go @@ -27,6 +27,7 @@ type cvmAPIResponse struct { ID json.RawMessage `json:"id"` Name string `json:"name"` Status string `json:"status"` + CreatedAt string `json:"created_at"` InProgress bool `json:"in_progress"` Listed *bool `json:"listed"` AppID string `json:"app_id"` diff --git a/internal/provider/resource_app.go b/internal/provider/resource_app.go index f4df1c0..f64bd48 100644 --- a/internal/provider/resource_app.go +++ b/internal/provider/resource_app.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -60,9 +61,42 @@ type appResourceModel struct { Status types.String `tfsdk:"status"` PrimaryCVMID types.String `tfsdk:"primary_cvm_id"` CVMIDs types.List `tfsdk:"cvm_ids"` + Instances types.List `tfsdk:"instances"` Endpoint types.String `tfsdk:"endpoint"` } +type appInstanceModel struct { + ID types.String `tfsdk:"id"` + AppID types.String `tfsdk:"app_id"` + Name types.String `tfsdk:"name"` + VMUUID types.String `tfsdk:"vm_uuid"` + InstanceID types.String `tfsdk:"instance_id"` + Status types.String `tfsdk:"status"` + Region types.String `tfsdk:"region"` + InstanceType types.String `tfsdk:"instance_type"` + Endpoint types.String `tfsdk:"endpoint"` + CreatedAt types.String `tfsdk:"created_at"` +} + +func appInstanceAttrTypes() map[string]attr.Type { + return map[string]attr.Type{ + "id": types.StringType, + "app_id": types.StringType, + "name": types.StringType, + "vm_uuid": types.StringType, + "instance_id": types.StringType, + "status": types.StringType, + "region": types.StringType, + "instance_type": types.StringType, + "endpoint": types.StringType, + "created_at": types.StringType, + } +} + +func appInstanceObjectType() types.ObjectType { + return types.ObjectType{AttrTypes: appInstanceAttrTypes()} +} + type appAPIResponse struct { ID json.RawMessage `json:"id"` Name string `json:"name"` @@ -121,6 +155,24 @@ func (r *appResource) Schema(_ context.Context, _ resource.SchemaRequest, resp * ElementType: types.StringType, MarkdownDescription: "Identifiers of CVMs currently attached to this app.", } + attrs["instances"] = schema.ListNestedAttribute{ + Computed: true, + MarkdownDescription: "Computed per-instance view of CVMs currently attached to this app.", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true}, + "app_id": schema.StringAttribute{Computed: true}, + "name": schema.StringAttribute{Computed: true}, + "vm_uuid": schema.StringAttribute{Computed: true}, + "instance_id": schema.StringAttribute{Computed: true}, + "status": schema.StringAttribute{Computed: true}, + "region": schema.StringAttribute{Computed: true}, + "instance_type": schema.StringAttribute{Computed: true}, + "endpoint": schema.StringAttribute{Computed: true}, + "created_at": schema.StringAttribute{Computed: true}, + }, + }, + } resp.Schema = schema.Schema{ MarkdownDescription: "Manages a Phala Cloud App (app_id + shared compose/env + replica count).", Attributes: attrs, @@ -882,6 +934,7 @@ func (r *appResource) populateState( state.Status = types.StringNull() state.Endpoint = types.StringNull() state.PrimaryCVMID = types.StringNull() + state.Instances = types.ListNull(appInstanceObjectType()) emptyIDs, listDiags := types.ListValueFrom(ctx, types.StringType, []string{}) diags.Append(listDiags...) if !diags.HasError() { @@ -917,7 +970,7 @@ func (r *appResource) populateState( if primary.Resource != nil && primary.Resource.DiskInGB != nil { state.DiskSize = types.Int64Value(*primary.Resource.DiskInGB) } - if region := primary.region(); region != "" { + if region := primary.region(); region != "" && !state.Region.IsNull() && !state.Region.IsUnknown() { state.Region = types.StringValue(region) } if image := primary.osImageName(); image != "" { @@ -963,6 +1016,7 @@ func (r *appResource) populateState( if len(cvms) == 0 { if state.Replicas.IsNull() || state.Replicas.IsUnknown() || state.Replicas.ValueInt64() <= 0 { state.Replicas = types.Int64Value(0) + state.Instances = types.ListNull(appInstanceObjectType()) emptyIDs, listDiags := types.ListValueFrom(ctx, types.StringType, []string{}) diags.Append(listDiags...) if !diags.HasError() { @@ -983,6 +1037,11 @@ func (r *appResource) populateState( if !diags.HasError() { state.CVMIDs = listValue } + instancesValue, instanceDiags := buildAppInstances(ctx, cvms) + diags.Append(instanceDiags...) + if !diags.HasError() { + state.Instances = instancesValue + } return diags } @@ -1168,6 +1227,53 @@ func orderedReplicaIDs(cvms []cvmAPIResponse, preferred string) []string { return ids } +func buildAppInstances(ctx context.Context, cvms []cvmAPIResponse) (types.List, diag.Diagnostics) { + var diags diag.Diagnostics + ordered := append([]cvmAPIResponse(nil), cvms...) + sort.SliceStable(ordered, func(i, j int) bool { + leftCreated := strings.TrimSpace(ordered[i].CreatedAt) + rightCreated := strings.TrimSpace(ordered[j].CreatedAt) + if leftCreated != rightCreated { + if leftCreated == "" { + return false + } + if rightCreated == "" { + return true + } + return leftCreated < rightCreated + } + + leftID := selectReplicaIdentifier(ordered[i]) + rightID := selectReplicaIdentifier(ordered[j]) + if leftID != rightID { + return leftID < rightID + } + return ordered[i].InstanceID < ordered[j].InstanceID + }) + + out := make([]appInstanceModel, 0, len(ordered)) + for _, cvm := range ordered { + out = append(out, appInstanceModel{ + ID: nullableString(selectReplicaIdentifier(cvm)), + AppID: nullableString(cvm.AppID), + Name: nullableString(cvm.Name), + VMUUID: nullableString(cvm.VMUUID), + InstanceID: nullableString(cvm.InstanceID), + Status: nullableString(cvm.Status), + Region: nullableString(cvm.region()), + InstanceType: nullableString(cvm.instanceType()), + Endpoint: nullableString(cvm.endpoint()), + CreatedAt: nullableString(cvm.CreatedAt), + }) + } + value, valueDiags := types.ListValueFrom(ctx, appInstanceObjectType(), out) + diags.Append(valueDiags...) + if diags.HasError() { + return types.ListNull(appInstanceObjectType()), diags + } + return value, diags +} + func (r *appResource) patchTextAcrossReplicas( ctx context.Context, cvms []cvmAPIResponse, diff --git a/internal/provider/resource_app_test.go b/internal/provider/resource_app_test.go index f463069..2adecf2 100644 --- a/internal/provider/resource_app_test.go +++ b/internal/provider/resource_app_test.go @@ -206,6 +206,81 @@ func TestAppResourcePopulateStatePreservesReplicaDerivedFieldsWithoutFreshCVMs(t } } +func TestAppResourcePopulateStateBuildsComputedInstances(t *testing.T) { + ctx := context.Background() + state := appResourceModel{ + ID: types.StringValue("app_test"), + AppID: types.StringValue("app_test"), + Replicas: types.Int64Null(), + DockerCompose: types.StringValue("services:\n app:\n"), + } + app := &appAPIResponse{ + AppID: "app_test", + Name: "demo", + } + cvms := []cvmAPIResponse{ + { + VMUUID: "vm-b", + InstanceID: "inst-b", + AppID: "app_test", + Name: "demo-b", + Status: "running", + CreatedAt: "2026-05-02T11:00:00Z", + InstanceType: "tdx.small", + NodeInfo: &struct { + Region string `json:"region"` + }{Region: "us-west-1"}, + Endpoints: []struct { + App string `json:"app"` + }{{App: "https://b.example"}}, + }, + { + VMUUID: "vm-a", + InstanceID: "inst-a", + AppID: "app_test", + Name: "demo-a", + Status: "running", + CreatedAt: "2026-05-02T10:00:00Z", + InstanceType: "tdx.small", + NodeInfo: &struct { + Region string `json:"region"` + }{Region: "us-west-1"}, + Endpoints: []struct { + App string `json:"app"` + }{{App: "https://a.example"}}, + }, + } + + resource := &appResource{} + diags := resource.populateState(ctx, &state, app, cvms) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if state.Instances.IsNull() || state.Instances.IsUnknown() { + t.Fatalf("expected concrete instances list, got %#v", state.Instances) + } + var instances []appInstanceModel + diags = state.Instances.ElementsAs(ctx, &instances, false) + if diags.HasError() { + t.Fatalf("decode instances: %v", diags) + } + if len(instances) != 2 { + t.Fatalf("expected 2 instances, got %d", len(instances)) + } + if got := instances[0].VMUUID.ValueString(); got != "vm-a" { + t.Fatalf("expected instances sorted by created_at, got first vm_uuid %q", got) + } + if got := instances[0].InstanceID.ValueString(); got != "inst-a" { + t.Fatalf("unexpected first instance_id: %q", got) + } + if got := instances[0].Endpoint.ValueString(); got != "https://a.example" { + t.Fatalf("unexpected first endpoint: %q", got) + } + if got := instances[1].VMUUID.ValueString(); got != "vm-b" { + t.Fatalf("unexpected second vm_uuid: %q", got) + } +} + func TestAppResourceWaitForAppReadyFailsFastOnStoppedReplica(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { diff --git a/templates/resources/app.md.tmpl b/templates/resources/app.md.tmpl index e6a1849..f7b0044 100644 --- a/templates/resources/app.md.tmpl +++ b/templates/resources/app.md.tmpl @@ -18,6 +18,7 @@ description: |- - `phala_app` is the main lifecycle resource for Phala Cloud deployments. - `replicas` scales one app definition horizontally across multiple CVMs. - `docker_compose`, runtime visibility flags, and encrypted environment updates are applied across the app. +- `instances` exposes the current per-CVM member view without introducing backend-native ordinals. - `wait_for_ready = true` waits until the app reports running replicas before returning. - `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.