diff --git a/docs/components/Railway.mdx b/docs/components/Railway.mdx new file mode 100644 index 0000000000..12977b7371 --- /dev/null +++ b/docs/components/Railway.mdx @@ -0,0 +1,154 @@ +--- +title: "Railway" +--- + +Deploy and monitor Railway applications + +## Triggers + + + + + +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + +## Actions + + + + + +## Instructions + +Create an API token in Railway and paste it below. + +**Important:** When creating the token, select a **specific workspace** to access its projects. The "No Workspace" option will not work. + +[Create Railway Token →](https://railway.com/account/tokens) + + + +## On Deployment Event + +The On Deployment Event trigger starts a workflow when Railway sends deployment status webhooks. + +### Setup + +After configuring this trigger: +1. Copy the webhook URL shown in the trigger settings +2. Go to Railway → Your Project → Settings → Webhooks +3. Add the webhook URL and select "Deploy" events +4. Save the webhook configuration + +### Use Cases + +- **Deployment notifications**: Notify Slack when deployments complete or fail +- **Incident creation**: Create tickets when deployments crash +- **Pipeline chaining**: Trigger downstream workflows after successful deployments + +### Configuration + +- **Project**: Select the Railway project to monitor +- **Event Filter**: Optionally filter by deployment event type (deployed, failed, crashed, etc.) + - Leave empty to receive all deployment events + +### Event Data + +Each deployment event includes: +- `type`: Event type (e.g., Deployment.deployed, Deployment.failed) +- `details.status`: Deployment status (SUCCESS, FAILED, etc.) +- `resource.deployment.id`: Deployment ID +- `resource.service`: Service name and ID +- `resource.environment`: Environment name and ID +- `resource.project`: Project information +- `timestamp`: When the event occurred + +### Example Data + +```json +{ + "details": { + "branch": "main", + "commitAuthor": "developer", + "commitHash": "abc123def456", + "commitMessage": "Update deployment", + "id": "deploy-abc123", + "serviceId": "srv-xyz789", + "source": "GitHub", + "status": "SUCCESS" + }, + "resource": { + "deployment": { + "id": "deploy-jkl345" + }, + "environment": { + "id": "env-def456", + "isEphemeral": false, + "name": "production" + }, + "project": { + "id": "proj-xyz789", + "name": "my-project" + }, + "service": { + "id": "srv-ghi012", + "name": "api-server" + }, + "workspace": { + "id": "ws-abc123", + "name": "My Workspace" + } + }, + "severity": "INFO", + "timestamp": "2024-01-15T10:30:00.000Z", + "type": "Deployment.deployed" +} +``` + + + +## Trigger Deploy + +The Trigger Deploy component starts a new deployment for a Railway service and waits for it to complete. + +### Use Cases + +- **Deploy on merge**: Automatically deploy when code is merged to main +- **Scheduled deployments**: Deploy on a schedule (e.g., nightly releases) +- **Manual approval**: Deploy after approval in the workflow +- **Cross-service orchestration**: Deploy services in sequence + +### How It Works + +1. Triggers a new deployment via Railway's API +2. Polls for deployment status updates (Queued → Building → Deploying → Success/Failed) +3. Routes execution based on final deployment status: + - **Deployed channel**: Deployment succeeded + - **Failed channel**: Deployment failed + - **Crashed channel**: Deployment crashed + +### Configuration + +- **Project**: Select the Railway project +- **Service**: Select the service to deploy +- **Environment**: Select the target environment (e.g., production, staging) + +### Output Channels + +- **Deployed**: Emitted when deployment succeeds +- **Failed**: Emitted when deployment fails +- **Crashed**: Emitted when deployment crashes + +### Example Output + +```json +{ + "deploymentId": "deploy-abc123", + "environment": "env-def456", + "project": "proj-xyz789", + "service": "srv-ghi012", + "status": "SUCCESS", + "url": "https://my-service-production.up.railway.app" +} +``` + diff --git a/pkg/integrations/railway/client.go b/pkg/integrations/railway/client.go new file mode 100644 index 0000000000..3435a25b81 --- /dev/null +++ b/pkg/integrations/railway/client.go @@ -0,0 +1,349 @@ +package railway + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/superplanehq/superplane/pkg/core" +) + +const GraphQLEndpoint = "https://backboard.railway.com/graphql/v2" + +type Client struct { + apiToken string + http core.HTTPContext +} + +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Services []Service `json:"services"` + Environments []Environment `json:"environments"` +} + +type Service struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` +} + +type Environment struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type graphqlRequest struct { + Query string `json:"query"` + Variables map[string]any `json:"variables,omitempty"` +} + +type graphqlResponse struct { + Data map[string]any `json:"data"` + Errors []graphqlError `json:"errors,omitempty"` +} + +type graphqlError struct { + Message string `json:"message"` +} + +func NewClient(httpCtx core.HTTPContext, integration core.IntegrationContext) (*Client, error) { + token, err := integration.GetConfig("apiToken") + if err != nil { + return nil, fmt.Errorf("failed to get API token: %v", err) + } + + if len(token) == 0 { + return nil, fmt.Errorf("API token is empty") + } + + return &Client{ + apiToken: string(token), + http: httpCtx, + }, nil +} + +func (c *Client) graphql(query string, variables map[string]any) (map[string]any, error) { + payload := graphqlRequest{ + Query: query, + Variables: variables, + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %v", err) + } + + req, err := http.NewRequest("POST", GraphQLEndpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Set("Authorization", "Bearer "+c.apiToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %v", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %v", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf( + "Railway GraphQL API request failed with status %d: %s", + resp.StatusCode, + string(respBody), + ) + } + + var gqlResp graphqlResponse + if err := json.Unmarshal(respBody, &gqlResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %v", err) + } + + if len(gqlResp.Errors) > 0 { + return nil, fmt.Errorf("Railway GraphQL error: %s", gqlResp.Errors[0].Message) + } + + return gqlResp.Data, nil +} + +func (c *Client) ListProjects() ([]Project, error) { + query := ` + query { + projects { + edges { + node { + id + name + description + createdAt + updatedAt + } + } + } + } + ` + + data, err := c.graphql(query, nil) + if err != nil { + return nil, err + } + + projectsData, ok := data["projects"].(map[string]any) + if !ok { + return nil, fmt.Errorf("unexpected response format for projects") + } + + edges, ok := projectsData["edges"].([]any) + if !ok { + return []Project{}, nil + } + + projects := make([]Project, 0, len(edges)) + for _, edge := range edges { + edgeMap, ok := edge.(map[string]any) + if !ok { + continue + } + node, ok := edgeMap["node"].(map[string]any) + if !ok { + continue + } + + projects = append(projects, Project{ + ID: getString(node, "id"), + Name: getString(node, "name"), + Description: getString(node, "description"), + CreatedAt: getString(node, "createdAt"), + UpdatedAt: getString(node, "updatedAt"), + }) + } + + return projects, nil +} + +func (c *Client) GetProject(projectID string) (*Project, error) { + query := ` + query project($id: String!) { + project(id: $id) { + id + name + description + services { + edges { + node { + id + name + icon + } + } + } + environments { + edges { + node { + id + name + } + } + } + } + } + ` + + data, err := c.graphql(query, map[string]any{"id": projectID}) + if err != nil { + return nil, err + } + + projectData, ok := data["project"].(map[string]any) + if !ok { + return nil, fmt.Errorf("project not found") + } + + project := &Project{ + ID: getString(projectData, "id"), + Name: getString(projectData, "name"), + Description: getString(projectData, "description"), + } + + // Parse services + if servicesData, ok := projectData["services"].(map[string]any); ok { + if edges, ok := servicesData["edges"].([]any); ok { + for _, edge := range edges { + if edgeMap, ok := edge.(map[string]any); ok { + if node, ok := edgeMap["node"].(map[string]any); ok { + project.Services = append(project.Services, Service{ + ID: getString(node, "id"), + Name: getString(node, "name"), + Icon: getString(node, "icon"), + }) + } + } + } + } + } + + // Parse environments + if envsData, ok := projectData["environments"].(map[string]any); ok { + if edges, ok := envsData["edges"].([]any); ok { + for _, edge := range edges { + if edgeMap, ok := edge.(map[string]any); ok { + if node, ok := edgeMap["node"].(map[string]any); ok { + project.Environments = append(project.Environments, Environment{ + ID: getString(node, "id"), + Name: getString(node, "name"), + }) + } + } + } + } + } + + return project, nil +} + +// Deployment represents a Railway deployment with its current status +type Deployment struct { + ID string `json:"id"` + Status string `json:"status"` // BUILDING, DEPLOYING, SUCCESS, FAILED, CRASHED, REMOVED, SLEEPING, SKIPPED, WAITING, QUEUED + URL string `json:"url"` +} + +// DeploymentStatus constants +const ( + DeploymentStatusQueued = "QUEUED" + DeploymentStatusWaiting = "WAITING" + DeploymentStatusBuilding = "BUILDING" + DeploymentStatusDeploying = "DEPLOYING" + DeploymentStatusSuccess = "SUCCESS" + DeploymentStatusFailed = "FAILED" + DeploymentStatusCrashed = "CRASHED" + DeploymentStatusRemoved = "REMOVED" + DeploymentStatusSleeping = "SLEEPING" + DeploymentStatusSkipped = "SKIPPED" +) + +// IsDeploymentFinalStatus returns true if the status is a final/terminal status +func IsDeploymentFinalStatus(status string) bool { + switch status { + case DeploymentStatusSuccess, DeploymentStatusFailed, DeploymentStatusCrashed, + DeploymentStatusRemoved, DeploymentStatusSkipped, DeploymentStatusSleeping: + return true + default: + return false + } +} + +func (c *Client) TriggerDeploy(serviceID, environmentID string) (string, error) { + // serviceInstanceDeployV2 returns a String! (deployment ID) + mutation := ` + mutation serviceInstanceDeployV2($serviceId: String!, $environmentId: String!) { + serviceInstanceDeployV2(serviceId: $serviceId, environmentId: $environmentId) + } + ` + + variables := map[string]any{ + "serviceId": serviceID, + "environmentId": environmentID, + } + + data, err := c.graphql(mutation, variables) + if err != nil { + return "", err + } + + deploymentID, ok := data["serviceInstanceDeployV2"].(string) + if !ok { + return "", fmt.Errorf("unexpected response format: deployment ID not found") + } + + return deploymentID, nil +} + +// GetDeployment retrieves the current status of a deployment +func (c *Client) GetDeployment(deploymentID string) (*Deployment, error) { + query := ` + query deployment($id: String!) { + deployment(id: $id) { + id + status + url + } + } + ` + + data, err := c.graphql(query, map[string]any{"id": deploymentID}) + if err != nil { + return nil, err + } + + deploymentData, ok := data["deployment"].(map[string]any) + if !ok { + return nil, fmt.Errorf("deployment not found") + } + + return &Deployment{ + ID: getString(deploymentData, "id"), + Status: getString(deploymentData, "status"), + URL: getString(deploymentData, "url"), + }, nil +} + +// Helper function to safely get string from map +func getString(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} diff --git a/pkg/integrations/railway/example.go b/pkg/integrations/railway/example.go new file mode 100644 index 0000000000..7b0aebe7a1 --- /dev/null +++ b/pkg/integrations/railway/example.go @@ -0,0 +1,36 @@ +package railway + +import ( + _ "embed" + "sync" + + "github.com/superplanehq/superplane/pkg/utils" +) + +//go:embed example_output_trigger_deploy.json +var exampleOutputTriggerDeployBytes []byte + +//go:embed example_data_on_deployment_event.json +var exampleDataOnDeploymentEventBytes []byte + +var exampleOutputTriggerDeployOnce sync.Once +var exampleOutputTriggerDeploy map[string]any + +var exampleDataOnDeploymentEventOnce sync.Once +var exampleDataOnDeploymentEvent map[string]any + +func (c *TriggerDeploy) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON( + &exampleOutputTriggerDeployOnce, + exampleOutputTriggerDeployBytes, + &exampleOutputTriggerDeploy, + ) +} + +func (t *OnDeploymentEvent) ExampleData() map[string]any { + return utils.UnmarshalEmbeddedJSON( + &exampleDataOnDeploymentEventOnce, + exampleDataOnDeploymentEventBytes, + &exampleDataOnDeploymentEvent, + ) +} diff --git a/pkg/integrations/railway/example_data_on_deployment_event.json b/pkg/integrations/railway/example_data_on_deployment_event.json new file mode 100644 index 0000000000..fc61081e66 --- /dev/null +++ b/pkg/integrations/railway/example_data_on_deployment_event.json @@ -0,0 +1,37 @@ +{ + "type": "Deployment.deployed", + "severity": "INFO", + "timestamp": "2024-01-15T10:30:00.000Z", + "details": { + "id": "deploy-abc123", + "status": "SUCCESS", + "source": "GitHub", + "branch": "main", + "commitHash": "abc123def456", + "commitMessage": "Update deployment", + "commitAuthor": "developer", + "serviceId": "srv-xyz789" + }, + "resource": { + "workspace": { + "id": "ws-abc123", + "name": "My Workspace" + }, + "project": { + "id": "proj-xyz789", + "name": "my-project" + }, + "environment": { + "id": "env-def456", + "name": "production", + "isEphemeral": false + }, + "service": { + "id": "srv-ghi012", + "name": "api-server" + }, + "deployment": { + "id": "deploy-jkl345" + } + } +} diff --git a/pkg/integrations/railway/example_output_trigger_deploy.json b/pkg/integrations/railway/example_output_trigger_deploy.json new file mode 100644 index 0000000000..11bcd24d10 --- /dev/null +++ b/pkg/integrations/railway/example_output_trigger_deploy.json @@ -0,0 +1,8 @@ +{ + "deploymentId": "deploy-abc123", + "status": "SUCCESS", + "url": "https://my-service-production.up.railway.app", + "project": "proj-xyz789", + "service": "srv-ghi012", + "environment": "env-def456" +} diff --git a/pkg/integrations/railway/on_deployment_event.go b/pkg/integrations/railway/on_deployment_event.go new file mode 100644 index 0000000000..6317fb25e1 --- /dev/null +++ b/pkg/integrations/railway/on_deployment_event.go @@ -0,0 +1,270 @@ +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 OnDeploymentEventConfiguration struct { + Project string `json:"project" mapstructure:"project"` + Statuses []string `json:"statuses" mapstructure:"statuses"` +} + +type OnDeploymentEventMetadata struct { + Project *ProjectInfo `json:"project" mapstructure:"project"` + WebhookURL string `json:"webhookUrl,omitempty" mapstructure:"webhookUrl,omitempty"` + WebhookConfigURL string `json:"webhookConfigUrl,omitempty" mapstructure:"webhookConfigUrl,omitempty"` +} + +type ProjectInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func (t *OnDeploymentEvent) Name() string { + return "railway.onDeploymentEvent" +} + +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 starts a workflow when Railway sends deployment status webhooks. + +## Setup + +After configuring this trigger: +1. Copy the webhook URL shown in the trigger settings +2. Go to Railway → Your Project → Settings → Webhooks +3. Add the webhook URL and select "Deploy" events +4. Save the webhook configuration + +## Use Cases + +- **Deployment notifications**: Notify Slack when deployments complete or fail +- **Incident creation**: Create tickets when deployments crash +- **Pipeline chaining**: Trigger downstream workflows after successful deployments + +## Configuration + +- **Project**: Select the Railway project to monitor +- **Event Filter**: Optionally filter by deployment event type (deployed, failed, crashed, etc.) + - Leave empty to receive all deployment events + +## Event Data + +Each deployment event includes: +- ` + "`type`" + `: Event type (e.g., Deployment.deployed, Deployment.failed) +- ` + "`details.status`" + `: Deployment status (SUCCESS, FAILED, etc.) +- ` + "`resource.deployment.id`" + `: Deployment ID +- ` + "`resource.service`" + `: Service name and ID +- ` + "`resource.environment`" + `: Environment name and ID +- ` + "`resource.project`" + `: Project information +- ` + "`timestamp`" + `: When the event occurred` +} + +func (t *OnDeploymentEvent) Icon() string { + return "railway" +} + +func (t *OnDeploymentEvent) Color() string { + return "purple" +} + +func (t *OnDeploymentEvent) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "project", + Label: "Project", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "Railway project to monitor for deployment events", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "project", + }, + }, + }, + { + Name: "statuses", + Label: "Event Filter", + Type: configuration.FieldTypeMultiSelect, + Required: false, + Description: "Only trigger for these deployment events. Leave empty to receive all events.", + TypeOptions: &configuration.TypeOptions{ + MultiSelect: &configuration.MultiSelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Deployed", Value: "deployed"}, + {Label: "Failed", Value: "failed"}, + {Label: "Crashed", Value: "crashed"}, + {Label: "Triggered", Value: "triggered"}, + {Label: "Resolved", Value: "resolved"}, + {Label: "Oom Killed", Value: "oom_killed"}, + {Label: "Redeployed", Value: "redeployed"}, + {Label: "Slept", Value: "slept"}, + {Label: "Resumed", Value: "resumed"}, + {Label: "Restarted", Value: "restarted"}, + {Label: "Removed", Value: "removed"}, + {Label: "Building", Value: "building"}, + {Label: "Deploying", Value: "deploying"}, + {Label: "Waiting", Value: "waiting"}, + {Label: "Needs Approval", Value: "needs_approval"}, + {Label: "Queued", Value: "queued"}, + }, + }, + }, + }, + } +} + +func (t *OnDeploymentEvent) Setup(ctx core.TriggerContext) error { + var metadata OnDeploymentEventMetadata + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return fmt.Errorf("failed to parse metadata: %w", err) + } + + config := OnDeploymentEventConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if config.Project == "" { + return fmt.Errorf("project is required") + } + + // If already set up with matching project and webhook URL, nothing to do + if metadata.Project != nil && metadata.Project.ID == config.Project && + metadata.WebhookURL != "" { + return nil + } + + // Fetch project details to validate it exists + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + project, err := client.GetProject(config.Project) + if err != nil { + return fmt.Errorf("failed to get project: %w", err) + } + + // Setup webhook and get the URL for manual configuration in Railway + webhookURL := metadata.WebhookURL + if webhookURL == "" { + webhookURL, err = ctx.Webhook.Setup() + if err != nil { + return fmt.Errorf("failed to setup webhook: %w", err) + } + } + + // Build direct link to Railway webhook configuration page + webhookConfigURL := fmt.Sprintf("https://railway.com/project/%s/settings/webhooks/new", project.ID) + + // Store metadata with webhook URL and config link + if err := ctx.Metadata.Set(OnDeploymentEventMetadata{ + Project: &ProjectInfo{ + ID: project.ID, + Name: project.Name, + }, + WebhookURL: webhookURL, + WebhookConfigURL: webhookConfigURL, + }); err != nil { + return fmt.Errorf("failed to set metadata: %w", err) + } + + return nil +} + +func (t *OnDeploymentEvent) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + // Note: Railway does NOT provide webhook signatures + // We cannot verify the request authenticity + + // Parse the webhook payload + var payload map[string]any + if err := json.Unmarshal(ctx.Body, &payload); err != nil { + return http.StatusBadRequest, fmt.Errorf("failed to parse webhook payload: %w", err) + } + + // Check if this is a deployment event (format: Deployment.succeeded, Deployment.failed, etc.) + eventType, _ := payload["type"].(string) + if !strings.HasPrefix(eventType, "Deployment.") { + // Not a deployment event, ignore silently + return http.StatusOK, nil + } + + // Extract event action from type (e.g., "succeeded" from "Deployment.succeeded") + eventAction := extractEventAction(eventType) + + // Load configuration to check status filter + config := OnDeploymentEventConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err) + } + + // Validate the project matches the configured project + // This is important since Railway doesn't provide webhook signatures + var payloadProjectID string + if resource, ok := payload["resource"].(map[string]any); ok { + if project, ok := resource["project"].(map[string]any); ok { + if projectID, ok := project["id"].(string); ok { + payloadProjectID = projectID + } + } + } + + if payloadProjectID != "" && payloadProjectID != config.Project { + return http.StatusOK, nil + } + + // Filter by event action if configured + if len(config.Statuses) > 0 { + // Reject events with empty action when filter is active + if eventAction == "" || !slices.Contains(config.Statuses, eventAction) { + return http.StatusOK, nil + } + } + + // Emit the event + if err := ctx.Events.Emit("railway.deployment", payload); err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to emit event: %w", err) + } + + return http.StatusOK, nil +} + +func (t *OnDeploymentEvent) Actions() []core.Action { + return []core.Action{} +} + +func (t *OnDeploymentEvent) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { + return nil, nil +} + +func (t *OnDeploymentEvent) Cleanup(ctx core.TriggerContext) error { + return nil +} + +// extractEventAction extracts the event action from the type field +// e.g., "Deployment.succeeded" -> "succeeded" +func extractEventAction(eventType string) string { + parts := strings.SplitN(eventType, ".", 2) + if len(parts) != 2 { + return "" + } + return parts[1] +} diff --git a/pkg/integrations/railway/on_deployment_event_test.go b/pkg/integrations/railway/on_deployment_event_test.go new file mode 100644 index 0000000000..090327f4a9 --- /dev/null +++ b/pkg/integrations/railway/on_deployment_event_test.go @@ -0,0 +1,294 @@ +package railway + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + contexts "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__OnDeploymentEvent__HandleWebhook(t *testing.T) { + trigger := &OnDeploymentEvent{} + + t.Run("invalid JSON payload -> 400", func(t *testing.T) { + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: []byte(`{invalid json`), + Headers: http.Header{}, + }) + + assert.Equal(t, http.StatusBadRequest, code) + assert.ErrorContains(t, err, "failed to parse webhook payload") + }) + + t.Run("non-deployment event is ignored", func(t *testing.T) { + body := []byte(`{"type": "OTHER_EVENT"}`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Zero(t, eventContext.Count()) + }) + + t.Run("deployment event with no status filter -> event is emitted", func(t *testing.T) { + body := []byte(`{ + "type": "Deployment.deployed", + "details": { + "status": "SUCCESS" + }, + "resource": { + "deployment": { + "id": "deploy-123" + } + } + }`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, eventContext.Count()) + }) + + t.Run("deployment event matching status filter -> event is emitted", func(t *testing.T) { + body := []byte(`{ + "type": "Deployment.deployed", + "details": { + "status": "SUCCESS" + }, + "resource": { + "deployment": { + "id": "deploy-123" + } + } + }`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{"deployed", "failed"}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, eventContext.Count()) + }) + + t.Run("deployment event not matching status filter -> event is not emitted", func(t *testing.T) { + body := []byte(`{ + "type": "Deployment.building", + "details": { + "status": "BUILDING" + }, + "resource": { + "deployment": { + "id": "deploy-123" + } + } + }`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{"deployed", "failed"}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Zero(t, eventContext.Count()) + }) + + t.Run("deployment event with failed status -> event is emitted", func(t *testing.T) { + body := []byte(`{ + "type": "Deployment.failed", + "details": { + "status": "FAILED" + }, + "resource": { + "deployment": { + "id": "deploy-456" + }, + "service": { + "id": "srv-123", + "name": "api-server" + } + } + }`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{"failed"}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, eventContext.Count()) + }) + + t.Run("deployment event with crashed status -> event is emitted", func(t *testing.T) { + body := []byte(`{ + "type": "Deployment.crashed", + "details": { + "status": "CRASHED" + }, + "resource": { + "deployment": { + "id": "deploy-789" + } + } + }`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{"crashed"}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, eventContext.Count()) + }) + + t.Run("deployment event without action in type -> event is emitted when no filter", func(t *testing.T) { + body := []byte(`{ + "type": "Deployment.", + "resource": { + "deployment": { + "id": "deploy-123" + } + } + }`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, eventContext.Count()) + }) + + t.Run("deployment event with empty action and filter configured -> event is not emitted", func(t *testing.T) { + body := []byte(`{ + "type": "Deployment.", + "resource": { + "deployment": { + "id": "deploy-123" + } + } + }`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{"deployed", "failed"}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Zero(t, eventContext.Count()) + }) +} + +func Test__OnDeploymentEvent__Setup(t *testing.T) { + trigger := OnDeploymentEvent{} + + t.Run("project is required", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + err := trigger.Setup(core.TriggerContext{ + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"project": ""}, + }) + + require.ErrorContains(t, err, "project is required") + }) +} + +func Test__ExtractEventAction(t *testing.T) { + t.Run("extracts action from deployed event type", func(t *testing.T) { + assert.Equal(t, "deployed", extractEventAction("Deployment.deployed")) + }) + + t.Run("extracts action from failed event type", func(t *testing.T) { + assert.Equal(t, "failed", extractEventAction("Deployment.failed")) + }) + + t.Run("extracts action from crashed event type", func(t *testing.T) { + assert.Equal(t, "crashed", extractEventAction("Deployment.crashed")) + }) + + t.Run("returns empty string for event type without dot", func(t *testing.T) { + assert.Equal(t, "", extractEventAction("Deployment")) + }) + + t.Run("returns empty string for empty event type", func(t *testing.T) { + assert.Equal(t, "", extractEventAction("")) + }) + + t.Run("handles event type with empty action", func(t *testing.T) { + assert.Equal(t, "", extractEventAction("Deployment.")) + }) + + t.Run("handles event type with multiple dots", func(t *testing.T) { + assert.Equal(t, "deployed.extra", extractEventAction("Deployment.deployed.extra")) + }) +} diff --git a/pkg/integrations/railway/railway.go b/pkg/integrations/railway/railway.go new file mode 100644 index 0000000000..8b0472f314 --- /dev/null +++ b/pkg/integrations/railway/railway.go @@ -0,0 +1,233 @@ +package railway + +import ( + "fmt" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/registry" +) + +func init() { + // Railway webhooks are manually configured by users via Railway UI + // (Railway doesn't have an API for creating webhooks) + // So we use RegisterIntegration without a webhook handler, like DockerHub + registry.RegisterIntegration("railway", &Railway{}) +} + +type Railway struct{} + +type Configuration struct { + APIToken string `json:"apiToken" mapstructure:"apiToken"` +} + +type Metadata struct { + Projects []ProjectResource `json:"projects,omitempty" mapstructure:"projects,omitempty"` +} + +type ProjectResource struct { + ID string `json:"id" mapstructure:"id"` + Name string `json:"name" mapstructure:"name"` +} + +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 and monitor Railway applications" +} + +func (r *Railway) Instructions() string { + return `Create an API token in Railway and paste it below. + +**Important:** When creating the token, select a **specific workspace** to access its projects. The "No Workspace" option will not work. + +[Create Railway Token →](https://railway.com/account/tokens)` +} + +func (r *Railway) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "apiToken", + Label: "API Token", + Type: configuration.FieldTypeString, + Sensitive: true, + Required: true, + Description: "Your Railway API token scoped to a workspace", + Placeholder: "YOUR RAILWAY API TOKEN", + }, + } +} + +func (r *Railway) Components() []core.Component { + return []core.Component{ + &TriggerDeploy{}, + } +} + +func (r *Railway) Triggers() []core.Trigger { + return []core.Trigger{ + &OnDeploymentEvent{}, + } +} + +func (r *Railway) Sync(ctx core.SyncContext) error { + config := Configuration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %v", err) + } + + if config.APIToken == "" { + return fmt.Errorf("API token is required") + } + + // Validate API token by making a test request + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %v", err) + } + + // Fetch and cache projects (the 'me' query requires additional permissions) + projects, err := client.ListProjects() + if err != nil { + return fmt.Errorf("failed to validate API token: %w", err) + } + + // Store projects in metadata for ListResources + projectResources := make([]ProjectResource, 0, len(projects)) + for _, p := range projects { + projectResources = append(projectResources, ProjectResource{ + ID: p.ID, + Name: p.Name, + }) + } + + ctx.Integration.SetMetadata(Metadata{ + Projects: projectResources, + }) + + ctx.Integration.Ready() + return nil +} + +func (r *Railway) Cleanup(ctx core.IntegrationCleanupContext) error { + return nil +} + +func (r *Railway) HandleRequest(ctx core.HTTPRequestContext) { + // No OAuth or special HTTP handling needed +} + +func (r *Railway) Actions() []core.Action { + return []core.Action{} +} + +func (r *Railway) HandleAction(ctx core.IntegrationActionContext) error { + return nil +} + +func (r *Railway) ListResources( + resourceType string, + ctx core.ListResourcesContext, +) ([]core.IntegrationResource, error) { + switch resourceType { + case "project": + return r.listProjectsFromMetadata(ctx) + case "service": + projectID := ctx.Parameters["projectId"] + if projectID == "" { + return []core.IntegrationResource{}, nil + } + return r.listServices(ctx, projectID) + case "environment": + projectID := ctx.Parameters["projectId"] + if projectID == "" { + return []core.IntegrationResource{}, nil + } + return r.listEnvironments(ctx, projectID) + default: + return []core.IntegrationResource{}, nil + } +} + +func (r *Railway) listProjectsFromMetadata( + ctx core.ListResourcesContext, +) ([]core.IntegrationResource, error) { + metadata := Metadata{} + if err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata); err != nil { + return nil, fmt.Errorf("failed to decode metadata: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(metadata.Projects)) + for _, project := range metadata.Projects { + resources = append(resources, core.IntegrationResource{ + Type: "project", + ID: project.ID, + Name: project.Name, + }) + } + + return resources, nil +} + +func (r *Railway) listServices( + ctx core.ListResourcesContext, + projectID string, +) ([]core.IntegrationResource, error) { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %v", err) + } + + project, err := client.GetProject(projectID) + if err != nil { + return nil, fmt.Errorf("failed to get project: %v", err) + } + + resources := make([]core.IntegrationResource, 0, len(project.Services)) + for _, service := range project.Services { + resources = append(resources, core.IntegrationResource{ + Type: "service", + ID: service.ID, + Name: service.Name, + }) + } + + return resources, nil +} + +func (r *Railway) listEnvironments( + ctx core.ListResourcesContext, + projectID string, +) ([]core.IntegrationResource, error) { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %v", err) + } + + project, err := client.GetProject(projectID) + if err != nil { + return nil, fmt.Errorf("failed to get project: %v", err) + } + + resources := make([]core.IntegrationResource, 0, len(project.Environments)) + for _, env := range project.Environments { + resources = append(resources, core.IntegrationResource{ + Type: "environment", + ID: env.ID, + Name: env.Name, + }) + } + + return resources, nil +} diff --git a/pkg/integrations/railway/trigger_deploy.go b/pkg/integrations/railway/trigger_deploy.go new file mode 100644 index 0000000000..23a4f50738 --- /dev/null +++ b/pkg/integrations/railway/trigger_deploy.go @@ -0,0 +1,396 @@ +package railway + +import ( + "fmt" + "time" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const ( + // Output channel names + DeployedOutputChannel = "deployed" + FailedOutputChannel = "failed" + CrashedOutputChannel = "crashed" + + // Event type + DeploymentPayloadType = "railway.deployment.finished" + + // Poll interval for checking deployment status + DeploymentPollInterval = 30 * time.Second +) + +// TriggerDeploy triggers a deployment and tracks its status until completion +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 TriggerDeployMetadata struct { + Project *ProjectInfo `json:"project" mapstructure:"project"` + Service *ServiceInfo `json:"service" mapstructure:"service"` + Environment *EnvironmentInfo `json:"environment" mapstructure:"environment"` +} + +// TriggerDeployExecutionMetadata tracks the deployment state during execution +type TriggerDeployExecutionMetadata struct { + DeploymentID string `json:"deploymentId" mapstructure:"deploymentId"` + Status string `json:"status" mapstructure:"status"` + URL string `json:"url" mapstructure:"url"` +} + +type ServiceInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type EnvironmentInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func (c *TriggerDeploy) Name() string { + return "railway.triggerDeploy" +} + +func (c *TriggerDeploy) Label() string { + return "Trigger Deploy" +} + +func (c *TriggerDeploy) Description() string { + return "Trigger a new deployment for a Railway service" +} + +func (c *TriggerDeploy) Documentation() string { + return `The Trigger Deploy component starts a new deployment for a Railway service and waits for it to complete. + +## Use Cases + +- **Deploy on merge**: Automatically deploy when code is merged to main +- **Scheduled deployments**: Deploy on a schedule (e.g., nightly releases) +- **Manual approval**: Deploy after approval in the workflow +- **Cross-service orchestration**: Deploy services in sequence + +## How It Works + +1. Triggers a new deployment via Railway's API +2. Polls for deployment status updates (Queued → Building → Deploying → Success/Failed) +3. Routes execution based on final deployment status: + - **Deployed channel**: Deployment succeeded + - **Failed channel**: Deployment failed + - **Crashed channel**: Deployment crashed + +## Configuration + +- **Project**: Select the Railway project +- **Service**: Select the service to deploy +- **Environment**: Select the target environment (e.g., production, staging) + +## Output Channels + +- **Deployed**: Emitted when deployment succeeds +- **Failed**: Emitted when deployment fails +- **Crashed**: Emitted when deployment crashes` +} + +func (c *TriggerDeploy) Icon() string { + return "railway" +} + +func (c *TriggerDeploy) Color() string { + return "purple" +} + +func (c *TriggerDeploy) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "project", + Label: "Project", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "Railway project containing the service", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "project", + }, + }, + }, + { + Name: "service", + Label: "Service", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "Service to deploy", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "service", + Parameters: []configuration.ParameterRef{ + { + Name: "projectId", + ValueFrom: &configuration.ParameterValueFrom{Field: "project"}, + }, + }, + }, + }, + }, + { + Name: "environment", + Label: "Environment", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "Target environment for the deployment", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "environment", + Parameters: []configuration.ParameterRef{ + { + Name: "projectId", + ValueFrom: &configuration.ParameterValueFrom{Field: "project"}, + }, + }, + }, + }, + }, + } +} + +func (c *TriggerDeploy) OutputChannels(config any) []core.OutputChannel { + return []core.OutputChannel{ + { + Name: DeployedOutputChannel, + Label: "Deployed", + }, + { + Name: FailedOutputChannel, + Label: "Failed", + }, + { + Name: CrashedOutputChannel, + Label: "Crashed", + }, + } +} + +func (c *TriggerDeploy) Setup(ctx core.SetupContext) error { + config := TriggerDeployConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if config.Project == "" { + return fmt.Errorf("project is required") + } + if config.Service == "" { + return fmt.Errorf("service is required") + } + if config.Environment == "" { + return fmt.Errorf("environment is required") + } + + // Validate the resources exist + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + project, err := client.GetProject(config.Project) + if err != nil { + return fmt.Errorf("failed to get project: %w", err) + } + + // Find service and environment in project + var serviceName, environmentName string + for _, svc := range project.Services { + if svc.ID == config.Service { + serviceName = svc.Name + break + } + } + for _, env := range project.Environments { + if env.ID == config.Environment { + environmentName = env.Name + break + } + } + + if serviceName == "" { + return fmt.Errorf("service not found in project") + } + if environmentName == "" { + return fmt.Errorf("environment not found in project") + } + + // Store metadata for display + return ctx.Metadata.Set(TriggerDeployMetadata{ + Project: &ProjectInfo{ID: project.ID, Name: project.Name}, + Service: &ServiceInfo{ID: config.Service, Name: serviceName}, + Environment: &EnvironmentInfo{ID: config.Environment, Name: environmentName}, + }) +} + +func (c *TriggerDeploy) Execute(ctx core.ExecutionContext) error { + config := TriggerDeployConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + // Trigger the deployment and get the deployment ID + deploymentID, err := client.TriggerDeploy(config.Service, config.Environment) + if err != nil { + return fmt.Errorf("failed to trigger deployment: %w", err) + } + + ctx.Logger.Infof( + "Triggered deployment %s for service %s in environment %s", + deploymentID, + config.Service, + config.Environment, + ) + + // Store deployment ID in execution metadata + err = ctx.Metadata.Set(TriggerDeployExecutionMetadata{ + DeploymentID: deploymentID, + Status: DeploymentStatusQueued, + }) + if err != nil { + return fmt.Errorf("failed to set execution metadata: %w", err) + } + + // Store deployment ID in KV for later retrieval + err = ctx.ExecutionState.SetKV("deployment_id", deploymentID) + if err != nil { + return fmt.Errorf("failed to store deployment ID: %w", err) + } + + // Schedule the first poll to check deployment status + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, DeploymentPollInterval) +} + +func (c *TriggerDeploy) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *TriggerDeploy) Actions() []core.Action { + return []core.Action{ + { + Name: "poll", + UserAccessible: false, + }, + } +} + +func (c *TriggerDeploy) HandleAction(ctx core.ActionContext) error { + switch ctx.Name { + case "poll": + return c.poll(ctx) + } + return fmt.Errorf("unknown action: %s", ctx.Name) +} + +func (c *TriggerDeploy) poll(ctx core.ActionContext) error { + // Check if execution is already finished + if ctx.ExecutionState.IsFinished() { + return nil + } + + // Get current execution metadata + execMetadata := TriggerDeployExecutionMetadata{} + if err := mapstructure.Decode(ctx.Metadata.Get(), &execMetadata); err != nil { + return fmt.Errorf("failed to decode execution metadata: %w", err) + } + + // If already in final state, nothing to do + if IsDeploymentFinalStatus(execMetadata.Status) { + return nil + } + + // Create client to check deployment status + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + // Get the latest deployment status + deployment, err := client.GetDeployment(execMetadata.DeploymentID) + if err != nil { + ctx.Logger.WithError(err).Warn("Failed to get deployment status, will retry") + // Schedule another poll - don't fail on transient errors + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, DeploymentPollInterval) + } + + ctx.Logger.Infof("Deployment %s status: %s", deployment.ID, deployment.Status) + + // Update metadata with current status + execMetadata.Status = deployment.Status + execMetadata.URL = deployment.URL + if err := ctx.Metadata.Set(execMetadata); err != nil { + return fmt.Errorf("failed to update metadata: %w", err) + } + + // If not in final state, schedule another poll + if !IsDeploymentFinalStatus(deployment.Status) { + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, DeploymentPollInterval) + } + + // Deployment reached final state - emit to appropriate channel + config := TriggerDeployConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + eventData := map[string]any{ + "deploymentId": deployment.ID, + "status": deployment.Status, + "url": deployment.URL, + "project": config.Project, + "service": config.Service, + "environment": config.Environment, + } + + switch deployment.Status { + case DeploymentStatusSuccess, DeploymentStatusSleeping: + // SLEEPING means the deployment succeeded but the app went to sleep due to inactivity + ctx.Logger.Infof("Deployment succeeded (status: %s)", deployment.Status) + return ctx.ExecutionState.Emit( + DeployedOutputChannel, + DeploymentPayloadType, + []any{eventData}, + ) + case DeploymentStatusCrashed: + ctx.Logger.Info("Deployment crashed") + return ctx.ExecutionState.Emit( + CrashedOutputChannel, + DeploymentPayloadType, + []any{eventData}, + ) + default: + // FAILED, REMOVED, SKIPPED all go to failed channel + ctx.Logger.Infof("Deployment ended with status: %s", deployment.Status) + return ctx.ExecutionState.Emit(FailedOutputChannel, DeploymentPayloadType, []any{eventData}) + } +} + +func (c *TriggerDeploy) Cancel(ctx core.ExecutionContext) error { + // Railway doesn't have a cancel deployment API, so we just log and return + ctx.Logger.Info("Cancel requested - Railway deployments cannot be cancelled via API") + return nil +} + +func (c *TriggerDeploy) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *TriggerDeploy) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return 200, nil +} diff --git a/pkg/integrations/railway/trigger_deploy_test.go b/pkg/integrations/railway/trigger_deploy_test.go new file mode 100644 index 0000000000..e41b2dfd71 --- /dev/null +++ b/pkg/integrations/railway/trigger_deploy_test.go @@ -0,0 +1,95 @@ +package railway + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + contexts "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__TriggerDeploy__Setup(t *testing.T) { + component := TriggerDeploy{} + + t.Run("project is required", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + err := component.Setup(core.SetupContext{ + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"project": "", "service": "srv-123", "environment": "env-123"}, + }) + + require.ErrorContains(t, err, "project is required") + }) + + t.Run("service is required", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + err := component.Setup(core.SetupContext{ + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"project": "proj-123", "service": "", "environment": "env-123"}, + }) + + require.ErrorContains(t, err, "service is required") + }) + + t.Run("environment is required", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + err := component.Setup(core.SetupContext{ + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"project": "proj-123", "service": "srv-123", "environment": ""}, + }) + + require.ErrorContains(t, err, "environment is required") + }) +} + +func Test__TriggerDeploy__Configuration(t *testing.T) { + component := TriggerDeploy{} + config := component.Configuration() + + t.Run("has three required fields", func(t *testing.T) { + require.Len(t, config, 3) + + // Verify project field + assert.Equal(t, "project", config[0].Name) + assert.Equal(t, "Project", config[0].Label) + assert.True(t, config[0].Required) + assert.Equal(t, "integration-resource", config[0].Type) + assert.Equal(t, "project", config[0].TypeOptions.Resource.Type) + + // Verify service field + assert.Equal(t, "service", config[1].Name) + assert.Equal(t, "Service", config[1].Label) + assert.True(t, config[1].Required) + assert.Equal(t, "integration-resource", config[1].Type) + assert.Equal(t, "service", config[1].TypeOptions.Resource.Type) + require.Len(t, config[1].TypeOptions.Resource.Parameters, 1) + assert.Equal(t, "projectId", config[1].TypeOptions.Resource.Parameters[0].Name) + assert.Equal(t, "project", config[1].TypeOptions.Resource.Parameters[0].ValueFrom.Field) + + // Verify environment field + assert.Equal(t, "environment", config[2].Name) + assert.Equal(t, "Environment", config[2].Label) + assert.True(t, config[2].Required) + assert.Equal(t, "integration-resource", config[2].Type) + assert.Equal(t, "environment", config[2].TypeOptions.Resource.Type) + require.Len(t, config[2].TypeOptions.Resource.Parameters, 1) + assert.Equal(t, "projectId", config[2].TypeOptions.Resource.Parameters[0].Name) + assert.Equal(t, "project", config[2].TypeOptions.Resource.Parameters[0].ValueFrom.Field) + }) +} diff --git a/pkg/server/server.go b/pkg/server/server.go index fdc996af82..d89d9c9243 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -50,6 +50,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/integrations/openai" _ "github.com/superplanehq/superplane/pkg/integrations/pagerduty" _ "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" @@ -62,7 +63,13 @@ import ( _ "github.com/superplanehq/superplane/pkg/widgets/annotation" ) -func startWorkers(encryptor crypto.Encryptor, registry *registry.Registry, oidcProvider oidc.Provider, baseURL string, authService authorization.Authorization) { +func startWorkers( + encryptor crypto.Encryptor, + registry *registry.Registry, + oidcProvider oidc.Provider, + baseURL string, + authService authorization.Authorization, +) { log.Println("Starting Workers") rabbitMQURL, err := config.RabbitMQURL() @@ -74,14 +81,16 @@ func startWorkers(encryptor crypto.Encryptor, registry *registry.Registry, oidcP startEmailConsumers(rabbitMQURL, encryptor, baseURL, authService) } - if os.Getenv("START_WORKFLOW_EVENT_ROUTER") == "yes" || os.Getenv("START_EVENT_ROUTER") == "yes" { + if os.Getenv("START_WORKFLOW_EVENT_ROUTER") == "yes" || + os.Getenv("START_EVENT_ROUTER") == "yes" { log.Println("Starting Event Router") w := workers.NewEventRouter() go w.Start(context.Background()) } - if os.Getenv("START_WORKFLOW_NODE_EXECUTOR") == "yes" || os.Getenv("START_NODE_EXECUTOR") == "yes" { + if os.Getenv("START_WORKFLOW_NODE_EXECUTOR") == "yes" || + os.Getenv("START_NODE_EXECUTOR") == "yes" { log.Println("Starting Node Executor") webhookBaseURL := getWebhookBaseURL(baseURL) @@ -96,15 +105,23 @@ func startWorkers(encryptor crypto.Encryptor, registry *registry.Registry, oidcP go w.Start(context.Background()) } - if os.Getenv("START_APP_INSTALLATION_REQUEST_WORKER") == "yes" || os.Getenv("START_INTEGRATION_REQUEST_WORKER") == "yes" { + if os.Getenv("START_APP_INSTALLATION_REQUEST_WORKER") == "yes" || + os.Getenv("START_INTEGRATION_REQUEST_WORKER") == "yes" { log.Println("Starting Integration Request Worker") webhooksBaseURL := getWebhookBaseURL(baseURL) - w := workers.NewIntegrationRequestWorker(encryptor, registry, oidcProvider, baseURL, webhooksBaseURL) + w := workers.NewIntegrationRequestWorker( + encryptor, + registry, + oidcProvider, + baseURL, + webhooksBaseURL, + ) go w.Start(context.Background()) } - if os.Getenv("START_WORKFLOW_NODE_QUEUE_WORKER") == "yes" || os.Getenv("START_NODE_QUEUE_WORKER") == "yes" { + if os.Getenv("START_WORKFLOW_NODE_QUEUE_WORKER") == "yes" || + os.Getenv("START_NODE_QUEUE_WORKER") == "yes" { log.Println("Starting Node Queue Worker") w := workers.NewNodeQueueWorker(registry) @@ -126,14 +143,16 @@ func startWorkers(encryptor crypto.Encryptor, registry *registry.Registry, oidcP go w.Start(context.Background()) } - if os.Getenv("START_INSTALLATION_CLEANUP_WORKER") == "yes" || os.Getenv("START_INTEGRATION_CLEANUP_WORKER") == "yes" { + if os.Getenv("START_INSTALLATION_CLEANUP_WORKER") == "yes" || + os.Getenv("START_INTEGRATION_CLEANUP_WORKER") == "yes" { log.Println("Starting Integration Cleanup Worker") w := workers.NewIntegrationCleanupWorker(registry, encryptor, baseURL) go w.Start(context.Background()) } - if os.Getenv("START_WORKFLOW_CLEANUP_WORKER") == "yes" || os.Getenv("START_CANVAS_CLEANUP_WORKER") == "yes" { + if os.Getenv("START_WORKFLOW_CLEANUP_WORKER") == "yes" || + os.Getenv("START_CANVAS_CLEANUP_WORKER") == "yes" { log.Println("Starting Canvas Cleanup Worker") w := workers.NewCanvasCleanupWorker() @@ -141,10 +160,17 @@ func startWorkers(encryptor crypto.Encryptor, registry *registry.Registry, oidcP } } -func startEmailConsumers(rabbitMQURL string, encryptor crypto.Encryptor, baseURL string, authService authorization.Authorization) { +func startEmailConsumers( + rabbitMQURL string, + encryptor crypto.Encryptor, + baseURL string, + authService authorization.Authorization, +) { templateDir := os.Getenv("TEMPLATE_DIR") if templateDir == "" { - log.Warn("Email Consumers not started - missing required environment variable (TEMPLATE_DIR)") + log.Warn( + "Email Consumers not started - missing required environment variable (TEMPLATE_DIR)", + ) return } @@ -160,7 +186,9 @@ func startEmailConsumers(rabbitMQURL string, encryptor crypto.Encryptor, baseURL fromName := os.Getenv("EMAIL_FROM_NAME") fromEmail := os.Getenv("EMAIL_FROM_ADDRESS") if resendAPIKey == "" || fromName == "" || fromEmail == "" { - log.Warn("Email Consumers not started - missing required environment variables (RESEND_API_KEY, EMAIL_FROM_NAME, EMAIL_FROM_ADDRESS)") + log.Warn( + "Email Consumers not started - missing required environment variables (RESEND_API_KEY, EMAIL_FROM_NAME, EMAIL_FROM_ADDRESS)", + ) return } @@ -168,22 +196,57 @@ func startEmailConsumers(rabbitMQURL string, encryptor crypto.Encryptor, baseURL startEmailConsumersWithService(rabbitMQURL, emailService, baseURL, authService) } -func startEmailConsumersWithService(rabbitMQURL string, emailService services.EmailService, baseURL string, authService authorization.Authorization) { +func startEmailConsumersWithService( + rabbitMQURL string, + emailService services.EmailService, + baseURL string, + authService authorization.Authorization, +) { log.Println("Starting Invitation Email Consumer") - invitationEmailConsumer := workers.NewInvitationEmailConsumer(rabbitMQURL, emailService, baseURL) + invitationEmailConsumer := workers.NewInvitationEmailConsumer( + rabbitMQURL, + emailService, + baseURL, + ) go invitationEmailConsumer.Start() log.Println("Starting Notification Email Consumer") - notificationEmailConsumer := workers.NewNotificationEmailConsumer(rabbitMQURL, emailService, authService) + notificationEmailConsumer := workers.NewNotificationEmailConsumer( + rabbitMQURL, + emailService, + authService, + ) go notificationEmailConsumer.Start() } -func startInternalAPI(baseURL, webhooksBaseURL, basePath string, encryptor crypto.Encryptor, authService authorization.Authorization, registry *registry.Registry, oidcProvider oidc.Provider) { +func startInternalAPI( + baseURL, webhooksBaseURL, basePath string, + encryptor crypto.Encryptor, + authService authorization.Authorization, + registry *registry.Registry, + oidcProvider oidc.Provider, +) { log.Println("Starting Internal API") - grpc.RunServer(baseURL, webhooksBaseURL, basePath, encryptor, authService, registry, oidcProvider, lookupInternalAPIPort()) + grpc.RunServer( + baseURL, + webhooksBaseURL, + basePath, + encryptor, + authService, + registry, + oidcProvider, + lookupInternalAPIPort(), + ) } -func startPublicAPI(baseURL, basePath string, encryptor crypto.Encryptor, registry *registry.Registry, jwtSigner *jwt.Signer, oidcProvider oidc.Provider, authService authorization.Authorization) { +func startPublicAPI( + baseURL, basePath string, + encryptor crypto.Encryptor, + registry *registry.Registry, + jwtSigner *jwt.Signer, + oidcProvider oidc.Provider, + authService authorization.Authorization, +) { log.Println("Starting Public API with integrated Web Server") appEnv := os.Getenv("APP_ENV") @@ -191,7 +254,19 @@ func startPublicAPI(baseURL, basePath string, encryptor crypto.Encryptor, regist blockSignup := os.Getenv("BLOCK_SIGNUP") == "yes" webhooksBaseURL := getWebhookBaseURL(baseURL) - server, err := public.NewServer(encryptor, registry, jwtSigner, oidcProvider, basePath, baseURL, webhooksBaseURL, appEnv, templateDir, authService, blockSignup) + server, err := public.NewServer( + encryptor, + registry, + jwtSigner, + oidcProvider, + basePath, + baseURL, + webhooksBaseURL, + appEnv, + templateDir, + authService, + blockSignup, + ) if err != nil { log.Panicf("Error creating public API server: %v", err) } @@ -362,11 +437,27 @@ func Start() { templates.Setup(registry) if os.Getenv("START_PUBLIC_API") == "yes" { - go startPublicAPI(baseURL, basePath, encryptorInstance, registry, jwtSigner, oidcProvider, authService) + go startPublicAPI( + baseURL, + basePath, + encryptorInstance, + registry, + jwtSigner, + oidcProvider, + authService, + ) } if os.Getenv("START_INTERNAL_API") == "yes" { - go startInternalAPI(baseURL, webhooksBaseURL, basePath, encryptorInstance, authService, registry, oidcProvider) + go startInternalAPI( + baseURL, + webhooksBaseURL, + basePath, + encryptorInstance, + authService, + registry, + oidcProvider, + ) } startWorkers(encryptorInstance, registry, oidcProvider, baseURL, authService) diff --git a/test/support/support.go b/test/support/support.go index 2bf66c101d..e5d85c67c8 100644 --- a/test/support/support.go +++ b/test/support/support.go @@ -31,6 +31,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/components/wait" _ "github.com/superplanehq/superplane/pkg/integrations/circleci" _ "github.com/superplanehq/superplane/pkg/integrations/github" + _ "github.com/superplanehq/superplane/pkg/integrations/railway" _ "github.com/superplanehq/superplane/pkg/integrations/semaphore" _ "github.com/superplanehq/superplane/pkg/triggers/schedule" _ "github.com/superplanehq/superplane/pkg/triggers/start" 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..93106345a1 --- /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 d996d130bc..311a1b6aff 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -108,6 +108,12 @@ import { eventStateRegistry as claudeEventStateRegistry, } from "./claude/index"; import { triggerRenderers as bitbucketTriggerRenderers } from "./bitbucket/index"; +import { + componentMappers as railwayComponentMappers, + triggerRenderers as railwayTriggerRenderers, + customFieldRenderers as railwayCustomFieldRenderers, + eventStateRegistry as railwayEventStateRegistry, +} from "./railway/index"; import { componentMappers as prometheusComponentMappers, customFieldRenderers as prometheusCustomFieldRenderers, @@ -175,6 +181,7 @@ const appMappers: Record> = { openai: openaiComponentMappers, circleci: circleCIComponentMappers, claude: claudeComponentMappers, + railway: railwayComponentMappers, prometheus: prometheusComponentMappers, cursor: cursorComponentMappers, hetzner: hetznerComponentMappers, @@ -200,6 +207,7 @@ const appTriggerRenderers: Record> = { openai: openaiTriggerRenderers, circleci: circleCITriggerRenderers, claude: claudeTriggerRenderers, + railway: railwayTriggerRenderers, bitbucket: bitbucketTriggerRenderers, prometheus: prometheusTriggerRenderers, cursor: cursorTriggerRenderers, @@ -224,6 +232,7 @@ const appEventStateRegistries: Record circleci: circleCIEventStateRegistry, claude: claudeEventStateRegistry, aws: awsEventStateRegistry, + railway: railwayEventStateRegistry, prometheus: prometheusEventStateRegistry, cursor: cursorEventStateRegistry, gitlab: gitlabEventStateRegistry, @@ -254,6 +263,7 @@ const customFieldRenderers: Record = { const appCustomFieldRenderers: Record> = { github: githubCustomFieldRenderers, prometheus: prometheusCustomFieldRenderers, + railway: railwayCustomFieldRenderers, dockerhub: dockerhubCustomFieldRenderers, }; diff --git a/web_src/src/pages/workflowv2/mappers/railway/custom_field_renderer.tsx b/web_src/src/pages/workflowv2/mappers/railway/custom_field_renderer.tsx new file mode 100644 index 0000000000..1017d51561 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/railway/custom_field_renderer.tsx @@ -0,0 +1,124 @@ +import { useState } from "react"; +import { CustomFieldRenderer, NodeInfo } from "../types"; +import { Icon } from "@/components/Icon"; +import { showErrorToast } from "@/utils/toast"; + +interface OnDeploymentEventMetadata { + project?: { + id: string; + name: string; + }; + webhookUrl?: string; + webhookConfigUrl?: string; +} + +/** + * Copy button component for code blocks + */ +const CopyButton: React.FC<{ text: string }> = ({ text }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (_err) { + showErrorToast("Failed to copy text"); + } + }; + + return ( + + ); +}; + +/** + * Custom field renderer for Railway On Deployment Event trigger + * Shows the webhook URL that needs to be configured in Railway + */ +export const onDeploymentEventCustomFieldRenderer: CustomFieldRenderer = { + render: (node: NodeInfo) => { + const metadata = node.metadata as OnDeploymentEventMetadata | undefined; + const webhookUrl = metadata?.webhookUrl || "[URL GENERATED ONCE THE CANVAS IS SAVED]"; + + const curlExample = `curl -X POST \\ + -H "Content-Type: application/json" \\ + --data '{"type":"Deployment.deployed","details":{"status":"SUCCESS"},"resource":{"deployment":{"id":"test-123"}}}' \\ + ${webhookUrl}`; + + return ( +
+
+
+ Railway Webhook Configuration +

+ Copy this URL and add it to your Railway project's webhook settings. +

+ + {/* Webhook URL Copy Field */} +
+ +
+ + +
+
+ + {/* Configure Webhook Button */} + {metadata?.webhookConfigUrl && ( +
+ + + Configure Webhook in Railway + +

+ Paste the webhook URL above and select "Deploy" events. +

+
+ )} + + {/* Test Command */} + {metadata?.webhookUrl && ( +
+ +
+
+                    {curlExample}
+                  
+ +
+
+ )} + + {!metadata?.webhookUrl && ( +

+ Save the canvas to generate the webhook URL. +

+ )} +
+
+
+ ); + }, +}; 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..d0a37c10a7 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/railway/index.ts @@ -0,0 +1,27 @@ +import { ComponentBaseMapper, TriggerRenderer, CustomFieldRenderer, EventStateRegistry } from "../types"; +import { onDeploymentEventTriggerRenderer } from "./on_deployment_event"; +import { + triggerDeployMapper, + TRIGGER_DEPLOY_STATE_MAP, + triggerDeployStateFunction, + TRIGGER_DEPLOY_STATE_REGISTRY, +} from "./trigger_deploy"; +import { onDeploymentEventCustomFieldRenderer } from "./custom_field_renderer"; + +export { TRIGGER_DEPLOY_STATE_MAP, triggerDeployStateFunction, TRIGGER_DEPLOY_STATE_REGISTRY }; + +export const componentMappers: Record = { + triggerDeploy: triggerDeployMapper, +}; + +export const triggerRenderers: Record = { + onDeploymentEvent: onDeploymentEventTriggerRenderer, +}; + +export const customFieldRenderers: Record = { + onDeploymentEvent: onDeploymentEventCustomFieldRenderer, +}; + +export const eventStateRegistry: Record = { + triggerDeploy: TRIGGER_DEPLOY_STATE_REGISTRY, +}; diff --git a/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts b/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts new file mode 100644 index 0000000000..9faa9a5428 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts @@ -0,0 +1,122 @@ +import { getColorClass, getBackgroundColorClass } from "@/utils/colors"; +import { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; +import { TriggerProps } from "@/ui/trigger"; +import RailwayLogo from "@/assets/icons/integrations/railway.svg"; +import { formatTimeAgo } from "@/utils/date"; + +interface OnDeploymentEventMetadata { + project?: { + id: string; + name: string; + }; +} + +interface OnDeploymentEventConfiguration { + statuses?: string[]; +} + +interface DeploymentResource { + owner?: { id?: string; email?: string }; + project?: { id?: string; name?: string }; + environment?: { id?: string; name?: string }; + service?: { id?: string; name?: string }; + deployment?: { id?: string }; +} + +interface OnDeploymentEventData { + type?: string; + details?: { status?: string }; + resource?: DeploymentResource; + timestamp?: string; +} + +/** + * Renderer for the "railway.onDeploymentEvent" trigger type + */ +export const onDeploymentEventTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => { + const eventData = context.event?.data as OnDeploymentEventData; + const serviceName = eventData?.resource?.service?.name || "Service"; + const status = eventData?.details?.status || ""; + const timeAgo = context.event?.createdAt ? formatTimeAgo(new Date(context.event?.createdAt)) : ""; + const subtitle = status && timeAgo ? `${status.toLowerCase()} · ${timeAgo}` : status.toLowerCase() || timeAgo; + + return { + title: `${serviceName} deployment`, + subtitle, + }; + }, + + getRootEventValues: (context: TriggerEventContext): Record => { + const eventData = context.event?.data as OnDeploymentEventData; + const resource = eventData?.resource; + const receivedAt = context.event?.createdAt ? new Date(context.event?.createdAt).toLocaleString() : ""; + + // Build Railway deployment URL + const projectId = resource?.project?.id; + const serviceId = resource?.service?.id; + const deploymentId = resource?.deployment?.id; + const deploymentLink = + projectId && serviceId + ? `https://railway.com/project/${projectId}/service/${serviceId}${deploymentId ? `?deploymentId=${deploymentId}` : ""}` + : ""; + + return { + "Received at": receivedAt, + Status: eventData?.details?.status || "", + Service: resource?.service?.name || "", + Environment: resource?.environment?.name || "", + Project: resource?.project?.name || "", + "Deployment link": deploymentLink, + }; + }, + + getTriggerProps: (context: TriggerRendererContext): TriggerProps => { + const { node, definition, lastEvent } = context; + const metadata = node.metadata as unknown as OnDeploymentEventMetadata; + const configuration = node.configuration as unknown as OnDeploymentEventConfiguration; + const metadataItems = []; + + // Show project name + if (metadata?.project?.name) { + metadataItems.push({ + icon: "folder", + label: metadata.project.name, + }); + } + + // Show status filter if configured + if (configuration?.statuses && configuration.statuses.length > 0) { + metadataItems.push({ + icon: "filter", + label: configuration.statuses.join(", "), + }); + } + + const props: TriggerProps = { + title: node.name || definition.label || "On Deployment Event", + iconSrc: RailwayLogo, + iconColor: getColorClass(definition.color), + collapsedBackground: getBackgroundColorClass(definition.color), + metadata: metadataItems, + }; + + if (lastEvent) { + const eventData = lastEvent.data as OnDeploymentEventData; + const serviceName = eventData?.resource?.service?.name || "Service"; + const status = eventData?.details?.status || ""; + const timeAgo = lastEvent.createdAt ? formatTimeAgo(new Date(lastEvent.createdAt)) : ""; + const subtitle = status && timeAgo ? `${status.toLowerCase()} · ${timeAgo}` : status.toLowerCase() || timeAgo; + + props.lastEventData = { + title: `${serviceName} deployment`, + subtitle, + 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..20bf5e3d87 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts @@ -0,0 +1,322 @@ +import { getColorClass, getBackgroundColorClass } from "@/utils/colors"; +import { + ComponentBaseMapper, + ComponentBaseContext, + SubtitleContext, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + StateFunction, + EventStateRegistry, +} from "../types"; +import RailwayLogo from "@/assets/icons/integrations/railway.svg"; +import { formatTimeAgo } from "@/utils/date"; +import { DEFAULT_EVENT_STATE_MAP, EventSection, EventState, EventStateMap } from "@/ui/componentBase"; +import { getTriggerRenderer } from ".."; + +interface TriggerDeployMetadata { + project?: { id?: string; name?: string }; + service?: { id?: string; name?: string }; + environment?: { id?: string; name?: string }; +} + +interface TriggerDeployExecutionMetadata { + deploymentId?: string; + status?: string; + url?: string; +} + +// Railway deployment status constants (matching backend) +const DeploymentStatus = { + QUEUED: "QUEUED", + WAITING: "WAITING", + BUILDING: "BUILDING", + DEPLOYING: "DEPLOYING", + SUCCESS: "SUCCESS", + FAILED: "FAILED", + CRASHED: "CRASHED", + REMOVED: "REMOVED", + SLEEPING: "SLEEPING", + SKIPPED: "SKIPPED", +} as const; + +/** + * Custom state map for Railway deployment statuses + */ +export const TRIGGER_DEPLOY_STATE_MAP: EventStateMap = { + ...DEFAULT_EVENT_STATE_MAP, + queued: { + icon: "clock", + textColor: "text-gray-800", + backgroundColor: "bg-gray-100", + badgeColor: "bg-gray-500", + }, + building: { + icon: "hammer", + textColor: "text-gray-800", + backgroundColor: "bg-blue-100", + badgeColor: "bg-blue-500", + }, + deploying: { + icon: "loader-circle", + textColor: "text-gray-800", + backgroundColor: "bg-purple-100", + badgeColor: "bg-purple-500", + }, + passed: { + icon: "circle-check", + textColor: "text-gray-800", + backgroundColor: "bg-green-100", + badgeColor: "bg-emerald-500", + }, + failed: { + icon: "circle-x", + textColor: "text-gray-800", + backgroundColor: "bg-red-100", + badgeColor: "bg-red-400", + }, + crashed: { + icon: "alert-triangle", + textColor: "text-gray-800", + backgroundColor: "bg-orange-100", + badgeColor: "bg-orange-500", + }, +}; + +/** + * Maps Railway deployment status to UI event state + */ +function mapDeploymentStatusToState(status: string | undefined): EventState { + switch (status) { + case DeploymentStatus.QUEUED: + case DeploymentStatus.WAITING: + return "queued"; + case DeploymentStatus.BUILDING: + return "building"; + case DeploymentStatus.DEPLOYING: + return "deploying"; + case DeploymentStatus.SUCCESS: + case DeploymentStatus.SLEEPING: + // SLEEPING means deployment succeeded but app went to sleep due to inactivity + return "passed"; + case DeploymentStatus.CRASHED: + return "crashed"; + case DeploymentStatus.FAILED: + case DeploymentStatus.REMOVED: + case DeploymentStatus.SKIPPED: + return "failed"; + default: + return "neutral"; + } +} + +/** + * State function for Railway TriggerDeploy component + */ +export const triggerDeployStateFunction: StateFunction = (execution: ExecutionInfo): EventState => { + if (!execution) return "neutral"; + + // Check for errors first + if ( + execution.resultMessage && + (execution.resultReason === "RESULT_REASON_ERROR" || + (execution.result === "RESULT_FAILED" && execution.resultReason !== "RESULT_REASON_ERROR_RESOLVED")) + ) { + return "error"; + } + + if (execution.result === "RESULT_CANCELLED") { + return "cancelled"; + } + + // If execution is finished, map based on final status + if (execution.state === "STATE_FINISHED") { + const metadata = execution.metadata as TriggerDeployExecutionMetadata; + if (metadata?.status) { + return mapDeploymentStatusToState(metadata.status); + } + // Fallback based on result + return execution.result === "RESULT_PASSED" ? "passed" : "failed"; + } + + // If still running, show the current deployment status from metadata + if (execution.state === "STATE_STARTED" || execution.state === "STATE_PENDING") { + const metadata = execution.metadata as TriggerDeployExecutionMetadata; + if (metadata?.status) { + return mapDeploymentStatusToState(metadata.status); + } + return "queued"; // Default to queued if no status yet + } + + return "neutral"; +}; + +/** + * State registry for Railway TriggerDeploy component + */ +export const TRIGGER_DEPLOY_STATE_REGISTRY: EventStateRegistry = { + stateMap: TRIGGER_DEPLOY_STATE_MAP, + getState: triggerDeployStateFunction, +}; + +/** + * Formats the deployment status for display + */ +function formatDeploymentStatus(status: string | undefined): string { + if (!status) return ""; + // Convert SCREAMING_CASE to Title Case + return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase(); +} + +/** + * Builds the subtitle string for a Railway deployment execution + */ +function getDeploymentSubtitle(execution: ExecutionInfo): string { + const execMetadata = execution.metadata as TriggerDeployExecutionMetadata; + const status = execMetadata?.status; + + // Show current deployment status while running + if (execution.state === "STATE_STARTED" || execution.state === "STATE_PENDING") { + switch (status) { + case DeploymentStatus.QUEUED: + case DeploymentStatus.WAITING: + return "Queued..."; + case DeploymentStatus.BUILDING: + return "Building..."; + case DeploymentStatus.DEPLOYING: + return "Deploying..."; + default: + return "Starting..."; + } + } + + // Finished states + if (execution.state === "STATE_FINISHED") { + const updatedAt = execution.updatedAt ? new Date(execution.updatedAt) : null; + const timeAgo = updatedAt ? formatTimeAgo(updatedAt) : ""; + + switch (status) { + case DeploymentStatus.SUCCESS: + case DeploymentStatus.SLEEPING: + // SLEEPING means deployment succeeded but app went to sleep + return timeAgo ? `Deployed ${timeAgo}` : "Deployed"; + case DeploymentStatus.CRASHED: + return timeAgo ? `Crashed ${timeAgo}` : "Crashed"; + case DeploymentStatus.FAILED: + case DeploymentStatus.REMOVED: + case DeploymentStatus.SKIPPED: + return timeAgo ? `Failed ${timeAgo}` : "Failed"; + default: + // Fallback to result-based status + if (execution.result === "RESULT_PASSED") { + return timeAgo ? `Deployed ${timeAgo}` : "Deployed"; + } + return timeAgo ? `Failed ${timeAgo}` : "Failed"; + } + } + + const createdAt = execution.createdAt ? new Date(execution.createdAt) : null; + return createdAt ? formatTimeAgo(createdAt) : ""; +} + +/** + * Builds event sections for a Railway TriggerDeploy execution + */ +function getTriggerDeployEventSections(nodes: NodeInfo[], execution: ExecutionInfo): EventSection[] { + const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName!); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent! }); + const isRunning = execution.state === "STATE_STARTED" || execution.state === "STATE_PENDING"; + + return [ + { + showAutomaticTime: isRunning, + receivedAt: new Date(execution.createdAt!), + eventTitle: title, + eventSubtitle: getDeploymentSubtitle(execution), + eventState: triggerDeployStateFunction(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} + +/** + * Mapper for the "railway.triggerDeploy" component type + */ +export const triggerDeployMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext) { + const { node, nodes, componentDefinition, lastExecutions } = context; + const metadata = node.metadata as unknown as TriggerDeployMetadata; + const metadataItems = []; + + // Show service name + if (metadata?.service?.name) { + metadataItems.push({ + icon: "box", + label: metadata.service.name, + }); + } + + // Show environment name + if (metadata?.environment?.name) { + metadataItems.push({ + icon: "globe", + label: metadata.environment.name, + }); + } + + return { + iconSrc: RailwayLogo, + iconColor: getColorClass(componentDefinition.color), + collapsedBackground: getBackgroundColorClass(componentDefinition.color), + title: node.name || componentDefinition.label || "Trigger Deploy", + metadata: metadataItems, + eventStateMap: TRIGGER_DEPLOY_STATE_MAP, + eventSections: lastExecutions[0] ? getTriggerDeployEventSections(nodes, lastExecutions[0]) : undefined, + includeEmptyState: !lastExecutions[0], + }; + }, + + subtitle(context: SubtitleContext): string { + return getDeploymentSubtitle(context.execution); + }, + + getExecutionDetails(context: ExecutionDetailsContext) { + const { execution, node } = context; + const details: Record = {}; + + if (execution.createdAt) { + details["Started At"] = new Date(execution.createdAt).toLocaleString(); + } + + if (execution.updatedAt && execution.state === "STATE_FINISHED") { + details["Finished At"] = new Date(execution.updatedAt).toLocaleString(); + } + + // Add execution metadata info (deployment status, ID, URL) + const execMetadata = execution.metadata as TriggerDeployExecutionMetadata; + if (execMetadata?.status) { + details["Deployment Status"] = formatDeploymentStatus(execMetadata.status); + } + if (execMetadata?.deploymentId) { + details["Deployment ID"] = execMetadata.deploymentId; + } + if (execMetadata?.url) { + details["Deployment URL"] = execMetadata.url; + } + + // Add node metadata info (project, service, environment) + const nodeMetadata = node.metadata as unknown as TriggerDeployMetadata; + if (nodeMetadata?.project?.name) { + details["Project"] = nodeMetadata.project.name; + } + if (nodeMetadata?.service?.name) { + details["Service"] = nodeMetadata.service.name; + } + if (nodeMetadata?.environment?.name) { + details["Environment"] = nodeMetadata.environment.name; + } + + return details; + }, +};