diff --git a/gen/proto/ctrl/v1/deployment.pb.go b/gen/proto/ctrl/v1/deployment.pb.go
index 71c204d959..b3e1c3ff40 100644
--- a/gen/proto/ctrl/v1/deployment.pb.go
+++ b/gen/proto/ctrl/v1/deployment.pb.go
@@ -27,9 +27,11 @@ type DeploymentStatus int32
const (
DeploymentStatus_DEPLOYMENT_STATUS_UNSPECIFIED DeploymentStatus = 0
DeploymentStatus_DEPLOYMENT_STATUS_PENDING DeploymentStatus = 1
+ DeploymentStatus_DEPLOYMENT_STATUS_STARTING DeploymentStatus = 7
DeploymentStatus_DEPLOYMENT_STATUS_BUILDING DeploymentStatus = 2
DeploymentStatus_DEPLOYMENT_STATUS_DEPLOYING DeploymentStatus = 3
DeploymentStatus_DEPLOYMENT_STATUS_NETWORK DeploymentStatus = 4
+ DeploymentStatus_DEPLOYMENT_STATUS_FINALIZING DeploymentStatus = 8
DeploymentStatus_DEPLOYMENT_STATUS_READY DeploymentStatus = 5
DeploymentStatus_DEPLOYMENT_STATUS_FAILED DeploymentStatus = 6
)
@@ -39,18 +41,22 @@ var (
DeploymentStatus_name = map[int32]string{
0: "DEPLOYMENT_STATUS_UNSPECIFIED",
1: "DEPLOYMENT_STATUS_PENDING",
+ 7: "DEPLOYMENT_STATUS_STARTING",
2: "DEPLOYMENT_STATUS_BUILDING",
3: "DEPLOYMENT_STATUS_DEPLOYING",
4: "DEPLOYMENT_STATUS_NETWORK",
+ 8: "DEPLOYMENT_STATUS_FINALIZING",
5: "DEPLOYMENT_STATUS_READY",
6: "DEPLOYMENT_STATUS_FAILED",
}
DeploymentStatus_value = map[string]int32{
"DEPLOYMENT_STATUS_UNSPECIFIED": 0,
"DEPLOYMENT_STATUS_PENDING": 1,
+ "DEPLOYMENT_STATUS_STARTING": 7,
"DEPLOYMENT_STATUS_BUILDING": 2,
"DEPLOYMENT_STATUS_DEPLOYING": 3,
"DEPLOYMENT_STATUS_NETWORK": 4,
+ "DEPLOYMENT_STATUS_FINALIZING": 8,
"DEPLOYMENT_STATUS_READY": 5,
"DEPLOYMENT_STATUS_FAILED": 6,
}
@@ -1141,13 +1147,15 @@ const file_ctrl_v1_deployment_proto_rawDesc = "" +
"\x10RollbackResponse\"B\n" +
"\x0ePromoteRequest\x120\n" +
"\x14target_deployment_id\x18\x01 \x01(\tR\x12targetDeploymentId\"\x11\n" +
- "\x0fPromoteResponse*\xef\x01\n" +
+ "\x0fPromoteResponse*\xb1\x02\n" +
"\x10DeploymentStatus\x12!\n" +
"\x1dDEPLOYMENT_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n" +
"\x19DEPLOYMENT_STATUS_PENDING\x10\x01\x12\x1e\n" +
+ "\x1aDEPLOYMENT_STATUS_STARTING\x10\a\x12\x1e\n" +
"\x1aDEPLOYMENT_STATUS_BUILDING\x10\x02\x12\x1f\n" +
"\x1bDEPLOYMENT_STATUS_DEPLOYING\x10\x03\x12\x1d\n" +
- "\x19DEPLOYMENT_STATUS_NETWORK\x10\x04\x12\x1b\n" +
+ "\x19DEPLOYMENT_STATUS_NETWORK\x10\x04\x12 \n" +
+ "\x1cDEPLOYMENT_STATUS_FINALIZING\x10\b\x12\x1b\n" +
"\x17DEPLOYMENT_STATUS_READY\x10\x05\x12\x1c\n" +
"\x18DEPLOYMENT_STATUS_FAILED\x10\x06*Z\n" +
"\n" +
diff --git a/pkg/db/BUILD.bazel b/pkg/db/BUILD.bazel
index 113660cb7c..de997a665f 100644
--- a/pkg/db/BUILD.bazel
+++ b/pkg/db/BUILD.bazel
@@ -120,6 +120,8 @@ go_library(
"deployment_step_end.sql_generated.go",
"deployment_step_insert.sql_generated.go",
"deployment_topology_by_id_and_region.sql_generated.go",
+ "deployment_topology_delete_by_deployment_id.sql_generated.go",
+ "deployment_topology_delete_by_deployment_region_version.sql_generated.go",
"deployment_topology_find_regions.sql_generated.go",
"deployment_topology_insert.sql_generated.go",
"deployment_topology_list_by_versions.sql_generated.go",
diff --git a/pkg/db/app_find_by_id.sql_generated.go b/pkg/db/app_find_by_id.sql_generated.go
index 80f07d2f3f..4dab4bf70b 100644
--- a/pkg/db/app_find_by_id.sql_generated.go
+++ b/pkg/db/app_find_by_id.sql_generated.go
@@ -10,36 +10,32 @@ import (
)
const findAppById = `-- name: FindAppById :one
-SELECT apps.pk, apps.id, apps.workspace_id, apps.project_id, apps.name, apps.slug, apps.default_branch, apps.current_deployment_id, apps.is_rolled_back, apps.delete_protection, apps.created_at, apps.updated_at
+SELECT pk, id, workspace_id, project_id, name, slug, default_branch, current_deployment_id, is_rolled_back, delete_protection, created_at, updated_at
FROM apps
WHERE id = ?
`
-type FindAppByIdRow struct {
- App App `db:"app"`
-}
-
// FindAppById
//
-// SELECT apps.pk, apps.id, apps.workspace_id, apps.project_id, apps.name, apps.slug, apps.default_branch, apps.current_deployment_id, apps.is_rolled_back, apps.delete_protection, apps.created_at, apps.updated_at
+// SELECT pk, id, workspace_id, project_id, name, slug, default_branch, current_deployment_id, is_rolled_back, delete_protection, created_at, updated_at
// FROM apps
// WHERE id = ?
-func (q *Queries) FindAppById(ctx context.Context, db DBTX, id string) (FindAppByIdRow, error) {
+func (q *Queries) FindAppById(ctx context.Context, db DBTX, id string) (App, error) {
row := db.QueryRowContext(ctx, findAppById, id)
- var i FindAppByIdRow
+ var i App
err := row.Scan(
- &i.App.Pk,
- &i.App.ID,
- &i.App.WorkspaceID,
- &i.App.ProjectID,
- &i.App.Name,
- &i.App.Slug,
- &i.App.DefaultBranch,
- &i.App.CurrentDeploymentID,
- &i.App.IsRolledBack,
- &i.App.DeleteProtection,
- &i.App.CreatedAt,
- &i.App.UpdatedAt,
+ &i.Pk,
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ProjectID,
+ &i.Name,
+ &i.Slug,
+ &i.DefaultBranch,
+ &i.CurrentDeploymentID,
+ &i.IsRolledBack,
+ &i.DeleteProtection,
+ &i.CreatedAt,
+ &i.UpdatedAt,
)
return i, err
}
diff --git a/pkg/db/app_update_deployments.sql_generated.go b/pkg/db/app_update_deployments.sql_generated.go
index 4a77aac8c4..733898197e 100644
--- a/pkg/db/app_update_deployments.sql_generated.go
+++ b/pkg/db/app_update_deployments.sql_generated.go
@@ -23,7 +23,7 @@ type UpdateAppDeploymentsParams struct {
CurrentDeploymentID sql.NullString `db:"current_deployment_id"`
IsRolledBack bool `db:"is_rolled_back"`
UpdatedAt sql.NullInt64 `db:"updated_at"`
- ID string `db:"id"`
+ AppID string `db:"app_id"`
}
// UpdateAppDeployments
@@ -39,7 +39,7 @@ func (q *Queries) UpdateAppDeployments(ctx context.Context, db DBTX, arg UpdateA
arg.CurrentDeploymentID,
arg.IsRolledBack,
arg.UpdatedAt,
- arg.ID,
+ arg.AppID,
)
return err
}
diff --git a/pkg/db/deployment_topology_delete_by_deployment_id.sql_generated.go b/pkg/db/deployment_topology_delete_by_deployment_id.sql_generated.go
new file mode 100644
index 0000000000..3ad0557b5a
--- /dev/null
+++ b/pkg/db/deployment_topology_delete_by_deployment_id.sql_generated.go
@@ -0,0 +1,24 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: deployment_topology_delete_by_deployment_id.sql
+
+package db
+
+import (
+ "context"
+)
+
+const deleteDeploymentTopologyByDeploymentId = `-- name: DeleteDeploymentTopologyByDeploymentId :exec
+DELETE FROM ` + "`" + `deployment_topology` + "`" + `
+WHERE deployment_id = ?
+`
+
+// DeleteDeploymentTopologyByDeploymentId
+//
+// DELETE FROM `deployment_topology`
+// WHERE deployment_id = ?
+func (q *Queries) DeleteDeploymentTopologyByDeploymentId(ctx context.Context, db DBTX, deploymentID string) error {
+ _, err := db.ExecContext(ctx, deleteDeploymentTopologyByDeploymentId, deploymentID)
+ return err
+}
diff --git a/pkg/db/deployment_topology_delete_by_deployment_region_version.sql_generated.go b/pkg/db/deployment_topology_delete_by_deployment_region_version.sql_generated.go
new file mode 100644
index 0000000000..0c45737f6e
--- /dev/null
+++ b/pkg/db/deployment_topology_delete_by_deployment_region_version.sql_generated.go
@@ -0,0 +1,34 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: deployment_topology_delete_by_deployment_region_version.sql
+
+package db
+
+import (
+ "context"
+)
+
+const deleteDeploymentTopologyByDeploymentRegionVersion = `-- name: DeleteDeploymentTopologyByDeploymentRegionVersion :exec
+DELETE FROM ` + "`" + `deployment_topology` + "`" + `
+WHERE deployment_id = ?
+ AND region = ?
+ AND version = ?
+`
+
+type DeleteDeploymentTopologyByDeploymentRegionVersionParams struct {
+ DeploymentID string `db:"deployment_id"`
+ Region string `db:"region"`
+ Version uint64 `db:"version"`
+}
+
+// DeleteDeploymentTopologyByDeploymentRegionVersion
+//
+// DELETE FROM `deployment_topology`
+// WHERE deployment_id = ?
+// AND region = ?
+// AND version = ?
+func (q *Queries) DeleteDeploymentTopologyByDeploymentRegionVersion(ctx context.Context, db DBTX, arg DeleteDeploymentTopologyByDeploymentRegionVersionParams) error {
+ _, err := db.ExecContext(ctx, deleteDeploymentTopologyByDeploymentRegionVersion, arg.DeploymentID, arg.Region, arg.Version)
+ return err
+}
diff --git a/pkg/db/environment_find_by_id.sql_generated.go b/pkg/db/environment_find_by_id.sql_generated.go
index 7c1738515f..8376486a3b 100644
--- a/pkg/db/environment_find_by_id.sql_generated.go
+++ b/pkg/db/environment_find_by_id.sql_generated.go
@@ -10,35 +10,30 @@ import (
)
const findEnvironmentById = `-- name: FindEnvironmentById :one
-SELECT id, workspace_id, project_id, app_id, slug, description
+SELECT pk, id, workspace_id, project_id, app_id, slug, description, delete_protection, created_at, updated_at
FROM environments
WHERE id = ?
`
-type FindEnvironmentByIdRow struct {
- ID string `db:"id"`
- WorkspaceID string `db:"workspace_id"`
- ProjectID string `db:"project_id"`
- AppID string `db:"app_id"`
- Slug string `db:"slug"`
- Description string `db:"description"`
-}
-
// FindEnvironmentById
//
-// SELECT id, workspace_id, project_id, app_id, slug, description
+// SELECT pk, id, workspace_id, project_id, app_id, slug, description, delete_protection, created_at, updated_at
// FROM environments
// WHERE id = ?
-func (q *Queries) FindEnvironmentById(ctx context.Context, db DBTX, id string) (FindEnvironmentByIdRow, error) {
+func (q *Queries) FindEnvironmentById(ctx context.Context, db DBTX, id string) (Environment, error) {
row := db.QueryRowContext(ctx, findEnvironmentById, id)
- var i FindEnvironmentByIdRow
+ var i Environment
err := row.Scan(
+ &i.Pk,
&i.ID,
&i.WorkspaceID,
&i.ProjectID,
&i.AppID,
&i.Slug,
&i.Description,
+ &i.DeleteProtection,
+ &i.CreatedAt,
+ &i.UpdatedAt,
)
return i, err
}
diff --git a/pkg/db/models_generated.go b/pkg/db/models_generated.go
index 06aa6a57a8..157aaf0a56 100644
--- a/pkg/db/models_generated.go
+++ b/pkg/db/models_generated.go
@@ -316,10 +316,12 @@ func (ns NullCustomDomainsVerificationStatus) Value() (driver.Value, error) {
type DeploymentStepsStep string
const (
- DeploymentStepsStepQueued DeploymentStepsStep = "queued"
- DeploymentStepsStepBuilding DeploymentStepsStep = "building"
- DeploymentStepsStepDeploying DeploymentStepsStep = "deploying"
- DeploymentStepsStepNetwork DeploymentStepsStep = "network"
+ DeploymentStepsStepQueued DeploymentStepsStep = "queued"
+ DeploymentStepsStepStarting DeploymentStepsStep = "starting"
+ DeploymentStepsStepBuilding DeploymentStepsStep = "building"
+ DeploymentStepsStepDeploying DeploymentStepsStep = "deploying"
+ DeploymentStepsStepNetwork DeploymentStepsStep = "network"
+ DeploymentStepsStepFinalizing DeploymentStepsStep = "finalizing"
)
func (e *DeploymentStepsStep) Scan(src interface{}) error {
@@ -491,12 +493,14 @@ func (ns NullDeploymentsShutdownSignal) Value() (driver.Value, error) {
type DeploymentsStatus string
const (
- DeploymentsStatusPending DeploymentsStatus = "pending"
- DeploymentsStatusBuilding DeploymentsStatus = "building"
- DeploymentsStatusDeploying DeploymentsStatus = "deploying"
- DeploymentsStatusNetwork DeploymentsStatus = "network"
- DeploymentsStatusReady DeploymentsStatus = "ready"
- DeploymentsStatusFailed DeploymentsStatus = "failed"
+ DeploymentsStatusPending DeploymentsStatus = "pending"
+ DeploymentsStatusStarting DeploymentsStatus = "starting"
+ DeploymentsStatusBuilding DeploymentsStatus = "building"
+ DeploymentsStatusDeploying DeploymentsStatus = "deploying"
+ DeploymentsStatusNetwork DeploymentsStatus = "network"
+ DeploymentsStatusFinalizing DeploymentsStatus = "finalizing"
+ DeploymentsStatusReady DeploymentsStatus = "ready"
+ DeploymentsStatusFailed DeploymentsStatus = "failed"
)
func (e *DeploymentsStatus) Scan(src interface{}) error {
diff --git a/pkg/db/project_find_by_id.sql_generated.go b/pkg/db/project_find_by_id.sql_generated.go
index 1811b5ae52..968fd3b3e1 100644
--- a/pkg/db/project_find_by_id.sql_generated.go
+++ b/pkg/db/project_find_by_id.sql_generated.go
@@ -7,59 +7,32 @@ package db
import (
"context"
- "database/sql"
)
const findProjectById = `-- name: FindProjectById :one
-SELECT
- id,
- workspace_id,
- name,
- slug,
- delete_protection,
- created_at,
- updated_at,
- depot_project_id
+SELECT pk, id, workspace_id, name, slug, depot_project_id, delete_protection, created_at, updated_at
FROM projects
WHERE id = ?
`
-type FindProjectByIdRow struct {
- ID string `db:"id"`
- WorkspaceID string `db:"workspace_id"`
- Name string `db:"name"`
- Slug string `db:"slug"`
- DeleteProtection sql.NullBool `db:"delete_protection"`
- CreatedAt int64 `db:"created_at"`
- UpdatedAt sql.NullInt64 `db:"updated_at"`
- DepotProjectID sql.NullString `db:"depot_project_id"`
-}
-
// FindProjectById
//
-// SELECT
-// id,
-// workspace_id,
-// name,
-// slug,
-// delete_protection,
-// created_at,
-// updated_at,
-// depot_project_id
+// SELECT pk, id, workspace_id, name, slug, depot_project_id, delete_protection, created_at, updated_at
// FROM projects
// WHERE id = ?
-func (q *Queries) FindProjectById(ctx context.Context, db DBTX, id string) (FindProjectByIdRow, error) {
+func (q *Queries) FindProjectById(ctx context.Context, db DBTX, id string) (Project, error) {
row := db.QueryRowContext(ctx, findProjectById, id)
- var i FindProjectByIdRow
+ var i Project
err := row.Scan(
+ &i.Pk,
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Slug,
+ &i.DepotProjectID,
&i.DeleteProtection,
&i.CreatedAt,
&i.UpdatedAt,
- &i.DepotProjectID,
)
return i, err
}
diff --git a/pkg/db/querier_generated.go b/pkg/db/querier_generated.go
index 427eca2305..9c30c53c3e 100644
--- a/pkg/db/querier_generated.go
+++ b/pkg/db/querier_generated.go
@@ -39,6 +39,18 @@ type Querier interface {
// DELETE FROM instances
// WHERE deployment_id = ? AND region = ?
DeleteDeploymentInstances(ctx context.Context, db DBTX, arg DeleteDeploymentInstancesParams) error
+ //DeleteDeploymentTopologyByDeploymentId
+ //
+ // DELETE FROM `deployment_topology`
+ // WHERE deployment_id = ?
+ DeleteDeploymentTopologyByDeploymentId(ctx context.Context, db DBTX, deploymentID string) error
+ //DeleteDeploymentTopologyByDeploymentRegionVersion
+ //
+ // DELETE FROM `deployment_topology`
+ // WHERE deployment_id = ?
+ // AND region = ?
+ // AND version = ?
+ DeleteDeploymentTopologyByDeploymentRegionVersion(ctx context.Context, db DBTX, arg DeleteDeploymentTopologyByDeploymentRegionVersionParams) error
//DeleteFrontlineRouteByFQDN
//
// DELETE FROM frontline_routes WHERE fully_qualified_domain_name = ?
@@ -170,10 +182,10 @@ type Querier interface {
FindApiByID(ctx context.Context, db DBTX, id string) (Api, error)
//FindAppById
//
- // SELECT apps.pk, apps.id, apps.workspace_id, apps.project_id, apps.name, apps.slug, apps.default_branch, apps.current_deployment_id, apps.is_rolled_back, apps.delete_protection, apps.created_at, apps.updated_at
+ // SELECT pk, id, workspace_id, project_id, name, slug, default_branch, current_deployment_id, is_rolled_back, delete_protection, created_at, updated_at
// FROM apps
// WHERE id = ?
- FindAppById(ctx context.Context, db DBTX, id string) (FindAppByIdRow, error)
+ FindAppById(ctx context.Context, db DBTX, id string) (App, error)
//FindAppByProjectAndSlug
//
// SELECT apps.pk, apps.id, apps.workspace_id, apps.project_id, apps.name, apps.slug, apps.default_branch, apps.current_deployment_id, apps.is_rolled_back, apps.delete_protection, apps.created_at, apps.updated_at
@@ -325,10 +337,10 @@ type Querier interface {
FindEnvironmentByAppIdAndSlug(ctx context.Context, db DBTX, arg FindEnvironmentByAppIdAndSlugParams) (FindEnvironmentByAppIdAndSlugRow, error)
//FindEnvironmentById
//
- // SELECT id, workspace_id, project_id, app_id, slug, description
+ // SELECT pk, id, workspace_id, project_id, app_id, slug, description, delete_protection, created_at, updated_at
// FROM environments
// WHERE id = ?
- FindEnvironmentById(ctx context.Context, db DBTX, id string) (FindEnvironmentByIdRow, error)
+ FindEnvironmentById(ctx context.Context, db DBTX, id string) (Environment, error)
//FindEnvironmentByProjectIdAndSlug
//
// SELECT pk, id, workspace_id, project_id, app_id, slug, description, delete_protection, created_at, updated_at
@@ -930,18 +942,10 @@ type Querier interface {
FindPermissionsBySlugs(ctx context.Context, db DBTX, arg FindPermissionsBySlugsParams) ([]Permission, error)
//FindProjectById
//
- // SELECT
- // id,
- // workspace_id,
- // name,
- // slug,
- // delete_protection,
- // created_at,
- // updated_at,
- // depot_project_id
+ // SELECT pk, id, workspace_id, name, slug, depot_project_id, delete_protection, created_at, updated_at
// FROM projects
// WHERE id = ?
- FindProjectById(ctx context.Context, db DBTX, id string) (FindProjectByIdRow, error)
+ FindProjectById(ctx context.Context, db DBTX, id string) (Project, error)
//FindProjectByWorkspaceSlug
//
// SELECT
diff --git a/pkg/db/queries/app_find_by_id.sql b/pkg/db/queries/app_find_by_id.sql
index eb727d5ac1..1020fa433e 100644
--- a/pkg/db/queries/app_find_by_id.sql
+++ b/pkg/db/queries/app_find_by_id.sql
@@ -1,4 +1,4 @@
-- name: FindAppById :one
-SELECT sqlc.embed(apps)
+SELECT *
FROM apps
WHERE id = sqlc.arg(id);
diff --git a/pkg/db/queries/app_update_deployments.sql b/pkg/db/queries/app_update_deployments.sql
index 04c641acc8..3cef9a686b 100644
--- a/pkg/db/queries/app_update_deployments.sql
+++ b/pkg/db/queries/app_update_deployments.sql
@@ -4,4 +4,4 @@ SET
current_deployment_id = sqlc.arg(current_deployment_id),
is_rolled_back = sqlc.arg(is_rolled_back),
updated_at = sqlc.arg(updated_at)
-WHERE id = sqlc.arg(id);
+WHERE id = sqlc.arg(app_id);
diff --git a/pkg/db/queries/deployment_topology_delete_by_deployment_id.sql b/pkg/db/queries/deployment_topology_delete_by_deployment_id.sql
new file mode 100644
index 0000000000..9f41377dc4
--- /dev/null
+++ b/pkg/db/queries/deployment_topology_delete_by_deployment_id.sql
@@ -0,0 +1,3 @@
+-- name: DeleteDeploymentTopologyByDeploymentId :exec
+DELETE FROM `deployment_topology`
+WHERE deployment_id = sqlc.arg(deployment_id);
diff --git a/pkg/db/queries/deployment_topology_delete_by_deployment_region_version.sql b/pkg/db/queries/deployment_topology_delete_by_deployment_region_version.sql
new file mode 100644
index 0000000000..716d2528a4
--- /dev/null
+++ b/pkg/db/queries/deployment_topology_delete_by_deployment_region_version.sql
@@ -0,0 +1,5 @@
+-- name: DeleteDeploymentTopologyByDeploymentRegionVersion :exec
+DELETE FROM `deployment_topology`
+WHERE deployment_id = sqlc.arg(deployment_id)
+ AND region = sqlc.arg(region)
+ AND version = sqlc.arg(version);
diff --git a/pkg/db/queries/environment_find_by_id.sql b/pkg/db/queries/environment_find_by_id.sql
index b9acc6a55b..7b12466833 100644
--- a/pkg/db/queries/environment_find_by_id.sql
+++ b/pkg/db/queries/environment_find_by_id.sql
@@ -1,4 +1,4 @@
-- name: FindEnvironmentById :one
-SELECT id, workspace_id, project_id, app_id, slug, description
+SELECT *
FROM environments
WHERE id = sqlc.arg(id);
diff --git a/pkg/db/queries/project_find_by_id.sql b/pkg/db/queries/project_find_by_id.sql
index 5d2071ea7b..a18dc9080d 100644
--- a/pkg/db/queries/project_find_by_id.sql
+++ b/pkg/db/queries/project_find_by_id.sql
@@ -1,12 +1,4 @@
-- name: FindProjectById :one
-SELECT
- id,
- workspace_id,
- name,
- slug,
- delete_protection,
- created_at,
- updated_at,
- depot_project_id
+SELECT *
FROM projects
WHERE id = ?;
diff --git a/pkg/db/schema.sql b/pkg/db/schema.sql
index 74d4f7f1a0..32bdd15b57 100644
--- a/pkg/db/schema.sql
+++ b/pkg/db/schema.sql
@@ -492,7 +492,7 @@ CREATE TABLE `deployments` (
`port` int NOT NULL DEFAULT 8080,
`shutdown_signal` enum('SIGTERM','SIGINT','SIGQUIT','SIGKILL') NOT NULL DEFAULT 'SIGTERM',
`healthcheck` json,
- `status` enum('pending','building','deploying','network','ready','failed') NOT NULL DEFAULT 'pending',
+ `status` enum('pending','starting','building','deploying','network','finalizing','ready','failed') NOT NULL DEFAULT 'pending',
`created_at` bigint NOT NULL,
`updated_at` bigint,
CONSTRAINT `deployments_pk` PRIMARY KEY(`pk`),
@@ -508,7 +508,7 @@ CREATE TABLE `deployment_steps` (
`environment_id` varchar(128) NOT NULL,
`deployment_id` varchar(128) NOT NULL,
`app_id` varchar(64) NOT NULL,
- `step` enum('queued','building','deploying','network') NOT NULL DEFAULT 'queued',
+ `step` enum('queued','starting','building','deploying','network','finalizing') NOT NULL DEFAULT 'queued',
`started_at` bigint unsigned NOT NULL,
`ended_at` bigint unsigned,
`error` varchar(512),
diff --git a/svc/api/internal/testutil/seed/seed.go b/svc/api/internal/testutil/seed/seed.go
index e9a9162409..02da24273a 100644
--- a/svc/api/internal/testutil/seed/seed.go
+++ b/svc/api/internal/testutil/seed/seed.go
@@ -200,10 +200,10 @@ func (s *Seeder) CreateApp(ctx context.Context, req CreateAppRequest) db.App {
})
require.NoError(s.t, err)
- row, err := db.Query.FindAppById(ctx, s.DB.RO(), req.ID)
+ app, err := db.Query.FindAppById(ctx, s.DB.RO(), req.ID)
require.NoError(s.t, err)
- return row.App
+ return app
}
// CreateEnvironmentRequest configures the environment to create.
diff --git a/svc/api/openapi/gen.go b/svc/api/openapi/gen.go
index 87e8020c9d..4c207d0832 100644
--- a/svc/api/openapi/gen.go
+++ b/svc/api/openapi/gen.go
@@ -28,9 +28,11 @@ const (
BUILDING V2DeployGetDeploymentResponseDataStatus = "BUILDING"
DEPLOYING V2DeployGetDeploymentResponseDataStatus = "DEPLOYING"
FAILED V2DeployGetDeploymentResponseDataStatus = "FAILED"
+ FINALIZING V2DeployGetDeploymentResponseDataStatus = "FINALIZING"
NETWORK V2DeployGetDeploymentResponseDataStatus = "NETWORK"
PENDING V2DeployGetDeploymentResponseDataStatus = "PENDING"
READY V2DeployGetDeploymentResponseDataStatus = "READY"
+ STARTING V2DeployGetDeploymentResponseDataStatus = "STARTING"
UNSPECIFIED V2DeployGetDeploymentResponseDataStatus = "UNSPECIFIED"
)
diff --git a/svc/api/openapi/openapi-generated.yaml b/svc/api/openapi/openapi-generated.yaml
index e896750555..8a4a7d9b25 100644
--- a/svc/api/openapi/openapi-generated.yaml
+++ b/svc/api/openapi/openapi-generated.yaml
@@ -2575,9 +2575,11 @@ components:
enum:
- UNSPECIFIED
- PENDING
+ - STARTING
- BUILDING
- DEPLOYING
- NETWORK
+ - FINALIZING
- READY
- FAILED
example: "READY"
diff --git a/svc/api/openapi/spec/paths/v2/deploy/getDeployment/V2DeployGetDeploymentResponseData.yaml b/svc/api/openapi/spec/paths/v2/deploy/getDeployment/V2DeployGetDeploymentResponseData.yaml
index 1d66bc8226..25696697c5 100644
--- a/svc/api/openapi/spec/paths/v2/deploy/getDeployment/V2DeployGetDeploymentResponseData.yaml
+++ b/svc/api/openapi/spec/paths/v2/deploy/getDeployment/V2DeployGetDeploymentResponseData.yaml
@@ -13,9 +13,11 @@ properties:
enum:
- UNSPECIFIED
- PENDING
+ - STARTING
- BUILDING
- DEPLOYING
- NETWORK
+ - FINALIZING
- READY
- FAILED
example: "READY"
diff --git a/svc/api/routes/v2_deploy_get_deployment/handler.go b/svc/api/routes/v2_deploy_get_deployment/handler.go
index 14331e2e3a..f94917cc5e 100644
--- a/svc/api/routes/v2_deploy_get_deployment/handler.go
+++ b/svc/api/routes/v2_deploy_get_deployment/handler.go
@@ -117,12 +117,16 @@ func dbStatusToOpenAPI(status db.DeploymentsStatus) openapi.V2DeployGetDeploymen
switch status {
case db.DeploymentsStatusPending:
return openapi.PENDING
+ case db.DeploymentsStatusStarting:
+ return openapi.STARTING
case db.DeploymentsStatusBuilding:
return openapi.BUILDING
case db.DeploymentsStatusDeploying:
return openapi.DEPLOYING
case db.DeploymentsStatusNetwork:
return openapi.NETWORK
+ case db.DeploymentsStatusFinalizing:
+ return openapi.FINALIZING
case db.DeploymentsStatusReady:
return openapi.READY
case db.DeploymentsStatusFailed:
diff --git a/svc/ctrl/integration/seed/seed.go b/svc/ctrl/integration/seed/seed.go
index bb319b3c9b..a1e12dce09 100644
--- a/svc/ctrl/integration/seed/seed.go
+++ b/svc/ctrl/integration/seed/seed.go
@@ -230,10 +230,10 @@ func (s *Seeder) CreateApp(ctx context.Context, req CreateAppRequest) db.App {
})
require.NoError(s.t, err)
- row, err := db.Query.FindAppById(ctx, s.DB.RO(), req.ID)
+ app, err := db.Query.FindAppById(ctx, s.DB.RO(), req.ID)
require.NoError(s.t, err)
- return row.App
+ return app
}
// CreateAppWithSettings creates an app plus its build and runtime settings for a given environment.
diff --git a/svc/ctrl/proto/ctrl/v1/deployment.proto b/svc/ctrl/proto/ctrl/v1/deployment.proto
index f642c62b74..b33daf9409 100644
--- a/svc/ctrl/proto/ctrl/v1/deployment.proto
+++ b/svc/ctrl/proto/ctrl/v1/deployment.proto
@@ -7,9 +7,11 @@ option go_package = "github.com/unkeyed/unkey/gen/proto/ctrl/v1;ctrlv1";
enum DeploymentStatus {
DEPLOYMENT_STATUS_UNSPECIFIED = 0;
DEPLOYMENT_STATUS_PENDING = 1;
+ DEPLOYMENT_STATUS_STARTING = 7;
DEPLOYMENT_STATUS_BUILDING = 2;
DEPLOYMENT_STATUS_DEPLOYING = 3;
DEPLOYMENT_STATUS_NETWORK = 4;
+ DEPLOYMENT_STATUS_FINALIZING = 8;
DEPLOYMENT_STATUS_READY = 5;
DEPLOYMENT_STATUS_FAILED = 6;
}
diff --git a/svc/ctrl/services/deployment/get_deployment.go b/svc/ctrl/services/deployment/get_deployment.go
index aa6b5feef3..fecd060825 100644
--- a/svc/ctrl/services/deployment/get_deployment.go
+++ b/svc/ctrl/services/deployment/get_deployment.go
@@ -100,12 +100,16 @@ func convertDbStatusToProto(status db.DeploymentsStatus) ctrlv1.DeploymentStatus
switch status {
case db.DeploymentsStatusPending:
return ctrlv1.DeploymentStatus_DEPLOYMENT_STATUS_PENDING
+ case db.DeploymentsStatusStarting:
+ return ctrlv1.DeploymentStatus_DEPLOYMENT_STATUS_STARTING
case db.DeploymentsStatusBuilding:
return ctrlv1.DeploymentStatus_DEPLOYMENT_STATUS_BUILDING
case db.DeploymentsStatusDeploying:
return ctrlv1.DeploymentStatus_DEPLOYMENT_STATUS_DEPLOYING
case db.DeploymentsStatusNetwork:
return ctrlv1.DeploymentStatus_DEPLOYMENT_STATUS_NETWORK
+ case db.DeploymentsStatusFinalizing:
+ return ctrlv1.DeploymentStatus_DEPLOYMENT_STATUS_FINALIZING
case db.DeploymentsStatusReady:
return ctrlv1.DeploymentStatus_DEPLOYMENT_STATUS_READY
case db.DeploymentsStatusFailed:
diff --git a/svc/ctrl/worker/customdomain/verify_handler.go b/svc/ctrl/worker/customdomain/verify_handler.go
index 59fba4a47a..fd55686686 100644
--- a/svc/ctrl/worker/customdomain/verify_handler.go
+++ b/svc/ctrl/worker/customdomain/verify_handler.go
@@ -267,14 +267,14 @@ func (s *Service) onVerificationSuccess(
// Create frontline route for traffic routing. If no deployment exists yet,
// the route will be assigned when the first deployment happens.
_, err = restate.Run(ctx, func(stepCtx restate.RunContext) (restate.Void, error) {
- appRow, appErr := db.Query.FindAppById(stepCtx, s.db.RO(), dom.AppID)
+ app, appErr := db.Query.FindAppById(stepCtx, s.db.RO(), dom.AppID)
if appErr != nil {
return restate.Void{}, fault.Wrap(appErr, fault.Internal("failed to find app for frontline route"))
}
deploymentID := ""
- if appRow.App.CurrentDeploymentID.Valid {
- deploymentID = appRow.App.CurrentDeploymentID.String
+ if app.CurrentDeploymentID.Valid {
+ deploymentID = app.CurrentDeploymentID.String
}
return restate.Void{}, db.Query.InsertFrontlineRoute(stepCtx, s.db.RW(), db.InsertFrontlineRouteParams{
diff --git a/svc/ctrl/worker/deploy/BUILD.bazel b/svc/ctrl/worker/deploy/BUILD.bazel
index 170ed94ec1..731f1adb32 100644
--- a/svc/ctrl/worker/deploy/BUILD.bazel
+++ b/svc/ctrl/worker/deploy/BUILD.bazel
@@ -5,10 +5,11 @@ go_library(
srcs = [
"build.go",
"cilium_policy.go",
+ "compensation.go",
"deploy_handler.go",
+ "deployment_step.go",
"doc.go",
"domains.go",
- "helpers.go",
"promote_handler.go",
"rollback_handler.go",
"scale_down_idle_preview_deployments.go",
diff --git a/svc/ctrl/worker/deploy/cilium_policy.go b/svc/ctrl/worker/deploy/cilium_policy.go
index 3dd6b3d879..6b14e0eec9 100644
--- a/svc/ctrl/worker/deploy/cilium_policy.go
+++ b/svc/ctrl/worker/deploy/cilium_policy.go
@@ -39,8 +39,8 @@ type ciliumPolicySpec struct {
func (w *Workflow) ensureCiliumNetworkPolicy(
ctx restate.WorkflowSharedContext,
workspace db.Workspace,
- project db.FindProjectByIdRow,
- environment db.FindEnvironmentByIdRow,
+ project db.Project,
+ environment db.Environment,
topologies []db.InsertDeploymentTopologyParams,
deployment db.Deployment,
) error {
@@ -138,7 +138,7 @@ func (w *Workflow) ensureCiliumNetworkPolicy(
// buildPolicySpecs returns the ingress and egress policy specs for a deployment.
func buildPolicySpecs(
workspace db.Workspace,
- environment db.FindEnvironmentByIdRow,
+ environment db.Environment,
deployment db.Deployment,
) []ciliumPolicySpec {
portStr := fmt.Sprintf("%d", deployment.Port)
diff --git a/svc/ctrl/worker/deploy/compensation.go b/svc/ctrl/worker/deploy/compensation.go
new file mode 100644
index 0000000000..70654e6eb9
--- /dev/null
+++ b/svc/ctrl/worker/deploy/compensation.go
@@ -0,0 +1,59 @@
+package deploy
+
+import (
+ "errors"
+ "fmt"
+ "slices"
+ "sync"
+
+ restate "github.com/restatedev/sdk-go"
+)
+
+// Compensation stores deployment rollback actions.
+//
+// Actions are registered in forward order with [Compensation.Add] and executed
+// in reverse order by [Compensation.Execute], so teardown naturally unwinds the
+// setup sequence that succeeded before a failure.
+type Compensation struct {
+ mu sync.Mutex
+ operations []func(ctx restate.WorkflowSharedContext) error
+}
+
+// NewCompensation creates an empty [Compensation].
+func NewCompensation() *Compensation {
+ return &Compensation{
+ mu: sync.Mutex{},
+ operations: []func(ctx restate.WorkflowSharedContext) error{},
+ }
+}
+
+// Add registers a compensation step.
+//
+// Add wraps run in [restate.RunVoid] and stores it for later execution during
+// [Compensation.Execute]. The step name is used as Restate run metadata.
+func (c *Compensation) Add(name string, run func(ctx restate.RunContext) error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.operations = append(c.operations, func(ctx restate.WorkflowSharedContext) error {
+ return restate.RunVoid(ctx, run, restate.WithName(fmt.Sprintf("[compensation]: %s", name)))
+ })
+}
+
+// Execute runs all registered compensations in reverse registration order.
+//
+// Execute returns nil when every step succeeds. When multiple steps fail, it
+// joins all failures with [errors.Join]. Execute does not clear registered
+// steps, so calling it more than once re-runs the same compensations.
+func (c *Compensation) Execute(ctx restate.WorkflowSharedContext) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ var errs error
+
+ for _, op := range slices.Backward(c.operations) {
+ if err := op(ctx); err != nil {
+ errs = errors.Join(errs, err)
+ }
+ }
+
+ return errs
+}
diff --git a/svc/ctrl/worker/deploy/deploy_handler.go b/svc/ctrl/worker/deploy/deploy_handler.go
index b6e19035bc..283e9104bd 100644
--- a/svc/ctrl/worker/deploy/deploy_handler.go
+++ b/svc/ctrl/worker/deploy/deploy_handler.go
@@ -26,153 +26,240 @@ const (
// sentinelPort is the port exposed by sentinel services for frontline traffic
// and must match the container port and service configuration.
sentinelPort = 8040
+
+ // regionReadyTimeout is how long to wait for all instances in a region to become
+ // ready before considering that region's deployment failed. This is a soft timeout:
+ // the workflow continues waiting for other regions and only fails if fewer than
+ // [waitForDeployments]'s required minimum become healthy within this window.
+ regionReadyTimeout = 15 * time.Minute
)
// Deploy executes a full deployment workflow for a new application version.
//
-// This durable workflow orchestrates the complete deployment lifecycle: building
-// Docker images (if a GitSource is provided via Depot), provisioning containers
-// across regions, waiting for instances to become healthy, and configuring domain
-// routing. The workflow is idempotent and can safely resume from any step after
-// a crash.
-//
-// The deployment request specifies a source as a oneof: either a GitSource (which
-// triggers a Docker build through Depot) or a DockerImage (which is deployed
-// directly).
+// This is a Restate durable workflow, meaning it is idempotent and can safely
+// resume from any step after a crash. The workflow orchestrates five phases:
//
-// The workflow creates deployment topologies for all configured regions, each with
-// a version obtained from VersioningService and 1 desired replica. Sentinel
-// containers are automatically provisioned for environments that don't already
-// have them, with production sentinels getting 3 replicas and others getting 1.
+// 1. [Workflow.buildImage] — resolve or build the container image
+// 2. [Workflow.createTopologies] — provision deployment topologies across regions
+// 3. [Workflow.ensureSentinels] and [Workflow.ensureCiliumNetworkPolicy] — set up
+// routing infrastructure and network policies
+// 4. [Workflow.configureRouting] — assign domain routes to the deployment
+// 5. [Workflow.swapLiveDeployment] — promote to live (production only)
//
-// Domain routing is configured through frontline routes. Sticky routes
-// (environment, and live for non-rolled-back production) are reassigned to the
-// new deployment. For production deployments, the app's live deployment
-// pointer is updated unless the app is in a rolled-back state. After a
-// successful deploy, the previous live deployment is scheduled for standby after
-// 30 minutes via DeploymentService.ScheduleDesiredStateChange.
-//
-// If any step fails, the deployment status is automatically set to failed via a
-// deferred cleanup handler, ensuring the database reflects the true deployment state.
+// Each phase is wrapped in deployment step tracking so the UI can show progress.
+// A compensation stack (executed in reverse on failure) cleans up partial state
+// such as inserted topologies and updates the deployment status to failed.
//
// Returns terminal errors for validation failures and retryable errors for
// transient system failures.
func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.DeployRequest) (_ *hydrav1.DeployResponse, retErr error) {
- finishedSuccessfully := false
- var currentStep *db.DeploymentStepsStep
- var failureReason string
err := assert.All(
assert.NotEmpty(req.GetDeploymentId(), "deployment_id is required"),
)
if err != nil {
- return nil, restate.TerminalError(err)
+ return nil, fault.Wrap(
+ restate.TerminalError(err),
+ fault.Public("This deployment request is invalid."),
+ )
}
+ // compensations are executed in reverse order on failure to clean up any partial state.
+ // We use this for steps that have side effects which need to be undone if a later step fails, such as updating deployment status or inserting topologies.
+ compensation := NewCompensation()
+
+ defer func() {
+ if retErr != nil {
+ retErr = errors.Join(retErr, compensation.Execute(ctx))
+ }
+ }()
+
logger.Info("deployment workflow started", "req", fmt.Sprintf("%+v", req))
+ compensation.Add("mark deployment as failed", func(runCtx restate.RunContext) error {
+ return db.Query.UpdateDeploymentStatus(runCtx, w.db.RW(), db.UpdateDeploymentStatusParams{
+ ID: req.GetDeploymentId(),
+ Status: db.DeploymentsStatusFailed,
+ UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()},
+ })
+ })
+
deployment, err := restate.Run(ctx, func(runCtx restate.RunContext) (db.Deployment, error) {
return db.Query.FindDeploymentById(runCtx, w.db.RW(), req.GetDeploymentId())
- }, restate.WithName("finding deployment"))
+ }, restate.WithName("finding deployment"), restate.WithMaxRetryDuration(time.Minute))
if err != nil {
- return nil, err
+ return nil, fault.Wrap(err, fault.Public("Failed to read from database. Please try again."))
}
- if err = w.startDeploymentStep(ctx, deployment, db.DeploymentStepsStepQueued); err != nil {
- return nil, err
+ // --- Dequeue ---
+ err = restate.RunVoid(ctx, func(runCtx restate.RunContext) error {
+ return db.Query.EndDeploymentStep(runCtx, w.db.RW(), db.EndDeploymentStepParams{
+ DeploymentID: req.GetDeploymentId(),
+ Step: db.DeploymentStepsStepQueued,
+ EndedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()},
+ Error: sql.NullString{Valid: false, String: ""},
+ })
+ })
+ if err != nil {
+ return nil, fault.Wrap(err, fault.Public("Deployment could not be started."))
}
- queuedStep := db.DeploymentStepsStepQueued
- currentStep = &queuedStep
- defer func() {
- if finishedSuccessfully {
- return
+ var (
+ workspace db.Workspace
+ project db.Project
+ app db.App
+ environment db.Environment
+ )
+
+ // --- Starting ---
+ err = w.DeploymentStep(ctx, db.DeploymentStepsStepStarting, deployment, func(stepCtx restate.WorkflowSharedContext) error {
+
+ workspace, err = restate.Run(ctx, func(runCtx restate.RunContext) (db.Workspace, error) {
+ var ws db.Workspace
+ err := db.TxRetry(runCtx, w.db.RW(), func(txCtx context.Context, tx db.DBTX) error {
+ found, err := db.Query.FindWorkspaceByID(txCtx, tx, deployment.WorkspaceID)
+ if err != nil {
+ if db.IsNotFound(err) {
+ return fault.Wrap(
+ restate.TerminalError(errors.New("workspace not found")),
+ fault.Public("The workspace for this deployment no longer exists."),
+ )
+ }
+ return fault.Wrap(err, fault.Public("Failed to read from database. Please try again."))
+ }
+ ws = found
+
+ if !found.K8sNamespace.Valid {
+ ws.K8sNamespace.Valid = true
+ ws.K8sNamespace.String = uid.DNS1035()
+ return db.Query.SetWorkspaceK8sNamespace(txCtx, tx, db.SetWorkspaceK8sNamespaceParams{
+ ID: ws.ID,
+ K8sNamespace: ws.K8sNamespace,
+ })
+ }
+ ws = found
+
+ return nil
+ })
+ return ws, err
+ }, restate.WithName("find workspace"))
+ if err != nil {
+ return fault.Wrap(err, fault.Public("Workspace settings could not be initialized."))
}
- if currentStep != nil {
- msg := failureReason
- if msg == "" {
- msg = fault.UserFacingMessage(retErr)
- }
- if msg == "" {
- msg = fmt.Sprintf("Deployment failed during %s step", string(*currentStep))
- }
- if stepErr := w.endDeploymentStep(ctx, deployment.ID, *currentStep, &msg); stepErr != nil {
- logger.Error("failed to end deployment step on failure", "step", *currentStep, "error", stepErr)
- }
+ project, err = restate.Run(ctx, func(runCtx restate.RunContext) (db.Project, error) {
+ return db.Query.FindProjectById(runCtx, w.db.RW(), deployment.ProjectID)
+ }, restate.WithName("finding project"))
+ if err != nil {
+ return fault.Wrap(err, fault.Public("Failed to read from database. Please try again."))
}
- if statusErr := w.updateDeploymentStatus(ctx, deployment.ID, db.DeploymentsStatusFailed); statusErr != nil {
- logger.Error("deployment failed but we can not set the status", "error", statusErr.Error())
+ app, err = restate.Run(ctx, func(runCtx restate.RunContext) (db.App, error) {
+ return db.Query.FindAppById(runCtx, w.db.RW(), deployment.AppID)
+ }, restate.WithName("finding app"))
+ if err != nil {
+ return fault.Wrap(err, fault.Public("Failed to read from database. Please try again."))
}
- }()
- workspace, err := restate.Run(ctx, func(runCtx restate.RunContext) (db.Workspace, error) {
- var ws db.Workspace
- err := db.TxRetry(runCtx, w.db.RW(), func(txCtx context.Context, tx db.DBTX) error {
- found, err := db.Query.FindWorkspaceByID(txCtx, tx, deployment.WorkspaceID)
- if err != nil {
- if db.IsNotFound(err) {
- return restate.TerminalError(errors.New("workspace not found"))
- }
- return err
- }
- ws = found
-
- if !found.K8sNamespace.Valid {
- ws.K8sNamespace.Valid = true
- ws.K8sNamespace.String = uid.DNS1035()
- return db.Query.SetWorkspaceK8sNamespace(txCtx, tx, db.SetWorkspaceK8sNamespaceParams{
- ID: ws.ID,
- K8sNamespace: ws.K8sNamespace,
- })
- }
- ws = found
+ environment, err = restate.Run(ctx, func(runCtx restate.RunContext) (db.Environment, error) {
+ return db.Query.FindEnvironmentById(runCtx, w.db.RW(), deployment.EnvironmentID)
+ }, restate.WithName("finding environment"))
+ if err != nil {
+ return fault.Wrap(err, fault.Public("Failed to read from database. Please try again."))
+ }
- return nil
- })
- return ws, err
- }, restate.WithName("find workspace"))
+ return nil
+
+ })
if err != nil {
return nil, err
}
- project, err := restate.Run(ctx, func(runCtx restate.RunContext) (db.FindProjectByIdRow, error) {
- return db.Query.FindProjectById(runCtx, w.db.RW(), deployment.ProjectID)
- }, restate.WithName("finding project"))
+
+ // --- Build ---
+ err = w.DeploymentStep(ctx, db.DeploymentStepsStepBuilding, deployment, func(stepCtx restate.WorkflowSharedContext) error {
+ return w.buildImage(stepCtx, req, &deployment)
+ })
if err != nil {
return nil, err
}
- // Load the app from the deployment's app_id
- app, err := restate.Run(ctx, func(runCtx restate.RunContext) (db.App, error) {
- row, err := db.Query.FindAppById(runCtx, w.db.RO(), deployment.AppID)
+ // --- Deploy ---
+ err = w.DeploymentStep(ctx, db.DeploymentStepsStepDeploying, deployment, func(stepCtx restate.WorkflowSharedContext) error {
+
+ topologies, err := w.createTopologies(stepCtx, compensation, workspace, deployment)
if err != nil {
- return db.App{}, err
+ return fault.Wrap(err, fault.Public("Regional deployment targets could not be prepared."))
+ }
+
+ if err = w.ensureSentinels(stepCtx, workspace, project, environment, topologies); err != nil {
+ return fault.Wrap(err, fault.Public("Sentinels could not be started."))
+ }
+
+ if err := w.ensureCiliumNetworkPolicy(stepCtx, workspace, project, environment, topologies, deployment); err != nil {
+ return fault.Wrap(err, fault.Public("Applying network policies failed."))
}
- return row.App, nil
- }, restate.WithName("finding app"))
+
+ if err = w.waitForDeployments(stepCtx, deployment.ID, topologies); err != nil {
+ return fault.Wrap(err, fault.Public("Instances did not become healthy in time."))
+ }
+ return nil
+ })
+
if err != nil {
return nil, err
}
- environment, err := restate.Run(ctx, func(runCtx restate.RunContext) (db.FindEnvironmentByIdRow, error) {
- return db.Query.FindEnvironmentById(runCtx, w.db.RW(), deployment.EnvironmentID)
- }, restate.WithName("finding environment"))
+ // --- Network ---
+ err = w.DeploymentStep(ctx, db.DeploymentStepsStepNetwork, deployment, func(stepCtx restate.WorkflowSharedContext) error {
+
+ return w.configureRouting(stepCtx, workspace, project, app, environment, deployment)
+ })
if err != nil {
return nil, err
}
- if err = w.endDeploymentStep(ctx, deployment.ID, db.DeploymentStepsStepQueued, nil); err != nil {
- return nil, err
- }
- currentStep = nil
+ // --- Finalize ---
+ err = w.DeploymentStep(ctx, db.DeploymentStepsStepFinalizing, deployment, func(stepCtx restate.WorkflowSharedContext) error {
+ err = restate.RunVoid(ctx, func(stepCtx restate.RunContext) error {
+ return db.Query.UpdateDeploymentStatus(stepCtx, w.db.RW(), db.UpdateDeploymentStatusParams{
+ ID: deployment.ID,
+ Status: db.DeploymentsStatusReady,
+ UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()},
+ })
+ }, restate.WithName("updating deployment status to ready"))
+ if err != nil {
+ return fault.Wrap(err, fault.Public("Deployment completed but final status could not be saved."))
+ }
- if err = w.startDeploymentStep(ctx, deployment, db.DeploymentStepsStepBuilding); err != nil {
+ if err = w.swapLiveDeployment(ctx, deployment, app, environment); err != nil {
+ return fault.Wrap(err, fault.Public("Deployment is ready but could not be promoted to live."))
+ }
+ return nil
+ })
+ if err != nil {
return nil, err
}
- buildingStep := db.DeploymentStepsStepBuilding
- currentStep = &buildingStep
+ logger.Info("deployment workflow completed",
+ "deployment_id", deployment.ID,
+ "status", "succeeded",
+ )
+ return &hydrav1.DeployResponse{}, nil
+}
+
+// buildImage resolves the container image for a deployment and persists the image
+// reference to the database. For a DockerImage source, the image name is used
+// directly. For a Git source, the branch HEAD is resolved to a commit SHA (if
+// needed), a Docker image is built via Depot using [Workflow.buildDockerImageFromGit],
+// and the build ID and git metadata are saved.
+//
+// The deployment pointer is mutated in place: GitCommitSha and GitBranch are
+// updated when a branch is resolved, so the caller sees the resolved values for
+// later use in domain generation.
+//
+// Returns a terminal error for unknown source types and build failures that
+// cannot be retried (e.g. bad Dockerfile).
+func (w *Workflow) buildImage(ctx restate.WorkflowSharedContext, req *hydrav1.DeployRequest, deployment *db.Deployment) error {
dockerImage := ""
switch source := req.GetSource().(type) {
@@ -198,7 +285,10 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy
)
}, restate.WithName("resolve branch head"))
if resolveErr != nil {
- return nil, restate.TerminalError(fmt.Errorf("failed to resolve HEAD of branch %q: %w", source.Git.GetBranch(), resolveErr))
+ return fault.Wrap(
+ restate.TerminalError(fmt.Errorf("failed to resolve HEAD of branch %q: %w", source.Git.GetBranch(), resolveErr)),
+ fault.Public("Selected Git branch could not be resolved."),
+ )
}
commitSHA = info.SHA
@@ -215,7 +305,10 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy
})
}, restate.WithName("update deployment git metadata"))
if resolveErr != nil {
- return nil, fmt.Errorf("failed to update deployment git metadata: %w", resolveErr)
+ return fault.Wrap(
+ fmt.Errorf("failed to update deployment git metadata: %w", resolveErr),
+ fault.Public("The commit was resolved but metadata could not be saved."),
+ )
}
deployment.GitCommitSha = sql.NullString{String: info.SHA, Valid: true}
@@ -234,8 +327,10 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy
WorkspaceID: deployment.WorkspaceID,
})
if err != nil {
- failureReason = fault.UserFacingMessage(err)
- return nil, fmt.Errorf("failed to build docker image from git: %w", err)
+ return fault.Wrap(
+ fmt.Errorf("failed to build docker image from git: %w", err),
+ fault.Public("Build failed. Please check the build logs for details."),
+ )
}
dockerImage = build.ImageName
@@ -247,14 +342,20 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy
})
})
if err != nil {
- return nil, fmt.Errorf("failed to update deployment build ID: %w", err)
+ return fault.Wrap(
+ fmt.Errorf("failed to update deployment build ID: %w", err),
+ fault.Public("Updating build metadata failed."),
+ )
}
default:
- return nil, restate.TerminalError(fmt.Errorf("unknown source type: %T", source))
+ return fault.Wrap(
+ restate.TerminalError(fmt.Errorf("unknown source type: %T", source)),
+ fault.Public(fmt.Sprintf("Deployment source %s is not supported.", source)),
+ )
}
- err = restate.RunVoid(ctx, func(runCtx restate.RunContext) error {
+ err := restate.RunVoid(ctx, func(runCtx restate.RunContext) error {
return db.Query.UpdateDeploymentImage(runCtx, w.db.RW(), db.UpdateDeploymentImageParams{
ID: deployment.ID,
Image: sql.NullString{Valid: true, String: dockerImage},
@@ -262,43 +363,47 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy
})
}, restate.WithName("update deployment image"))
if err != nil {
- return nil, err
- }
-
- if err = w.endDeploymentStep(ctx, deployment.ID, db.DeploymentStepsStepBuilding, nil); err != nil {
- return nil, err
+ return fault.Wrap(err, fault.Public("Unable to save deployment image."))
}
- currentStep = nil
- if err = w.updateDeploymentStatus(ctx, deployment.ID, db.DeploymentsStatusDeploying); err != nil {
- return nil, err
- }
-
- if err = w.startDeploymentStep(ctx, deployment, db.DeploymentStepsStepDeploying); err != nil {
- return nil, err
- }
- deployingStep := db.DeploymentStepsStepDeploying
- currentStep = &deployingStep
+ return nil
+}
- // Read region config from app runtime settings to determine per-region replica counts.
+// createTopologies determines the target regions and replica counts, obtains a
+// monotonic version for each region from VersioningService, and bulk-inserts the
+// deployment topology records.
+//
+// Region selection uses the environment's runtime settings: if a region config is
+// present, only those regions are used with their configured replica counts;
+// otherwise all of [Workflow.availableRegions] are used with 1 replica each.
+//
+// createTopologies also registers compensations for every inserted
+// topology. Compensation deletes by deployment, region, and version so retries
+// never remove topologies created by a newer attempt.
+func (w *Workflow) createTopologies(
+ ctx restate.WorkflowSharedContext,
+ compensation *Compensation,
+ workspace db.Workspace,
+ deployment db.Deployment,
+) ([]db.InsertDeploymentTopologyParams, error) {
+ // Read region config from runtime settings to determine per-region replica counts.
// If regionConfig is empty, deploy to all available regions with 1 replica each (default).
// If regionConfig has entries, only deploy to those regions with the specified counts.
regionConfig := map[string]int{}
- appRuntimeSettings, runtimeSettingsErr := restate.Run(ctx, func(runCtx restate.RunContext) (db.AppRuntimeSetting, error) {
- row, err := db.Query.FindAppRuntimeSettingsByAppAndEnv(runCtx, w.db.RO(), db.FindAppRuntimeSettingsByAppAndEnvParams{
- AppID: app.ID,
+ runtimeSettings, err := restate.Run(ctx, func(runCtx restate.RunContext) (db.FindAppRuntimeSettingsByAppAndEnvRow, error) {
+ return db.Query.FindAppRuntimeSettingsByAppAndEnv(runCtx, w.db.RO(), db.FindAppRuntimeSettingsByAppAndEnvParams{
+ AppID: deployment.AppID,
EnvironmentID: deployment.EnvironmentID,
})
- if err != nil {
- return db.AppRuntimeSetting{}, err
- }
- return row.AppRuntimeSetting, nil
- }, restate.WithName("find app runtime settings for region config"))
- if runtimeSettingsErr != nil {
- return nil, fmt.Errorf("failed to find app runtime settings for app %s environment %s: %w", app.ID, deployment.EnvironmentID, runtimeSettingsErr)
+ }, restate.WithName("find runtime settings for region config"))
+ if err != nil {
+ return nil, fault.Wrap(
+ fmt.Errorf("failed to find runtime settings for environment %s: %w", deployment.EnvironmentID, err),
+ fault.Public("Failed to read from database. Please try again."),
+ )
}
- if len(appRuntimeSettings.RegionConfig) > 0 {
- for region, count := range appRuntimeSettings.RegionConfig {
+ if len(runtimeSettings.AppRuntimeSetting.RegionConfig) > 0 {
+ for region, count := range runtimeSettings.AppRuntimeSetting.RegionConfig {
regionConfig[region] = count
}
}
@@ -317,7 +422,10 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy
for _, region := range regions {
versionResp, err := hydrav1.NewVersioningServiceClient(ctx, region).NextVersion().Request(&hydrav1.NextVersionRequest{})
if err != nil {
- return nil, fmt.Errorf("failed to get next version: %w", err)
+ return nil, fault.Wrap(
+ fmt.Errorf("failed to get next version: %w", err),
+ fault.Public("Failed to generate new version."),
+ )
}
replicas := int32(1)
@@ -342,16 +450,55 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy
})
}, restate.WithName("insert deployment topologies"))
if err != nil {
- return nil, fmt.Errorf("failed to insert deployment topologies: %w", err)
+ return nil, fault.Wrap(
+ fmt.Errorf("failed to insert deployment topologies: %w", err),
+ fault.Public("Deployment targets could not be saved."),
+ )
+ }
+
+ // In case anything goes wrong, delete the inserted topologies.
+ // Deleting by version keeps retries safe: a newer retry creates a higher
+ // version and will not be removed by this compensation.
+ for _, topo := range topologies {
+ compensation.Add(
+ fmt.Sprintf("delete deployment topology %s/%s/%d", topo.DeploymentID, topo.Region, topo.Version),
+ func(runCtx restate.RunContext) error {
+ return db.Query.DeleteDeploymentTopologyByDeploymentRegionVersion(runCtx, w.db.RW(), db.DeleteDeploymentTopologyByDeploymentRegionVersionParams{
+ DeploymentID: topo.DeploymentID,
+ Region: topo.Region,
+ Version: topo.Version,
+ })
+ },
+ )
}
- // Ensure sentinels exist in each region for this deployment
+ return topologies, nil
+}
+// ensureSentinels creates sentinel instances in any region that doesn't already
+// have one for this environment. Sentinels are the reverse-proxy layer that
+// routes frontline traffic to deployment containers, so every region serving
+// traffic needs one.
+//
+// Production environments get 3 sentinel replicas for availability; all others
+// get 1. The insert relies on a unique index on (environment_id, region) to be
+// idempotent — if a concurrent workflow already created the sentinel, the
+// duplicate key error is silently ignored.
+func (w *Workflow) ensureSentinels(
+ ctx restate.WorkflowSharedContext,
+ workspace db.Workspace,
+ project db.Project,
+ environment db.Environment,
+ topologies []db.InsertDeploymentTopologyParams,
+) error {
existingSentinels, err := restate.Run(ctx, func(runCtx restate.RunContext) ([]db.Sentinel, error) {
return db.Query.FindSentinelsByEnvironmentID(runCtx, w.db.RO(), environment.ID)
}, restate.WithName("find existing sentinels"))
if err != nil {
- return nil, fmt.Errorf("failed to find existing sentinels: %w", err)
+ return fault.Wrap(
+ fmt.Errorf("failed to find existing sentinels: %w", err),
+ fault.Public("Failed to read from database. Please try again."),
+ )
}
existingSentinelsByRegion := make(map[string]db.Sentinel)
@@ -370,7 +517,10 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy
sentinelVersion, err := hydrav1.NewVersioningServiceClient(ctx, topology.Region).NextVersion().Request(&hydrav1.NextVersionRequest{})
if err != nil {
- return nil, fmt.Errorf("failed to get next version for sentinel: %w", err)
+ return fault.Wrap(
+ fmt.Errorf("failed to get next version for sentinel: %w", err),
+ fault.Public("Traffic proxies could not be versioned in one region."),
+ )
}
err = restate.RunVoid(ctx, func(runCtx restate.RunContext) error {
@@ -406,70 +556,33 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy
})
}, restate.WithName("ensure sentinel exists in db"))
if err != nil {
- return nil, err
- }
-
- }
-
- }
-
- if err := w.ensureCiliumNetworkPolicy(ctx, workspace, project, environment, topologies, deployment); err != nil {
- return nil, err
- }
-
- logger.Info("waiting for deployments to be ready", "deployment_id", deployment.ID)
-
- readygates := make([]restate.Future, len(topologies))
- for i, region := range topologies {
- promise := restate.RunAsync(ctx, func(runCtx restate.RunContext) (bool, error) {
- for {
- time.Sleep(time.Second)
-
- instances, err := db.Query.FindInstancesByDeploymentIdAndRegion(runCtx, w.db.RO(), db.FindInstancesByDeploymentIdAndRegionParams{
- Deploymentid: deployment.ID,
- Region: region.Region,
- })
- if err != nil {
- return false, err
- }
- if len(instances) < int(region.DesiredReplicas) {
- continue
- }
- allRunning := true
- for _, instance := range instances {
- if instance.Status != db.InstancesStatusRunning {
- allRunning = false
- break
- }
- }
- if allRunning {
- return true, nil
- }
-
+ return fault.Wrap(err, fault.Public("Traffic proxy could not be created for a region."))
}
- }, restate.WithName(fmt.Sprintf("wait for instances in %s", region.Region)))
- readygates[i] = promise
- }
- for _, err := range restate.Wait(ctx, readygates...) {
- if err != nil {
- return nil, err
}
- }
- logger.Info("deployments ready", "deployment_id", deployment.ID)
-
- if err = w.endDeploymentStep(ctx, deployment.ID, db.DeploymentStepsStepDeploying, nil); err != nil {
- return nil, err
}
- currentStep = nil
- if err = w.startDeploymentStep(ctx, deployment, db.DeploymentStepsStepNetwork); err != nil {
- return nil, err
- }
- networkStep := db.DeploymentStepsStepNetwork
- currentStep = &networkStep
+ return nil
+}
+// configureRouting sets up domain-based routing for a deployment. It generates
+// domain names via [buildDomains] (per-commit, per-branch, and per-environment
+// URLs), upserts a frontline route record for each domain, and then collects
+// any existing sticky routes (environment-level, and live-level for non-rolled-back
+// production) so they point to the new deployment.
+//
+// All collected route IDs are passed to the RoutingService in a single
+// [hydrav1.AssignFrontlineRoutesRequest] so that the routing layer atomically
+// switches traffic to this deployment's topologies.
+func (w *Workflow) configureRouting(
+ ctx restate.WorkflowSharedContext,
+ workspace db.Workspace,
+ project db.Project,
+ app db.App,
+ environment db.Environment,
+ deployment db.Deployment,
+) error {
allDomains := buildDomains(
workspace.Slug,
project.Slug,
@@ -510,7 +623,7 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy
})
}, restate.WithName(fmt.Sprintf("inserting frontline route %s", domain.domain)))
if getFrontlineRouteErr != nil {
- return nil, getFrontlineRouteErr
+ return fault.Wrap(getFrontlineRouteErr, fault.Public("Route records could not be created."))
}
if frontlineRouteID != "" {
existingRouteIDs = append(existingRouteIDs, frontlineRouteID)
@@ -552,7 +665,10 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy
})
}, restate.WithName("finding sticky routes"))
if err != nil {
- return nil, fmt.Errorf("failed to find sticky routes: %w", err)
+ return fault.Wrap(
+ fmt.Errorf("failed to find sticky routes: %w", err),
+ fault.Public("Failed to read from database. Please try again."),
+ )
}
for _, route := range stickyRoutes {
@@ -566,72 +682,166 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy
FrontlineRouteIds: existingRouteIDs,
})
if err != nil {
- return nil, fmt.Errorf("failed to assign domains: %w", err)
+ return fault.Wrap(
+ fmt.Errorf("failed to assign domains: %w", err),
+ fault.Public("Domain routing could not be updated."),
+ )
}
- if err = w.endDeploymentStep(ctx, deployment.ID, db.DeploymentStepsStepNetwork, nil); err != nil {
- return nil, err
+ return nil
+}
+
+// swapLiveDeployment atomically updates the project's live deployment pointer to
+// this deployment and schedules the previous live deployment for standby after
+// 30 minutes via [hydrav1.DeploymentServiceClient.ScheduleDesiredStateChange].
+//
+// The read-then-update happens inside a single transaction to prevent a race where
+// two concurrent deploys both capture the same previous deployment ID and one of
+// them never gets scheduled for standby.
+//
+// This only applies to production environments that are not in a rolled-back state;
+// for all other cases the method is a no-op and returns nil.
+func (w *Workflow) swapLiveDeployment(
+ ctx restate.WorkflowSharedContext,
+ deployment db.Deployment,
+ app db.App,
+ environment db.Environment,
+) error {
+ if app.IsRolledBack || environment.Slug != "production" {
+ return nil
+ }
+
+ // Atomically read the current live deployment and swap it to the new one.
+ // This prevents a race where two concurrent deploys both capture the same
+ // previousLiveDeploymentID and one of them never gets scheduled for standby.
+ previousLiveDeploymentID, err := restate.Run(ctx, func(runCtx restate.RunContext) (sql.NullString, error) {
+ return db.TxWithResult(runCtx, w.db.RW(), func(txCtx context.Context, tx db.DBTX) (sql.NullString, error) {
+ currentApp, findErr := db.Query.FindAppById(txCtx, tx, deployment.AppID)
+ if findErr != nil {
+ return sql.NullString{}, findErr
+ }
+
+ updateErr := db.Query.UpdateAppDeployments(txCtx, tx, db.UpdateAppDeploymentsParams{
+ IsRolledBack: false,
+ AppID: currentApp.ID,
+ CurrentDeploymentID: sql.NullString{Valid: true, String: deployment.ID},
+ UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()},
+ })
+ if updateErr != nil {
+ return sql.NullString{}, updateErr
+ }
+
+ return currentApp.CurrentDeploymentID, nil
+ })
+ }, restate.WithName("swapping project live deployment"))
+ if err != nil {
+ return fault.Wrap(err, fault.Public("Project live deployment could not be updated."))
+ }
+
+ if previousLiveDeploymentID.Valid {
+ _, err = hydrav1.NewDeploymentServiceClient(ctx, previousLiveDeploymentID.String).
+ ScheduleDesiredStateChange().Request(
+ &hydrav1.ScheduleDesiredStateChangeRequest{
+ DelayMillis: (30 * time.Minute).Milliseconds(),
+ State: hydrav1.DeploymentDesiredState_DEPLOYMENT_DESIRED_STATE_STANDBY,
+ },
+ restate.WithIdempotencyKey(deployment.ID),
+ )
+ if err != nil {
+ return fault.Wrap(err, fault.Public("Previous live deployment could not be scheduled for standby."))
+ }
}
- currentStep = nil
- if err = w.updateDeploymentStatus(ctx, deployment.ID, db.DeploymentsStatusReady); err != nil {
- return nil, err
+ return nil
+}
+
+// waitForDeployments polls instance status across all regions until enough
+// regions have all their desired replicas running, or [regionReadyTimeout]
+// elapses. Each region is polled concurrently via restate.RunAsync.
+//
+// The method requires min(2, len(topologies)) healthy regions to succeed.
+// This threshold ensures at least two regions are serving traffic before the
+// workflow proceeds to routing, while still allowing single-region deployments
+// to pass. Regions that time out or error are skipped rather than failing the
+// entire deployment, so a degraded region does not block progress.
+func (w *Workflow) waitForDeployments(ctx restate.WorkflowSharedContext, deploymentID string, topologies []db.InsertDeploymentTopologyParams) error {
+ logger.Info("waiting for deployments to be ready", "deployment_id", deploymentID)
+
+ deadline, err := restate.Run(ctx, func(_ restate.RunContext) (time.Time, error) {
+ return time.Now().Add(regionReadyTimeout), nil
+ }, restate.WithName("calculate deadline"))
+ if err != nil {
+ return fault.Wrap(err, fault.Public("Deployment readiness checks could not start."))
}
- if autoPromote {
- // Atomically read the current deployment and swap it to the new one.
- // This prevents a race where two concurrent deploys both capture the same
- // previousCurrentDeploymentID and one of them never gets scheduled for standby.
- previousCurrentDeploymentID, err := restate.Run(ctx, func(runCtx restate.RunContext) (sql.NullString, error) {
- return db.TxWithResult(runCtx, w.db.RW(), func(txCtx context.Context, tx db.DBTX) (sql.NullString, error) {
- currentApp, findErr := db.Query.FindAppByProjectAndSlug(txCtx, tx, db.FindAppByProjectAndSlugParams{
- ProjectID: app.ProjectID,
- Slug: app.Slug,
- })
- if findErr != nil {
- return sql.NullString{}, findErr
- }
+ readygates := make([]restate.Future, len(topologies))
+ for i, region := range topologies {
+ promise := restate.RunAsync(ctx, func(runCtx restate.RunContext) (bool, error) {
+ for time.Now().Before(deadline) {
+ time.Sleep(time.Second)
- updateErr := db.Query.UpdateAppDeployments(txCtx, tx, db.UpdateAppDeploymentsParams{
- ID: app.ID,
- CurrentDeploymentID: sql.NullString{Valid: true, String: deployment.ID},
- IsRolledBack: false,
- UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()},
+ instances, err := db.Query.FindInstancesByDeploymentIdAndRegion(runCtx, w.db.RO(), db.FindInstancesByDeploymentIdAndRegionParams{
+ Deploymentid: deploymentID,
+ Region: region.Region,
})
- if updateErr != nil {
- return sql.NullString{}, updateErr
+ if err != nil {
+ return false, err
+ }
+ logger.Info("checking instances for region", "deployment_id", deploymentID, "region", region.Region, "instances_found", len(instances))
+ if len(instances) < int(region.DesiredReplicas) {
+ logger.Info("not all instances are up yet", "deployment_id", deploymentID, "region", region.Region, "instances_found", len(instances), "desired_replicas", region.DesiredReplicas)
+ continue
}
+ allRunning := true
+ for _, instance := range instances {
+ if instance.Status != db.InstancesStatusRunning {
+ logger.Info("instance not running yet", "deployment_id", deploymentID, "region", instance.Region, "instance_id", instance.ID, "status", instance.Status)
+ allRunning = false
+ break
+ }
+ }
+ if allRunning {
+ return true, nil
+ }
+ }
+ return false, nil
+ }, restate.WithName(fmt.Sprintf("wait for %d instances in %s", region.DesiredReplicas, region.Region)))
+ readygates[i] = promise
- return currentApp.App.CurrentDeploymentID, nil
- })
- }, restate.WithName("swapping app current deployment"))
+ }
+ requiredHealthyRegions := min(2, len(topologies))
+ healthyRegions := 0
+
+ for fut, err := range restate.Wait(ctx, readygates...) {
if err != nil {
- return nil, err
+ continue
}
-
- if previousCurrentDeploymentID.Valid {
- _, err = hydrav1.NewDeploymentServiceClient(ctx, previousCurrentDeploymentID.String).
- ScheduleDesiredStateChange().Request(
- &hydrav1.ScheduleDesiredStateChangeRequest{
- DelayMillis: (30 * time.Minute).Milliseconds(),
- State: hydrav1.DeploymentDesiredState_DEPLOYMENT_DESIRED_STATE_STANDBY,
- },
- restate.WithIdempotencyKey(deployment.ID),
+ raf, ok := fut.(restate.RunAsyncFuture[bool])
+ if !ok {
+ return fault.Wrap(
+ fmt.Errorf("unexpected future type: %T", fut),
+ fault.Public("Deployment readiness checks returned an unexpected response."),
)
- if err != nil {
- return nil, err
- }
}
- }
-
- logger.Info("deployment workflow completed",
- "deployment_id", deployment.ID,
- "app_id", app.ID,
- "status", "succeeded",
- "domains", len(allDomains),
- )
+ ready, err := raf.Result()
+ if err != nil {
+ continue
+ }
+ if ready {
+ healthyRegions++
+ }
- finishedSuccessfully = true
+ if healthyRegions >= requiredHealthyRegions {
+ break
+ }
+ }
+ if healthyRegions < requiredHealthyRegions {
+ return fault.Wrap(
+ fmt.Errorf("only %d healthy regions, required at least %d", healthyRegions, requiredHealthyRegions),
+ fault.Public("Not enough regions became healthy."),
+ )
+ }
- return &hydrav1.DeployResponse{}, nil
+ logger.Info("deployments ready", "deployment_id", deploymentID)
+ return nil
}
diff --git a/svc/ctrl/worker/deploy/deployment_step.go b/svc/ctrl/worker/deploy/deployment_step.go
new file mode 100644
index 0000000000..430105aa84
--- /dev/null
+++ b/svc/ctrl/worker/deploy/deployment_step.go
@@ -0,0 +1,84 @@
+package deploy
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+
+ restate "github.com/restatedev/sdk-go"
+ "github.com/unkeyed/unkey/pkg/db"
+ "github.com/unkeyed/unkey/pkg/fault"
+)
+
+func (w *Workflow) DeploymentStep(
+ ctx restate.WorkflowSharedContext,
+ step db.DeploymentStepsStep,
+ deployment db.Deployment,
+ fn func(innerCtx restate.WorkflowSharedContext) error) error {
+
+ err := restate.RunVoid(ctx, func(runCtx restate.RunContext) error {
+ now := time.Now().UnixMilli()
+ deploymentStatus := db.DeploymentsStatusPending
+ switch step {
+ case db.DeploymentStepsStepQueued:
+ deploymentStatus = db.DeploymentsStatusPending
+ case db.DeploymentStepsStepStarting:
+ deploymentStatus = db.DeploymentsStatusStarting
+ case db.DeploymentStepsStepBuilding:
+ deploymentStatus = db.DeploymentsStatusBuilding
+ case db.DeploymentStepsStepDeploying:
+ deploymentStatus = db.DeploymentsStatusDeploying
+ case db.DeploymentStepsStepNetwork:
+ deploymentStatus = db.DeploymentsStatusNetwork
+ case db.DeploymentStepsStepFinalizing:
+ deploymentStatus = db.DeploymentsStatusFinalizing
+ default:
+ return fmt.Errorf("unexpected deployment step: %s", step)
+ }
+
+ return db.Tx(runCtx, w.db.RW(), func(txCtx context.Context, tx db.DBTX) error {
+ if err := db.Query.InsertDeploymentStep(txCtx, tx, db.InsertDeploymentStepParams{
+ WorkspaceID: deployment.WorkspaceID,
+ ProjectID: deployment.ProjectID,
+ AppID: deployment.AppID,
+ EnvironmentID: deployment.EnvironmentID,
+ DeploymentID: deployment.ID,
+ Step: step,
+ StartedAt: uint64(now),
+ }); err != nil {
+ return err
+ }
+
+ if err := db.Query.UpdateDeploymentStatus(txCtx, tx, db.UpdateDeploymentStatusParams{
+ ID: deployment.ID,
+ Status: deploymentStatus,
+ UpdatedAt: sql.NullInt64{Valid: true, Int64: now},
+ }); err != nil {
+ return err
+ }
+ return nil
+ })
+
+ }, restate.WithName(fmt.Sprintf("starting step: %s", step)))
+ if err != nil {
+ return err
+ }
+
+ stepErr := fn(ctx)
+
+ err = restate.RunVoid(ctx, func(runCtx restate.RunContext) error {
+ return db.Query.EndDeploymentStep(runCtx, w.db.RW(), db.EndDeploymentStepParams{
+ DeploymentID: deployment.ID,
+ Step: step,
+ EndedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()},
+ Error: sql.NullString{Valid: stepErr != nil, String: fault.UserFacingMessage(stepErr)},
+ })
+ }, restate.WithName(fmt.Sprintf("ending step: %s", step)))
+ if err != nil {
+ return err
+ }
+
+ return stepErr
+
+}
diff --git a/svc/ctrl/worker/deploy/helpers.go b/svc/ctrl/worker/deploy/helpers.go
deleted file mode 100644
index 76aece65e4..0000000000
--- a/svc/ctrl/worker/deploy/helpers.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package deploy
-
-import (
- "database/sql"
- "fmt"
- "time"
-
- restate "github.com/restatedev/sdk-go"
- "github.com/unkeyed/unkey/pkg/db"
-)
-
-// updateDeploymentStatus updates the status of a deployment in the database.
-//
-// This is a durable operation wrapped in restate.Run to ensure the status update
-// is persisted even if the workflow is interrupted. Status updates are critical
-// for tracking deployment progress and handling failures.
-func (w *Workflow) updateDeploymentStatus(ctx restate.WorkflowSharedContext, deploymentID string, status db.DeploymentsStatus) error {
- _, err := restate.Run(ctx, func(stepCtx restate.RunContext) (restate.Void, error) {
- updateErr := db.Query.UpdateDeploymentStatus(stepCtx, w.db.RW(), db.UpdateDeploymentStatusParams{
- ID: deploymentID,
- Status: status,
- UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()},
- })
- if updateErr != nil {
- return restate.Void{}, fmt.Errorf("failed to update version status to building: %w", updateErr)
- }
- return restate.Void{}, nil
- }, restate.WithName(fmt.Sprintf("updating deployment status to %s", status)))
- return err
-}
-
-// startDeploymentStep records the start of a deployment step.
-func (w *Workflow) startDeploymentStep(
- ctx restate.WorkflowSharedContext,
- deployment db.Deployment,
- step db.DeploymentStepsStep,
-) error {
- return restate.RunVoid(ctx, func(runCtx restate.RunContext) error {
- return db.Query.InsertDeploymentStep(runCtx, w.db.RW(), db.InsertDeploymentStepParams{
- WorkspaceID: deployment.WorkspaceID,
- ProjectID: deployment.ProjectID,
- AppID: deployment.AppID,
- EnvironmentID: deployment.EnvironmentID,
- DeploymentID: deployment.ID,
- Step: step,
- StartedAt: uint64(time.Now().UnixMilli()),
- })
- }, restate.WithName(fmt.Sprintf("start deployment step %s", step)))
-}
-
-// endDeploymentStep marks a deployment step as completed, optionally recording an error.
-func (w *Workflow) endDeploymentStep(
- ctx restate.WorkflowSharedContext,
- deploymentID string,
- step db.DeploymentStepsStep,
- errorMessage *string,
-) error {
- return restate.RunVoid(ctx, func(runCtx restate.RunContext) error {
- errStr := sql.NullString{}
- if errorMessage != nil {
- errStr = sql.NullString{Valid: true, String: *errorMessage}
- }
- return db.Query.EndDeploymentStep(runCtx, w.db.RW(), db.EndDeploymentStepParams{
- DeploymentID: deploymentID,
- Step: step,
- EndedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()},
- Error: errStr,
- })
- }, restate.WithName(fmt.Sprintf("end deployment step %s", step)))
-}
diff --git a/svc/ctrl/worker/deploy/promote_handler.go b/svc/ctrl/worker/deploy/promote_handler.go
index 4323b5ec63..08786beeee 100644
--- a/svc/ctrl/worker/deploy/promote_handler.go
+++ b/svc/ctrl/worker/deploy/promote_handler.go
@@ -8,6 +8,7 @@ import (
restate "github.com/restatedev/sdk-go"
hydrav1 "github.com/unkeyed/unkey/gen/proto/hydra/v1"
"github.com/unkeyed/unkey/pkg/db"
+ "github.com/unkeyed/unkey/pkg/fault"
"github.com/unkeyed/unkey/pkg/logger"
)
@@ -38,35 +39,46 @@ func (w *Workflow) Promote(ctx restate.WorkflowSharedContext, req *hydrav1.Promo
}, restate.WithName("finding target deployment"))
if err != nil {
if db.IsNotFound(err) {
- return nil, restate.TerminalError(fmt.Errorf("deployment not found: %s", req.GetTargetDeploymentId()), 404)
+ return nil, fault.Wrap(
+ restate.TerminalError(fmt.Errorf("deployment not found: %s", req.GetTargetDeploymentId()), 404),
+ fault.Public("The deployment could not be found"),
+ )
}
- return nil, fmt.Errorf("failed to get target deployment: %w", err)
+ return nil, fault.Wrap(err, fault.Public("Failed to find the target deployment"))
}
// Get app from deployment's app_id
app, err := restate.Run(ctx, func(stepCtx restate.RunContext) (db.App, error) {
- row, err := db.Query.FindAppById(stepCtx, w.db.RO(), targetDeployment.AppID)
- if err != nil {
- return db.App{}, err
- }
- return row.App, nil
+ return db.Query.FindAppById(stepCtx, w.db.RO(), targetDeployment.AppID)
}, restate.WithName("finding app"))
if err != nil {
if db.IsNotFound(err) {
- return nil, restate.TerminalError(fmt.Errorf("app not found: %s", targetDeployment.AppID), 404)
+ return nil, fault.Wrap(
+ restate.TerminalError(fmt.Errorf("app not found: %s", targetDeployment.AppID), 404),
+ fault.Public("The project could not be found"),
+ )
}
- return nil, fmt.Errorf("failed to get app: %w", err)
+ return nil, fault.Wrap(err, fault.Public("Failed to find the app"))
}
// Validate preconditions
if targetDeployment.Status != db.DeploymentsStatusReady {
- return nil, restate.TerminalError(fmt.Errorf("deployment status must be ready, got: %s", targetDeployment.Status), 400)
+ return nil, fault.Wrap(
+ restate.TerminalError(fmt.Errorf("deployment status must be ready, got: %s", targetDeployment.Status), 400),
+ fault.Public("The deployment is not ready for promotion"),
+ )
}
if !app.CurrentDeploymentID.Valid {
- return nil, restate.TerminalError(fmt.Errorf("app has no current deployment"), 400)
+ return nil, fault.Wrap(
+ restate.TerminalError(fmt.Errorf("app has no live deployment"), 400),
+ fault.Public("The app has no live deployment to promote from"),
+ )
}
if targetDeployment.ID == app.CurrentDeploymentID.String {
- return nil, restate.TerminalError(fmt.Errorf("target deployment is already the current deployment"), 400)
+ return nil, fault.Wrap(
+ restate.TerminalError(fmt.Errorf("target deployment is already the live deployment"), 400),
+ fault.Public("This deployment is already live"),
+ )
}
// Get all frontlineRoutes for promotion
@@ -80,11 +92,14 @@ func (w *Workflow) Promote(ctx restate.WorkflowSharedContext, req *hydrav1.Promo
})
}, restate.WithName("finding frontlineRoutes for promotion"))
if err != nil {
- return nil, fmt.Errorf("failed to get frontlineRoutes: %w", err)
+ return nil, fault.Wrap(err, fault.Public("Failed to find routes for promotion"))
}
if len(frontlineRoutes) == 0 {
- return nil, restate.TerminalError(fmt.Errorf("no frontlineRoutes found for promotion"), 400)
+ return nil, fault.Wrap(
+ restate.TerminalError(fmt.Errorf("no frontline routes found for promotion"), 400),
+ fault.Public("No routes found to promote"),
+ )
}
logger.Info("found frontlineRoutes for promotion", "count", len(frontlineRoutes), "deployment_id", targetDeployment.ID)
@@ -102,31 +117,31 @@ func (w *Workflow) Promote(ctx restate.WorkflowSharedContext, req *hydrav1.Promo
FrontlineRouteIds: routeIDs,
})
if err != nil {
- return nil, fmt.Errorf("failed to switch domains: %w", err)
+ return nil, fault.Wrap(err, fault.Public("Failed to switch routes to the promoted deployment"))
}
// Update app's current deployment
_, err = restate.Run(ctx, func(stepCtx restate.RunContext) (restate.Void, error) {
err = db.Query.UpdateAppDeployments(stepCtx, w.db.RW(), db.UpdateAppDeploymentsParams{
- ID: app.ID,
+ AppID: app.ID,
CurrentDeploymentID: sql.NullString{Valid: true, String: targetDeployment.ID},
IsRolledBack: false,
UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()},
})
if err != nil {
- return restate.Void{}, fmt.Errorf("failed to update app's current deployment id: %w", err)
+ return restate.Void{}, fault.Wrap(err, fault.Internal("failed to update app's current deployment id"))
}
logger.Info("updated app current deployment", "app_id", app.ID, "current_deployment_id", targetDeployment.ID)
return restate.Void{}, nil
}, restate.WithName("updating app current deployment"))
if err != nil {
- return nil, err
+ return nil, fault.Wrap(err, fault.Public("Failed to update the project after promotion"))
}
// ensure the new promoted deployment does not get spun down from existing scheduled actions
_, err = hydrav1.NewDeploymentServiceClient(ctx, targetDeployment.ID).ClearScheduledStateChanges().Request(&hydrav1.ClearScheduledStateChangesRequest{})
if err != nil {
- return nil, err
+ return nil, fault.Wrap(err, fault.Public("Failed to clear scheduled state changes on the promoted deployment"))
}
// schedule old deployment to be spun down
diff --git a/svc/ctrl/worker/deploy/rollback_handler.go b/svc/ctrl/worker/deploy/rollback_handler.go
index aacb623853..f4ecd34378 100644
--- a/svc/ctrl/worker/deploy/rollback_handler.go
+++ b/svc/ctrl/worker/deploy/rollback_handler.go
@@ -78,11 +78,7 @@ func (w *Workflow) Rollback(ctx restate.WorkflowSharedContext, req *hydrav1.Roll
// Get app from deployment's app_id
app, err := restate.Run(ctx, func(stepCtx restate.RunContext) (db.App, error) {
- row, err := db.Query.FindAppById(stepCtx, w.db.RO(), sourceDeployment.AppID)
- if err != nil {
- return db.App{}, err
- }
- return row.App, nil
+ return db.Query.FindAppById(stepCtx, w.db.RO(), sourceDeployment.AppID)
}, restate.WithName("finding app"))
if err != nil {
if db.IsNotFound(err) {
@@ -144,7 +140,7 @@ func (w *Workflow) Rollback(ctx restate.WorkflowSharedContext, req *hydrav1.Roll
// Update app's current deployment
_, err = restate.Run(ctx, func(stepCtx restate.RunContext) (restate.Void, error) {
err = db.Query.UpdateAppDeployments(stepCtx, w.db.RW(), db.UpdateAppDeploymentsParams{
- ID: app.ID,
+ AppID: app.ID,
CurrentDeploymentID: sql.NullString{Valid: true, String: targetDeployment.ID},
IsRolledBack: true,
UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()},
diff --git a/svc/ctrl/worker/deployment/deployment_state.go b/svc/ctrl/worker/deployment/deployment_state.go
index 5032f189da..bf060feeb8 100644
--- a/svc/ctrl/worker/deployment/deployment_state.go
+++ b/svc/ctrl/worker/deployment/deployment_state.go
@@ -101,12 +101,12 @@ func (v *VirtualObject) ChangeDesiredState(ctx restate.ObjectContext, req *hydra
if err != nil {
return err
}
- appRow, err := db.Query.FindAppById(txCtx, tx, deployment.AppID)
+ app, err := db.Query.FindAppById(txCtx, tx, deployment.AppID)
if err != nil {
return err
}
- if appRow.App.CurrentDeploymentID.Valid && appRow.App.CurrentDeploymentID.String == deploymentID {
+ if app.CurrentDeploymentID.Valid && app.CurrentDeploymentID.String == deploymentID {
return restate.TerminalErrorf("not allowed to modify the current deployment")
}
diff --git a/svc/ctrl/worker/githubwebhook/handle_push.go b/svc/ctrl/worker/githubwebhook/handle_push.go
index 1f4e88df85..bd18ac2523 100644
--- a/svc/ctrl/worker/githubwebhook/handle_push.go
+++ b/svc/ctrl/worker/githubwebhook/handle_push.go
@@ -1,6 +1,7 @@
package githubwebhook
import (
+ "context"
"database/sql"
"time"
@@ -102,32 +103,53 @@ func (s *Service) HandlePush(ctx restate.ObjectContext, req *hydrav1.HandlePushR
commitTimestamp := req.GetCommitTimestamp()
err = restate.RunVoid(ctx, func(runCtx restate.RunContext) error {
- return db.Query.InsertDeployment(runCtx, s.db.RW(), db.InsertDeploymentParams{
- ID: deploymentID,
- K8sName: uid.DNS1035(12),
- WorkspaceID: project.WorkspaceID,
- ProjectID: project.ID,
- AppID: app.ID,
- EnvironmentID: env.ID,
- SentinelConfig: runtimeSettings.SentinelConfig,
- EncryptedEnvironmentVariables: secretsBlob,
- Command: runtimeSettings.Command,
- Status: db.DeploymentsStatusPending,
- CreatedAt: now,
- UpdatedAt: sql.NullInt64{Valid: false},
- GitCommitSha: sql.NullString{String: req.GetAfter(), Valid: req.GetAfter() != ""},
- GitBranch: sql.NullString{String: req.GetBranch(), Valid: req.GetBranch() != ""},
- GitCommitMessage: sql.NullString{String: commitMessage, Valid: commitMessage != ""},
- GitCommitAuthorHandle: sql.NullString{String: authorHandle, Valid: authorHandle != ""},
- GitCommitAuthorAvatarUrl: sql.NullString{String: authorAvatarURL, Valid: authorAvatarURL != ""},
- GitCommitTimestamp: sql.NullInt64{Int64: commitTimestamp, Valid: commitTimestamp != 0},
- OpenapiSpec: sql.NullString{Valid: false},
- CpuMillicores: runtimeSettings.CpuMillicores,
- MemoryMib: runtimeSettings.MemoryMib,
- Port: runtimeSettings.Port,
- ShutdownSignal: db.DeploymentsShutdownSignal(runtimeSettings.ShutdownSignal),
- Healthcheck: runtimeSettings.Healthcheck,
+ return db.Tx(runCtx, s.db.RW(), func(txCtx context.Context, tx db.DBTX) error {
+ err = db.Query.InsertDeployment(txCtx, tx, db.InsertDeploymentParams{
+ ID: deploymentID,
+ K8sName: uid.DNS1035(12),
+ WorkspaceID: project.WorkspaceID,
+ ProjectID: project.ID,
+ AppID: app.ID,
+ EnvironmentID: env.ID,
+ SentinelConfig: runtimeSettings.SentinelConfig,
+ EncryptedEnvironmentVariables: secretsBlob,
+ Command: runtimeSettings.Command,
+ Status: db.DeploymentsStatusPending,
+ CreatedAt: now,
+ UpdatedAt: sql.NullInt64{Valid: false},
+ GitCommitSha: sql.NullString{String: req.GetAfter(), Valid: req.GetAfter() != ""},
+ GitBranch: sql.NullString{String: req.GetBranch(), Valid: req.GetBranch() != ""},
+ GitCommitMessage: sql.NullString{String: commitMessage, Valid: commitMessage != ""},
+ GitCommitAuthorHandle: sql.NullString{String: authorHandle, Valid: authorHandle != ""},
+ GitCommitAuthorAvatarUrl: sql.NullString{String: authorAvatarURL, Valid: authorAvatarURL != ""},
+ GitCommitTimestamp: sql.NullInt64{Int64: commitTimestamp, Valid: commitTimestamp != 0},
+ OpenapiSpec: sql.NullString{Valid: false},
+ CpuMillicores: runtimeSettings.CpuMillicores,
+ MemoryMib: runtimeSettings.MemoryMib,
+ Port: runtimeSettings.Port,
+ ShutdownSignal: db.DeploymentsShutdownSignal(runtimeSettings.ShutdownSignal),
+ Healthcheck: runtimeSettings.Healthcheck,
+ })
+ if err != nil {
+ return err
+ }
+
+ err = db.Query.InsertDeploymentStep(txCtx, tx, db.InsertDeploymentStepParams{
+ WorkspaceID: app.WorkspaceID,
+ ProjectID: app.ProjectID,
+ AppID: app.ID,
+ EnvironmentID: env.ID,
+ DeploymentID: deploymentID,
+ Step: db.DeploymentStepsStepQueued,
+ StartedAt: uint64(now),
+ })
+ if err != nil {
+ return err
+ }
+ return nil
+
})
+
}, restate.WithName("insert deployment"))
if err != nil {
logger.Error("failed to insert deployment", "appId", app.ID, "error", err)
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-progress.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-progress.tsx
index 09c4d7a360..064736e8a9 100644
--- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-progress.tsx
+++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-progress.tsx
@@ -1,7 +1,7 @@
"use client";
import { trpc } from "@/lib/trpc/client";
-import { CloudUp, Earth, Hammer2, LayerFront } from "@unkey/icons";
+import { CloudUp, Earth, Hammer2, LayerFront, Pulse, Sparkle3 } from "@unkey/icons";
import { Button, SettingCardGroup } from "@unkey/ui";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
@@ -51,7 +51,7 @@ export function DeploymentProgress() {
};
}, [isFailed]);
- const { building, deploying, network, queued } = steps.data ?? {};
+ const { building, deploying, network, queued, starting, finalizing } = steps.data ?? {};
const [redeployOpen, setRedeployOpen] = useState(false);
const domainsForDeployment = getDomainsForDeployment(deployment.id);
@@ -80,7 +80,7 @@ export function DeploymentProgress() {
? queued.endedAt
? (queued.error ?? "Deployment has started")
: "Deployment is queued"
- : "Waiting deployment to start"
+ : "Pending"
}
duration={queued ? (queued.endedAt ?? now) - queued.startedAt : undefined}
status={
@@ -93,6 +93,27 @@ export function DeploymentProgress() {
: "pending"
}
/>
+