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" } /> + } + title="Deployment Starting" + description={ + starting + ? starting.endedAt + ? (starting.error ?? "Deployment has started") + : "Deployment has started" + : "Preparing deployment for building" + } + duration={starting ? (starting.endedAt ?? now) - starting.startedAt : undefined} + status={ + starting?.error + ? "error" + : starting?.completed + ? "completed" + : starting + ? "started" + : "pending" + } + /> } @@ -134,7 +155,7 @@ export function DeploymentProgress() { : "Deploying to all machines" : isFailed ? "Skipped" - : "Waiting for build" + : "Pending" } duration={deploying ? (deploying.endedAt ?? now) - deploying.startedAt : undefined} status={ @@ -159,7 +180,7 @@ export function DeploymentProgress() { : "Assigning domains" : isFailed ? "Skipped" - : "Waiting for deployments" + : "Pending" } duration={network ? (network.endedAt ?? now) - network.startedAt : undefined} status={ @@ -174,6 +195,27 @@ export function DeploymentProgress() { : "pending" } /> + } + title="Deployment finalizing" + description={ + finalizing + ? finalizing.endedAt + ? (finalizing.error ?? "Deployment has finished") + : "Finalizing deployment" + : "Pending" + } + duration={finalizing ? (finalizing.endedAt ?? now) - finalizing.startedAt : undefined} + status={ + finalizing?.error + ? "error" + : finalizing?.completed + ? "completed" + : finalizing + ? "started" + : "pending" + } + /> {isFailed && (
@@ -182,8 +224,9 @@ export function DeploymentProgress() {
Deployment failed - {[queued, building, deploying, network].find((s) => s?.error)?.error ?? - "Deployment failed"} + {[queued, starting, building, deploying, network, finalizing].find( + (s) => s?.error, + )?.error ?? "Deployment failed"}
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/deployment-status-badge.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/deployment-status-badge.tsx index 2f6de02bc9..ffeec65b2f 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/deployment-status-badge.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/deployment-status-badge.tsx @@ -29,6 +29,14 @@ const statusConfigs: Record = { textColor: "text-grayA-11", iconColor: "text-gray-11", }, + starting: { + icon: HalfDottedCirclePlay, + label: "Starting", + bgColor: "bg-linear-to-r from-infoA-5 to-transparent", + textColor: "text-infoA-11", + iconColor: "text-info-11", + animated: true, + }, building: { icon: Nut, label: "Building", @@ -53,6 +61,14 @@ const statusConfigs: Record = { iconColor: "text-info-11", animated: true, }, + finalizing: { + icon: Nut, + label: "Finalizing", + bgColor: "bg-linear-to-r from-infoA-5 to-transparent", + textColor: "text-infoA-11", + iconColor: "text-info-11", + animated: true, + }, ready: { icon: CircleCheck, label: "Ready", diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/filters.schema.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/filters.schema.ts index 1f0a21ffd6..afb7bc2807 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/filters.schema.ts +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/filters.schema.ts @@ -9,9 +9,11 @@ import { z } from "zod"; export const DEPLOYMENT_STATUSES = [ "pending", + "starting", "building", "deploying", "network", + "finalizing", "ready", "failed", ] as const; @@ -91,7 +93,7 @@ export const expandGroupedStatus = (groupedStatus: GroupedDeploymentStatus): Dep case "pending": return ["pending"]; case "deploying": - return ["building", "deploying", "network"]; + return ["starting", "building", "deploying", "network", "finalizing"]; case "ready": return ["ready"]; case "failed": diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/deployment-status-badge.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/deployment-status-badge.tsx index 73b9d6f1e0..fefe2e19e3 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/deployment-status-badge.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/deployment-status-badge.tsx @@ -2,7 +2,15 @@ import { CircleWarning } from "@unkey/icons"; import { Badge } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; -type DeploymentStatus = "pending" | "building" | "deploying" | "network" | "ready" | "failed"; +type DeploymentStatus = + | "pending" + | "starting" + | "building" + | "deploying" + | "network" + | "finalizing" + | "ready" + | "failed"; type StatusConfig = { variant: "warning" | "success" | "error" | "secondary"; @@ -14,6 +22,10 @@ const STATUS_CONFIG: Record = { variant: "secondary", text: "Queued", }, + starting: { + variant: "secondary", + text: "Starting", + }, building: { variant: "secondary", text: "Building", @@ -26,6 +38,10 @@ const STATUS_CONFIG: Record = { variant: "secondary", text: "Assigning Domains", }, + finalizing: { + variant: "secondary", + text: "Finalizing", + }, ready: { variant: "success", text: "Ready", diff --git a/web/apps/dashboard/gen/proto/ctrl/v1/deployment_pb.ts b/web/apps/dashboard/gen/proto/ctrl/v1/deployment_pb.ts index 2665786018..45c8bec491 100644 --- a/web/apps/dashboard/gen/proto/ctrl/v1/deployment_pb.ts +++ b/web/apps/dashboard/gen/proto/ctrl/v1/deployment_pb.ts @@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file ctrl/v1/deployment.proto. */ export const file_ctrl_v1_deployment: GenFile = /*@__PURE__*/ - fileDesc("ChhjdHJsL3YxL2RlcGxveW1lbnQucHJvdG8SB2N0cmwudjEi6AEKF0NyZWF0ZURlcGxveW1lbnRSZXF1ZXN0EhIKCnByb2plY3RfaWQYASABKAkSGAoQZW52aXJvbm1lbnRfc2x1ZxgCIAEoCRIUCgxkb2NrZXJfaW1hZ2UYAyABKAkSLwoKZ2l0X2NvbW1pdBgEIAEoCzIWLmN0cmwudjEuR2l0Q29tbWl0SW5mb0gAiAEBEhgKC2tleXNwYWNlX2lkGAUgASgJSAGIAQESDwoHY29tbWFuZBgGIAMoCRIOCgZhcHBfaWQYByABKAlCDQoLX2dpdF9jb21taXRCDgoMX2tleXNwYWNlX2lkIpABCg1HaXRDb21taXRJbmZvEhIKCmNvbW1pdF9zaGEYASABKAkSFgoOY29tbWl0X21lc3NhZ2UYAiABKAkSFQoNYXV0aG9yX2hhbmRsZRgDIAEoCRIZChFhdXRob3JfYXZhdGFyX3VybBgEIAEoCRIRCgl0aW1lc3RhbXAYBSABKAMSDgoGYnJhbmNoGAYgASgJIlwKGENyZWF0ZURlcGxveW1lbnRSZXNwb25zZRIVCg1kZXBsb3ltZW50X2lkGAEgASgJEikKBnN0YXR1cxgCIAEoDjIZLmN0cmwudjEuRGVwbG95bWVudFN0YXR1cyItChRHZXREZXBsb3ltZW50UmVxdWVzdBIVCg1kZXBsb3ltZW50X2lkGAEgASgJIkAKFUdldERlcGxveW1lbnRSZXNwb25zZRInCgpkZXBsb3ltZW50GAEgASgLMhMuY3RybC52MS5EZXBsb3ltZW50IpgFCgpEZXBsb3ltZW50EgoKAmlkGAEgASgJEhQKDHdvcmtzcGFjZV9pZBgCIAEoCRISCgpwcm9qZWN0X2lkGAMgASgJEhYKDmVudmlyb25tZW50X2lkGAQgASgJEg4KBmFwcF9pZBgVIAEoCRIWCg5naXRfY29tbWl0X3NoYRgFIAEoCRISCgpnaXRfYnJhbmNoGAYgASgJEikKBnN0YXR1cxgHIAEoDjIZLmN0cmwudjEuRGVwbG95bWVudFN0YXR1cxIVCg1lcnJvcl9tZXNzYWdlGAggASgJEkwKFWVudmlyb25tZW50X3ZhcmlhYmxlcxgJIAMoCzItLmN0cmwudjEuRGVwbG95bWVudC5FbnZpcm9ubWVudFZhcmlhYmxlc0VudHJ5EiMKCHRvcG9sb2d5GAogASgLMhEuY3RybC52MS5Ub3BvbG9neRISCgpjcmVhdGVkX2F0GAsgASgDEhIKCnVwZGF0ZWRfYXQYDCABKAMSEQoJaG9zdG5hbWVzGA0gAygJEhcKD3Jvb3Rmc19pbWFnZV9pZBgOIAEoCRIQCghidWlsZF9pZBgPIAEoCRImCgVzdGVwcxgQIAMoCzIXLmN0cmwudjEuRGVwbG95bWVudFN0ZXASGgoSZ2l0X2NvbW1pdF9tZXNzYWdlGBEgASgJEiAKGGdpdF9jb21taXRfYXV0aG9yX2hhbmRsZRgSIAEoCRIkChxnaXRfY29tbWl0X2F1dGhvcl9hdmF0YXJfdXJsGBMgASgJEhwKFGdpdF9jb21taXRfdGltZXN0YW1wGBQgASgDGjsKGUVudmlyb25tZW50VmFyaWFibGVzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASJcCg5EZXBsb3ltZW50U3RlcBIOCgZzdGF0dXMYASABKAkSDwoHbWVzc2FnZRgCIAEoCRIVCg1lcnJvcl9tZXNzYWdlGAMgASgJEhIKCmNyZWF0ZWRfYXQYBCABKAMipwEKCFRvcG9sb2d5EhYKDmNwdV9taWxsaWNvcmVzGAEgASgFEhIKCm1lbW9yeV9taWIYAiABKAUSKAoHcmVnaW9ucxgDIAMoCzIXLmN0cmwudjEuUmVnaW9uYWxDb25maWcSHAoUaWRsZV90aW1lb3V0X3NlY29uZHMYBCABKAUSGQoRaGVhbHRoX2NoZWNrX3BhdGgYBSABKAkSDAoEcG9ydBgGIAEoBSJOCg5SZWdpb25hbENvbmZpZxIOCgZyZWdpb24YASABKAkSFQoNbWluX2luc3RhbmNlcxgCIAEoBRIVCg1tYXhfaW5zdGFuY2VzGAMgASgFIk0KD1JvbGxiYWNrUmVxdWVzdBIcChRzb3VyY2VfZGVwbG95bWVudF9pZBgBIAEoCRIcChR0YXJnZXRfZGVwbG95bWVudF9pZBgCIAEoCSISChBSb2xsYmFja1Jlc3BvbnNlIi4KDlByb21vdGVSZXF1ZXN0EhwKFHRhcmdldF9kZXBsb3ltZW50X2lkGAEgASgJIhEKD1Byb21vdGVSZXNwb25zZSrvAQoQRGVwbG95bWVudFN0YXR1cxIhCh1ERVBMT1lNRU5UX1NUQVRVU19VTlNQRUNJRklFRBAAEh0KGURFUExPWU1FTlRfU1RBVFVTX1BFTkRJTkcQARIeChpERVBMT1lNRU5UX1NUQVRVU19CVUlMRElORxACEh8KG0RFUExPWU1FTlRfU1RBVFVTX0RFUExPWUlORxADEh0KGURFUExPWU1FTlRfU1RBVFVTX05FVFdPUksQBBIbChdERVBMT1lNRU5UX1NUQVRVU19SRUFEWRAFEhwKGERFUExPWU1FTlRfU1RBVFVTX0ZBSUxFRBAGKloKClNvdXJjZVR5cGUSGwoXU09VUkNFX1RZUEVfVU5TUEVDSUZJRUQQABITCg9TT1VSQ0VfVFlQRV9HSVQQARIaChZTT1VSQ0VfVFlQRV9DTElfVVBMT0FEEAIyvwIKDURlcGxveVNlcnZpY2USWQoQQ3JlYXRlRGVwbG95bWVudBIgLmN0cmwudjEuQ3JlYXRlRGVwbG95bWVudFJlcXVlc3QaIS5jdHJsLnYxLkNyZWF0ZURlcGxveW1lbnRSZXNwb25zZSIAElAKDUdldERlcGxveW1lbnQSHS5jdHJsLnYxLkdldERlcGxveW1lbnRSZXF1ZXN0Gh4uY3RybC52MS5HZXREZXBsb3ltZW50UmVzcG9uc2UiABJBCghSb2xsYmFjaxIYLmN0cmwudjEuUm9sbGJhY2tSZXF1ZXN0GhkuY3RybC52MS5Sb2xsYmFja1Jlc3BvbnNlIgASPgoHUHJvbW90ZRIXLmN0cmwudjEuUHJvbW90ZVJlcXVlc3QaGC5jdHJsLnYxLlByb21vdGVSZXNwb25zZSIAQo4BCgtjb20uY3RybC52MUIPRGVwbG95bWVudFByb3RvUAFaMWdpdGh1Yi5jb20vdW5rZXllZC91bmtleS9nZW4vcHJvdG8vY3RybC92MTtjdHJsdjGiAgNDWFiqAgdDdHJsLlYxygIHQ3RybFxWMeICE0N0cmxcVjFcR1BCTWV0YWRhdGHqAghDdHJsOjpWMWIGcHJvdG8z"); + fileDesc("ChhjdHJsL3YxL2RlcGxveW1lbnQucHJvdG8SB2N0cmwudjEi6AEKF0NyZWF0ZURlcGxveW1lbnRSZXF1ZXN0EhIKCnByb2plY3RfaWQYASABKAkSGAoQZW52aXJvbm1lbnRfc2x1ZxgCIAEoCRIUCgxkb2NrZXJfaW1hZ2UYAyABKAkSLwoKZ2l0X2NvbW1pdBgEIAEoCzIWLmN0cmwudjEuR2l0Q29tbWl0SW5mb0gAiAEBEhgKC2tleXNwYWNlX2lkGAUgASgJSAGIAQESDwoHY29tbWFuZBgGIAMoCRIOCgZhcHBfaWQYByABKAlCDQoLX2dpdF9jb21taXRCDgoMX2tleXNwYWNlX2lkIpABCg1HaXRDb21taXRJbmZvEhIKCmNvbW1pdF9zaGEYASABKAkSFgoOY29tbWl0X21lc3NhZ2UYAiABKAkSFQoNYXV0aG9yX2hhbmRsZRgDIAEoCRIZChFhdXRob3JfYXZhdGFyX3VybBgEIAEoCRIRCgl0aW1lc3RhbXAYBSABKAMSDgoGYnJhbmNoGAYgASgJIlwKGENyZWF0ZURlcGxveW1lbnRSZXNwb25zZRIVCg1kZXBsb3ltZW50X2lkGAEgASgJEikKBnN0YXR1cxgCIAEoDjIZLmN0cmwudjEuRGVwbG95bWVudFN0YXR1cyItChRHZXREZXBsb3ltZW50UmVxdWVzdBIVCg1kZXBsb3ltZW50X2lkGAEgASgJIkAKFUdldERlcGxveW1lbnRSZXNwb25zZRInCgpkZXBsb3ltZW50GAEgASgLMhMuY3RybC52MS5EZXBsb3ltZW50IpgFCgpEZXBsb3ltZW50EgoKAmlkGAEgASgJEhQKDHdvcmtzcGFjZV9pZBgCIAEoCRISCgpwcm9qZWN0X2lkGAMgASgJEhYKDmVudmlyb25tZW50X2lkGAQgASgJEg4KBmFwcF9pZBgVIAEoCRIWCg5naXRfY29tbWl0X3NoYRgFIAEoCRISCgpnaXRfYnJhbmNoGAYgASgJEikKBnN0YXR1cxgHIAEoDjIZLmN0cmwudjEuRGVwbG95bWVudFN0YXR1cxIVCg1lcnJvcl9tZXNzYWdlGAggASgJEkwKFWVudmlyb25tZW50X3ZhcmlhYmxlcxgJIAMoCzItLmN0cmwudjEuRGVwbG95bWVudC5FbnZpcm9ubWVudFZhcmlhYmxlc0VudHJ5EiMKCHRvcG9sb2d5GAogASgLMhEuY3RybC52MS5Ub3BvbG9neRISCgpjcmVhdGVkX2F0GAsgASgDEhIKCnVwZGF0ZWRfYXQYDCABKAMSEQoJaG9zdG5hbWVzGA0gAygJEhcKD3Jvb3Rmc19pbWFnZV9pZBgOIAEoCRIQCghidWlsZF9pZBgPIAEoCRImCgVzdGVwcxgQIAMoCzIXLmN0cmwudjEuRGVwbG95bWVudFN0ZXASGgoSZ2l0X2NvbW1pdF9tZXNzYWdlGBEgASgJEiAKGGdpdF9jb21taXRfYXV0aG9yX2hhbmRsZRgSIAEoCRIkChxnaXRfY29tbWl0X2F1dGhvcl9hdmF0YXJfdXJsGBMgASgJEhwKFGdpdF9jb21taXRfdGltZXN0YW1wGBQgASgDGjsKGUVudmlyb25tZW50VmFyaWFibGVzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASJcCg5EZXBsb3ltZW50U3RlcBIOCgZzdGF0dXMYASABKAkSDwoHbWVzc2FnZRgCIAEoCRIVCg1lcnJvcl9tZXNzYWdlGAMgASgJEhIKCmNyZWF0ZWRfYXQYBCABKAMipwEKCFRvcG9sb2d5EhYKDmNwdV9taWxsaWNvcmVzGAEgASgFEhIKCm1lbW9yeV9taWIYAiABKAUSKAoHcmVnaW9ucxgDIAMoCzIXLmN0cmwudjEuUmVnaW9uYWxDb25maWcSHAoUaWRsZV90aW1lb3V0X3NlY29uZHMYBCABKAUSGQoRaGVhbHRoX2NoZWNrX3BhdGgYBSABKAkSDAoEcG9ydBgGIAEoBSJOCg5SZWdpb25hbENvbmZpZxIOCgZyZWdpb24YASABKAkSFQoNbWluX2luc3RhbmNlcxgCIAEoBRIVCg1tYXhfaW5zdGFuY2VzGAMgASgFIk0KD1JvbGxiYWNrUmVxdWVzdBIcChRzb3VyY2VfZGVwbG95bWVudF9pZBgBIAEoCRIcChR0YXJnZXRfZGVwbG95bWVudF9pZBgCIAEoCSISChBSb2xsYmFja1Jlc3BvbnNlIi4KDlByb21vdGVSZXF1ZXN0EhwKFHRhcmdldF9kZXBsb3ltZW50X2lkGAEgASgJIhEKD1Byb21vdGVSZXNwb25zZSqxAgoQRGVwbG95bWVudFN0YXR1cxIhCh1ERVBMT1lNRU5UX1NUQVRVU19VTlNQRUNJRklFRBAAEh0KGURFUExPWU1FTlRfU1RBVFVTX1BFTkRJTkcQARIeChpERVBMT1lNRU5UX1NUQVRVU19TVEFSVElORxAHEh4KGkRFUExPWU1FTlRfU1RBVFVTX0JVSUxESU5HEAISHwobREVQTE9ZTUVOVF9TVEFUVVNfREVQTE9ZSU5HEAMSHQoZREVQTE9ZTUVOVF9TVEFUVVNfTkVUV09SSxAEEiAKHERFUExPWU1FTlRfU1RBVFVTX0ZJTkFMSVpJTkcQCBIbChdERVBMT1lNRU5UX1NUQVRVU19SRUFEWRAFEhwKGERFUExPWU1FTlRfU1RBVFVTX0ZBSUxFRBAGKloKClNvdXJjZVR5cGUSGwoXU09VUkNFX1RZUEVfVU5TUEVDSUZJRUQQABITCg9TT1VSQ0VfVFlQRV9HSVQQARIaChZTT1VSQ0VfVFlQRV9DTElfVVBMT0FEEAIyvwIKDURlcGxveVNlcnZpY2USWQoQQ3JlYXRlRGVwbG95bWVudBIgLmN0cmwudjEuQ3JlYXRlRGVwbG95bWVudFJlcXVlc3QaIS5jdHJsLnYxLkNyZWF0ZURlcGxveW1lbnRSZXNwb25zZSIAElAKDUdldERlcGxveW1lbnQSHS5jdHJsLnYxLkdldERlcGxveW1lbnRSZXF1ZXN0Gh4uY3RybC52MS5HZXREZXBsb3ltZW50UmVzcG9uc2UiABJBCghSb2xsYmFjaxIYLmN0cmwudjEuUm9sbGJhY2tSZXF1ZXN0GhkuY3RybC52MS5Sb2xsYmFja1Jlc3BvbnNlIgASPgoHUHJvbW90ZRIXLmN0cmwudjEuUHJvbW90ZVJlcXVlc3QaGC5jdHJsLnYxLlByb21vdGVSZXNwb25zZSIAQo4BCgtjb20uY3RybC52MUIPRGVwbG95bWVudFByb3RvUAFaMWdpdGh1Yi5jb20vdW5rZXllZC91bmtleS9nZW4vcHJvdG8vY3RybC92MTtjdHJsdjGiAgNDWFiqAgdDdHJsLlYxygIHQ3RybFxWMeICE0N0cmxcVjFcR1BCTWV0YWRhdGHqAghDdHJsOjpWMWIGcHJvdG8z"); /** * @generated from message ctrl.v1.CreateDeploymentRequest @@ -502,6 +502,11 @@ export enum DeploymentStatus { */ PENDING = 1, + /** + * @generated from enum value: DEPLOYMENT_STATUS_STARTING = 7; + */ + STARTING = 7, + /** * @generated from enum value: DEPLOYMENT_STATUS_BUILDING = 2; */ @@ -517,6 +522,11 @@ export enum DeploymentStatus { */ NETWORK = 4, + /** + * @generated from enum value: DEPLOYMENT_STATUS_FINALIZING = 8; + */ + FINALIZING = 8, + /** * @generated from enum value: DEPLOYMENT_STATUS_READY = 5; */ diff --git a/web/apps/dashboard/lib/collections/deploy/deployments.ts b/web/apps/dashboard/lib/collections/deploy/deployments.ts index 576333897d..53422f6ddc 100644 --- a/web/apps/dashboard/lib/collections/deploy/deployments.ts +++ b/web/apps/dashboard/lib/collections/deploy/deployments.ts @@ -19,7 +19,16 @@ const schema = z.object({ // OpenAPI hasOpenApiSpec: z.boolean(), // Deployment status - status: z.enum(["pending", "building", "deploying", "network", "ready", "failed"]), + status: z.enum([ + "pending", + "starting", + "building", + "deploying", + "network", + "finalizing", + "ready", + "failed", + ]), instances: z.array( z.object({ id: z.string(), diff --git a/web/internal/db/src/schema/deployment_steps.ts b/web/internal/db/src/schema/deployment_steps.ts index 00482d7401..e97a265f01 100644 --- a/web/internal/db/src/schema/deployment_steps.ts +++ b/web/internal/db/src/schema/deployment_steps.ts @@ -15,7 +15,14 @@ export const deploymentSteps = mysqlTable( deploymentId: varchar("deployment_id", { length: 128 }).notNull(), appId: varchar("app_id", { length: 64 }).notNull(), - step: mysqlEnum("step", ["queued", "building", "deploying", "network"]) + step: mysqlEnum("step", [ + "queued", + "starting", + "building", + "deploying", + "network", + "finalizing", + ]) .notNull() .default("queued"), diff --git a/web/internal/db/src/schema/deployments.ts b/web/internal/db/src/schema/deployments.ts index f95badf47a..ea68061890 100644 --- a/web/internal/db/src/schema/deployments.ts +++ b/web/internal/db/src/schema/deployments.ts @@ -81,7 +81,16 @@ export const deployments = mysqlTable( healthcheck: json("healthcheck").$type(), // Deployment status - status: mysqlEnum("status", ["pending", "building", "deploying", "network", "ready", "failed"]) + status: mysqlEnum("status", [ + "pending", + "starting", + "building", + "deploying", + "network", + "finalizing", + "ready", + "failed", + ]) .notNull() .default("pending"), ...lifecycleDates,