Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/resources/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.


12 changes: 12 additions & 0 deletions examples/smoke/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions internal/provider/cvm_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
108 changes: 107 additions & 1 deletion internal/provider/resource_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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() {
Expand All @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down
75 changes: 75 additions & 0 deletions internal/provider/resource_app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions templates/resources/app.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading