From 7a6b5d0b68ce2f514bb1027f0df8786961d8143a Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Fri, 22 May 2026 10:41:10 +0300 Subject: [PATCH 1/5] feat: add gcp get vm instance component Signed-off-by: WashingtonKK --- pkg/integrations/gcp/compute/example.go | 10 + .../example_output_get_vm_instance.json | 14 + .../gcp/compute/get_vm_instance.go | 198 +++++++++ .../gcp/compute/get_vm_instance_test.go | 378 ++++++++++++++++++ .../gcp/compute/list_resource_handler.go | 42 ++ pkg/integrations/gcp/gcp.go | 1 + .../workflowv2/mappers/gcp/get_vm_instance.ts | 137 +++++++ .../src/pages/workflowv2/mappers/gcp/index.ts | 3 + 8 files changed, 783 insertions(+) create mode 100644 pkg/integrations/gcp/compute/example_output_get_vm_instance.json create mode 100644 pkg/integrations/gcp/compute/get_vm_instance.go create mode 100644 pkg/integrations/gcp/compute/get_vm_instance_test.go create mode 100644 web_src/src/pages/workflowv2/mappers/gcp/get_vm_instance.ts diff --git a/pkg/integrations/gcp/compute/example.go b/pkg/integrations/gcp/compute/example.go index 75fc79ce45..4fbab8c3d9 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 + var ( exampleOutputCreateVMOnce sync.Once exampleOutputCreateVM map[string]any @@ -25,6 +28,9 @@ var ( exampleDataOnVMInstanceOnce sync.Once exampleDataOnVMInstance map[string]any + + exampleOutputGetVMInstanceOnce sync.Once + exampleOutputGetVMInstance map[string]any ) func (c *CreateVM) ExampleOutput() map[string]any { @@ -35,6 +41,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..5609875655 --- /dev/null +++ b/pkg/integrations/gcp/compute/get_vm_instance.go @@ -0,0 +1,198 @@ +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") + } + + // Expressions are resolved at execution time. Store the raw value so the UI + // can still display something meaningful in the collapsed node. + if strings.Contains(instanceValue, "{{") { + return ctx.Metadata.Set(VMInstanceNodeMetadata{ + InstanceName: instanceValue, + }) + } + + _, zone, name, err := ParseInstancePath(instanceValue) + if err != nil { + return err + } + + return ctx.Metadata.Set(VMInstanceNodeMetadata{ + InstanceName: name, + Zone: zone, + }) +} + +// VMInstanceNodeMetadata is stored by Setup so the frontend can display +// instance name + zone in the collapsed node view. +type VMInstanceNodeMetadata struct { + InstanceName string `json:"instanceName" mapstructure:"instanceName"` + Zone string `json:"zone" mapstructure:"zone"` +} + +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..792b528604 --- /dev/null +++ b/pkg/integrations/gcp/compute/get_vm_instance_test.go @@ -0,0 +1,378 @@ +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) 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__ParseInstancePath(t *testing.T) { + t.Run("relative path", func(t *testing.T) { + project, zone, name, err := ParseInstancePath("zones/us-central1-a/instances/my-vm") + require.NoError(t, err) + assert.Equal(t, "", project) + assert.Equal(t, "us-central1-a", zone) + assert.Equal(t, "my-vm", name) + }) + + t.Run("full selfLink URL", func(t *testing.T) { + selfLink := "https://www.googleapis.com/compute/v1/projects/elffie/zones/europe-west1-b/instances/web-server-01" + project, zone, name, err := ParseInstancePath(selfLink) + require.NoError(t, err) + assert.Equal(t, "elffie", project) + assert.Equal(t, "europe-west1-b", zone) + assert.Equal(t, "web-server-01", name) + }) + + t.Run("project-qualified relative path", func(t *testing.T) { + project, zone, name, err := ParseInstancePath("projects/elffie/zones/us-east1-c/instances/db-1") + require.NoError(t, err) + assert.Equal(t, "elffie", project) + assert.Equal(t, "us-east1-c", zone) + assert.Equal(t, "db-1", name) + }) + + t.Run("plain name is rejected", func(t *testing.T) { + _, _, _, err := ParseInstancePath("just-a-name") + require.Error(t, err) + }) + + t.Run("empty value is rejected", func(t *testing.T) { + _, _, _, err := ParseInstancePath("") + require.Error(t, err) + }) + + t.Run("missing instances segment is rejected", func(t *testing.T) { + _, _, _, err := ParseInstancePath("zones/us-central1-a/foo/my-vm") + require.Error(t, err) + }) +} + +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/compute/list_resource_handler.go b/pkg/integrations/gcp/compute/list_resource_handler.go index 5b5972a315..2ee7937d5c 100644 --- a/pkg/integrations/gcp/compute/list_resource_handler.go +++ b/pkg/integrations/gcp/compute/list_resource_handler.go @@ -1393,6 +1393,48 @@ func ListInstances(ctx context.Context, c Client, project string) ([]Instance, e return all, nil } +// ParseInstancePath extracts (project, zone, name) from a value of the form +// `zones//instances/` (relative path) or a full GCE selfLink URL +// containing `projects//zones//instances/`. The project +// segment is optional — relative paths from the dropdown have no project, but +// chained selfLinks do, and the caller must verify it matches the integration's +// bound project before issuing the API call. +func ParseInstancePath(value string) (project, zone, name string, err error) { + s := strings.TrimSpace(value) + if s == "" { + return "", "", "", fmt.Errorf("instance is required") + } + if idx := strings.Index(s, "projects/"); idx >= 0 { + rest := s[idx+len("projects/"):] + if slash := strings.Index(rest, "/"); slash > 0 { + project = rest[:slash] + } + } + idx := strings.Index(s, "zones/") + if idx < 0 { + return "", "", "", fmt.Errorf("instance %q must be a path like zones//instances/ or a GCE selfLink URL", value) + } + rest := s[idx+len("zones/"):] + slash := strings.Index(rest, "/") + if slash <= 0 { + return "", "", "", fmt.Errorf("instance %q is missing a zone segment", value) + } + zone = rest[:slash] + after := rest[slash+1:] + const prefix = "instances/" + if !strings.HasPrefix(after, prefix) { + return "", "", "", fmt.Errorf("instance %q is missing an instances/ segment", value) + } + name = after[len(prefix):] + if q := strings.IndexAny(name, "/?#"); q >= 0 { + name = name[:q] + } + if zone == "" || name == "" { + return "", "", "", fmt.Errorf("instance %q is missing a zone or name", value) + } + return project, zone, name, nil +} + func ListInstanceResources(ctx context.Context, c Client, project string) ([]core.IntegrationResource, error) { list, err := ListInstances(ctx, c, project) if err != nil { diff --git a/pkg/integrations/gcp/gcp.go b/pkg/integrations/gcp/gcp.go index 5c66af4919..7f4b943efa 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{}, &cloudbuild.CreateBuild{}, &cloudbuild.GetBuild{}, &cloudbuild.RunTrigger{}, 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..19345e9d78 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/gcp/get_vm_instance.ts @@ -0,0 +1,137 @@ +import type { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import type React from "react"; +import { getState, getStateMap, getTriggerRenderer } from ".."; +import type { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import type { MetadataItem } from "@/ui/metadataList"; +import gcpIcon from "@/assets/icons/integrations/gcp.svg"; +import { renderTimeAgo } from "@/components/TimeAgo"; + +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; +} + +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 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; +} + +function baseEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + const rootEvent = execution.rootEvent; + if (!rootEvent?.nodeId) { + return []; + } + + const rootTriggerNode = nodes.find((n) => n.id === rootEvent.nodeId); + if (!rootTriggerNode?.componentName) { + return []; + } + + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode.componentName); + const { title, subtitle } = rootTriggerRenderer.getTitleAndSubtitle({ event: rootEvent }); + const subtitleTimestamp = execution.updatedAt || execution.createdAt; + const fallbackSubtitle = subtitleTimestamp ? renderTimeAgo(new Date(subtitleTimestamp)) : ""; + const eventSubtitle = subtitle || fallbackSubtitle; + + return [ + { + receivedAt: new Date(execution.createdAt!), + eventTitle: title, + eventSubtitle, + eventState: getState(componentName)(execution), + eventId: rootEvent.id!, + }, + ]; +} diff --git a/web_src/src/pages/workflowv2/mappers/gcp/index.ts b/web_src/src/pages/workflowv2/mappers/gcp/index.ts index d18ad67003..83bb069836 100644 --- a/web_src/src/pages/workflowv2/mappers/gcp/index.ts +++ b/web_src/src/pages/workflowv2/mappers/gcp/index.ts @@ -20,10 +20,12 @@ import { import { onMessageTriggerRenderer } from "./on_message"; import { cloudDNSMapper } from "./clouddns"; import { deleteVMInstanceMapper } from "./delete_vm_instance"; +import { getVMInstanceMapper } from "./get_vm_instance"; export const componentMappers: Record = { createVM: baseMapper, deleteVMInstance: deleteVMInstanceMapper, + getVMInstance: getVMInstanceMapper, "cloudbuild.createBuild": cloudBuildBaseMapper, "cloudbuild.getBuild": cloudBuildBaseMapper, "cloudbuild.runTrigger": runTriggerMapper, @@ -51,6 +53,7 @@ export const triggerRenderers: Record = { export const eventStateRegistry: Record = { createVM: buildActionStateRegistry("completed"), deleteVMInstance: buildActionStateRegistry("completed"), + getVMInstance: buildActionStateRegistry("completed"), "cloudbuild.createBuild": CLOUD_BUILD_EXECUTION_STATE_REGISTRY, "cloudbuild.getBuild": CLOUD_BUILD_EXECUTION_STATE_REGISTRY, "cloudbuild.runTrigger": CLOUD_BUILD_EXECUTION_STATE_REGISTRY, From 31889f052bad1624f11c2f51114b8c783d3cbb7c Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Tue, 26 May 2026 10:40:23 +0300 Subject: [PATCH 2/5] feat: add "Get VM Instance" component to Google Cloud documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced a new LinkCard for "Compute • Get VM Instance" in the GoogleCloud.mdx file. - Added detailed documentation for the new component, including use cases, configuration options, output structure, and important notes. Signed-off-by: WashingtonKK --- docs/components/GoogleCloud.mdx | 54 +++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/components/GoogleCloud.mdx b/docs/components/GoogleCloud.mdx index 958159eaea..b522477ab5 100644 --- a/docs/components/GoogleCloud.mdx +++ b/docs/components/GoogleCloud.mdx @@ -30,6 +30,7 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + @@ -798,6 +799,59 @@ Returns information about the deleted instance: } ``` + + +## Compute • Get VM Instance + +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" +} +``` + ## Pub/Sub • Create Subscription From 91c396ea80910908b69b2578ed67817e842f63dd Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Tue, 26 May 2026 12:19:31 +0300 Subject: [PATCH 3/5] fix: resolve duplicate symbols between GetVMInstance and DeleteVMInstance - Remove duplicate VMInstanceNodeMetadata type from get_vm_instance.go - Remove duplicate ParseInstancePath export from list_resource_handler.go; reuse existing private parseInstancePath from delete_vm_instance.go - Update get_vm_instance.go callers to use the private function - Add missing Delete method to mockGetClient to satisfy Client interface - Remove duplicate Test__ParseInstancePath from get_vm_instance_test.go Co-Authored-By: WashingtonKK Signed-off-by: WashingtonKK --- .../gcp/compute/get_vm_instance.go | 11 +---- .../gcp/compute/get_vm_instance_test.go | 46 ++----------------- .../gcp/compute/list_resource_handler.go | 42 ----------------- 3 files changed, 6 insertions(+), 93 deletions(-) diff --git a/pkg/integrations/gcp/compute/get_vm_instance.go b/pkg/integrations/gcp/compute/get_vm_instance.go index 5609875655..8406c094c7 100644 --- a/pkg/integrations/gcp/compute/get_vm_instance.go +++ b/pkg/integrations/gcp/compute/get_vm_instance.go @@ -111,7 +111,7 @@ func (g *GetVMInstance) Setup(ctx core.SetupContext) error { }) } - _, zone, name, err := ParseInstancePath(instanceValue) + _, zone, name, err := parseInstancePath(instanceValue) if err != nil { return err } @@ -122,20 +122,13 @@ func (g *GetVMInstance) Setup(ctx core.SetupContext) error { }) } -// VMInstanceNodeMetadata is stored by Setup so the frontend can display -// instance name + zone in the collapsed node view. -type VMInstanceNodeMetadata struct { - InstanceName string `json:"instanceName" mapstructure:"instanceName"` - Zone string `json:"zone" mapstructure:"zone"` -} - 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) + urlProject, zone, instanceName, err := parseInstancePath(spec.Instance) if err != nil { return ctx.ExecutionState.Fail("error", err.Error()) } diff --git a/pkg/integrations/gcp/compute/get_vm_instance_test.go b/pkg/integrations/gcp/compute/get_vm_instance_test.go index 792b528604..5497573da9 100644 --- a/pkg/integrations/gcp/compute/get_vm_instance_test.go +++ b/pkg/integrations/gcp/compute/get_vm_instance_test.go @@ -32,6 +32,10 @@ func (m *mockGetClient) Post(ctx context.Context, path string, body any) ([]byte 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") } @@ -63,48 +67,6 @@ func instanceResponse(name, zone, status string) []byte { return b } -func Test__ParseInstancePath(t *testing.T) { - t.Run("relative path", func(t *testing.T) { - project, zone, name, err := ParseInstancePath("zones/us-central1-a/instances/my-vm") - require.NoError(t, err) - assert.Equal(t, "", project) - assert.Equal(t, "us-central1-a", zone) - assert.Equal(t, "my-vm", name) - }) - - t.Run("full selfLink URL", func(t *testing.T) { - selfLink := "https://www.googleapis.com/compute/v1/projects/elffie/zones/europe-west1-b/instances/web-server-01" - project, zone, name, err := ParseInstancePath(selfLink) - require.NoError(t, err) - assert.Equal(t, "elffie", project) - assert.Equal(t, "europe-west1-b", zone) - assert.Equal(t, "web-server-01", name) - }) - - t.Run("project-qualified relative path", func(t *testing.T) { - project, zone, name, err := ParseInstancePath("projects/elffie/zones/us-east1-c/instances/db-1") - require.NoError(t, err) - assert.Equal(t, "elffie", project) - assert.Equal(t, "us-east1-c", zone) - assert.Equal(t, "db-1", name) - }) - - t.Run("plain name is rejected", func(t *testing.T) { - _, _, _, err := ParseInstancePath("just-a-name") - require.Error(t, err) - }) - - t.Run("empty value is rejected", func(t *testing.T) { - _, _, _, err := ParseInstancePath("") - require.Error(t, err) - }) - - t.Run("missing instances segment is rejected", func(t *testing.T) { - _, _, _, err := ParseInstancePath("zones/us-central1-a/foo/my-vm") - require.Error(t, err) - }) -} - func Test__GetVMInstance__Setup(t *testing.T) { component := &GetVMInstance{} diff --git a/pkg/integrations/gcp/compute/list_resource_handler.go b/pkg/integrations/gcp/compute/list_resource_handler.go index 2ee7937d5c..5b5972a315 100644 --- a/pkg/integrations/gcp/compute/list_resource_handler.go +++ b/pkg/integrations/gcp/compute/list_resource_handler.go @@ -1393,48 +1393,6 @@ func ListInstances(ctx context.Context, c Client, project string) ([]Instance, e return all, nil } -// ParseInstancePath extracts (project, zone, name) from a value of the form -// `zones//instances/` (relative path) or a full GCE selfLink URL -// containing `projects//zones//instances/`. The project -// segment is optional — relative paths from the dropdown have no project, but -// chained selfLinks do, and the caller must verify it matches the integration's -// bound project before issuing the API call. -func ParseInstancePath(value string) (project, zone, name string, err error) { - s := strings.TrimSpace(value) - if s == "" { - return "", "", "", fmt.Errorf("instance is required") - } - if idx := strings.Index(s, "projects/"); idx >= 0 { - rest := s[idx+len("projects/"):] - if slash := strings.Index(rest, "/"); slash > 0 { - project = rest[:slash] - } - } - idx := strings.Index(s, "zones/") - if idx < 0 { - return "", "", "", fmt.Errorf("instance %q must be a path like zones//instances/ or a GCE selfLink URL", value) - } - rest := s[idx+len("zones/"):] - slash := strings.Index(rest, "/") - if slash <= 0 { - return "", "", "", fmt.Errorf("instance %q is missing a zone segment", value) - } - zone = rest[:slash] - after := rest[slash+1:] - const prefix = "instances/" - if !strings.HasPrefix(after, prefix) { - return "", "", "", fmt.Errorf("instance %q is missing an instances/ segment", value) - } - name = after[len(prefix):] - if q := strings.IndexAny(name, "/?#"); q >= 0 { - name = name[:q] - } - if zone == "" || name == "" { - return "", "", "", fmt.Errorf("instance %q is missing a zone or name", value) - } - return project, zone, name, nil -} - func ListInstanceResources(ctx context.Context, c Client, project string) ([]core.IntegrationResource, error) { list, err := ListInstances(ctx, c, project) if err != nil { From 07ba0bc84a440f203209573686c728a548aa781d Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Fri, 29 May 2026 17:45:51 +0300 Subject: [PATCH 4/5] refactor: consolidate GetVMInstance onto shared event/metadata helpers Signed-off-by: WashingtonKK --- .../gcp/compute/get_vm_instance.go | 20 ++--------- .../workflowv2/mappers/gcp/get_vm_instance.ts | 34 ++----------------- 2 files changed, 6 insertions(+), 48 deletions(-) diff --git a/pkg/integrations/gcp/compute/get_vm_instance.go b/pkg/integrations/gcp/compute/get_vm_instance.go index 8406c094c7..f5a0b5da02 100644 --- a/pkg/integrations/gcp/compute/get_vm_instance.go +++ b/pkg/integrations/gcp/compute/get_vm_instance.go @@ -103,23 +103,9 @@ func (g *GetVMInstance) Setup(ctx core.SetupContext) error { return fmt.Errorf("instance is required") } - // Expressions are resolved at execution time. Store the raw value so the UI - // can still display something meaningful in the collapsed node. - if strings.Contains(instanceValue, "{{") { - return ctx.Metadata.Set(VMInstanceNodeMetadata{ - InstanceName: instanceValue, - }) - } - - _, zone, name, err := parseInstancePath(instanceValue) - if err != nil { - return err - } - - return ctx.Metadata.Set(VMInstanceNodeMetadata{ - InstanceName: name, - Zone: zone, - }) + // 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 { 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 index 19345e9d78..09c3648e42 100644 --- a/web_src/src/pages/workflowv2/mappers/gcp/get_vm_instance.ts +++ b/web_src/src/pages/workflowv2/mappers/gcp/get_vm_instance.ts @@ -1,11 +1,10 @@ -import type { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import type { ComponentBaseProps } from "@/ui/componentBase"; import type React from "react"; -import { getState, getStateMap, getTriggerRenderer } from ".."; +import { getStateMap } from ".."; import type { ComponentBaseContext, ComponentBaseMapper, ExecutionDetailsContext, - ExecutionInfo, NodeInfo, OutputPayload, SubtitleContext, @@ -13,6 +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"; interface VMInstanceNodeMetadata { instanceName?: string; @@ -107,31 +107,3 @@ function metadataList(node: NodeInfo): MetadataItem[] { return metadata; } - -function baseEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { - const rootEvent = execution.rootEvent; - if (!rootEvent?.nodeId) { - return []; - } - - const rootTriggerNode = nodes.find((n) => n.id === rootEvent.nodeId); - if (!rootTriggerNode?.componentName) { - return []; - } - - const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode.componentName); - const { title, subtitle } = rootTriggerRenderer.getTitleAndSubtitle({ event: rootEvent }); - const subtitleTimestamp = execution.updatedAt || execution.createdAt; - const fallbackSubtitle = subtitleTimestamp ? renderTimeAgo(new Date(subtitleTimestamp)) : ""; - const eventSubtitle = subtitle || fallbackSubtitle; - - return [ - { - receivedAt: new Date(execution.createdAt!), - eventTitle: title, - eventSubtitle, - eventState: getState(componentName)(execution), - eventId: rootEvent.id!, - }, - ]; -} From 1979b43c166c2c6c92c3d3528e194015fca5a2b2 Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Fri, 29 May 2026 18:01:02 +0300 Subject: [PATCH 5/5] refactor: share parseInstancePath helper across GCP VM mappers Signed-off-by: WashingtonKK --- .../workflowv2/mappers/gcp/delete_vm_instance.ts | 11 +---------- .../pages/workflowv2/mappers/gcp/event_helpers.ts | 12 ++++++++++++ .../pages/workflowv2/mappers/gcp/get_vm_instance.ts | 11 +---------- 3 files changed, 14 insertions(+), 20 deletions(-) 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 index 09c3648e42..e4a374fec7 100644 --- a/web_src/src/pages/workflowv2/mappers/gcp/get_vm_instance.ts +++ b/web_src/src/pages/workflowv2/mappers/gcp/get_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; @@ -34,15 +34,6 @@ interface GetVMInstanceOutputData { externalIP?: 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 getVMInstanceMapper: ComponentBaseMapper = { props(context: ComponentBaseContext): ComponentBaseProps { const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null;