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,