diff --git a/docs/components/GoogleCloud.mdx b/docs/components/GoogleCloud.mdx
index ba2394eb05..8406855668 100644
--- a/docs/components/GoogleCloud.mdx
+++ b/docs/components/GoogleCloud.mdx
@@ -30,6 +30,7 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components";
+
@@ -833,6 +834,61 @@ Returns information about the deleted instance:
}
```
+
+
+## Compute • Get VM Instance
+
+**Component key:** `gcp.getVMInstance`
+
+The Get VM Instance component reads the current state of a Compute Engine VM
+instance and emits its details on the default output channel.
+
+### Use Cases
+
+- **Status checks**: Verify a VM is in the expected state (e.g. `RUNNING`) before
+ proceeding with downstream work.
+- **Detail lookup**: Fetch IPs, machine type, or selfLink for use in later workflow steps.
+- **Health gates**: Pair with a condition to branch a workflow based on instance status.
+
+### Configuration
+
+- **VM Instance**: Pick from the list of VMs in your project, or pass an expression chained
+ from an upstream node (e.g. `selfLink` from `gcp.createVM`). The
+ selection encodes both the zone and the instance name.
+
+### Output
+
+The emitted payload contains the full instance summary:
+
+- **instanceId**, **selfLink**, **status**, **zone**, **name**, **machineType**
+- **internalIP**, **externalIP** (when present)
+
+### Important Notes
+
+- If the instance is not found at the resolved zone/name, the action fails so that
+ misconfigured or stale expressions do not silently mask a missing resource.
+- The integration's bound project is authoritative; a chained `selfLink` pointing
+ at a different project is rejected rather than silently rewritten.
+
+### Example Output
+
+```json
+{
+ "data": {
+ "externalIP": "34.1.2.3",
+ "instanceId": "1234567890123456789",
+ "internalIP": "10.0.0.2",
+ "machineType": "e2-medium",
+ "name": "my-vm",
+ "selfLink": "https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a/instances/my-vm",
+ "status": "RUNNING",
+ "zone": "us-central1-a"
+ },
+ "timestamp": "2025-02-14T12:00:00Z",
+ "type": "gcp.compute.vmInstance.fetched"
+}
+```
+
## Compute • Get VM Metrics
diff --git a/pkg/integrations/gcp/compute/example.go b/pkg/integrations/gcp/compute/example.go
index 6c68e3f938..664265a98e 100644
--- a/pkg/integrations/gcp/compute/example.go
+++ b/pkg/integrations/gcp/compute/example.go
@@ -16,6 +16,9 @@ var exampleOutputDeleteVMInstanceBytes []byte
//go:embed example_data_on_vm_instance.json
var exampleDataOnVMInstanceBytes []byte
+//go:embed example_output_get_vm_instance.json
+var exampleOutputGetVMInstanceBytes []byte
+
//go:embed example_output_manage_vm_instance_power.json
var exampleOutputManageVMInstancePowerBytes []byte
@@ -35,6 +38,9 @@ var (
exampleDataOnVMInstanceOnce sync.Once
exampleDataOnVMInstance map[string]any
+ exampleOutputGetVMInstanceOnce sync.Once
+ exampleOutputGetVMInstance map[string]any
+
exampleOutputManageVMInstancePowerOnce sync.Once
exampleOutputManageVMInstancePower map[string]any
@@ -53,6 +59,10 @@ func (d *DeleteVMInstance) ExampleOutput() map[string]any {
return utils.UnmarshalEmbeddedJSON(&exampleOutputDeleteVMInstanceOnce, exampleOutputDeleteVMInstanceBytes, &exampleOutputDeleteVMInstance)
}
+func (g *GetVMInstance) ExampleOutput() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(&exampleOutputGetVMInstanceOnce, exampleOutputGetVMInstanceBytes, &exampleOutputGetVMInstance)
+}
+
func (t *OnVMInstance) ExampleData() map[string]any {
return utils.UnmarshalEmbeddedJSON(&exampleDataOnVMInstanceOnce, exampleDataOnVMInstanceBytes, &exampleDataOnVMInstance)
}
diff --git a/pkg/integrations/gcp/compute/example_output_get_vm_instance.json b/pkg/integrations/gcp/compute/example_output_get_vm_instance.json
new file mode 100644
index 0000000000..7dbfb54e5b
--- /dev/null
+++ b/pkg/integrations/gcp/compute/example_output_get_vm_instance.json
@@ -0,0 +1,14 @@
+{
+ "type": "gcp.compute.vmInstance.fetched",
+ "data": {
+ "instanceId": "1234567890123456789",
+ "selfLink": "https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a/instances/my-vm",
+ "internalIP": "10.0.0.2",
+ "externalIP": "34.1.2.3",
+ "status": "RUNNING",
+ "zone": "us-central1-a",
+ "name": "my-vm",
+ "machineType": "e2-medium"
+ },
+ "timestamp": "2025-02-14T12:00:00Z"
+}
diff --git a/pkg/integrations/gcp/compute/get_vm_instance.go b/pkg/integrations/gcp/compute/get_vm_instance.go
new file mode 100644
index 0000000000..f5a0b5da02
--- /dev/null
+++ b/pkg/integrations/gcp/compute/get_vm_instance.go
@@ -0,0 +1,177 @@
+package compute
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+type GetVMInstance struct{}
+
+type GetVMInstanceSpec struct {
+ Instance string `mapstructure:"instance"`
+}
+
+func (g *GetVMInstance) Name() string {
+ return "gcp.getVMInstance"
+}
+
+func (g *GetVMInstance) Label() string {
+ return "Compute • Get VM Instance"
+}
+
+func (g *GetVMInstance) Description() string {
+ return "Fetch the current state of a Google Compute Engine VM instance"
+}
+
+func (g *GetVMInstance) Documentation() string {
+ return `The Get VM Instance component reads the current state of a Compute Engine VM
+instance and emits its details on the default output channel.
+
+## Use Cases
+
+- **Status checks**: Verify a VM is in the expected state (e.g. ` + "`RUNNING`" + `) before
+ proceeding with downstream work.
+- **Detail lookup**: Fetch IPs, machine type, or selfLink for use in later workflow steps.
+- **Health gates**: Pair with a condition to branch a workflow based on instance status.
+
+## Configuration
+
+- **VM Instance**: Pick from the list of VMs in your project, or pass an expression chained
+ from an upstream node (e.g. ` + "`selfLink`" + ` from ` + "`gcp.createVM`" + `). The
+ selection encodes both the zone and the instance name.
+
+## Output
+
+The emitted payload contains the full instance summary:
+
+- **instanceId**, **selfLink**, **status**, **zone**, **name**, **machineType**
+- **internalIP**, **externalIP** (when present)
+
+## Important Notes
+
+- If the instance is not found at the resolved zone/name, the action fails so that
+ misconfigured or stale expressions do not silently mask a missing resource.
+- The integration's bound project is authoritative; a chained ` + "`selfLink`" + ` pointing
+ at a different project is rejected rather than silently rewritten.`
+}
+
+func (g *GetVMInstance) Icon() string {
+ return "search"
+}
+
+func (g *GetVMInstance) Color() string {
+ return "blue"
+}
+
+func (g *GetVMInstance) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{core.DefaultOutputChannel}
+}
+
+func (g *GetVMInstance) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "instance",
+ Label: "VM Instance",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The VM instance to fetch. Lists every VM in your project across all zones.",
+ Placeholder: "Select instance",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: ResourceTypeInstance,
+ },
+ },
+ },
+ }
+}
+
+func (g *GetVMInstance) Setup(ctx core.SetupContext) error {
+ spec := GetVMInstanceSpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return fmt.Errorf("error decoding configuration: %v", err)
+ }
+
+ instanceValue := strings.TrimSpace(spec.Instance)
+ if instanceValue == "" {
+ return fmt.Errorf("instance is required")
+ }
+
+ // Reuse the shared instance-metadata resolver (see instance_helpers.go),
+ // which the delete/power/update/metrics components also use.
+ return resolveInstanceNodeMetadata(ctx, instanceValue)
+}
+
+func (g *GetVMInstance) Execute(ctx core.ExecutionContext) error {
+ spec := GetVMInstanceSpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return ctx.ExecutionState.Fail("error", fmt.Sprintf("failed to decode configuration: %v", err))
+ }
+
+ urlProject, zone, instanceName, err := parseInstancePath(spec.Instance)
+ if err != nil {
+ return ctx.ExecutionState.Fail("error", err.Error())
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return ctx.ExecutionState.Fail("error", fmt.Sprintf("failed to create GCP client: %v", err))
+ }
+
+ project := client.ProjectID()
+ // If the value carried an explicit project (selfLink form), it must match
+ // the integration's bound project. Silently rewriting could fetch the
+ // wrong VM in a different project that happens to share the same name.
+ if urlProject != "" && urlProject != project {
+ return ctx.ExecutionState.Fail("error", fmt.Sprintf(
+ "instance belongs to project %q but this GCP integration is bound to project %q; cross-project reads are not supported",
+ urlProject, project,
+ ))
+ }
+
+ body, err := GetInstance(context.Background(), client, project, zone, instanceName)
+ if err != nil {
+ return ctx.ExecutionState.Fail("error", fmt.Sprintf("failed to get VM instance: %v", err))
+ }
+
+ payload, err := InstancePayloadFromGetResponse(body, zone)
+ if err != nil {
+ return ctx.ExecutionState.Fail("error", fmt.Sprintf("parse instance response: %v", err))
+ }
+
+ return ctx.ExecutionState.Emit(
+ core.DefaultOutputChannel.Name,
+ "gcp.compute.vmInstance.fetched",
+ []any{payload},
+ )
+}
+
+func (g *GetVMInstance) Cancel(ctx core.ExecutionContext) error {
+ return nil
+}
+
+func (g *GetVMInstance) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
+ return ctx.DefaultProcessing()
+}
+
+func (g *GetVMInstance) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) {
+ return http.StatusOK, nil, nil
+}
+
+func (g *GetVMInstance) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
+
+func (g *GetVMInstance) Hooks() []core.Hook {
+ return []core.Hook{}
+}
+
+func (g *GetVMInstance) HandleHook(ctx core.ActionHookContext) error {
+ return nil
+}
diff --git a/pkg/integrations/gcp/compute/get_vm_instance_test.go b/pkg/integrations/gcp/compute/get_vm_instance_test.go
new file mode 100644
index 0000000000..5497573da9
--- /dev/null
+++ b/pkg/integrations/gcp/compute/get_vm_instance_test.go
@@ -0,0 +1,340 @@
+package compute
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ gcpcommon "github.com/superplanehq/superplane/pkg/integrations/gcp/common"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+type mockGetClient struct {
+ projectID string
+ getFunc func(ctx context.Context, path string) ([]byte, error)
+}
+
+func (m *mockGetClient) Get(ctx context.Context, path string) ([]byte, error) {
+ if m.getFunc != nil {
+ return m.getFunc(ctx, path)
+ }
+ return nil, fmt.Errorf("not implemented")
+}
+
+func (m *mockGetClient) Post(ctx context.Context, path string, body any) ([]byte, error) {
+ return nil, fmt.Errorf("not implemented")
+}
+
+func (m *mockGetClient) Delete(ctx context.Context, path string) ([]byte, error) {
+ return nil, fmt.Errorf("not implemented")
+}
+
+func (m *mockGetClient) GetURL(ctx context.Context, fullURL string) ([]byte, error) {
+ return nil, fmt.Errorf("not implemented")
+}
+
+func (m *mockGetClient) ProjectID() string {
+ return m.projectID
+}
+
+// instanceResponse returns a minimal serialized instance GET response. Note
+// that GCE's API returns the instance id as a string-encoded number, which the
+// `instanceGetResp` struct deserializes via `json:"id,string"`.
+func instanceResponse(name, zone, status string) []byte {
+ b, _ := json.Marshal(map[string]any{
+ "id": "12345",
+ "name": name,
+ "status": status,
+ "zone": fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/p/zones/%s", zone),
+ "machineType": fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/p/zones/%s/machineTypes/f1-micro", zone),
+ "selfLink": fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/p/zones/%s/instances/%s", zone, name),
+ "networkInterfaces": []map[string]any{
+ {
+ "networkIP": "10.0.0.5",
+ "accessConfigs": []map[string]any{
+ {"natIP": "35.1.2.3"},
+ },
+ },
+ },
+ })
+ return b
+}
+
+func Test__GetVMInstance__Setup(t *testing.T) {
+ component := &GetVMInstance{}
+
+ t.Run("missing instance returns error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{},
+ Metadata: &contexts.MetadataContext{},
+ })
+ require.ErrorContains(t, err, "instance is required")
+ })
+
+ t.Run("empty instance returns error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{"instance": ""},
+ Metadata: &contexts.MetadataContext{},
+ })
+ require.ErrorContains(t, err, "instance is required")
+ })
+
+ t.Run("plain instance name is rejected", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{"instance": "my-vm"},
+ Metadata: &contexts.MetadataContext{},
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("expression instance is accepted without API call", func(t *testing.T) {
+ meta := &contexts.MetadataContext{}
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "instance": "{{ $.nodes.create.outputs.default[0].data.selfLink }}",
+ },
+ Metadata: meta,
+ })
+ require.NoError(t, err)
+ var stored VMInstanceNodeMetadata
+ require.NoError(t, mapstructure.Decode(meta.Get(), &stored))
+ assert.Equal(t, "{{ $.nodes.create.outputs.default[0].data.selfLink }}", stored.InstanceName)
+ })
+
+ t.Run("relative path stores parsed metadata", func(t *testing.T) {
+ meta := &contexts.MetadataContext{}
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "instance": "zones/us-central1-a/instances/my-vm",
+ },
+ Metadata: meta,
+ })
+ require.NoError(t, err)
+ var stored VMInstanceNodeMetadata
+ require.NoError(t, mapstructure.Decode(meta.Get(), &stored))
+ assert.Equal(t, "my-vm", stored.InstanceName)
+ assert.Equal(t, "us-central1-a", stored.Zone)
+ })
+
+ t.Run("selfLink URL stores parsed metadata", func(t *testing.T) {
+ meta := &contexts.MetadataContext{}
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "instance": "https://www.googleapis.com/compute/v1/projects/elffie/zones/europe-west1-b/instances/db-1",
+ },
+ Metadata: meta,
+ })
+ require.NoError(t, err)
+ var stored VMInstanceNodeMetadata
+ require.NoError(t, mapstructure.Decode(meta.Get(), &stored))
+ assert.Equal(t, "db-1", stored.InstanceName)
+ assert.Equal(t, "europe-west1-b", stored.Zone)
+ })
+}
+
+func Test__GetVMInstance__Execute(t *testing.T) {
+ component := &GetVMInstance{}
+
+ t.Run("successful fetch -> emits instance payload", func(t *testing.T) {
+ mc := &mockGetClient{
+ projectID: "my-project",
+ getFunc: func(ctx context.Context, path string) ([]byte, error) {
+ assert.True(t, strings.HasSuffix(path, "/zones/us-central1-a/instances/my-vm"))
+ return instanceResponse("my-vm", "us-central1-a", "RUNNING"), nil
+ },
+ }
+
+ SetClientFactory(func(ctx core.ExecutionContext) (Client, error) {
+ return mc, nil
+ })
+
+ state := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "instance": "zones/us-central1-a/instances/my-vm",
+ },
+ ExecutionState: state,
+ })
+
+ require.NoError(t, err)
+ assert.True(t, state.Passed)
+ assert.Equal(t, "default", state.Channel)
+ assert.Equal(t, "gcp.compute.vmInstance.fetched", state.Type)
+ require.Len(t, state.Payloads, 1)
+ wrapped := state.Payloads[0].(map[string]any)
+ data := wrapped["data"].(map[string]any)
+ assert.Equal(t, "my-vm", data["name"])
+ assert.Equal(t, "us-central1-a", data["zone"])
+ assert.Equal(t, "RUNNING", data["status"])
+ assert.Equal(t, "10.0.0.5", data["internalIP"])
+ assert.Equal(t, "35.1.2.3", data["externalIP"])
+ assert.Equal(t, "f1-micro", data["machineType"])
+ })
+
+ t.Run("selfLink form -> extracts zone and name", func(t *testing.T) {
+ var capturedPath string
+ mc := &mockGetClient{
+ projectID: "my-project",
+ getFunc: func(ctx context.Context, path string) ([]byte, error) {
+ capturedPath = path
+ return instanceResponse("my-vm", "us-central1-a", "RUNNING"), nil
+ },
+ }
+
+ SetClientFactory(func(ctx core.ExecutionContext) (Client, error) {
+ return mc, nil
+ })
+
+ state := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "instance": "https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a/instances/my-vm",
+ },
+ ExecutionState: state,
+ })
+
+ require.NoError(t, err)
+ assert.True(t, state.Passed)
+ assert.True(t, strings.Contains(capturedPath, "zones/us-central1-a/instances/my-vm"))
+ })
+
+ t.Run("instance not found (404) -> fails execution", func(t *testing.T) {
+ mc := &mockGetClient{
+ projectID: "my-project",
+ getFunc: func(ctx context.Context, path string) ([]byte, error) {
+ return nil, &gcpcommon.GCPAPIError{StatusCode: http.StatusNotFound, Message: "Instance not found"}
+ },
+ }
+
+ SetClientFactory(func(ctx core.ExecutionContext) (Client, error) {
+ return mc, nil
+ })
+
+ state := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "instance": "zones/us-central1-a/instances/my-vm",
+ },
+ ExecutionState: state,
+ })
+
+ require.NoError(t, err)
+ assert.False(t, state.Passed)
+ assert.True(t, state.Finished)
+ assert.Contains(t, state.FailureMessage, "failed to get VM instance")
+ })
+
+ t.Run("API error (not 404) -> fails execution", func(t *testing.T) {
+ mc := &mockGetClient{
+ projectID: "my-project",
+ getFunc: func(ctx context.Context, path string) ([]byte, error) {
+ return nil, &gcpcommon.GCPAPIError{StatusCode: http.StatusInternalServerError, Message: "internal error"}
+ },
+ }
+
+ SetClientFactory(func(ctx core.ExecutionContext) (Client, error) {
+ return mc, nil
+ })
+
+ state := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "instance": "zones/us-central1-a/instances/my-vm",
+ },
+ ExecutionState: state,
+ })
+
+ require.NoError(t, err)
+ assert.False(t, state.Passed)
+ assert.Contains(t, state.FailureMessage, "failed to get VM instance")
+ })
+
+ t.Run("unparseable response -> fails execution", func(t *testing.T) {
+ mc := &mockGetClient{
+ projectID: "my-project",
+ getFunc: func(ctx context.Context, path string) ([]byte, error) {
+ return []byte("not-json"), nil
+ },
+ }
+
+ SetClientFactory(func(ctx core.ExecutionContext) (Client, error) {
+ return mc, nil
+ })
+
+ state := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "instance": "zones/us-central1-a/instances/my-vm",
+ },
+ ExecutionState: state,
+ })
+
+ require.NoError(t, err)
+ assert.False(t, state.Passed)
+ assert.Contains(t, state.FailureMessage, "parse instance response")
+ })
+
+ t.Run("invalid instance value -> fails execution before any API call", func(t *testing.T) {
+ var called bool
+ mc := &mockGetClient{
+ projectID: "my-project",
+ getFunc: func(ctx context.Context, path string) ([]byte, error) {
+ called = true
+ return instanceResponse("my-vm", "us-central1-a", "RUNNING"), nil
+ },
+ }
+
+ SetClientFactory(func(ctx core.ExecutionContext) (Client, error) {
+ return mc, nil
+ })
+
+ state := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "instance": "just-a-name",
+ },
+ ExecutionState: state,
+ })
+
+ require.NoError(t, err)
+ assert.False(t, state.Passed)
+ assert.False(t, called, "Get API must not be called for an invalid instance value")
+ })
+
+ t.Run("cross-project selfLink -> fails execution before any API call", func(t *testing.T) {
+ var called bool
+ mc := &mockGetClient{
+ projectID: "my-project",
+ getFunc: func(ctx context.Context, path string) ([]byte, error) {
+ called = true
+ return instanceResponse("my-vm", "us-central1-a", "RUNNING"), nil
+ },
+ }
+
+ SetClientFactory(func(ctx core.ExecutionContext) (Client, error) {
+ return mc, nil
+ })
+
+ state := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "instance": "https://www.googleapis.com/compute/v1/projects/other-project/zones/us-central1-a/instances/my-vm",
+ },
+ ExecutionState: state,
+ })
+
+ require.NoError(t, err)
+ assert.False(t, state.Passed)
+ assert.False(t, called, "Get API must not be called when the URL project mismatches the integration")
+ assert.Contains(t, state.FailureMessage, "other-project")
+ assert.Contains(t, state.FailureMessage, "my-project")
+ assert.Contains(t, state.FailureMessage, "cross-project")
+ })
+}
diff --git a/pkg/integrations/gcp/gcp.go b/pkg/integrations/gcp/gcp.go
index 9d12cc9370..7159d5fcbb 100644
--- a/pkg/integrations/gcp/gcp.go
+++ b/pkg/integrations/gcp/gcp.go
@@ -163,6 +163,7 @@ func (g *GCP) Actions() []core.Action {
return []core.Action{
&compute.CreateVM{},
&compute.DeleteVMInstance{},
+ &compute.GetVMInstance{},
&compute.ManageVMInstancePower{},
&compute.UpdateVMInstanceType{},
&compute.GetVMInstanceMetrics{},
diff --git a/web_src/src/pages/workflowv2/mappers/gcp/delete_vm_instance.ts b/web_src/src/pages/workflowv2/mappers/gcp/delete_vm_instance.ts
index 467aa4251f..06d9e386a5 100644
--- a/web_src/src/pages/workflowv2/mappers/gcp/delete_vm_instance.ts
+++ b/web_src/src/pages/workflowv2/mappers/gcp/delete_vm_instance.ts
@@ -12,7 +12,7 @@ import type {
import type { MetadataItem } from "@/ui/metadataList";
import gcpIcon from "@/assets/icons/integrations/gcp.svg";
import { renderTimeAgo } from "@/components/TimeAgo";
-import { baseEventSections } from "./event_helpers";
+import { baseEventSections, parseInstancePath } from "./event_helpers";
interface VMInstanceNodeMetadata {
instanceName?: string;
@@ -28,15 +28,6 @@ interface DeleteVMInstanceOutputData {
zone?: string;
}
-function parseInstancePath(value: string | undefined): { zone: string; name: string } | null {
- if (!value) return null;
- const trimmed = value.trim();
- if (!trimmed || trimmed.includes("{{")) return null;
- const match = trimmed.match(/zones\/([^/]+)\/instances\/([^/?#]+)/);
- if (!match) return null;
- return { zone: match[1], name: match[2] };
-}
-
export const deleteVMInstanceMapper: ComponentBaseMapper = {
props(context: ComponentBaseContext): ComponentBaseProps {
const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null;
diff --git a/web_src/src/pages/workflowv2/mappers/gcp/event_helpers.ts b/web_src/src/pages/workflowv2/mappers/gcp/event_helpers.ts
index d5046df7ad..3596b899e1 100644
--- a/web_src/src/pages/workflowv2/mappers/gcp/event_helpers.ts
+++ b/web_src/src/pages/workflowv2/mappers/gcp/event_helpers.ts
@@ -3,6 +3,18 @@ import { renderTimeAgo } from "@/components/TimeAgo";
import { getState, getTriggerRenderer } from "..";
import type { ExecutionInfo, NodeInfo } from "../types";
+// parseInstancePath extracts the zone and name from a VM instance value of the
+// form `zones//instances/` (or a full selfLink). Returns null for
+// empty values or unresolved expressions. Shared by the VM mapper components.
+export function parseInstancePath(value: string | undefined): { zone: string; name: string } | null {
+ if (!value) return null;
+ const trimmed = value.trim();
+ if (!trimmed || trimmed.includes("{{")) return null;
+ const match = trimmed.match(/zones\/([^/]+)\/instances\/([^/?#]+)/);
+ if (!match) return null;
+ return { zone: match[1], name: match[2] };
+}
+
// baseEventSections builds the single event section shown on an action node from
// its most recent execution, deriving the title/subtitle from the root trigger.
// Pass eventStateOverride when a component resolves a custom event state (e.g.
diff --git a/web_src/src/pages/workflowv2/mappers/gcp/get_vm_instance.ts b/web_src/src/pages/workflowv2/mappers/gcp/get_vm_instance.ts
new file mode 100644
index 0000000000..e4a374fec7
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/gcp/get_vm_instance.ts
@@ -0,0 +1,100 @@
+import type { ComponentBaseProps } from "@/ui/componentBase";
+import type React from "react";
+import { getStateMap } from "..";
+import type {
+ ComponentBaseContext,
+ ComponentBaseMapper,
+ ExecutionDetailsContext,
+ NodeInfo,
+ OutputPayload,
+ SubtitleContext,
+} from "../types";
+import type { MetadataItem } from "@/ui/metadataList";
+import gcpIcon from "@/assets/icons/integrations/gcp.svg";
+import { renderTimeAgo } from "@/components/TimeAgo";
+import { baseEventSections, parseInstancePath } from "./event_helpers";
+
+interface VMInstanceNodeMetadata {
+ instanceName?: string;
+ zone?: string;
+}
+
+interface GetVMInstanceConfiguration {
+ instance?: string;
+}
+
+interface GetVMInstanceOutputData {
+ instanceId?: string;
+ selfLink?: string;
+ status?: string;
+ zone?: string;
+ name?: string;
+ machineType?: string;
+ internalIP?: string;
+ externalIP?: string;
+}
+
+export const getVMInstanceMapper: ComponentBaseMapper = {
+ props(context: ComponentBaseContext): ComponentBaseProps {
+ const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null;
+ const componentName = context.componentDefinition.name ?? "gcp";
+
+ return {
+ iconSrc: gcpIcon,
+ iconSlug: context.componentDefinition?.icon ?? "search",
+ collapsedBackground: "bg-white",
+ collapsed: context.node.isCollapsed,
+ title: context.node.name || context.componentDefinition?.label || "Get VM Instance",
+ eventSections: lastExecution ? baseEventSections(context.nodes, lastExecution, componentName) : undefined,
+ metadata: metadataList(context.node),
+ includeEmptyState: !lastExecution,
+ eventStateMap: getStateMap(componentName),
+ };
+ },
+
+ getExecutionDetails(context: ExecutionDetailsContext): Record {
+ const details: Record = {};
+
+ if (context.execution.createdAt) {
+ details["Executed At"] = new Date(context.execution.createdAt).toLocaleString();
+ }
+
+ const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined;
+ const result = outputs?.default?.[0]?.data as GetVMInstanceOutputData | undefined;
+ if (!result) return details;
+
+ if (result.name) details["Instance Name"] = result.name;
+ if (result.zone) details["Zone"] = result.zone;
+ if (result.status) details["Status"] = result.status;
+ if (result.machineType) details["Machine Type"] = result.machineType;
+ if (result.internalIP) details["Internal IP"] = result.internalIP;
+ if (result.externalIP) details["External IP"] = result.externalIP;
+ if (result.selfLink) details["Self Link"] = result.selfLink;
+
+ return details;
+ },
+
+ subtitle(context: SubtitleContext): string | React.ReactNode {
+ const timestamp = context.execution.updatedAt || context.execution.createdAt;
+ return timestamp ? renderTimeAgo(new Date(timestamp)) : "";
+ },
+};
+
+function metadataList(node: NodeInfo): MetadataItem[] {
+ const metadata: MetadataItem[] = [];
+ const nodeMetadata = node.metadata as VMInstanceNodeMetadata | undefined;
+ const configuration = node.configuration as GetVMInstanceConfiguration | undefined;
+
+ const parsed = parseInstancePath(configuration?.instance);
+ const instanceName = nodeMetadata?.instanceName || parsed?.name || configuration?.instance;
+ const zone = nodeMetadata?.zone || parsed?.zone;
+
+ if (instanceName) {
+ metadata.push({ icon: "search", label: instanceName });
+ }
+ if (zone) {
+ metadata.push({ icon: "map-pin", label: zone });
+ }
+
+ return metadata;
+}
diff --git a/web_src/src/pages/workflowv2/mappers/gcp/index.ts b/web_src/src/pages/workflowv2/mappers/gcp/index.ts
index a218f3a297..2ab842283f 100644
--- a/web_src/src/pages/workflowv2/mappers/gcp/index.ts
+++ b/web_src/src/pages/workflowv2/mappers/gcp/index.ts
@@ -20,6 +20,7 @@ import {
import { onMessageTriggerRenderer } from "./on_message";
import { cloudDNSMapper } from "./clouddns";
import { deleteVMInstanceMapper } from "./delete_vm_instance";
+import { getVMInstanceMapper } from "./get_vm_instance";
import { manageVMInstancePowerMapper, MANAGE_VM_INSTANCE_POWER_STATE_REGISTRY } from "./manage_vm_instance_power";
import { updateVMInstanceTypeMapper } from "./update_vm_instance_type";
import { getVMInstanceMetricsMapper, GET_VM_INSTANCE_METRICS_STATE_REGISTRY } from "./get_vm_instance_metrics";
@@ -27,6 +28,7 @@ import { getVMInstanceMetricsMapper, GET_VM_INSTANCE_METRICS_STATE_REGISTRY } fr
export const componentMappers: Record = {
createVM: baseMapper,
deleteVMInstance: deleteVMInstanceMapper,
+ getVMInstance: getVMInstanceMapper,
manageVMInstancePower: manageVMInstancePowerMapper,
updateVMInstanceType: updateVMInstanceTypeMapper,
getVMInstanceMetrics: getVMInstanceMetricsMapper,
@@ -57,6 +59,7 @@ export const triggerRenderers: Record = {
export const eventStateRegistry: Record = {
createVM: buildActionStateRegistry("completed"),
deleteVMInstance: buildActionStateRegistry("completed"),
+ getVMInstance: buildActionStateRegistry("completed"),
manageVMInstancePower: MANAGE_VM_INSTANCE_POWER_STATE_REGISTRY,
updateVMInstanceType: buildActionStateRegistry("completed"),
getVMInstanceMetrics: GET_VM_INSTANCE_METRICS_STATE_REGISTRY,