From 19925bde94d27fb6c9dede410c0e6f001f3a8876 Mon Sep 17 00:00:00 2001 From: Shang En Sim <6071014+12458@users.noreply.github.com> Date: Sat, 30 May 2026 13:39:53 -0700 Subject: [PATCH 1/7] feat(railway): add base railway integration and setup wizard Signed-off-by: Shang En Sim <6071014+12458@users.noreply.github.com> --- pkg/integrations/railway/railway.go | 183 ++++++++++++ pkg/integrations/railway/setup_provider.go | 270 ++++++++++++++++++ .../railway/setup_provider_test.go | 265 +++++++++++++++++ .../templates/api-token-instructions.tpl | 6 + .../railway/templates/setup-complete.tpl | 7 + pkg/registryimports/registryimports.go | 1 + 6 files changed, 732 insertions(+) create mode 100644 pkg/integrations/railway/railway.go create mode 100644 pkg/integrations/railway/setup_provider.go create mode 100644 pkg/integrations/railway/setup_provider_test.go create mode 100644 pkg/integrations/railway/templates/api-token-instructions.tpl create mode 100644 pkg/integrations/railway/templates/setup-complete.tpl diff --git a/pkg/integrations/railway/railway.go b/pkg/integrations/railway/railway.go new file mode 100644 index 0000000000..f8b8a42243 --- /dev/null +++ b/pkg/integrations/railway/railway.go @@ -0,0 +1,183 @@ +package railway + +import ( + "fmt" + "strings" + + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/registry" +) + +func init() { + registry.RegisterIntegrationWithOptions("railway", &Railway{}, registry.IntegrationRegistrationOptions{ + WebhookHandler: &RailwayWebhookHandler{}, + SetupProvider: &SetupProvider{}, + }) +} + +type Railway struct{} + +func (r *Railway) Name() string { + return "railway" +} + +func (r *Railway) Label() string { + return "Railway" +} + +func (r *Railway) Icon() string { + return "railway" +} + +func (r *Railway) Description() string { + return "Deploy services and react to deployment events on Railway" +} + +func (r *Railway) Instructions() string { + return "" +} + +func (r *Railway) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "apiToken", + Label: "API Token", + Type: configuration.FieldTypeString, + Sensitive: true, + Description: "Railway Workspace API Token", + Required: true, + }, + } +} + +func (r *Railway) Actions() []core.Action { + return []core.Action{ + &TriggerDeploy{}, + } +} + +func (r *Railway) Triggers() []core.Trigger { + return []core.Trigger{ + &OnDeploymentEvent{}, + } +} + +func (r *Railway) Cleanup(ctx core.IntegrationCleanupContext) error { + return nil +} + +func (r *Railway) Sync(ctx core.SyncContext) error { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return err + } + + if err := client.Verify(); err != nil { + return fmt.Errorf("failed to verify Railway credentials: %w", err) + } + + ctx.Integration.Ready() + return nil +} + +func (r *Railway) HandleRequest(ctx core.HTTPRequestContext) { + // no-op +} + +func (r *Railway) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, err + } + + switch resourceType { + case "project": + return r.listProjects(client) + case "service": + return r.listServices(client, ctx.Parameters["project"]) + case "environment": + return r.listEnvironments(client, ctx.Parameters["project"]) + default: + return []core.IntegrationResource{}, nil + } +} + +func (r *Railway) listProjects(client *Client) ([]core.IntegrationResource, error) { + workspaces, err := client.ListWorkspaces() + if err != nil { + return nil, err + } + + var resources []core.IntegrationResource + for _, workspace := range workspaces { + projects, err := client.ListProjects(workspace.ID) + if err != nil { + return nil, err + } + + for _, p := range projects { + resources = append(resources, core.IntegrationResource{ + Type: "project", + ID: p.ID, + Name: p.Name, + }) + } + } + + return resources, nil +} + +func (r *Railway) listServices(client *Client, projectID string) ([]core.IntegrationResource, error) { + projectID = strings.TrimSpace(projectID) + if projectID == "" || strings.Contains(projectID, "{{") { + return []core.IntegrationResource{}, nil + } + + project, err := client.GetProjectDetails(projectID) + if err != nil { + return nil, err + } + + var resources []core.IntegrationResource + for _, edge := range project.Services.Edges { + resources = append(resources, core.IntegrationResource{ + Type: "service", + ID: edge.Node.ID, + Name: edge.Node.Name, + }) + } + + return resources, nil +} + +func (r *Railway) listEnvironments(client *Client, projectID string) ([]core.IntegrationResource, error) { + projectID = strings.TrimSpace(projectID) + if projectID == "" || strings.Contains(projectID, "{{") { + return []core.IntegrationResource{}, nil + } + + project, err := client.GetProjectDetails(projectID) + if err != nil { + return nil, err + } + + var resources []core.IntegrationResource + for _, edge := range project.Environments.Edges { + resources = append(resources, core.IntegrationResource{ + Type: "environment", + ID: edge.Node.ID, + Name: edge.Node.Name, + }) + } + + return resources, nil +} + +func (r *Railway) Hooks() []core.Hook { + return []core.Hook{} +} + +func (r *Railway) HandleHook(ctx core.IntegrationHookContext) error { + return nil +} diff --git a/pkg/integrations/railway/setup_provider.go b/pkg/integrations/railway/setup_provider.go new file mode 100644 index 0000000000..02b26aea45 --- /dev/null +++ b/pkg/integrations/railway/setup_provider.go @@ -0,0 +1,270 @@ +package railway + +import ( + "bytes" + _ "embed" + "errors" + "fmt" + "slices" + "strings" + "text/template" + + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const ( + SetupStepCapabilitySelection = "capabilitySelection" + SetupStepEnterAPIToken = "enterAPIToken" + SetupStepDone = "done" +) + +const ( + SecretAPIToken = "apiToken" + PropertyWorkspaceID = "workspaceId" + PropertyWorkspaceName = "workspaceName" +) + +//go:embed templates/api-token-instructions.tpl +var apiTokenInstructionsTemplate []byte + +//go:embed templates/setup-complete.tpl +var setupCompleteTemplate []byte + +type SetupProvider struct{} + +func (s *SetupProvider) genCapabilities(actions []core.Action, triggers []core.Trigger) []core.Capability { + capabilities := []core.Capability{} + for _, action := range actions { + capabilities = append(capabilities, core.Capability{ + Type: core.IntegrationCapabilityTypeAction, + Name: action.Name(), + Label: action.Label(), + Description: action.Description(), + Configuration: action.Configuration(), + OutputChannels: action.OutputChannels(nil), + ExampleOutput: action.ExampleOutput(), + }) + } + for _, trigger := range triggers { + capabilities = append(capabilities, core.Capability{ + Type: core.IntegrationCapabilityTypeTrigger, + Name: trigger.Name(), + Label: trigger.Label(), + Description: trigger.Description(), + Configuration: trigger.Configuration(), + ExampleData: trigger.ExampleData(), + }) + } + return capabilities +} + +func (s *SetupProvider) capabilityDiff(capabilities []string) []string { + groups := s.CapabilityGroups() + diff := []string{} + for _, group := range groups { + for _, capability := range group.Capabilities { + if !slices.Contains(capabilities, capability.Name) { + diff = append(diff, capability.Name) + } + } + } + return diff +} + +func (s *SetupProvider) CapabilityGroups() []core.CapabilityGroup { + return []core.CapabilityGroup{ + { + Label: "Deployments", + Capabilities: s.genCapabilities( + []core.Action{ + &TriggerDeploy{}, + }, + []core.Trigger{ + &OnDeploymentEvent{}, + }, + ), + }, + } +} + +func (s *SetupProvider) OnCapabilityUpdate(ctx core.CapabilityUpdateContext) (*core.SetupStep, error) { + requested, ok := ctx.Changes[core.IntegrationCapabilityStateRequested] + if !ok { + return nil, errors.New("no requested capabilities") + } + + ctx.Capabilities.Enable(requested...) + return nil, nil +} + +func (s *SetupProvider) FirstStep(ctx core.SetupStepContext) core.SetupStep { + capabilities := []string{} + for _, group := range s.CapabilityGroups() { + for _, capability := range group.Capabilities { + capabilities = append(capabilities, capability.Name) + } + } + + return core.SetupStep{ + Type: core.SetupStepTypeCapabilitySelection, + Name: SetupStepCapabilitySelection, + Label: "Select capabilities", + Capabilities: capabilities, + } +} + +func (s *SetupProvider) OnStepSubmit(ctx core.SetupStepContext) (*core.SetupStep, error) { + switch ctx.Step.Name { + case SetupStepCapabilitySelection: + return s.onCapabilitySelectionSubmit(ctx) + case SetupStepEnterAPIToken: + return s.onEnterAPITokenSubmit(ctx.Step.Inputs, ctx) + } + return nil, errors.New("unknown step") +} + +func (s *SetupProvider) onCapabilitySelectionSubmit(ctx core.SetupStepContext) (*core.SetupStep, error) { + ctx.Capabilities.Request(ctx.Step.Capabilities...) + ctx.Capabilities.Available(s.capabilityDiff(ctx.Step.Capabilities)...) + + return &core.SetupStep{ + Type: core.SetupStepTypeInputs, + Name: SetupStepEnterAPIToken, + Label: "Enter Railway Workspace Token", + Inputs: []configuration.Field{ + { + Name: SecretAPIToken, + Label: "API Token", + Type: configuration.FieldTypeString, + Required: true, + Sensitive: true, + }, + }, + Instructions: string(apiTokenInstructionsTemplate), + }, nil +} + +func (s *SetupProvider) onEnterAPITokenSubmit(input any, ctx core.SetupStepContext) (*core.SetupStep, error) { + m, ok := input.(map[string]any) + if !ok { + return nil, errors.New("invalid input") + } + + apiToken, ok := m[SecretAPIToken].(string) + if !ok { + return nil, errors.New("invalid API token") + } + + apiToken = strings.TrimSpace(apiToken) + if apiToken == "" { + return nil, errors.New("API token is required") + } + + client := NewClientWithAPIToken(ctx.HTTP, apiToken) + if err := client.Verify(); err != nil { + return nil, fmt.Errorf("invalid API token: %w", err) + } + + workspaces, err := client.ListWorkspaces() + if err != nil { + return nil, fmt.Errorf("failed to retrieve workspaces: %w", err) + } + if len(workspaces) == 0 { + return nil, errors.New("no workspaces accessible with this token") + } + + workspace := workspaces[0] + + err = ctx.Properties.CreateMany([]core.IntegrationPropertyDefinition{ + { + Name: PropertyWorkspaceID, + Label: "Workspace ID", + Description: "The ID of the connected Railway workspace", + Type: configuration.FieldTypeString, + Value: workspace.ID, + Editable: false, + }, + { + Name: PropertyWorkspaceName, + Label: "Workspace Name", + Description: "The name of the connected Railway workspace", + Type: configuration.FieldTypeString, + Value: workspace.Name, + Editable: false, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to save properties: %w", err) + } + + err = ctx.Secrets.Create(core.IntegrationSecretDefinition{ + Name: SecretAPIToken, + Label: "API Token", + Description: "Railway Workspace API Token", + Value: apiToken, + Editable: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to save secret: %w", err) + } + + ctx.Capabilities.Enable(ctx.Capabilities.Requested()...) + + tmpl, err := template.New("setupComplete").Parse(string(setupCompleteTemplate)) + if err != nil { + return nil, fmt.Errorf("failed to parse template: %w", err) + } + + data := map[string]any{ + "WorkspaceName": workspace.Name, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("failed to execute template: %w", err) + } + + return &core.SetupStep{ + Type: core.SetupStepTypeDone, + Name: SetupStepDone, + Label: "Setup complete", + Instructions: buf.String(), + }, nil +} + +func (s *SetupProvider) OnStepRevert(ctx core.SetupStepContext) error { + switch ctx.Step.Name { + case SetupStepCapabilitySelection: + ctx.Capabilities.Clear() + return nil + case SetupStepEnterAPIToken: + _ = ctx.Properties.Delete(PropertyWorkspaceID, PropertyWorkspaceName) + _ = ctx.Secrets.Delete(SecretAPIToken) + return nil + } + return errors.New("unknown step") +} + +func (s *SetupProvider) OnPropertyUpdate(ctx core.PropertyUpdateContext) (*core.SetupStep, error) { + return nil, errors.New("property updates are not supported for Railway") +} + +func (s *SetupProvider) OnSecretUpdate(ctx core.SecretUpdateContext) (*core.SetupStep, error) { + switch ctx.SecretName { + case SecretAPIToken: + v := strings.TrimSpace(ctx.Value) + if v == "" { + return nil, errors.New("value is required") + } + + client := NewClientWithAPIToken(ctx.HTTP, v) + if err := client.Verify(); err != nil { + return nil, fmt.Errorf("failed to verify new API token: %w", err) + } + + return nil, ctx.Secrets.Update(SecretAPIToken, v) + default: + return nil, fmt.Errorf("unknown secret: %s", ctx.SecretName) + } +} diff --git a/pkg/integrations/railway/setup_provider_test.go b/pkg/integrations/railway/setup_provider_test.go new file mode 100644 index 0000000000..4d4be848a3 --- /dev/null +++ b/pkg/integrations/railway/setup_provider_test.go @@ -0,0 +1,265 @@ +package railway + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" + "github.com/superplanehq/superplane/test/support/logger" +) + +func Test__Railway__SetupProvider__OnCapabilityUpdate(t *testing.T) { + s := &SetupProvider{} + logger := logger.DiscardLogger() + + t.Run("returns error when no requested capabilities entry", func(t *testing.T) { + _, err := s.OnCapabilityUpdate(core.CapabilityUpdateContext{ + Logger: logger, + Changes: map[core.IntegrationCapabilityState][]string{}, + Capabilities: &contexts.CapabilityContext{}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "no requested capabilities") + }) + + t.Run("delegates Enable for requested capability names", func(t *testing.T) { + localCap := &contexts.CapabilityContext{} + _, err := s.OnCapabilityUpdate(core.CapabilityUpdateContext{ + Logger: logger, + Changes: map[core.IntegrationCapabilityState][]string{ + core.IntegrationCapabilityStateRequested: {"railway.triggerDeploy"}, + }, + Capabilities: localCap, + }) + require.NoError(t, err) + assert.Equal(t, []string{"railway.triggerDeploy"}, localCap.EnabledCapabilities) + }) +} + +func Test__Railway__SetupProvider__OnSecretUpdate(t *testing.T) { + s := &SetupProvider{} + logger := logger.DiscardLogger() + + intCtx := &contexts.IntegrationContext{} + props := intCtx.Properties() + secrets := intCtx.Secrets() + + t.Run("unknown secret", func(t *testing.T) { + _, err := s.OnSecretUpdate(core.SecretUpdateContext{ + Logger: logger, + SecretName: "other", + Value: "x", + HTTP: &contexts.HTTPContext{}, + Properties: props, + Secrets: secrets, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown secret") + }) + + t.Run("api token required", func(t *testing.T) { + _, err := s.OnSecretUpdate(core.SecretUpdateContext{ + Logger: logger, + SecretName: "apiToken", + Value: " ", + HTTP: &contexts.HTTPContext{}, + Properties: props, + Secrets: secrets, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "value is required") + }) + + t.Run("verification fails", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader("GraphQL error")), + }, + }, + } + _, err := s.OnSecretUpdate(core.SecretUpdateContext{ + Logger: logger, + SecretName: "apiToken", + Value: "tok", + HTTP: httpCtx, + Properties: props, + Secrets: secrets, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to verify new API token") + }) + + t.Run("success updates secret", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"apiToken":{"workspaces":[{"id":"w-1"}]}}}`)), + }, + }, + } + _, err := s.OnSecretUpdate(core.SecretUpdateContext{ + Logger: logger, + SecretName: "apiToken", + Value: "valid-token", + HTTP: httpCtx, + Properties: props, + Secrets: secrets, + }) + require.NoError(t, err) + v, getErr := secrets.Get("apiToken") + require.NoError(t, getErr) + assert.Equal(t, "valid-token", v) + }) +} + +func Test__Railway__SetupProvider__OnStepSubmit(t *testing.T) { + s := &SetupProvider{} + logger := logger.DiscardLogger() + + t.Run("unknown step", func(t *testing.T) { + _, err := s.OnStepSubmit(core.SetupStepContext{ + Step: core.StepInfo{Name: "nope"}, + Logger: logger, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown step") + }) + + t.Run("capabilitySelection success", func(t *testing.T) { + capCtx := &contexts.CapabilityContext{} + next, err := s.OnStepSubmit(core.SetupStepContext{ + Step: core.StepInfo{Name: SetupStepCapabilitySelection, Capabilities: []string{"railway.triggerDeploy"}}, + Logger: logger, + Capabilities: capCtx, + }) + require.NoError(t, err) + require.NotNil(t, next) + assert.Equal(t, SetupStepEnterAPIToken, next.Name) + assert.Equal(t, core.SetupStepTypeInputs, next.Type) + assert.ElementsMatch(t, []string{"railway.triggerDeploy"}, capCtx.RequestedCapabilties) + }) + + t.Run("enterAPIToken validation", func(t *testing.T) { + intCtx := &contexts.IntegrationContext{} + props := intCtx.Properties() + _, err := s.OnStepSubmit(core.SetupStepContext{ + Step: core.StepInfo{Name: SetupStepEnterAPIToken, Inputs: "not-a-map"}, + Logger: logger, + Properties: props, + Secrets: intCtx.Secrets(), + Capabilities: &contexts.CapabilityContext{}, + HTTP: &contexts.HTTPContext{}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid input") + + _, err = s.OnStepSubmit(core.SetupStepContext{ + Step: core.StepInfo{Name: SetupStepEnterAPIToken, Inputs: map[string]any{"apiToken": 123}}, + Logger: logger, + Properties: props, + Secrets: intCtx.Secrets(), + Capabilities: &contexts.CapabilityContext{}, + HTTP: &contexts.HTTPContext{}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid API token") + + _, err = s.OnStepSubmit(core.SetupStepContext{ + Step: core.StepInfo{Name: SetupStepEnterAPIToken, Inputs: map[string]any{"apiToken": ""}}, + Logger: logger, + Properties: props, + Secrets: intCtx.Secrets(), + Capabilities: &contexts.CapabilityContext{}, + HTTP: &contexts.HTTPContext{}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "API token is required") + }) + + t.Run("enterAPIToken success", func(t *testing.T) { + intCtx := &contexts.IntegrationContext{} + props := intCtx.Properties() + secrets := intCtx.Secrets() + capCtx := &contexts.CapabilityContext{ + RequestedCapabilties: []string{"railway.triggerDeploy"}, + } + + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"apiToken":{"workspaces":[{"id":"w-1"}]}}}`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"apiToken":{"workspaces":[{"id":"w-1","name":"Main Workspace"}]}}}`)), + }, + }, + } + + next, err := s.OnStepSubmit(core.SetupStepContext{ + Step: core.StepInfo{Name: SetupStepEnterAPIToken, Inputs: map[string]any{"apiToken": "good-token"}}, + Logger: logger, + Properties: props, + Secrets: secrets, + Capabilities: capCtx, + HTTP: httpCtx, + }) + require.NoError(t, err) + require.NotNil(t, next) + assert.Equal(t, core.SetupStepTypeDone, next.Type) + assert.Equal(t, SetupStepDone, next.Name) + + wsID, _ := props.GetString("workspaceId") + assert.Equal(t, "w-1", wsID) + wsName, _ := props.GetString("workspaceName") + assert.Equal(t, "Main Workspace", wsName) + + tok, _ := secrets.Get("apiToken") + assert.Equal(t, "good-token", tok) + + assert.ElementsMatch(t, []string{"railway.triggerDeploy"}, capCtx.EnabledCapabilities) + }) +} + +func Test__Railway__SetupProvider__OnStepRevert(t *testing.T) { + s := &SetupProvider{} + logger := logger.DiscardLogger() + + t.Run("unknown step", func(t *testing.T) { + err := s.OnStepRevert(core.SetupStepContext{ + Step: core.StepInfo{Name: "nope"}, + Logger: logger, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown step") + }) + + t.Run("enterAPIToken Revert deletes properties and secrets", func(t *testing.T) { + intCtx := &contexts.IntegrationContext{} + props := intCtx.Properties() + _ = props.Create(core.IntegrationPropertyDefinition{Name: "workspaceId", Value: "w-1"}) + _ = intCtx.SetSecret("apiToken", []byte("tok")) + + err := s.OnStepRevert(core.SetupStepContext{ + Step: core.StepInfo{Name: SetupStepEnterAPIToken}, + Logger: logger, + Properties: props, + Secrets: intCtx.Secrets(), + }) + require.NoError(t, err) + + _, err = props.GetString("workspaceId") + assert.Error(t, err) + _, err = intCtx.Secrets().Get("apiToken") + assert.Error(t, err) + }) +} diff --git a/pkg/integrations/railway/templates/api-token-instructions.tpl b/pkg/integrations/railway/templates/api-token-instructions.tpl new file mode 100644 index 0000000000..794afac1a6 --- /dev/null +++ b/pkg/integrations/railway/templates/api-token-instructions.tpl @@ -0,0 +1,6 @@ +To connect SuperPlane to your Railway workspace, you need to provide a **Workspace API Token**: + +1. Open the [Railway Tokens Settings](https://railway.com/account/tokens) page. +2. Under **Workspace Tokens**, select your target workspace and click **Create a Workspace Token**. +3. Name your token (e.g., `SuperPlane Integration`) and copy the token value. +4. Paste the copied token value below. diff --git a/pkg/integrations/railway/templates/setup-complete.tpl b/pkg/integrations/railway/templates/setup-complete.tpl new file mode 100644 index 0000000000..53b0262367 --- /dev/null +++ b/pkg/integrations/railway/templates/setup-complete.tpl @@ -0,0 +1,7 @@ +### Connection Successful! + +SuperPlane is now connected to your Railway workspace: **{{.WorkspaceName}}**. + +You can now use the following capabilities in your workflows: +- **On Deployment Event**: Trigger workflows when your Railway deployments change status. +- **Trigger Deploy**: Programmatically trigger redeployments of your services. diff --git a/pkg/registryimports/registryimports.go b/pkg/registryimports/registryimports.go index 946ff2b885..30d655d902 100644 --- a/pkg/registryimports/registryimports.go +++ b/pkg/registryimports/registryimports.go @@ -56,6 +56,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/integrations/pagerduty" _ "github.com/superplanehq/superplane/pkg/integrations/perplexity" _ "github.com/superplanehq/superplane/pkg/integrations/prometheus" + _ "github.com/superplanehq/superplane/pkg/integrations/railway" _ "github.com/superplanehq/superplane/pkg/integrations/render" _ "github.com/superplanehq/superplane/pkg/integrations/rootly" _ "github.com/superplanehq/superplane/pkg/integrations/semaphore" From 651ac8bd21b9a88a47a6046e1f5ca5f6ebb9a96e Mon Sep 17 00:00:00 2001 From: Shang En Sim <6071014+12458@users.noreply.github.com> Date: Sat, 30 May 2026 13:39:56 -0700 Subject: [PATCH 2/7] feat(railway): implement graphql client and project models Signed-off-by: Shang En Sim <6071014+12458@users.noreply.github.com> --- pkg/integrations/railway/client.go | 387 ++++++++++++++++++++++++ pkg/integrations/railway/client_test.go | 170 +++++++++++ pkg/integrations/railway/payloads.go | 95 ++++++ 3 files changed, 652 insertions(+) create mode 100644 pkg/integrations/railway/client.go create mode 100644 pkg/integrations/railway/client_test.go create mode 100644 pkg/integrations/railway/payloads.go diff --git a/pkg/integrations/railway/client.go b/pkg/integrations/railway/client.go new file mode 100644 index 0000000000..b3cd18cfac --- /dev/null +++ b/pkg/integrations/railway/client.go @@ -0,0 +1,387 @@ +package railway + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/superplanehq/superplane/pkg/core" +) + +const defaultRailwayBaseURL = "https://backboard.railway.com/graphql/v2" + +type Client struct { + APIToken string + BaseURL string + http core.HTTPContext +} + +type APIError struct { + Errors []GraphQLError +} + +func (e *APIError) Error() string { + var msgs []string + for _, err := range e.Errors { + msgs = append(msgs, err.Message) + } + return fmt.Sprintf("GraphQL error: %s", strings.Join(msgs, "; ")) +} + +func NewClientWithAPIToken(http core.HTTPContext, apiToken string) *Client { + return &Client{ + APIToken: strings.TrimSpace(apiToken), + BaseURL: defaultRailwayBaseURL, + http: http, + } +} + +func NewClientWithStorageContexts(http core.HTTPContext, properties core.IntegrationPropertyStorageReader, secrets core.IntegrationSecretStorageReader) (*Client, error) { + apiToken, err := secrets.Get("apiToken") + if err != nil { + return nil, fmt.Errorf("apiToken not found in secrets: %w", err) + } + + return NewClientWithAPIToken(http, apiToken), nil +} + +func NewClient(http core.HTTPContext, ctx core.IntegrationContext) (*Client, error) { + if ctx == nil { + return nil, fmt.Errorf("no integration context") + } + + if !ctx.LegacySetup() { + return NewClientWithStorageContexts(http, ctx.Properties(), ctx.Secrets()) + } + + apiToken, err := ctx.GetConfig("apiToken") + if err != nil { + return nil, fmt.Errorf("apiToken config not found: %w", err) + } + + return NewClientWithAPIToken(http, string(apiToken)), nil +} + +func (c *Client) execQuery(query string, variables map[string]any, responseData any) error { + reqBody := GraphQLRequest{ + Query: query, + Variables: variables, + } + + encodedBody, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request payload: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, c.BaseURL, bytes.NewReader(encodedBody)) + if err != nil { + return fmt.Errorf("failed to build request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIToken) + + res, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer res.Body.Close() + + responseBody, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("http request failed with status %d: %s", res.StatusCode, string(responseBody)) + } + + var gqlResponse GraphQLResponse + if err := json.Unmarshal(responseBody, &gqlResponse); err != nil { + return fmt.Errorf("failed to unmarshal GraphQL response: %w", err) + } + + if len(gqlResponse.Errors) > 0 { + return &APIError{Errors: gqlResponse.Errors} + } + + if responseData != nil { + if err := json.Unmarshal(gqlResponse.Data, responseData); err != nil { + return fmt.Errorf("failed to unmarshal response data: %w", err) + } + } + + return nil +} + +func (c *Client) Verify() error { + query := ` + query { + apiToken { + workspaces { + id + } + } + } + ` + var result struct { + APIToken struct { + Workspaces []struct { + ID string `json:"id"` + } `json:"workspaces"` + } `json:"apiToken"` + } + + return c.execQuery(query, nil, &result) +} + +func (c *Client) ListWorkspaces() ([]Workspace, error) { + query := ` + query { + apiToken { + workspaces { + id + name + } + } + } + ` + var result struct { + APIToken struct { + Workspaces []Workspace `json:"workspaces"` + } `json:"apiToken"` + } + + if err := c.execQuery(query, nil, &result); err != nil { + return nil, err + } + + return result.APIToken.Workspaces, nil +} + +func (c *Client) ListProjects(workspaceID string) ([]Project, error) { + query := ` + query($workspaceId: String!) { + projects(workspaceId: $workspaceId) { + edges { + node { + id + name + } + } + } + } + ` + variables := map[string]any{ + "workspaceId": workspaceID, + } + + var result struct { + Projects struct { + Edges []struct { + Node Project `json:"node"` + } `json:"edges"` + } `json:"projects"` + } + + if err := c.execQuery(query, variables, &result); err != nil { + return nil, err + } + + projects := make([]Project, 0, len(result.Projects.Edges)) + for _, edge := range result.Projects.Edges { + projects = append(projects, edge.Node) + } + + return projects, nil +} + +func (c *Client) GetProjectDetails(projectID string) (*Project, error) { + query := ` + query($id: String!) { + project(id: $id) { + id + name + workspaceId + services { + edges { + node { + id + name + } + } + } + environments { + edges { + node { + id + name + } + } + } + } + } + ` + variables := map[string]any{ + "id": projectID, + } + + var result struct { + Project Project `json:"project"` + } + + if err := c.execQuery(query, variables, &result); err != nil { + return nil, err + } + + return &result.Project, nil +} + +func (c *Client) TriggerDeploy(environmentID, serviceID string) (string, error) { + query := ` + mutation($environmentId: String!, $serviceId: String!) { + serviceInstanceDeployV2(environmentId: $environmentId, serviceId: $serviceId) + } + ` + variables := map[string]any{ + "environmentId": environmentID, + "serviceId": serviceID, + } + + var result struct { + ServiceInstanceDeployV2 string `json:"serviceInstanceDeployV2"` + } + + if err := c.execQuery(query, variables, &result); err != nil { + return "", err + } + + return result.ServiceInstanceDeployV2, nil +} + +func (c *Client) GetDeployment(deploymentID string) (*Deployment, error) { + query := ` + query($id: String!) { + deployment(id: $id) { + id + status + createdAt + updatedAt + } + } + ` + variables := map[string]any{ + "id": deploymentID, + } + + var result struct { + Deployment Deployment `json:"deployment"` + } + + if err := c.execQuery(query, variables, &result); err != nil { + return nil, err + } + + return &result.Deployment, nil +} + +func (c *Client) ListNotificationRules(workspaceID, projectID string) ([]NotificationRule, error) { + query := ` + query($workspaceId: String!, $projectId: String!) { + notificationRules(workspaceId: $workspaceId, projectId: $projectId) { + id + projectId + eventTypes + severities + ephemeralEnvironments + createdAt + updatedAt + channels { + id + config + createdAt + updatedAt + } + } + } + ` + variables := map[string]any{ + "workspaceId": workspaceID, + "projectId": projectID, + } + + var result struct { + NotificationRules []NotificationRule `json:"notificationRules"` + } + + if err := c.execQuery(query, variables, &result); err != nil { + return nil, err + } + + return result.NotificationRules, nil +} + +func (c *Client) CreateNotificationRule(workspaceID, projectID string, eventTypes []string, webhookURL string) (*NotificationRule, error) { + query := ` + mutation($input: CreateNotificationRuleInput!) { + notificationRuleCreate(input: $input) { + id + projectId + eventTypes + severities + ephemeralEnvironments + createdAt + updatedAt + channels { + id + config + createdAt + updatedAt + } + } + } + ` + variables := map[string]any{ + "input": map[string]any{ + "workspaceId": workspaceID, + "projectId": projectID, + "eventTypes": eventTypes, + "channelConfigs": []map[string]any{ + { + "url": webhookURL, + "type": "webhook", + }, + }, + }, + } + + var result struct { + NotificationRuleCreate NotificationRule `json:"notificationRuleCreate"` + } + + if err := c.execQuery(query, variables, &result); err != nil { + return nil, err + } + + return &result.NotificationRuleCreate, nil +} + +func (c *Client) DeleteNotificationRule(ruleID string) error { + query := ` + mutation($id: String!) { + notificationRuleDelete(id: $id) + } + ` + variables := map[string]any{ + "id": ruleID, + } + + var result struct { + NotificationRuleDelete bool `json:"notificationRuleDelete"` + } + + return c.execQuery(query, variables, &result) +} diff --git a/pkg/integrations/railway/client_test.go b/pkg/integrations/railway/client_test.go new file mode 100644 index 0000000000..29654200c4 --- /dev/null +++ b/pkg/integrations/railway/client_test.go @@ -0,0 +1,170 @@ +package railway + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__Railway__Client__Verify(t *testing.T) { + t.Run("success", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"apiToken":{"workspaces":[]}}}`)), + }, + }, + } + + client := NewClientWithAPIToken(httpCtx, "test-token") + err := client.Verify() + require.NoError(t, err) + + require.Len(t, httpCtx.Requests, 1) + assert.Equal(t, "Bearer test-token", httpCtx.Requests[0].Header.Get("Authorization")) + }) + + t.Run("error", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"errors":[{"message":"Not Authorized"}]}`)), + }, + }, + } + + client := NewClientWithAPIToken(httpCtx, "test-token") + err := client.Verify() + require.Error(t, err) + assert.Contains(t, err.Error(), "GraphQL error: Not Authorized") + }) +} + +func Test__Railway__Client__ListWorkspaces(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"apiToken":{"workspaces":[{"id":"w-1","name":"WS 1"}]}}}`)), + }, + }, + } + + client := NewClientWithAPIToken(httpCtx, "test-token") + workspaces, err := client.ListWorkspaces() + require.NoError(t, err) + require.Len(t, workspaces, 1) + assert.Equal(t, "w-1", workspaces[0].ID) + assert.Equal(t, "WS 1", workspaces[0].Name) +} + +func Test__Railway__Client__ListProjects(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"projects":{"edges":[{"node":{"id":"p-1","name":"Project 1"}}]}}}`)), + }, + }, + } + + client := NewClientWithAPIToken(httpCtx, "test-token") + projects, err := client.ListProjects("w-1") + require.NoError(t, err) + require.Len(t, projects, 1) + assert.Equal(t, "p-1", projects[0].ID) + assert.Equal(t, "Project 1", projects[0].Name) +} + +func Test__Railway__Client__GetProjectDetails(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"project":{"id":"p-1","name":"Project 1","workspaceId":"w-2","services":{"edges":[{"node":{"id":"s-1","name":"Service 1"}}]},"environments":{"edges":[{"node":{"id":"e-1","name":"Env 1"}}]}}}}`)), + }, + }, + } + + client := NewClientWithAPIToken(httpCtx, "test-token") + project, err := client.GetProjectDetails("p-1") + require.NoError(t, err) + assert.Equal(t, "p-1", project.ID) + assert.Equal(t, "Project 1", project.Name) + assert.Equal(t, "w-2", project.WorkspaceID) + require.Len(t, project.Services.Edges, 1) + assert.Equal(t, "s-1", project.Services.Edges[0].Node.ID) + require.Len(t, project.Environments.Edges, 1) + assert.Equal(t, "e-1", project.Environments.Edges[0].Node.ID) +} + +func Test__Railway__Client__TriggerDeploy(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"serviceInstanceDeployV2":"deploy-123"}}`)), + }, + }, + } + + client := NewClientWithAPIToken(httpCtx, "test-token") + deployID, err := client.TriggerDeploy("e-1", "s-1") + require.NoError(t, err) + assert.Equal(t, "deploy-123", deployID) +} + +func Test__Railway__Client__GetDeployment(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"deployment":{"id":"deploy-123","status":"SUCCESS","createdAt":"2026-05-30T00:00:00Z","updatedAt":"2026-05-30T00:01:00Z"}}}`)), + }, + }, + } + + client := NewClientWithAPIToken(httpCtx, "test-token") + deploy, err := client.GetDeployment("deploy-123") + require.NoError(t, err) + assert.Equal(t, "deploy-123", deploy.ID) + assert.Equal(t, "SUCCESS", deploy.Status) +} + +func Test__Railway__Client__CreateNotificationRule(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"notificationRuleCreate":{"id":"rule-123","projectId":"p-1","eventTypes":["Deployment.deployed"],"severities":[],"ephemeralEnvironments":false,"createdAt":"","updatedAt":"","channels":[]}}}`)), + }, + }, + } + + client := NewClientWithAPIToken(httpCtx, "test-token") + rule, err := client.CreateNotificationRule("w-1", "p-1", []string{"Deployment.deployed"}, "https://hook.superplane.dev") + require.NoError(t, err) + assert.Equal(t, "rule-123", rule.ID) +} + +func Test__Railway__Client__DeleteNotificationRule(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"notificationRuleDelete":true}}`)), + }, + }, + } + + client := NewClientWithAPIToken(httpCtx, "test-token") + err := client.DeleteNotificationRule("rule-123") + require.NoError(t, err) +} diff --git a/pkg/integrations/railway/payloads.go b/pkg/integrations/railway/payloads.go new file mode 100644 index 0000000000..7d99bc5d24 --- /dev/null +++ b/pkg/integrations/railway/payloads.go @@ -0,0 +1,95 @@ +package railway + +import "encoding/json" + +type GraphQLRequest struct { + Query string `json:"query"` + Variables map[string]any `json:"variables,omitempty"` + OperationName string `json:"operationName,omitempty"` +} + +type GraphQLResponse struct { + Data json.RawMessage `json:"data,omitempty"` + Errors []GraphQLError `json:"errors,omitempty"` +} + +type GraphQLError struct { + Message string `json:"message"` + Locations []GraphQLErrorLocation `json:"locations,omitempty"` + Path []any `json:"path,omitempty"` + Extensions map[string]any `json:"extensions,omitempty"` +} + +type GraphQLErrorLocation struct { + Line int `json:"line"` + Column int `json:"column"` +} + +type Workspace struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + WorkspaceID string `json:"workspaceId,omitempty"` + Environments ProjectEnvironments `json:"environments,omitempty"` + Services ProjectServices `json:"services,omitempty"` +} + +type ProjectEnvironments struct { + Edges []EnvironmentEdge `json:"edges"` +} + +type EnvironmentEdge struct { + Node Environment `json:"node"` +} + +type Environment struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type ProjectServices struct { + Edges []ServiceEdge `json:"edges"` +} + +type ServiceEdge struct { + Node Service `json:"node"` +} + +type Service struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Deployment struct { + ID string `json:"id"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type NotificationRule struct { + ID string `json:"id"` + ProjectID string `json:"projectId,omitempty"` + EventTypes []string `json:"eventTypes"` + Severities []string `json:"severities"` + EphemeralEnvironments bool `json:"ephemeralEnvironments"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Channels []NotificationChannel `json:"channels"` +} + +type NotificationChannel struct { + ID string `json:"id"` + Config NotificationChannelConfig `json:"config"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type NotificationChannelConfig struct { + URL string `json:"url"` + Type string `json:"type"` +} From 046e4916563118371c8764f96d294fca972986d0 Mon Sep 17 00:00:00 2001 From: Shang En Sim <6071014+12458@users.noreply.github.com> Date: Sat, 30 May 2026 13:39:59 -0700 Subject: [PATCH 3/7] feat(railway): implement webhook provisioning and deployment trigger Signed-off-by: Shang En Sim <6071014+12458@users.noreply.github.com> --- pkg/integrations/railway/on_deployment.go | 248 ++++++++++++++++++ .../railway/on_deployment_test.go | 179 +++++++++++++ pkg/integrations/railway/webhook_handler.go | 147 +++++++++++ .../railway/webhook_handler_test.go | 183 +++++++++++++ 4 files changed, 757 insertions(+) create mode 100644 pkg/integrations/railway/on_deployment.go create mode 100644 pkg/integrations/railway/on_deployment_test.go create mode 100644 pkg/integrations/railway/webhook_handler.go create mode 100644 pkg/integrations/railway/webhook_handler_test.go diff --git a/pkg/integrations/railway/on_deployment.go b/pkg/integrations/railway/on_deployment.go new file mode 100644 index 0000000000..eaa733960c --- /dev/null +++ b/pkg/integrations/railway/on_deployment.go @@ -0,0 +1,248 @@ +package railway + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type OnDeploymentEvent struct{} + +type OnDeploymentConfiguration struct { + Project string `json:"project" mapstructure:"project"` + EventTypes []string `json:"eventTypes" mapstructure:"eventTypes"` +} + +type OnDeploymentPayload struct { + Type string `json:"type"` + ProjectID string `json:"projectId"` + EnvironmentID string `json:"environmentId"` + ServiceID string `json:"serviceId"` + DeploymentID string `json:"deploymentId"` + Status string `json:"status,omitempty"` + Timestamp string `json:"timestamp,omitempty"` +} + +var defaultEventTypes = []string{"Deployment.deployed", "Deployment.failed"} + +var eventTypeOptions = []configuration.FieldOption{ + {Label: "Deployed", Value: "Deployment.deployed"}, + {Label: "Failed", Value: "Deployment.failed"}, + {Label: "Crashed", Value: "Deployment.crashed"}, + {Label: "Redeployed", Value: "Deployment.redeployed"}, + {Label: "Building", Value: "Deployment.building"}, +} + +func (t *OnDeploymentEvent) Name() string { + return "railway.onDeployment" +} + +func (t *OnDeploymentEvent) Label() string { + return "On Deployment Event" +} + +func (t *OnDeploymentEvent) Description() string { + return "Trigger when a Railway deployment status changes" +} + +func (t *OnDeploymentEvent) Documentation() string { + return `The On Deployment Event trigger fires when a deployment changes status in the specified Railway project. + +## Use Cases + +- **Slack Notification**: Send notifications when build/deploy fails or succeeds. +- **Auto-verification**: Run integration test workflows on successful deploys. + +## Configuration + +- **Project**: The Railway project to watch. +- **Event Types**: Deployment statuses to listen for (defaults to Deployed and Failed).` +} + +func (t *OnDeploymentEvent) Icon() string { + return "railway" +} + +func (t *OnDeploymentEvent) Color() string { + return "gray" +} + +func (t *OnDeploymentEvent) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "project", + Label: "Project", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The Railway project to watch", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "project", + }, + }, + }, + { + Name: "eventTypes", + Label: "Event Types", + Type: configuration.FieldTypeMultiSelect, + Required: false, + Default: defaultEventTypes, + Description: "Deployment statuses to trigger on", + TypeOptions: &configuration.TypeOptions{ + MultiSelect: &configuration.MultiSelectTypeOptions{ + Options: eventTypeOptions, + }, + }, + }, + } +} + +func decodeOnDeploymentConfiguration(config any) (OnDeploymentConfiguration, error) { + spec := OnDeploymentConfiguration{} + if err := mapstructure.Decode(config, &spec); err != nil { + return OnDeploymentConfiguration{}, fmt.Errorf("failed to decode configuration: %w", err) + } + + spec.Project = strings.TrimSpace(spec.Project) + if spec.Project == "" { + return OnDeploymentConfiguration{}, fmt.Errorf("project is required") + } + + if len(spec.EventTypes) == 0 { + spec.EventTypes = defaultEventTypes + } + + return spec, nil +} + +func (t *OnDeploymentEvent) Setup(ctx core.TriggerContext) error { + config, err := decodeOnDeploymentConfiguration(ctx.Configuration) + if err != nil { + return err + } + + // Request webhook for the specific project and event types + return ctx.Integration.RequestWebhook(WebhookConfiguration{ + ProjectID: config.Project, + EventTypes: config.EventTypes, + }) +} + +func (t *OnDeploymentEvent) Hooks() []core.Hook { + return []core.Hook{} +} + +func (t *OnDeploymentEvent) HandleHook(ctx core.TriggerHookContext) (map[string]any, error) { + return nil, nil +} + +func parseRailwayDeploymentWebhook(body []byte) (OnDeploymentPayload, error) { + var raw struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + Details struct { + Status string `json:"status"` + } `json:"details"` + Resource struct { + Project struct { + ID string `json:"id"` + } `json:"project"` + Environment struct { + ID string `json:"id"` + } `json:"environment"` + Service struct { + ID string `json:"id"` + } `json:"service"` + Deployment struct { + ID string `json:"id"` + } `json:"deployment"` + } `json:"resource"` + ProjectID string `json:"projectId"` + EnvironmentID string `json:"environmentId"` + ServiceID string `json:"serviceId"` + DeploymentID string `json:"deploymentId"` + Status string `json:"status"` + } + + if err := json.Unmarshal(body, &raw); err != nil { + return OnDeploymentPayload{}, fmt.Errorf("failed to parse webhook payload: %w", err) + } + + if raw.ProjectID != "" { + return OnDeploymentPayload{ + Type: raw.Type, + ProjectID: raw.ProjectID, + EnvironmentID: raw.EnvironmentID, + ServiceID: raw.ServiceID, + DeploymentID: raw.DeploymentID, + Status: raw.Status, + Timestamp: raw.Timestamp, + }, nil + } + + status := raw.Details.Status + if status == "" { + status = raw.Status + } + + return OnDeploymentPayload{ + Type: raw.Type, + ProjectID: raw.Resource.Project.ID, + EnvironmentID: raw.Resource.Environment.ID, + ServiceID: raw.Resource.Service.ID, + DeploymentID: raw.Resource.Deployment.ID, + Status: status, + Timestamp: raw.Timestamp, + }, nil +} + +func (t *OnDeploymentEvent) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + config, err := decodeOnDeploymentConfiguration(ctx.Configuration) + if err != nil { + return http.StatusInternalServerError, nil, fmt.Errorf("failed to decode configuration: %w", err) + } + + payload, err := parseRailwayDeploymentWebhook(ctx.Body) + if err != nil { + return http.StatusBadRequest, nil, err + } + + // Filter by project ID + if payload.ProjectID != config.Project { + return http.StatusOK, nil, nil + } + + // Filter by event type + if !slices.Contains(config.EventTypes, payload.Type) { + return http.StatusOK, nil, nil + } + + // Emit event data + if err := ctx.Events.Emit(t.Name(), []any{payload}); err != nil { + return http.StatusInternalServerError, nil, err + } + + return http.StatusOK, nil, nil +} + +func (t *OnDeploymentEvent) Cleanup(ctx core.TriggerContext) error { + return nil +} + +func (t *OnDeploymentEvent) ExampleData() map[string]any { + return map[string]any{ + "type": "Deployment.deployed", + "projectId": "8db400fa-357e-4646-90f0-c7eb36e88a92", + "environmentId": "9a1d7a89-2cf4-4446-9b69-4cde850918aa", + "serviceId": "2a345678-bcde-4fgh-1234-567812345678", + "deploymentId": "ebda9796-09e4-456f-af60-d1a66dee66a0", + "status": "SUCCESS", + "timestamp": "2026-05-30T19:46:09.816Z", + } +} diff --git a/pkg/integrations/railway/on_deployment_test.go b/pkg/integrations/railway/on_deployment_test.go new file mode 100644 index 0000000000..721ed1a9e6 --- /dev/null +++ b/pkg/integrations/railway/on_deployment_test.go @@ -0,0 +1,179 @@ +package railway + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/mitchellh/mapstructure" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__Railway__OnDeployment__Setup(t *testing.T) { + trigger := &OnDeploymentEvent{} + + t.Run("success", func(t *testing.T) { + intCtx := &contexts.IntegrationContext{} + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{ + "project": "p-1", + "eventTypes": []string{"Deployment.deployed"}, + }, + Integration: intCtx, + }) + require.NoError(t, err) + require.Len(t, intCtx.WebhookRequests, 1) + + config, ok := intCtx.WebhookRequests[0].(WebhookConfiguration) + require.True(t, ok) + assert.Equal(t, "p-1", config.ProjectID) + assert.Equal(t, []string{"Deployment.deployed"}, config.EventTypes) + }) + + t.Run("validation failure", func(t *testing.T) { + intCtx := &contexts.IntegrationContext{} + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{ + "project": "", + }, + Integration: intCtx, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "project is required") + }) +} + +func Test__Railway__OnDeployment__HandleWebhook(t *testing.T) { + trigger := &OnDeploymentEvent{} + + t.Run("emits matching payload", func(t *testing.T) { + payload := OnDeploymentPayload{ + Type: "Deployment.deployed", + ProjectID: "p-1", + DeploymentID: "deploy-123", + } + body, _ := json.Marshal(payload) + + eventCtx := &contexts.EventContext{} + code, res, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Configuration: map[string]any{ + "project": "p-1", + "eventTypes": []string{"Deployment.deployed"}, + }, + Body: body, + Events: eventCtx, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + assert.Nil(t, res) + + require.Len(t, eventCtx.Payloads, 1) + assert.Equal(t, "railway.onDeployment", eventCtx.Payloads[0].Type) + + emittedPayload, ok := eventCtx.Payloads[0].Data.([]any) + require.True(t, ok) + require.Len(t, emittedPayload, 1) + + var parsed OnDeploymentPayload + err = mapstructureDecode(emittedPayload[0], &parsed) + require.NoError(t, err) + assert.Equal(t, "Deployment.deployed", parsed.Type) + assert.Equal(t, "p-1", parsed.ProjectID) + }) + + t.Run("ignores mismatched projectID", func(t *testing.T) { + payload := OnDeploymentPayload{ + Type: "Deployment.deployed", + ProjectID: "other-project", + DeploymentID: "deploy-123", + } + body, _ := json.Marshal(payload) + + eventCtx := &contexts.EventContext{} + code, _, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Configuration: map[string]any{ + "project": "p-1", + "eventTypes": []string{"Deployment.deployed"}, + }, + Body: body, + Events: eventCtx, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + assert.Len(t, eventCtx.Payloads, 0) + }) + + t.Run("emits Railway nested resource payload", func(t *testing.T) { + body := []byte(`{ + "type": "Deployment.deployed", + "details": { "status": "SUCCESS" }, + "resource": { + "project": { "id": "p-1" }, + "environment": { "id": "e-1" }, + "service": { "id": "s-1" }, + "deployment": { "id": "deploy-123" } + }, + "timestamp": "2025-11-21T23:48:42.311Z" + }`) + + eventCtx := &contexts.EventContext{} + code, res, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Configuration: map[string]any{ + "project": "p-1", + "eventTypes": []string{"Deployment.deployed"}, + }, + Body: body, + Events: eventCtx, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + assert.Nil(t, res) + require.Len(t, eventCtx.Payloads, 1) + + emittedPayload, ok := eventCtx.Payloads[0].Data.([]any) + require.True(t, ok) + var parsed OnDeploymentPayload + err = mapstructureDecode(emittedPayload[0], &parsed) + require.NoError(t, err) + assert.Equal(t, "p-1", parsed.ProjectID) + assert.Equal(t, "deploy-123", parsed.DeploymentID) + assert.Equal(t, "SUCCESS", parsed.Status) + }) + + t.Run("ignores mismatched eventType", func(t *testing.T) { + payload := OnDeploymentPayload{ + Type: "Deployment.failed", + ProjectID: "p-1", + DeploymentID: "deploy-123", + } + body, _ := json.Marshal(payload) + + eventCtx := &contexts.EventContext{} + code, _, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Configuration: map[string]any{ + "project": "p-1", + "eventTypes": []string{"Deployment.deployed"}, + }, + Body: body, + Events: eventCtx, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + assert.Len(t, eventCtx.Payloads, 0) + }) +} + +func mapstructureDecode(input any, output any) error { + var config = &mapstructure.DecoderConfig{ + TagName: "json", + Result: output, + } + decoder, err := mapstructure.NewDecoder(config) + if err != nil { + return err + } + return decoder.Decode(input) +} diff --git a/pkg/integrations/railway/webhook_handler.go b/pkg/integrations/railway/webhook_handler.go new file mode 100644 index 0000000000..0893c728ce --- /dev/null +++ b/pkg/integrations/railway/webhook_handler.go @@ -0,0 +1,147 @@ +package railway + +import ( + "fmt" + "slices" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/core" +) + +type RailwayWebhookHandler struct{} + +type WebhookConfiguration struct { + ProjectID string `json:"projectId" mapstructure:"projectId"` + EventTypes []string `json:"eventTypes" mapstructure:"eventTypes"` +} + +type WebhookMetadata struct { + RuleID string `json:"ruleId" mapstructure:"ruleId"` + WorkspaceID string `json:"workspaceId" mapstructure:"workspaceId"` +} + +func decodeWebhookConfiguration(value any) (WebhookConfiguration, error) { + config := WebhookConfiguration{} + if err := mapstructure.Decode(value, &config); err != nil { + return WebhookConfiguration{}, err + } + return config, nil +} + +func decodeWebhookMetadata(value any) (WebhookMetadata, error) { + metadata := WebhookMetadata{} + if err := mapstructure.Decode(value, &metadata); err != nil { + return WebhookMetadata{}, err + } + return metadata, nil +} + +func (h *RailwayWebhookHandler) CompareConfig(a, b any) (bool, error) { + configA, err := decodeWebhookConfiguration(a) + if err != nil { + return false, fmt.Errorf("failed to decode webhook configuration A: %w", err) + } + + configB, err := decodeWebhookConfiguration(b) + if err != nil { + return false, fmt.Errorf("failed to decode webhook configuration B: %w", err) + } + + return configA.ProjectID == configB.ProjectID, nil +} + +func (h *RailwayWebhookHandler) Merge(current, requested any) (any, bool, error) { + currentConfig, err := decodeWebhookConfiguration(current) + if err != nil { + return nil, false, fmt.Errorf("failed to decode current webhook configuration: %w", err) + } + + requestedConfig, err := decodeWebhookConfiguration(requested) + if err != nil { + return nil, false, fmt.Errorf("failed to decode requested webhook configuration: %w", err) + } + + if currentConfig.ProjectID != requestedConfig.ProjectID { + return currentConfig, false, nil + } + + mergedEventTypes := currentConfig.EventTypes + for _, eventType := range requestedConfig.EventTypes { + if !slices.Contains(mergedEventTypes, eventType) { + mergedEventTypes = append(mergedEventTypes, eventType) + } + } + + changed := len(mergedEventTypes) > len(currentConfig.EventTypes) + mergedConfig := currentConfig + mergedConfig.EventTypes = mergedEventTypes + + return mergedConfig, changed, nil +} + +func (h *RailwayWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, err + } + + config, err := decodeWebhookConfiguration(ctx.Webhook.GetConfiguration()) + if err != nil { + return nil, err + } + + project, err := client.GetProjectDetails(config.ProjectID) + if err != nil { + return nil, fmt.Errorf("failed to load Railway project %q: %w", config.ProjectID, err) + } + + workspaceID := strings.TrimSpace(project.WorkspaceID) + if workspaceID == "" { + return nil, fmt.Errorf("project %q has no workspaceId", config.ProjectID) + } + + webhookURL := ctx.Webhook.GetURL() + if webhookURL == "" { + return nil, fmt.Errorf("webhook URL is required") + } + + rule, err := client.CreateNotificationRule(workspaceID, config.ProjectID, config.EventTypes, webhookURL) + if err != nil { + return nil, fmt.Errorf("failed to create Railway notification rule: %w", err) + } + + return WebhookMetadata{ + RuleID: rule.ID, + WorkspaceID: workspaceID, + }, nil +} + +func (h *RailwayWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { + metadata, err := decodeWebhookMetadata(ctx.Webhook.GetMetadata()) + if err != nil { + return fmt.Errorf("failed to decode webhook metadata: %w", err) + } + + if metadata.RuleID == "" { + return nil + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return err + } + + err = client.DeleteNotificationRule(metadata.RuleID) + if err != nil { + // Log warning and ignore "Not Authorized" or typical token permissions errors + // to prevent blocking node/integration deletion in SuperPlane. + ctx.Logger.Warnf("Failed to delete Railway notification rule %q: %v", metadata.RuleID, err) + if strings.Contains(strings.ToLower(err.Error()), "not authorized") || strings.Contains(strings.ToLower(err.Error()), "unauthorized") { + return nil + } + return err + } + + return nil +} diff --git a/pkg/integrations/railway/webhook_handler_test.go b/pkg/integrations/railway/webhook_handler_test.go new file mode 100644 index 0000000000..0b50dfb6ff --- /dev/null +++ b/pkg/integrations/railway/webhook_handler_test.go @@ -0,0 +1,183 @@ +package railway + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" + "github.com/superplanehq/superplane/test/support/logger" +) + +type integrationWebhookContext struct { + id string + url string + configuration any + metadata any + secret []byte +} + +func (w *integrationWebhookContext) GetID() string { return w.id } +func (w *integrationWebhookContext) GetURL() string { return w.url } +func (w *integrationWebhookContext) GetSecret() ([]byte, error) { return w.secret, nil } +func (w *integrationWebhookContext) GetMetadata() any { return w.metadata } +func (w *integrationWebhookContext) GetConfiguration() any { return w.configuration } +func (w *integrationWebhookContext) SetSecret(secret []byte) error { + w.secret = secret + return nil +} + +func Test__Railway__WebhookHandler__CompareConfig(t *testing.T) { + h := &RailwayWebhookHandler{} + + equal, err := h.CompareConfig( + WebhookConfiguration{ProjectID: "p-1"}, + WebhookConfiguration{ProjectID: "p-1"}, + ) + require.NoError(t, err) + assert.True(t, equal) + + equal, err = h.CompareConfig( + WebhookConfiguration{ProjectID: "p-1"}, + WebhookConfiguration{ProjectID: "p-2"}, + ) + require.NoError(t, err) + assert.False(t, equal) +} + +func Test__Railway__WebhookHandler__Merge(t *testing.T) { + h := &RailwayWebhookHandler{} + + merged, changed, err := h.Merge( + WebhookConfiguration{ProjectID: "p-1", EventTypes: []string{"Deployment.deployed"}}, + WebhookConfiguration{ProjectID: "p-1", EventTypes: []string{"Deployment.failed"}}, + ) + require.NoError(t, err) + assert.True(t, changed) + + expected := WebhookConfiguration{ + ProjectID: "p-1", + EventTypes: []string{"Deployment.deployed", "Deployment.failed"}, + } + assert.Equal(t, expected, merged) +} + +func Test__Railway__WebhookHandler__Setup(t *testing.T) { + h := &RailwayWebhookHandler{} + + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"project":{"id":"p-1","name":"Project 1","workspaceId":"w-2"}}}`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"notificationRuleCreate":{"id":"rule-123"}}}`)), + }, + }, + } + + webhookCtx := &integrationWebhookContext{ + id: "wh-123", + url: "https://hook.superplane.dev", + configuration: WebhookConfiguration{ProjectID: "p-1", EventTypes: []string{"Deployment.deployed"}}, + } + + intCtx := &contexts.IntegrationContext{NewSetupFlow: true} + _ = intCtx.SetSecret("apiToken", []byte("test-token")) + + metadata, err := h.Setup(core.WebhookHandlerContext{ + HTTP: httpCtx, + Webhook: webhookCtx, + Integration: intCtx, + }) + require.NoError(t, err) + + storedMetadata, ok := metadata.(WebhookMetadata) + require.True(t, ok) + assert.Equal(t, "rule-123", storedMetadata.RuleID) + assert.Equal(t, "w-2", storedMetadata.WorkspaceID) + require.Len(t, httpCtx.Requests, 2) +} + +func Test__Railway__WebhookHandler__Setup_missingWorkspaceId(t *testing.T) { + h := &RailwayWebhookHandler{} + + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"project":{"id":"p-1","name":"Project 1"}}}`)), + }, + }, + } + + intCtx := &contexts.IntegrationContext{NewSetupFlow: true} + _ = intCtx.SetSecret("apiToken", []byte("test-token")) + + _, err := h.Setup(core.WebhookHandlerContext{ + HTTP: httpCtx, + Webhook: &integrationWebhookContext{configuration: WebhookConfiguration{ProjectID: "p-1"}}, + Integration: intCtx, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "no workspaceId") +} + +func Test__Railway__WebhookHandler__Cleanup(t *testing.T) { + h := &RailwayWebhookHandler{} + logger := logger.DiscardLogger() + + t.Run("success", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"notificationRuleDelete":true}}`)), + }, + }, + } + + intCtx := &contexts.IntegrationContext{NewSetupFlow: true} + _ = intCtx.SetSecret("apiToken", []byte("test-token")) + + err := h.Cleanup(core.WebhookHandlerContext{ + HTTP: httpCtx, + Logger: logger, + Webhook: &integrationWebhookContext{ + metadata: WebhookMetadata{RuleID: "rule-123", WorkspaceID: "w-1"}, + }, + Integration: intCtx, + }) + require.NoError(t, err) + }) + + t.Run("ignores not authorized error", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"errors":[{"message":"Not Authorized"}]}`)), + }, + }, + } + + intCtx := &contexts.IntegrationContext{NewSetupFlow: true} + _ = intCtx.SetSecret("apiToken", []byte("test-token")) + + err := h.Cleanup(core.WebhookHandlerContext{ + HTTP: httpCtx, + Logger: logger, + Webhook: &integrationWebhookContext{ + metadata: WebhookMetadata{RuleID: "rule-123", WorkspaceID: "w-1"}, + }, + Integration: intCtx, + }) + require.NoError(t, err) // Should succeed because Not Authorized is caught and handled + }) +} From 1be9ce80f3937b7c418033d6e09286a6c785ab53 Mon Sep 17 00:00:00 2001 From: Shang En Sim <6071014+12458@users.noreply.github.com> Date: Sat, 30 May 2026 13:40:04 -0700 Subject: [PATCH 4/7] feat(railway): implement redeployment action with status polling Signed-off-by: Shang En Sim <6071014+12458@users.noreply.github.com> --- pkg/integrations/railway/trigger_deploy.go | 300 ++++++++++++++++++ .../railway/trigger_deploy_test.go | 229 +++++++++++++ 2 files changed, 529 insertions(+) create mode 100644 pkg/integrations/railway/trigger_deploy.go create mode 100644 pkg/integrations/railway/trigger_deploy_test.go diff --git a/pkg/integrations/railway/trigger_deploy.go b/pkg/integrations/railway/trigger_deploy.go new file mode 100644 index 0000000000..8983b605b5 --- /dev/null +++ b/pkg/integrations/railway/trigger_deploy.go @@ -0,0 +1,300 @@ +package railway + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const ( + TriggerDeployPayloadType = "railway.deploy.finished" + TriggerDeploySuccessOutputChannel = "success" + TriggerDeployFailedOutputChannel = "failed" + TriggerDeployPollInterval = 15 * time.Second + deployExecutionKey = "deploy_id" +) + +type TriggerDeploy struct{} + +type TriggerDeployConfiguration struct { + Project string `json:"project" mapstructure:"project"` + Service string `json:"service" mapstructure:"service"` + Environment string `json:"environment" mapstructure:"environment"` +} + +type TriggerDeployExecutionMetadata struct { + Deploy *TriggerDeployMetadata `json:"deploy" mapstructure:"deploy"` +} + +type TriggerDeployMetadata struct { + ID string `json:"id"` + Status string `json:"status"` + ProjectID string `json:"projectId"` + ServiceID string `json:"serviceId"` + Environment string `json:"environmentId"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +func (c *TriggerDeploy) Name() string { + return "railway.triggerDeploy" +} + +func (c *TriggerDeploy) Label() string { + return "Trigger Deploy" +} + +func (c *TriggerDeploy) Description() string { + return "Trigger a deploy for a Railway service and wait for it to complete" +} + +func (c *TriggerDeploy) Documentation() string { + return `The Trigger Deploy action starts a new deploy for a Railway service and waits for it to complete. + +## Configuration + +- **Project**: The Railway project containing the service. +- **Service**: The service to deploy. +- **Environment**: The target environment. + +## Output Channels + +- **Success**: Emitted when the deploy completes successfully. +- **Failed**: Emitted when the deploy fails or is cancelled.` +} + +func (c *TriggerDeploy) Icon() string { + return "railway" +} + +func (c *TriggerDeploy) Color() string { + return "gray" +} + +func (c *TriggerDeploy) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{ + {Name: TriggerDeploySuccessOutputChannel, Label: "Success"}, + {Name: TriggerDeployFailedOutputChannel, Label: "Failed"}, + } +} + +func (c *TriggerDeploy) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "project", + Label: "Project", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The Railway project", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "project", + }, + }, + }, + { + Name: "service", + Label: "Service", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The service to deploy", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "service", + Parameters: []configuration.ParameterRef{ + { + Name: "project", + ValueFrom: &configuration.ParameterValueFrom{Field: "project"}, + }, + }, + }, + }, + }, + { + Name: "environment", + Label: "Environment", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The target environment", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "environment", + Parameters: []configuration.ParameterRef{ + { + Name: "project", + ValueFrom: &configuration.ParameterValueFrom{Field: "project"}, + }, + }, + }, + }, + }, + } +} + +func decodeTriggerDeployConfiguration(config any) (TriggerDeployConfiguration, error) { + spec := TriggerDeployConfiguration{} + if err := mapstructure.Decode(config, &spec); err != nil { + return TriggerDeployConfiguration{}, fmt.Errorf("failed to decode configuration: %w", err) + } + + spec.Project = strings.TrimSpace(spec.Project) + spec.Service = strings.TrimSpace(spec.Service) + spec.Environment = strings.TrimSpace(spec.Environment) + + if spec.Project == "" { + return TriggerDeployConfiguration{}, fmt.Errorf("project is required") + } + if spec.Service == "" { + return TriggerDeployConfiguration{}, fmt.Errorf("service is required") + } + if spec.Environment == "" { + return TriggerDeployConfiguration{}, fmt.Errorf("environment is required") + } + + return spec, nil +} + +func (c *TriggerDeploy) Setup(ctx core.SetupContext) error { + _, err := decodeTriggerDeployConfiguration(ctx.Configuration) + return err +} + +func (c *TriggerDeploy) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *TriggerDeploy) Execute(ctx core.ExecutionContext) error { + spec, err := decodeTriggerDeployConfiguration(ctx.Configuration) + if err != nil { + return err + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return err + } + + deployID, err := client.TriggerDeploy(spec.Environment, spec.Service) + if err != nil { + return err + } + + if deployID == "" { + return fmt.Errorf("deploy response missing deployment ID") + } + + err = ctx.Metadata.Set(TriggerDeployExecutionMetadata{ + Deploy: &TriggerDeployMetadata{ + ID: deployID, + Status: "QUEUED", + ProjectID: spec.Project, + ServiceID: spec.Service, + Environment: spec.Environment, + }, + }) + if err != nil { + return err + } + + if err := ctx.ExecutionState.SetKV(deployExecutionKey, deployID); err != nil { + return err + } + + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, TriggerDeployPollInterval) +} + +func (c *TriggerDeploy) Hooks() []core.Hook { + return []core.Hook{ + { + Name: "poll", + Type: core.HookTypeInternal, + }, + } +} + +func (c *TriggerDeploy) HandleHook(ctx core.ActionHookContext) error { + switch ctx.Name { + case "poll": + return c.poll(ctx) + } + return fmt.Errorf("unknown hook: %s", ctx.Name) +} + +func (c *TriggerDeploy) poll(ctx core.ActionHookContext) error { + if ctx.ExecutionState.IsFinished() { + return nil + } + + metadata := TriggerDeployExecutionMetadata{} + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return fmt.Errorf("failed to decode metadata: %w", err) + } + + if metadata.Deploy == nil || metadata.Deploy.ID == "" { + return nil + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return err + } + + deploy, err := client.GetDeployment(metadata.Deploy.ID) + if err != nil { + return err + } + + metadata.Deploy.Status = deploy.Status + metadata.Deploy.CreatedAt = deploy.CreatedAt + metadata.Deploy.UpdatedAt = deploy.UpdatedAt + if err := ctx.Metadata.Set(metadata); err != nil { + return err + } + + // Terminal states check + payload := map[string]any{ + "deployId": deploy.ID, + "status": deploy.Status, + "projectId": metadata.Deploy.ProjectID, + "serviceId": metadata.Deploy.ServiceID, + "environmentId": metadata.Deploy.Environment, + } + + switch deploy.Status { + case "SUCCESS": + return ctx.ExecutionState.Emit(TriggerDeploySuccessOutputChannel, TriggerDeployPayloadType, []any{payload}) + case "FAILED", "CRASHED", "REMOVED", "SKIPPED", "SLEEPING": + return ctx.ExecutionState.Emit(TriggerDeployFailedOutputChannel, TriggerDeployPayloadType, []any{payload}) + default: + // Not finished yet, schedule next poll + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, TriggerDeployPollInterval) + } +} + +func (c *TriggerDeploy) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (c *TriggerDeploy) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *TriggerDeploy) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *TriggerDeploy) ExampleOutput() map[string]any { + return map[string]any{ + "deployId": "ebda9796-09e4-456f-af60-d1a66dee66a0", + "status": "SUCCESS", + "projectId": "8db400fa-357e-4646-90f0-c7eb36e88a92", + "serviceId": "2a345678-bcde-4fgh-1234-567812345678", + "environmentId": "9a1d7a89-2cf4-4446-9b69-4cde850918aa", + } +} diff --git a/pkg/integrations/railway/trigger_deploy_test.go b/pkg/integrations/railway/trigger_deploy_test.go new file mode 100644 index 0000000000..34436f3e8e --- /dev/null +++ b/pkg/integrations/railway/trigger_deploy_test.go @@ -0,0 +1,229 @@ +package railway + +import ( + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/mitchellh/mapstructure" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__Railway__TriggerDeploy__Setup(t *testing.T) { + action := &TriggerDeploy{} + + t.Run("success", func(t *testing.T) { + err := action.Setup(core.SetupContext{ + Configuration: map[string]any{ + "project": "p-1", + "service": "s-1", + "environment": "e-1", + }, + }) + require.NoError(t, err) + }) + + t.Run("missing project", func(t *testing.T) { + err := action.Setup(core.SetupContext{ + Configuration: map[string]any{ + "service": "s-1", + "environment": "e-1", + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "project is required") + }) +} + +func Test__Railway__TriggerDeploy__Execute(t *testing.T) { + action := &TriggerDeploy{} + + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"serviceInstanceDeployV2":"deploy-123"}}`)), + }, + }, + } + + intCtx := &contexts.IntegrationContext{NewSetupFlow: true} + _ = intCtx.SetSecret("apiToken", []byte("test-token")) + + execCtx := core.ExecutionContext{ + HTTP: httpCtx, + Configuration: map[string]any{ + "project": "p-1", + "service": "s-1", + "environment": "e-1", + }, + Integration: intCtx, + Metadata: &contexts.MetadataContext{}, + ExecutionState: &contexts.ExecutionStateContext{}, + Requests: &contexts.RequestContext{}, + } + + err := action.Execute(execCtx) + require.NoError(t, err) + + // Check metadata is set to QUEUED + metadata := TriggerDeployExecutionMetadata{} + err = mapstructure.Decode(execCtx.Metadata.Get(), &metadata) + require.NoError(t, err) + require.NotNil(t, metadata.Deploy) + assert.Equal(t, "deploy-123", metadata.Deploy.ID) + assert.Equal(t, "QUEUED", metadata.Deploy.Status) + + // Check poll hook is scheduled + reqs := execCtx.Requests.(*contexts.RequestContext) + assert.Equal(t, "poll", reqs.Action) + assert.Equal(t, 15*time.Second, reqs.Duration) +} + +func Test__Railway__TriggerDeploy__Poll(t *testing.T) { + action := &TriggerDeploy{} + + t.Run("continues polling when active", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"deployment":{"id":"deploy-123","status":"BUILDING","createdAt":"","updatedAt":""}}}`)), + }, + }, + } + + intCtx := &contexts.IntegrationContext{NewSetupFlow: true} + _ = intCtx.SetSecret("apiToken", []byte("test-token")) + + metaCtx := &contexts.MetadataContext{ + Metadata: TriggerDeployExecutionMetadata{ + Deploy: &TriggerDeployMetadata{ + ID: "deploy-123", + Status: "QUEUED", + ProjectID: "p-1", + ServiceID: "s-1", + Environment: "e-1", + }, + }, + } + + reqsCtx := &contexts.RequestContext{} + + err := action.poll(core.ActionHookContext{ + HTTP: httpCtx, + Integration: intCtx, + Metadata: metaCtx, + ExecutionState: &contexts.ExecutionStateContext{}, + Requests: reqsCtx, + }) + require.NoError(t, err) + + // Check metadata updated to BUILDING + metadata := TriggerDeployExecutionMetadata{} + err = mapstructure.Decode(metaCtx.Get(), &metadata) + require.NoError(t, err) + assert.Equal(t, "BUILDING", metadata.Deploy.Status) + + // Check next poll is scheduled + assert.Equal(t, "poll", reqsCtx.Action) + assert.Equal(t, 15*time.Second, reqsCtx.Duration) + }) + + t.Run("emits success on SUCCESS status", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"deployment":{"id":"deploy-123","status":"SUCCESS","createdAt":"","updatedAt":""}}}`)), + }, + }, + } + + intCtx := &contexts.IntegrationContext{NewSetupFlow: true} + _ = intCtx.SetSecret("apiToken", []byte("test-token")) + + metaCtx := &contexts.MetadataContext{ + Metadata: TriggerDeployExecutionMetadata{ + Deploy: &TriggerDeployMetadata{ + ID: "deploy-123", + Status: "BUILDING", + ProjectID: "p-1", + ServiceID: "s-1", + Environment: "e-1", + }, + }, + } + + stateCtx := &contexts.ExecutionStateContext{} + reqsCtx := &contexts.RequestContext{} + + err := action.poll(core.ActionHookContext{ + HTTP: httpCtx, + Integration: intCtx, + Metadata: metaCtx, + ExecutionState: stateCtx, + Requests: reqsCtx, + }) + require.NoError(t, err) + + // Check emitted event + assert.Equal(t, "success", stateCtx.Channel) + assert.Equal(t, "railway.deploy.finished", stateCtx.Type) + require.Len(t, stateCtx.Payloads, 1) + + // No further polls scheduled + assert.Equal(t, "", reqsCtx.Action) + }) + + t.Run("emits failed on FAILED status", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"deployment":{"id":"deploy-123","status":"FAILED","createdAt":"","updatedAt":""}}}`)), + }, + }, + } + + intCtx := &contexts.IntegrationContext{NewSetupFlow: true} + _ = intCtx.SetSecret("apiToken", []byte("test-token")) + + metaCtx := &contexts.MetadataContext{ + Metadata: TriggerDeployExecutionMetadata{ + Deploy: &TriggerDeployMetadata{ + ID: "deploy-123", + Status: "BUILDING", + ProjectID: "p-1", + ServiceID: "s-1", + Environment: "e-1", + }, + }, + } + + stateCtx := &contexts.ExecutionStateContext{} + reqsCtx := &contexts.RequestContext{} + + err := action.poll(core.ActionHookContext{ + HTTP: httpCtx, + Integration: intCtx, + Metadata: metaCtx, + ExecutionState: stateCtx, + Requests: reqsCtx, + }) + require.NoError(t, err) + + // Check emitted event + assert.Equal(t, "failed", stateCtx.Channel) + assert.Equal(t, "railway.deploy.finished", stateCtx.Type) + require.Len(t, stateCtx.Payloads, 1) + + // No further polls scheduled + assert.Equal(t, "", reqsCtx.Action) + }) +} From 829b91a885c0735e2b446225534acb33031b0bdc Mon Sep 17 00:00:00 2001 From: Shang En Sim <6071014+12458@users.noreply.github.com> Date: Sat, 30 May 2026 13:40:10 -0700 Subject: [PATCH 5/7] feat(railway): add branding icon and register integration mappers Signed-off-by: Shang En Sim <6071014+12458@users.noreply.github.com> --- .../src/assets/icons/integrations/railway.svg | 1 + web_src/src/pages/workflowv2/mappers/index.ts | 8 + .../pages/workflowv2/mappers/railway/base.ts | 46 +++++ .../pages/workflowv2/mappers/railway/index.ts | 15 ++ .../mappers/railway/on_deployment.ts | 129 +++++++++++++ .../mappers/railway/trigger_deploy.ts | 172 ++++++++++++++++++ .../componentSidebar/integrationIconMaps.ts | 3 + 7 files changed, 374 insertions(+) create mode 100644 web_src/src/assets/icons/integrations/railway.svg create mode 100644 web_src/src/pages/workflowv2/mappers/railway/base.ts create mode 100644 web_src/src/pages/workflowv2/mappers/railway/index.ts create mode 100644 web_src/src/pages/workflowv2/mappers/railway/on_deployment.ts create mode 100644 web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts diff --git a/web_src/src/assets/icons/integrations/railway.svg b/web_src/src/assets/icons/integrations/railway.svg new file mode 100644 index 0000000000..619ce5071f --- /dev/null +++ b/web_src/src/assets/icons/integrations/railway.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index d346a40de5..c1765b8ecf 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -90,6 +90,11 @@ import { triggerRenderers as renderTriggerRenderers, eventStateRegistry as renderEventStateRegistry, } from "./render"; +import { + componentMappers as railwayComponentMappers, + triggerRenderers as railwayTriggerRenderers, + eventStateRegistry as railwayEventStateRegistry, +} from "./railway"; import { componentMappers as rootlyComponentMappers, triggerRenderers as rootlyTriggerRenderers, @@ -307,6 +312,7 @@ const appMappers: Record> = { sendgrid: sendgridComponentMappers, sentry: sentryComponentMappers, render: renderComponentMappers, + railway: railwayComponentMappers, rootly: rootlyComponentMappers, incident: incidentComponentMappers, newrelic: newrelicComponentMappers, @@ -353,6 +359,7 @@ const appTriggerRenderers: Record> = { sendgrid: sendgridTriggerRenderers, sentry: sentryTriggerRenderers, render: renderTriggerRenderers, + railway: railwayTriggerRenderers, rootly: rootlyTriggerRenderers, incident: incidentTriggerRenderers, newrelic: newrelicTriggerRenderers, @@ -398,6 +405,7 @@ const appEventStateRegistries: Record sendgrid: sendgridEventStateRegistry, sentry: sentryEventStateRegistry, render: renderEventStateRegistry, + railway: railwayEventStateRegistry, discord: discordEventStateRegistry, telegram: telegramEventStateRegistry, teams: teamsEventStateRegistry, diff --git a/web_src/src/pages/workflowv2/mappers/railway/base.ts b/web_src/src/pages/workflowv2/mappers/railway/base.ts new file mode 100644 index 0000000000..aac9f52d2b --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/railway/base.ts @@ -0,0 +1,46 @@ +import type { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import { getBackgroundColorClass, getColorClass } from "@/lib/colors"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import railwayIcon from "@/assets/icons/integrations/railway.svg"; +import { getState, getStateMap, getTriggerRenderer } from ".."; +import type { ComponentDefinition, ExecutionInfo, NodeInfo } from "../types"; + +export function baseProps( + nodes: NodeInfo[], + node: NodeInfo, + componentDefinition: ComponentDefinition, + lastExecutions: ExecutionInfo[], +): ComponentBaseProps { + const lastExecution = lastExecutions.length > 0 ? lastExecutions[0] : null; + const componentName = componentDefinition.name || node.componentName || "railway.unknown"; + + return { + title: node.name || componentDefinition.label || componentDefinition.name || "Unnamed component", + iconSrc: railwayIcon, + iconColor: getColorClass(componentDefinition.color), + collapsedBackground: getBackgroundColorClass(componentDefinition.color), + collapsed: node.isCollapsed, + eventSections: lastExecution ? baseEventSections(nodes, lastExecution, componentName) : undefined, + includeEmptyState: !lastExecution, + eventStateMap: getStateMap(componentName), + }; +} + +function baseEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + const rootTriggerNode = nodes.find((node) => node.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName || ""); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent }); + + const subtitleTimestamp = execution.updatedAt || execution.createdAt; + const eventSubtitle = subtitleTimestamp ? renderTimeAgo(new Date(subtitleTimestamp)) : ""; + + return [ + { + receivedAt: new Date(execution.createdAt!), + eventTitle: title, + eventSubtitle, + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} diff --git a/web_src/src/pages/workflowv2/mappers/railway/index.ts b/web_src/src/pages/workflowv2/mappers/railway/index.ts new file mode 100644 index 0000000000..853c9fab6a --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/railway/index.ts @@ -0,0 +1,15 @@ +import type { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../types"; +import { triggerDeployMapper, DEPLOY_STATE_REGISTRY } from "./trigger_deploy"; +import { onDeploymentTriggerRenderer } from "./on_deployment"; + +export const componentMappers: Record = { + triggerDeploy: triggerDeployMapper, +}; + +export const triggerRenderers: Record = { + onDeployment: onDeploymentTriggerRenderer, +}; + +export const eventStateRegistry: Record = { + triggerDeploy: DEPLOY_STATE_REGISTRY, +}; diff --git a/web_src/src/pages/workflowv2/mappers/railway/on_deployment.ts b/web_src/src/pages/workflowv2/mappers/railway/on_deployment.ts new file mode 100644 index 0000000000..f6a6870c17 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/railway/on_deployment.ts @@ -0,0 +1,129 @@ +import type { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; +import type React from "react"; +import type { TriggerProps } from "@/ui/trigger"; +import { getBackgroundColorClass, getColorClass } from "@/lib/colors"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import railwayIcon from "@/assets/icons/integrations/railway.svg"; + +interface RailwayEventData { + type?: string; + projectId?: string; + environmentId?: string; + serviceId?: string; + deploymentId?: string; + status?: string; + timestamp?: string; +} + +interface OnDeploymentConfiguration { + project?: string; + eventTypes?: string[]; +} + +const eventLabels: Record = { + "Deployment.deployed": "Deployed", + "Deployment.failed": "Failed", + "Deployment.crashed": "Crashed", + "Deployment.redeployed": "Redeployed", + "Deployment.building": "Building", +}; + +function formatEventLabel(event?: string): string { + if (!event) { + return "Deployment Event"; + } + return eventLabels[event] || event; +} + +function stringOrDash(value?: unknown): string { + if (value === undefined || value === null || value === "") { + return "-"; + } + return String(value); +} + +function formatTimestamp(value?: string, fallback?: string): string { + const timestamp = value || fallback; + if (!timestamp) { + return "-"; + } + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return "-"; + } + return date.toLocaleString(); +} + +export const onDeploymentTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string | React.ReactNode } => { + const event = context.event?.data as RailwayEventData | undefined; + const projectLabel = event?.projectId || "Project"; + const statusLabel = formatEventLabel(context.event?.type || event?.type); + const title = `${projectLabel} · ${statusLabel}`; + + return { + title, + subtitle: context.event?.createdAt ? renderTimeAgo(new Date(context.event.createdAt)) : "", + }; + }, + + getRootEventValues: (context: TriggerEventContext): Record => { + const event = context.event?.data as RailwayEventData | undefined; + return { + "Received At": formatTimestamp(context.event?.createdAt), + "Event Type": stringOrDash(context.event?.type || event?.type), + "Project ID": stringOrDash(event?.projectId), + "Environment ID": stringOrDash(event?.environmentId), + "Service ID": stringOrDash(event?.serviceId), + "Deployment ID": stringOrDash(event?.deploymentId), + Status: stringOrDash(event?.status), + }; + }, + + getTriggerProps: (context: TriggerRendererContext) => { + const { node, definition, lastEvent } = context; + const configuration = node.configuration as OnDeploymentConfiguration | undefined; + + const metadata: TriggerProps["metadata"] = []; + if (configuration?.project) { + metadata.push({ + icon: "folder", + label: `Project: ${configuration.project}`, + }); + } + + if (configuration?.eventTypes && configuration.eventTypes.length > 0) { + const formattedEvents = configuration.eventTypes.map(formatEventLabel); + metadata.push({ + icon: "funnel", + label: + formattedEvents.length > 2 + ? `Events: ${formattedEvents.length} selected` + : `Events: ${formattedEvents.join(", ")}`, + }); + } + + const props: TriggerProps = { + title: node.name || definition.label || "Unnamed trigger", + iconSrc: railwayIcon, + iconColor: getColorClass(definition.color), + collapsedBackground: getBackgroundColorClass(definition.color), + metadata, + }; + + if (lastEvent) { + const event = lastEvent.data as RailwayEventData | undefined; + const projectLabel = event?.projectId || "Project"; + const statusLabel = formatEventLabel(lastEvent.type || event?.type); + props.lastEventData = { + title: `${projectLabel} · ${statusLabel}`, + subtitle: lastEvent.createdAt ? renderTimeAgo(new Date(lastEvent.createdAt)) : "", + receivedAt: new Date(lastEvent.createdAt), + state: "triggered", + eventId: lastEvent.id, + }; + } + + return props; + }, +}; diff --git a/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts b/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts new file mode 100644 index 0000000000..9c468014a1 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts @@ -0,0 +1,172 @@ +import type { + ComponentBaseContext, + ComponentBaseMapper, + EventStateRegistry, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + StateFunction, + SubtitleContext, +} from "../types"; +import type { ComponentBaseProps, EventSection, EventStateMap } from "@/ui/componentBase"; +import { DEFAULT_EVENT_STATE_MAP } from "@/ui/componentBase"; +import type React from "react"; +import { getBackgroundColorClass, getColorClass } from "@/lib/colors"; +import { getState, getTriggerRenderer } from ".."; +import type { MetadataItem } from "@/ui/metadataList"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import railwayIcon from "@/assets/icons/integrations/railway.svg"; +import { defaultStateFunction } from "../stateRegistry"; + +interface TriggerDeployConfiguration { + project?: string; + service?: string; + environment?: string; +} + +interface TriggerDeployOutput { + deployId?: string; + status?: string; + projectId?: string; + serviceId?: string; + environmentId?: string; +} + +export const DEPLOY_STATE_MAP: EventStateMap = { + ...DEFAULT_EVENT_STATE_MAP, + failed: { + icon: "circle-x", + textColor: "text-gray-800", + backgroundColor: "bg-red-100", + badgeColor: "bg-red-500", + }, + crashed: { + icon: "circle-x", + textColor: "text-gray-800", + backgroundColor: "bg-red-100", + badgeColor: "bg-red-500", + }, + removed: { + icon: "trash-2", + textColor: "text-gray-800", + backgroundColor: "bg-gray-100", + badgeColor: "bg-gray-500", + }, + skipped: { + icon: "circle-slash-2", + textColor: "text-gray-800", + backgroundColor: "bg-gray-100", + badgeColor: "bg-gray-500", + }, +}; + +export const deployStateFunction: StateFunction = (execution) => { + if (!execution) return "neutral"; + + const outputs = execution.outputs as { failed?: OutputPayload[]; success?: OutputPayload[] } | undefined; + if (outputs?.failed?.length) { + const failedOutput = outputs.failed[0]?.data as TriggerDeployOutput | undefined; + const status = failedOutput?.status?.toLowerCase(); + if (status === "crashed") return "crashed"; + if (status === "removed") return "removed"; + if (status === "skipped") return "skipped"; + return "failed"; + } + + if (outputs?.success?.length) { + return "success"; + } + + return defaultStateFunction(execution); +}; + +export const DEPLOY_STATE_REGISTRY: EventStateRegistry = { + stateMap: DEPLOY_STATE_MAP, + getState: deployStateFunction, +}; + +function stringOrDash(value?: unknown): string { + if (value === undefined || value === null || value === "") { + return "-"; + } + return String(value); +} + +export const triggerDeployMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name || context.node.componentName || "unknown"; + + return { + title: + context.node.name || + context.componentDefinition.label || + context.componentDefinition.name || + "Unnamed component", + iconSrc: railwayIcon, + iconColor: getColorClass(context.componentDefinition.color), + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + eventSections: lastExecution ? deployEventSections(context.nodes, lastExecution, componentName) : undefined, + includeEmptyState: !lastExecution, + metadata: deployMetadataList(context.node), + eventStateMap: DEPLOY_STATE_MAP, + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const outputs = context.execution.outputs as { success?: OutputPayload[]; failed?: OutputPayload[] } | undefined; + const result = + (outputs?.success?.[0]?.data as TriggerDeployOutput | undefined) ?? + (outputs?.failed?.[0]?.data as TriggerDeployOutput | undefined); + + return { + "Deploy ID": stringOrDash(result?.deployId), + Status: stringOrDash(result?.status), + "Project ID": stringOrDash(result?.projectId), + "Service ID": stringOrDash(result?.serviceId), + "Environment ID": stringOrDash(result?.environmentId), + }; + }, + + subtitle(context: SubtitleContext): string | React.ReactNode { + if (!context.execution.createdAt) return ""; + return renderTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function deployMetadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as TriggerDeployConfiguration | undefined; + + if (configuration?.project) { + metadata.push({ icon: "folder", label: `Project: ${configuration.project}` }); + } + + if (configuration?.service) { + metadata.push({ icon: "server", label: `Service: ${configuration.service}` }); + } + + if (configuration?.environment) { + metadata.push({ icon: "globe", label: `Environment: ${configuration.environment}` }); + } + + return metadata; +} + +function deployEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + const rootTriggerNode = nodes.find((node) => node.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName || ""); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent }); + + return [ + { + receivedAt: new Date(execution.createdAt!), + eventTitle: title, + eventSubtitle: renderTimeAgo(new Date(execution.createdAt!)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} diff --git a/web_src/src/ui/componentSidebar/integrationIconMaps.ts b/web_src/src/ui/componentSidebar/integrationIconMaps.ts index a986803087..08da42c9c2 100644 --- a/web_src/src/ui/componentSidebar/integrationIconMaps.ts +++ b/web_src/src/ui/componentSidebar/integrationIconMaps.ts @@ -46,6 +46,7 @@ import SemaphoreLogo from "@/assets/semaphore-logo-sign-black.svg"; import sendgridIcon from "@/assets/icons/integrations/sendgrid.svg"; import prometheusIcon from "@/assets/icons/integrations/prometheus.svg"; import renderIcon from "@/assets/icons/integrations/render.svg"; +import railwayIcon from "@/assets/icons/integrations/railway.svg"; import sentryIcon from "@/assets/icons/integrations/sentry.svg"; import dockerIcon from "@/assets/icons/integrations/docker.svg"; import hetznerIcon from "@/assets/icons/integrations/hetzner.svg"; @@ -97,6 +98,7 @@ export const INTEGRATION_APP_LOGO_MAP: Record = { sentry: sentryIcon, prometheus: prometheusIcon, render: renderIcon, + railway: railwayIcon, dockerhub: dockerIcon, honeycomb: honeycombIcon, gcp: gcpIcon, @@ -146,6 +148,7 @@ export const APP_LOGO_MAP: Record> = { sentry: sentryIcon, prometheus: prometheusIcon, render: renderIcon, + railway: railwayIcon, dockerhub: dockerIcon, harness: harnessIcon, newrelic: newrelicIcon, From 18bdfb31bd3098134f654993acf6c9ed18b4817c Mon Sep 17 00:00:00 2001 From: Shang En Sim <6071014+12458@users.noreply.github.com> Date: Sat, 30 May 2026 13:44:36 -0700 Subject: [PATCH 6/7] docs(railway): generate components documentation for railway Signed-off-by: Shang En Sim <6071014+12458@users.noreply.github.com> --- docs/components/Railway.mdx | 83 +++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/components/Railway.mdx diff --git a/docs/components/Railway.mdx b/docs/components/Railway.mdx new file mode 100644 index 0000000000..1bc882eddf --- /dev/null +++ b/docs/components/Railway.mdx @@ -0,0 +1,83 @@ +--- +title: "Railway" +--- + +Deploy services and react to deployment events on Railway + +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + +## Triggers + + + + + +## Actions + + + + + + + +## On Deployment Event + +**Trigger key:** `railway.onDeployment` + +The On Deployment Event trigger fires when a deployment changes status in the specified Railway project. + +### Use Cases + +- **Slack Notification**: Send notifications when build/deploy fails or succeeds. +- **Auto-verification**: Run integration test workflows on successful deploys. + +### Configuration + +- **Project**: The Railway project to watch. +- **Event Types**: Deployment statuses to listen for (defaults to Deployed and Failed). + +### Example Data + +```json +{ + "deploymentId": "ebda9796-09e4-456f-af60-d1a66dee66a0", + "environmentId": "9a1d7a89-2cf4-4446-9b69-4cde850918aa", + "projectId": "8db400fa-357e-4646-90f0-c7eb36e88a92", + "serviceId": "2a345678-bcde-4fgh-1234-567812345678", + "status": "SUCCESS", + "timestamp": "2026-05-30T19:46:09.816Z", + "type": "Deployment.deployed" +} +``` + + + +## Trigger Deploy + +**Component key:** `railway.triggerDeploy` + +The Trigger Deploy action starts a new deploy for a Railway service and waits for it to complete. + +### Configuration + +- **Project**: The Railway project containing the service. +- **Service**: The service to deploy. +- **Environment**: The target environment. + +### Output Channels + +- **Success**: Emitted when the deploy completes successfully. +- **Failed**: Emitted when the deploy fails or is cancelled. + +### Example Output + +```json +{ + "deployId": "ebda9796-09e4-456f-af60-d1a66dee66a0", + "environmentId": "9a1d7a89-2cf4-4446-9b69-4cde850918aa", + "projectId": "8db400fa-357e-4646-90f0-c7eb36e88a92", + "serviceId": "2a345678-bcde-4fgh-1234-567812345678", + "status": "SUCCESS" +} +``` + From 809e5b2f31caa09f32cf1e48395d17d8828685cd Mon Sep 17 00:00:00 2001 From: Shang En Sim <6071014+12458@users.noreply.github.com> Date: Sat, 30 May 2026 15:52:50 -0700 Subject: [PATCH 7/7] feat: add Railway deployment inspection and rollback actions --- docs/components/Railway.mdx | 27 ++- pkg/integrations/railway/client.go | 42 +++++ pkg/integrations/railway/client_test.go | 15 ++ pkg/integrations/railway/get_deployment.go | 160 ++++++++++++++++++ .../railway/get_deployment_test.go | 50 ++++++ pkg/integrations/railway/payloads.go | 21 ++- pkg/integrations/railway/railway.go | 2 + pkg/integrations/railway/rollback_deploy.go | 138 +++++++++++++++ .../railway/rollback_deploy_test.go | 49 ++++++ pkg/integrations/railway/setup_provider.go | 2 + .../mappers/railway/get_deployment.ts | 68 ++++++++ .../pages/workflowv2/mappers/railway/index.ts | 4 + .../mappers/railway/rollback_deploy.ts | 54 ++++++ 13 files changed, 627 insertions(+), 5 deletions(-) create mode 100644 pkg/integrations/railway/get_deployment.go create mode 100644 pkg/integrations/railway/get_deployment_test.go create mode 100644 pkg/integrations/railway/rollback_deploy.go create mode 100644 pkg/integrations/railway/rollback_deploy_test.go create mode 100644 web_src/src/pages/workflowv2/mappers/railway/get_deployment.ts create mode 100644 web_src/src/pages/workflowv2/mappers/railway/rollback_deploy.ts diff --git a/docs/components/Railway.mdx b/docs/components/Railway.mdx index 1bc882eddf..8b4a4c3c5b 100644 --- a/docs/components/Railway.mdx +++ b/docs/components/Railway.mdx @@ -16,6 +16,8 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + + @@ -50,6 +52,30 @@ The On Deployment Event trigger fires when a deployment changes status in the sp } ``` + + +## Get Deployment + +**Component key:** `railway.getDeployment` + +The Get Deployment action retrieves the current details of a Railway deployment. + +### Configuration + +- **Deploy ID**: The Railway deployment ID to retrieve. + + + +## Rollback Deploy + +**Component key:** `railway.rollbackDeploy` + +The Rollback Deploy action rolls a Railway service back to a previous deployment. + +### Configuration + +- **Deploy ID**: The previous Railway deployment to restore. + ## Trigger Deploy @@ -80,4 +106,3 @@ The Trigger Deploy action starts a new deploy for a Railway service and waits fo "status": "SUCCESS" } ``` - diff --git a/pkg/integrations/railway/client.go b/pkg/integrations/railway/client.go index b3cd18cfac..1212c58495 100644 --- a/pkg/integrations/railway/client.go +++ b/pkg/integrations/railway/client.go @@ -270,6 +270,24 @@ func (c *Client) GetDeployment(deploymentID string) (*Deployment, error) { status createdAt updatedAt + statusUpdatedAt + projectId + serviceId + environmentId + snapshotId + staticUrl + url + canRollback + canRedeploy + deploymentStopped + meta + diagnosis + creator { + id + name + email + avatar + } } } ` @@ -288,6 +306,30 @@ func (c *Client) GetDeployment(deploymentID string) (*Deployment, error) { return &result.Deployment, nil } +func (c *Client) RollbackDeployment(deploymentID string) error { + query := ` + mutation($id: String!) { + deploymentRollback(id: $id) + } + ` + variables := map[string]any{ + "id": deploymentID, + } + var result struct { + DeploymentRollback bool `json:"deploymentRollback"` + } + + if err := c.execQuery(query, variables, &result); err != nil { + return err + } + + if !result.DeploymentRollback { + return fmt.Errorf("Railway did not accept rollback request") + } + + return nil +} + func (c *Client) ListNotificationRules(workspaceID, projectID string) ([]NotificationRule, error) { query := ` query($workspaceId: String!, $projectId: String!) { diff --git a/pkg/integrations/railway/client_test.go b/pkg/integrations/railway/client_test.go index 29654200c4..334bb1f34f 100644 --- a/pkg/integrations/railway/client_test.go +++ b/pkg/integrations/railway/client_test.go @@ -138,6 +138,21 @@ func Test__Railway__Client__GetDeployment(t *testing.T) { assert.Equal(t, "SUCCESS", deploy.Status) } +func Test__Railway__Client__RollbackDeployment(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"deploymentRollback":true}}`)), + }, + }, + } + + client := NewClientWithAPIToken(httpCtx, "test-token") + err := client.RollbackDeployment("deploy-123") + require.NoError(t, err) +} + func Test__Railway__Client__CreateNotificationRule(t *testing.T) { httpCtx := &contexts.HTTPContext{ Responses: []*http.Response{ diff --git a/pkg/integrations/railway/get_deployment.go b/pkg/integrations/railway/get_deployment.go new file mode 100644 index 0000000000..c77875b07d --- /dev/null +++ b/pkg/integrations/railway/get_deployment.go @@ -0,0 +1,160 @@ +package railway + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const GetDeploymentPayloadType = "railway.deployment" + +type GetDeployment struct{} + +type GetDeploymentConfiguration struct { + DeployID string `json:"deployId" mapstructure:"deployId"` +} + +func (c *GetDeployment) Name() string { + return "railway.getDeployment" +} + +func (c *GetDeployment) Label() string { + return "Get Deployment" +} + +func (c *GetDeployment) Description() string { + return "Retrieve a Railway deployment by ID" +} + +func (c *GetDeployment) Documentation() string { + return `The Get Deployment action retrieves the current details of a Railway deployment. + +## Configuration + +- **Deploy ID**: The Railway deployment ID to retrieve.` +} + +func (c *GetDeployment) Icon() string { + return "railway" +} + +func (c *GetDeployment) Color() string { + return "gray" +} + +func (c *GetDeployment) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *GetDeployment) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "deployId", + Label: "Deploy ID", + Type: configuration.FieldTypeString, + Required: true, + Placeholder: "e.g., {{$['Trigger Deploy'].data.deployId}}", + Description: "Railway deployment ID to retrieve", + }, + } +} + +func decodeGetDeploymentConfiguration(configuration any) (GetDeploymentConfiguration, error) { + spec := GetDeploymentConfiguration{} + if err := mapstructure.Decode(configuration, &spec); err != nil { + return GetDeploymentConfiguration{}, fmt.Errorf("failed to decode configuration: %w", err) + } + + spec.DeployID = strings.TrimSpace(spec.DeployID) + if spec.DeployID == "" { + return GetDeploymentConfiguration{}, fmt.Errorf("deployId is required") + } + + return spec, nil +} + +func (c *GetDeployment) Setup(ctx core.SetupContext) error { + _, err := decodeGetDeploymentConfiguration(ctx.Configuration) + return err +} + +func (c *GetDeployment) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *GetDeployment) Execute(ctx core.ExecutionContext) error { + spec, err := decodeGetDeploymentConfiguration(ctx.Configuration) + if err != nil { + return err + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return err + } + + deployment, err := client.GetDeployment(spec.DeployID) + if err != nil { + return err + } + + return ctx.ExecutionState.Emit(core.DefaultOutputChannel.Name, GetDeploymentPayloadType, []any{deploymentData(deployment)}) +} + +func (c *GetDeployment) Hooks() []core.Hook { + return []core.Hook{} +} + +func (c *GetDeployment) HandleHook(ctx core.ActionHookContext) error { + return nil +} + +func (c *GetDeployment) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (c *GetDeployment) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *GetDeployment) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *GetDeployment) ExampleOutput() map[string]any { + return map[string]any{ + "deployId": "ebda9796-09e4-456f-af60-d1a66dee66a0", + "status": "SUCCESS", + "projectId": "8db400fa-357e-4646-90f0-c7eb36e88a92", + "serviceId": "2a345678-bcde-4fgh-1234-567812345678", + "environmentId": "9a1d7a89-2cf4-4446-9b69-4cde850918aa", + "canRollback": true, + } +} + +func deploymentData(deployment *Deployment) map[string]any { + return map[string]any{ + "deployId": deployment.ID, + "status": deployment.Status, + "createdAt": deployment.CreatedAt, + "updatedAt": deployment.UpdatedAt, + "statusUpdatedAt": deployment.StatusUpdatedAt, + "projectId": deployment.ProjectID, + "serviceId": deployment.ServiceID, + "environmentId": deployment.EnvironmentID, + "snapshotId": deployment.SnapshotID, + "staticUrl": deployment.StaticURL, + "url": deployment.URL, + "canRollback": deployment.CanRollback, + "canRedeploy": deployment.CanRedeploy, + "deploymentStopped": deployment.DeploymentStopped, + "meta": deployment.Meta, + "diagnosis": deployment.Diagnosis, + "creator": deployment.Creator, + } +} diff --git a/pkg/integrations/railway/get_deployment_test.go b/pkg/integrations/railway/get_deployment_test.go new file mode 100644 index 0000000000..2f2ada8543 --- /dev/null +++ b/pkg/integrations/railway/get_deployment_test.go @@ -0,0 +1,50 @@ +package railway + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__Railway__GetDeployment__Setup(t *testing.T) { + action := &GetDeployment{} + + require.ErrorContains(t, action.Setup(core.SetupContext{Configuration: map[string]any{}}), "deployId is required") + require.NoError(t, action.Setup(core.SetupContext{Configuration: map[string]any{"deployId": "deploy-123"}})) +} + +func Test__Railway__GetDeployment__Execute(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"deployment":{"id":"deploy-123","status":"SUCCESS","createdAt":"2026-05-30T00:00:00Z","updatedAt":"2026-05-30T00:01:00Z","projectId":"p-1","serviceId":"s-1","environmentId":"e-1","canRollback":true,"canRedeploy":true,"deploymentStopped":false}}}`)), + }, + }, + } + intCtx := &contexts.IntegrationContext{NewSetupFlow: true} + _ = intCtx.SetSecret("apiToken", []byte("test-token")) + stateCtx := &contexts.ExecutionStateContext{} + + err := (&GetDeployment{}).Execute(core.ExecutionContext{ + HTTP: httpCtx, + Integration: intCtx, + Configuration: map[string]any{"deployId": "deploy-123"}, + ExecutionState: stateCtx, + }) + require.NoError(t, err) + + assert.Equal(t, core.DefaultOutputChannel.Name, stateCtx.Channel) + assert.Equal(t, GetDeploymentPayloadType, stateCtx.Type) + require.Len(t, stateCtx.Payloads, 1) + payload := stateCtx.Payloads[0].(map[string]any)["data"].(map[string]any) + assert.Equal(t, "deploy-123", payload["deployId"]) + assert.Equal(t, "SUCCESS", payload["status"]) + assert.Equal(t, true, payload["canRollback"]) +} diff --git a/pkg/integrations/railway/payloads.go b/pkg/integrations/railway/payloads.go index 7d99bc5d24..1ec96efa06 100644 --- a/pkg/integrations/railway/payloads.go +++ b/pkg/integrations/railway/payloads.go @@ -65,10 +65,23 @@ type Service struct { } type Deployment struct { - ID string `json:"id"` - Status string `json:"status"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID string `json:"id"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + StatusUpdatedAt string `json:"statusUpdatedAt,omitempty"` + ProjectID string `json:"projectId,omitempty"` + ServiceID string `json:"serviceId,omitempty"` + EnvironmentID string `json:"environmentId,omitempty"` + SnapshotID string `json:"snapshotId,omitempty"` + StaticURL string `json:"staticUrl,omitempty"` + URL string `json:"url,omitempty"` + CanRollback bool `json:"canRollback"` + CanRedeploy bool `json:"canRedeploy"` + DeploymentStopped bool `json:"deploymentStopped"` + Meta any `json:"meta,omitempty"` + Diagnosis any `json:"diagnosis,omitempty"` + Creator any `json:"creator,omitempty"` } type NotificationRule struct { diff --git a/pkg/integrations/railway/railway.go b/pkg/integrations/railway/railway.go index f8b8a42243..611ad919fb 100644 --- a/pkg/integrations/railway/railway.go +++ b/pkg/integrations/railway/railway.go @@ -54,6 +54,8 @@ func (r *Railway) Configuration() []configuration.Field { func (r *Railway) Actions() []core.Action { return []core.Action{ &TriggerDeploy{}, + &GetDeployment{}, + &RollbackDeploy{}, } } diff --git a/pkg/integrations/railway/rollback_deploy.go b/pkg/integrations/railway/rollback_deploy.go new file mode 100644 index 0000000000..5b25650124 --- /dev/null +++ b/pkg/integrations/railway/rollback_deploy.go @@ -0,0 +1,138 @@ +package railway + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const RollbackDeployPayloadType = "railway.deployment.rollback" + +type RollbackDeploy struct{} + +type RollbackDeployConfiguration struct { + DeployID string `json:"deployId" mapstructure:"deployId"` +} + +func (c *RollbackDeploy) Name() string { + return "railway.rollbackDeploy" +} + +func (c *RollbackDeploy) Label() string { + return "Rollback Deploy" +} + +func (c *RollbackDeploy) Description() string { + return "Roll back to a previous Railway deployment" +} + +func (c *RollbackDeploy) Documentation() string { + return `The Rollback Deploy action rolls a Railway service back to a previous deployment. + +## Configuration + +- **Deploy ID**: The previous Railway deployment to restore.` +} + +func (c *RollbackDeploy) Icon() string { + return "railway" +} + +func (c *RollbackDeploy) Color() string { + return "gray" +} + +func (c *RollbackDeploy) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *RollbackDeploy) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "deployId", + Label: "Deploy ID", + Type: configuration.FieldTypeString, + Required: true, + Placeholder: "e.g., {{$['Get Deployment'].data.deployId}}", + Description: "Previous Railway deployment ID to restore", + }, + } +} + +func decodeRollbackDeployConfiguration(configuration any) (RollbackDeployConfiguration, error) { + spec := RollbackDeployConfiguration{} + if err := mapstructure.Decode(configuration, &spec); err != nil { + return RollbackDeployConfiguration{}, fmt.Errorf("failed to decode configuration: %w", err) + } + + spec.DeployID = strings.TrimSpace(spec.DeployID) + if spec.DeployID == "" { + return RollbackDeployConfiguration{}, fmt.Errorf("deployId is required") + } + + return spec, nil +} + +func (c *RollbackDeploy) Setup(ctx core.SetupContext) error { + _, err := decodeRollbackDeployConfiguration(ctx.Configuration) + return err +} + +func (c *RollbackDeploy) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *RollbackDeploy) Execute(ctx core.ExecutionContext) error { + spec, err := decodeRollbackDeployConfiguration(ctx.Configuration) + if err != nil { + return err + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return err + } + + if err := client.RollbackDeployment(spec.DeployID); err != nil { + return err + } + + return ctx.ExecutionState.Emit(core.DefaultOutputChannel.Name, RollbackDeployPayloadType, []any{ + map[string]any{ + "deployId": spec.DeployID, + "rolledBack": true, + }, + }) +} + +func (c *RollbackDeploy) Hooks() []core.Hook { + return []core.Hook{} +} + +func (c *RollbackDeploy) HandleHook(ctx core.ActionHookContext) error { + return nil +} + +func (c *RollbackDeploy) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (c *RollbackDeploy) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *RollbackDeploy) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *RollbackDeploy) ExampleOutput() map[string]any { + return map[string]any{ + "deployId": "ebda9796-09e4-456f-af60-d1a66dee66a0", + "rolledBack": true, + } +} diff --git a/pkg/integrations/railway/rollback_deploy_test.go b/pkg/integrations/railway/rollback_deploy_test.go new file mode 100644 index 0000000000..b8ff2ef189 --- /dev/null +++ b/pkg/integrations/railway/rollback_deploy_test.go @@ -0,0 +1,49 @@ +package railway + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__Railway__RollbackDeploy__Setup(t *testing.T) { + action := &RollbackDeploy{} + + require.ErrorContains(t, action.Setup(core.SetupContext{Configuration: map[string]any{}}), "deployId is required") + require.NoError(t, action.Setup(core.SetupContext{Configuration: map[string]any{"deployId": "deploy-123"}})) +} + +func Test__Railway__RollbackDeploy__Execute(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"deploymentRollback":true}}`)), + }, + }, + } + intCtx := &contexts.IntegrationContext{NewSetupFlow: true} + _ = intCtx.SetSecret("apiToken", []byte("test-token")) + stateCtx := &contexts.ExecutionStateContext{} + + err := (&RollbackDeploy{}).Execute(core.ExecutionContext{ + HTTP: httpCtx, + Integration: intCtx, + Configuration: map[string]any{"deployId": "deploy-123"}, + ExecutionState: stateCtx, + }) + require.NoError(t, err) + + assert.Equal(t, core.DefaultOutputChannel.Name, stateCtx.Channel) + assert.Equal(t, RollbackDeployPayloadType, stateCtx.Type) + require.Len(t, stateCtx.Payloads, 1) + payload := stateCtx.Payloads[0].(map[string]any)["data"].(map[string]any) + assert.Equal(t, "deploy-123", payload["deployId"]) + assert.Equal(t, true, payload["rolledBack"]) +} diff --git a/pkg/integrations/railway/setup_provider.go b/pkg/integrations/railway/setup_provider.go index 02b26aea45..737c21a48f 100644 --- a/pkg/integrations/railway/setup_provider.go +++ b/pkg/integrations/railway/setup_provider.go @@ -79,6 +79,8 @@ func (s *SetupProvider) CapabilityGroups() []core.CapabilityGroup { Capabilities: s.genCapabilities( []core.Action{ &TriggerDeploy{}, + &GetDeployment{}, + &RollbackDeploy{}, }, []core.Trigger{ &OnDeploymentEvent{}, diff --git a/web_src/src/pages/workflowv2/mappers/railway/get_deployment.ts b/web_src/src/pages/workflowv2/mappers/railway/get_deployment.ts new file mode 100644 index 0000000000..b85ed8e9e6 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/railway/get_deployment.ts @@ -0,0 +1,68 @@ +import type { ComponentBaseProps } from "@/ui/componentBase"; +import type React from "react"; +import type { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import type { MetadataItem } from "@/ui/metadataList"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import { baseProps } from "./base"; + +interface GetDeploymentConfiguration { + deployId?: string; +} + +interface GetDeploymentOutput { + deployId?: string; + status?: string; + projectId?: string; + serviceId?: string; + environmentId?: string; + createdAt?: string; + updatedAt?: string; + canRollback?: boolean; + canRedeploy?: boolean; +} + +function stringOrDash(value?: unknown): string { + return value === undefined || value === null || value === "" ? "-" : String(value); +} + +function metadataList(node: NodeInfo): MetadataItem[] { + const configuration = node.configuration as GetDeploymentConfiguration | undefined; + return configuration?.deployId ? [{ icon: "hash", label: `Deploy: ${configuration.deployId}` }] : []; +} + +export const getDeploymentMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + return { + ...baseProps(context.nodes, context.node, context.componentDefinition, context.lastExecutions), + metadata: metadataList(context.node), + }; + }, + + subtitle(context: SubtitleContext): string | React.ReactNode { + return context.execution.createdAt ? renderTimeAgo(new Date(context.execution.createdAt)) : ""; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const result = outputs?.default?.[0]?.data as GetDeploymentOutput | undefined; + + return { + "Deploy ID": stringOrDash(result?.deployId), + Status: stringOrDash(result?.status), + "Project ID": stringOrDash(result?.projectId), + "Service ID": stringOrDash(result?.serviceId), + "Environment ID": stringOrDash(result?.environmentId), + "Created At": stringOrDash(result?.createdAt), + "Updated At": stringOrDash(result?.updatedAt), + "Can Rollback": stringOrDash(result?.canRollback), + "Can Redeploy": stringOrDash(result?.canRedeploy), + }; + }, +}; diff --git a/web_src/src/pages/workflowv2/mappers/railway/index.ts b/web_src/src/pages/workflowv2/mappers/railway/index.ts index 853c9fab6a..6819736482 100644 --- a/web_src/src/pages/workflowv2/mappers/railway/index.ts +++ b/web_src/src/pages/workflowv2/mappers/railway/index.ts @@ -1,9 +1,13 @@ import type { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../types"; import { triggerDeployMapper, DEPLOY_STATE_REGISTRY } from "./trigger_deploy"; import { onDeploymentTriggerRenderer } from "./on_deployment"; +import { getDeploymentMapper } from "./get_deployment"; +import { rollbackDeployMapper } from "./rollback_deploy"; export const componentMappers: Record = { triggerDeploy: triggerDeployMapper, + getDeployment: getDeploymentMapper, + rollbackDeploy: rollbackDeployMapper, }; export const triggerRenderers: Record = { diff --git a/web_src/src/pages/workflowv2/mappers/railway/rollback_deploy.ts b/web_src/src/pages/workflowv2/mappers/railway/rollback_deploy.ts new file mode 100644 index 0000000000..cf54b2ea9e --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/railway/rollback_deploy.ts @@ -0,0 +1,54 @@ +import type { ComponentBaseProps } from "@/ui/componentBase"; +import type React from "react"; +import type { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import type { MetadataItem } from "@/ui/metadataList"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import { baseProps } from "./base"; + +interface RollbackDeployConfiguration { + deployId?: string; +} + +interface RollbackDeployOutput { + deployId?: string; + rolledBack?: boolean; +} + +function stringOrDash(value?: unknown): string { + return value === undefined || value === null || value === "" ? "-" : String(value); +} + +function metadataList(node: NodeInfo): MetadataItem[] { + const configuration = node.configuration as RollbackDeployConfiguration | undefined; + return configuration?.deployId ? [{ icon: "rotate-ccw", label: `Rollback to: ${configuration.deployId}` }] : []; +} + +export const rollbackDeployMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + return { + ...baseProps(context.nodes, context.node, context.componentDefinition, context.lastExecutions), + metadata: metadataList(context.node), + }; + }, + + subtitle(context: SubtitleContext): string | React.ReactNode { + return context.execution.createdAt ? renderTimeAgo(new Date(context.execution.createdAt)) : ""; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const result = outputs?.default?.[0]?.data as RollbackDeployOutput | undefined; + + return { + "Deploy ID": stringOrDash(result?.deployId), + "Rollback Requested": stringOrDash(result?.rolledBack), + }; + }, +};