From 7e8df6b2045babaeef6c402aaf39dcd4060fc3ec Mon Sep 17 00:00:00 2001
From: bbedward
Date: Thu, 15 Oct 2026 14:48:45 +0000
Subject: [PATCH] api | operator | web | docs: add rollout strategy for
deployments
Replaces single_instance boolean with rollout enum. rolling (default) starts the new version alongside the old; stop-first stops the old before starting the new, for apps that can't run concurrently like bots or wallets. Database migration defaults to rolling for backward compatibility.
---
.../migrations/20261015133505_rollout.sql | 7 +
apps/api/ent/migrate/migrations/atlas.sum | 3 +-
apps/api/ent/migrate/schema.go | 5 +-
apps/api/ent/mutation.go | 56 ++-
apps/api/ent/runtime.go | 6 +-
apps/api/ent/schema/service_config.go | 1 +
apps/api/ent/schema/service_config_types.go | 34 ++
apps/api/ent/serviceconfig.go | 13 +-
apps/api/ent/serviceconfig/serviceconfig.go | 20 +
apps/api/ent/serviceconfig/where.go | 30 ++
apps/api/ent/serviceconfig_create.go | 70 +++
apps/api/ent/serviceconfig_update.go | 44 ++
apps/api/internal/mcpserver/mcpserver.go | 2 +-
.../models/service_config_response.go | 2 +
apps/api/internal/models/service_input.go | 2 +
.../repositories/service/mutations.go | 3 +
.../internal/repositories/service/queries.go | 2 +
.../repositories/service/runtime_config.go | 9 +
.../service/runtime_config_test.go | 9 +
apps/api/internal/services/service/create.go | 4 +
apps/api/internal/services/service/rollout.go | 1 +
.../internal/services/service/rollout_test.go | 3 +
apps/api/internal/services/service/update.go | 13 +
.../(platform)/services/deployments.mdx | 2 +
.../content/(platform)/services/domains.mdx | 10 +-
.../services/replicas-and-health.mdx | 11 +
.../content/(platform)/services/volumes.mdx | 2 +
apps/docs/generated/openapi.gen.yaml | 16 +
apps/operator/api/v1/service_types.go | 10 +
.../crd/bases/unbind.unbind.app_services.yaml | 7 +
.../crd/unbind.unbind.app_services.yaml | 75 +++
.../internal/resourcebuilder/deployment.go | 4 +-
.../resourcebuilder/deployment_test.go | 22 +
apps/web/src/components/block.tsx | 5 +
.../components/deployment/deployment-card.tsx | 10 +-
.../deployment/deployment-status-chip.tsx | 37 +-
.../advanced-settings-button.tsx | 0
.../src/components/service/cron/cron-runs.tsx | 189 ++++---
.../settings/sections/deploy-section.tsx | 373 +++++++++-----
.../settings/sections/health-section.tsx | 6 +-
.../_components/domain-options-fields.tsx | 476 +++++++++---------
.../networking/networking-section.tsx | 9 +-
.../deployed/settings/use-service-changes.ts | 1 +
.../service-types/undeployed-docker-image.tsx | 2 +-
.../service-types/undeployed-git.tsx | 2 +-
.../setting-notice.tsx} | 4 +-
.../src/components/settings/settings-ids.ts | 1 +
.../components/settings/settings-search.ts | 14 +
.../staged-changes/payload.node-test.ts | 8 +
.../src/components/staged-changes/types.ts | 1 +
.../components/team/create-team-dialog.tsx | 4 +-
.../settings/members/members-tab-content.tsx | 2 +-
.../src/lib/queries/update-service-input.ts | 6 +
apps/web/src/lib/server/client.gen.ts | 6 +
54 files changed, 1166 insertions(+), 488 deletions(-)
create mode 100644 apps/api/ent/migrate/migrations/20261015133505_rollout.sql
rename apps/web/src/components/service/{panel/content/undeployed/service-types => }/advanced-settings-button.tsx (100%)
rename apps/web/src/components/{service/cron/cron-schedule-notice.tsx => settings/setting-notice.tsx} (81%)
diff --git a/apps/api/ent/migrate/migrations/20261015133505_rollout.sql b/apps/api/ent/migrate/migrations/20261015133505_rollout.sql
new file mode 100644
index 000000000..383915b33
--- /dev/null
+++ b/apps/api/ent/migrate/migrations/20261015133505_rollout.sql
@@ -0,0 +1,7 @@
+-- +goose Up
+-- modify "service_configs" table
+ALTER TABLE "service_configs" ADD COLUMN "rollout" character varying NOT NULL DEFAULT 'rolling';
+
+-- +goose Down
+-- reverse: modify "service_configs" table
+ALTER TABLE "service_configs" DROP COLUMN "rollout";
diff --git a/apps/api/ent/migrate/migrations/atlas.sum b/apps/api/ent/migrate/migrations/atlas.sum
index bad362962..8bb3969eb 100644
--- a/apps/api/ent/migrate/migrations/atlas.sum
+++ b/apps/api/ent/migrate/migrations/atlas.sum
@@ -1,4 +1,4 @@
-h1:nFUTWScufO1T6gaYOKn5Qt4r+0paDcwJiF/+LDsDM8s=
+h1:65UApAk8j0+DxhB/htSahwHu8dqBAzw0dWDxDH3ZQGA=
20250519010757_initial_migration.sql h1:94lMwKemoNX/ichD+2Vzb7GmOHXVj4qVTfeBInQAe0g=
20250519163449_add_init_containers.sql h1:7bt+zCbtmlYr1QDztgka0R5wUxdjD7XYUkrhL9GYYIQ=
20250521202532_non_nillable_kubernetes_secret.sql h1:eDpMWyeBXh5cG4poavaUMeYs5QXddFBBIyYlxc+nq64=
@@ -43,3 +43,4 @@ h1:nFUTWScufO1T6gaYOKn5Qt4r+0paDcwJiF/+LDsDM8s=
20261013141524_service_stopped.sql h1:SF0LMABvT1NKaa/uRbydNSBvqh4jKo4CVkZVjnGuKfE=
20261013164138_cron_schedule.sql h1:b+ANbYZXwCko/4diPsulL6mabMsF5+7soU4lxwENlMk=
20261013182619_service_client_ip_header.sql h1:y0X8oYXovXLj3Ko0uIXTCob/LHfdl5aCGAOMYHd1AUI=
+20261015133505_rollout.sql h1:XojFA6OkWm6QLDihXjqRTvCSO9alvWluFHNvBW82AUA=
diff --git a/apps/api/ent/migrate/schema.go b/apps/api/ent/migrate/schema.go
index 047da94bf..6504adc5c 100644
--- a/apps/api/ent/migrate/schema.go
+++ b/apps/api/ent/migrate/schema.go
@@ -711,6 +711,7 @@ var (
{Name: "is_public", Type: field.TypeBool, Default: false},
{Name: "max_request_body_size_mb", Type: field.TypeInt32, Nullable: true},
{Name: "session_affinity", Type: field.TypeBool, Default: false},
+ {Name: "rollout", Type: field.TypeEnum, Enums: []string{"rolling", "stop-first"}, Default: "rolling"},
{Name: "client_ip_header", Type: field.TypeString, Nullable: true},
{Name: "stopped", Type: field.TypeBool, Default: false},
{Name: "cron_schedule", Type: field.TypeString, Nullable: true},
@@ -739,13 +740,13 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "service_configs_s3_buckets_service_backup_configs",
- Columns: []*schema.Column{ServiceConfigsColumns[39]},
+ Columns: []*schema.Column{ServiceConfigsColumns[40]},
RefColumns: []*schema.Column{S3BucketsColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "service_configs_services_service_config",
- Columns: []*schema.Column{ServiceConfigsColumns[40]},
+ Columns: []*schema.Column{ServiceConfigsColumns[41]},
RefColumns: []*schema.Column{ServicesColumns[0]},
OnDelete: schema.Cascade,
},
diff --git a/apps/api/ent/mutation.go b/apps/api/ent/mutation.go
index bbc45d129..b109c718f 100644
--- a/apps/api/ent/mutation.go
+++ b/apps/api/ent/mutation.go
@@ -20132,6 +20132,7 @@ type ServiceConfigMutation struct {
max_request_body_size_mb *int32
addmax_request_body_size_mb *int32
session_affinity *bool
+ rollout *schema.Rollout
client_ip_header *string
stopped *bool
cron_schedule *string
@@ -21318,6 +21319,42 @@ func (m *ServiceConfigMutation) ResetSessionAffinity() {
m.session_affinity = nil
}
+// SetRollout sets the "rollout" field.
+func (m *ServiceConfigMutation) SetRollout(s schema.Rollout) {
+ m.rollout = &s
+}
+
+// Rollout returns the value of the "rollout" field in the mutation.
+func (m *ServiceConfigMutation) Rollout() (r schema.Rollout, exists bool) {
+ v := m.rollout
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldRollout returns the old "rollout" field's value of the ServiceConfig entity.
+// If the ServiceConfig object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *ServiceConfigMutation) OldRollout(ctx context.Context) (v schema.Rollout, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldRollout is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldRollout requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldRollout: %w", err)
+ }
+ return oldValue.Rollout, nil
+}
+
+// ResetRollout resets all changes to the "rollout" field.
+func (m *ServiceConfigMutation) ResetRollout() {
+ m.rollout = nil
+}
+
// SetClientIPHeader sets the "client_ip_header" field.
func (m *ServiceConfigMutation) SetClientIPHeader(s string) {
m.client_ip_header = &s
@@ -22333,7 +22370,7 @@ func (m *ServiceConfigMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *ServiceConfigMutation) Fields() []string {
- fields := make([]string, 0, 40)
+ fields := make([]string, 0, 41)
if m.created_at != nil {
fields = append(fields, serviceconfig.FieldCreatedAt)
}
@@ -22400,6 +22437,9 @@ func (m *ServiceConfigMutation) Fields() []string {
if m.session_affinity != nil {
fields = append(fields, serviceconfig.FieldSessionAffinity)
}
+ if m.rollout != nil {
+ fields = append(fields, serviceconfig.FieldRollout)
+ }
if m.client_ip_header != nil {
fields = append(fields, serviceconfig.FieldClientIPHeader)
}
@@ -22506,6 +22546,8 @@ func (m *ServiceConfigMutation) Field(name string) (ent.Value, bool) {
return m.MaxRequestBodySizeMB()
case serviceconfig.FieldSessionAffinity:
return m.SessionAffinity()
+ case serviceconfig.FieldRollout:
+ return m.Rollout()
case serviceconfig.FieldClientIPHeader:
return m.ClientIPHeader()
case serviceconfig.FieldStopped:
@@ -22595,6 +22637,8 @@ func (m *ServiceConfigMutation) OldField(ctx context.Context, name string) (ent.
return m.OldMaxRequestBodySizeMB(ctx)
case serviceconfig.FieldSessionAffinity:
return m.OldSessionAffinity(ctx)
+ case serviceconfig.FieldRollout:
+ return m.OldRollout(ctx)
case serviceconfig.FieldClientIPHeader:
return m.OldClientIPHeader(ctx)
case serviceconfig.FieldStopped:
@@ -22794,6 +22838,13 @@ func (m *ServiceConfigMutation) SetField(name string, value ent.Value) error {
}
m.SetSessionAffinity(v)
return nil
+ case serviceconfig.FieldRollout:
+ v, ok := value.(schema.Rollout)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetRollout(v)
+ return nil
case serviceconfig.FieldClientIPHeader:
v, ok := value.(string)
if !ok {
@@ -23245,6 +23296,9 @@ func (m *ServiceConfigMutation) ResetField(name string) error {
case serviceconfig.FieldSessionAffinity:
m.ResetSessionAffinity()
return nil
+ case serviceconfig.FieldRollout:
+ m.ResetRollout()
+ return nil
case serviceconfig.FieldClientIPHeader:
m.ResetClientIPHeader()
return nil
diff --git a/apps/api/ent/runtime.go b/apps/api/ent/runtime.go
index 423276e38..f34732a5d 100644
--- a/apps/api/ent/runtime.go
+++ b/apps/api/ent/runtime.go
@@ -558,15 +558,15 @@ func init() {
// serviceconfig.DefaultSessionAffinity holds the default value on creation for the session_affinity field.
serviceconfig.DefaultSessionAffinity = serviceconfigDescSessionAffinity.Default.(bool)
// serviceconfigDescStopped is the schema descriptor for stopped field.
- serviceconfigDescStopped := serviceconfigFields[21].Descriptor()
+ serviceconfigDescStopped := serviceconfigFields[22].Descriptor()
// serviceconfig.DefaultStopped holds the default value on creation for the stopped field.
serviceconfig.DefaultStopped = serviceconfigDescStopped.Default.(bool)
// serviceconfigDescBackupSchedule is the schema descriptor for backup_schedule field.
- serviceconfigDescBackupSchedule := serviceconfigFields[28].Descriptor()
+ serviceconfigDescBackupSchedule := serviceconfigFields[29].Descriptor()
// serviceconfig.DefaultBackupSchedule holds the default value on creation for the backup_schedule field.
serviceconfig.DefaultBackupSchedule = serviceconfigDescBackupSchedule.Default.(string)
// serviceconfigDescBackupRetentionCount is the schema descriptor for backup_retention_count field.
- serviceconfigDescBackupRetentionCount := serviceconfigFields[29].Descriptor()
+ serviceconfigDescBackupRetentionCount := serviceconfigFields[30].Descriptor()
// serviceconfig.DefaultBackupRetentionCount holds the default value on creation for the backup_retention_count field.
serviceconfig.DefaultBackupRetentionCount = serviceconfigDescBackupRetentionCount.Default.(int)
// serviceconfigDescID is the schema descriptor for id field.
diff --git a/apps/api/ent/schema/service_config.go b/apps/api/ent/schema/service_config.go
index 1a78921cf..6d30878ea 100644
--- a/apps/api/ent/schema/service_config.go
+++ b/apps/api/ent/schema/service_config.go
@@ -51,6 +51,7 @@ func (ServiceConfig) Fields() []ent.Field {
field.Bool("is_public").Default(false).Comment("Whether the service is publicly accessible, creates an ingress resource"),
field.Int32("max_request_body_size_mb").Optional().Nillable().Comment("Largest request body public domains accept in MB, unset uses the default of 100"),
field.Bool("session_affinity").Default(false).Comment("Whether public domains pin each visitor to one replica with a cookie"),
+ field.Enum("rollout").GoType(Rollout("")).Default(string(RolloutRolling)).Comment("How a deployment replaces the running version: rolling starts the new one next to the old one, stop-first stops the old one before the new one starts"),
field.String("client_ip_header").Optional().Nillable().Comment("Header a proxy in front of the service carries the visitor's address in, unset picks Cloudflare's when a domain is proxied through it"),
field.Bool("stopped").Default(false).Comment("Whether the service is stopped and runs no replicas"),
field.String("cron_schedule").Optional().Nillable().Comment("Cron expression. When set the service runs on this schedule instead of continuously"),
diff --git a/apps/api/ent/schema/service_config_types.go b/apps/api/ent/schema/service_config_types.go
index 9c2133b11..4bd87f5ba 100644
--- a/apps/api/ent/schema/service_config_types.go
+++ b/apps/api/ent/schema/service_config_types.go
@@ -822,6 +822,40 @@ func intOrZero(value *int) int {
//* Enums
// Builder enum
+// Rollout is how a deployment replaces the running version
+type Rollout string
+
+const (
+ // RolloutRolling starts the new version next to the old one and stops the old one once the new one is ready
+ RolloutRolling Rollout = "rolling"
+ // RolloutStopFirst stops the old version before the new one starts, so the two never run at once
+ RolloutStopFirst Rollout = "stop-first"
+)
+
+var allRollouts = []Rollout{RolloutRolling, RolloutStopFirst}
+
+// Values provides list valid values for Enum.
+func (r Rollout) Values() (kinds []string) {
+ for _, r := range allRollouts {
+ kinds = append(kinds, string(r))
+ }
+ return
+}
+
+// Register enum in OpenAPI specification
+// https://github.com/danielgtaylor/huma/issues/621
+func (r Rollout) Schema(reg huma.Registry) *huma.Schema {
+ if reg.Map()["Rollout"] == nil {
+ schemaRef := reg.Schema(reflect.TypeOf(""), true, "Rollout")
+ schemaRef.Title = "Rollout"
+ for _, v := range allRollouts {
+ schemaRef.Enum = append(schemaRef.Enum, string(v))
+ }
+ reg.Map()["Rollout"] = schemaRef
+ }
+ return &huma.Schema{Ref: "#/components/schemas/Rollout"}
+}
+
type ServiceBuilder string
const (
diff --git a/apps/api/ent/serviceconfig.go b/apps/api/ent/serviceconfig.go
index 9aaf49a2e..419ed1ac7 100644
--- a/apps/api/ent/serviceconfig.go
+++ b/apps/api/ent/serviceconfig.go
@@ -68,6 +68,8 @@ type ServiceConfig struct {
MaxRequestBodySizeMB *int32 `json:"max_request_body_size_mb,omitempty"`
// Whether public domains pin each visitor to one replica with a cookie
SessionAffinity bool `json:"session_affinity,omitempty"`
+ // How a deployment replaces the running version: rolling starts the new one next to the old one, stop-first stops the old one before the new one starts
+ Rollout schema.Rollout `json:"rollout,omitempty"`
// Header a proxy in front of the service carries the visitor's address in, unset picks Cloudflare's when a domain is proxied through it
ClientIPHeader *string `json:"client_ip_header,omitempty"`
// Whether the service is stopped and runs no replicas
@@ -156,7 +158,7 @@ func (*ServiceConfig) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullBool)
case serviceconfig.FieldReplicas, serviceconfig.FieldMaxRequestBodySizeMB, serviceconfig.FieldBackupRetentionCount:
values[i] = new(sql.NullInt64)
- case serviceconfig.FieldBuilder, serviceconfig.FieldIcon, serviceconfig.FieldDockerBuilderDockerfilePath, serviceconfig.FieldDockerBuilderBuildContext, serviceconfig.FieldRailpackProvider, serviceconfig.FieldRailpackFramework, serviceconfig.FieldGitBranch, serviceconfig.FieldGitTag, serviceconfig.FieldRailpackBuilderInstallCommand, serviceconfig.FieldRailpackBuilderBuildCommand, serviceconfig.FieldRunCommand, serviceconfig.FieldClientIPHeader, serviceconfig.FieldCronSchedule, serviceconfig.FieldCronTimezone, serviceconfig.FieldImage, serviceconfig.FieldDefinitionVersion, serviceconfig.FieldBackupSchedule:
+ case serviceconfig.FieldBuilder, serviceconfig.FieldIcon, serviceconfig.FieldDockerBuilderDockerfilePath, serviceconfig.FieldDockerBuilderBuildContext, serviceconfig.FieldRailpackProvider, serviceconfig.FieldRailpackFramework, serviceconfig.FieldGitBranch, serviceconfig.FieldGitTag, serviceconfig.FieldRailpackBuilderInstallCommand, serviceconfig.FieldRailpackBuilderBuildCommand, serviceconfig.FieldRunCommand, serviceconfig.FieldRollout, serviceconfig.FieldClientIPHeader, serviceconfig.FieldCronSchedule, serviceconfig.FieldCronTimezone, serviceconfig.FieldImage, serviceconfig.FieldDefinitionVersion, serviceconfig.FieldBackupSchedule:
values[i] = new(sql.NullString)
case serviceconfig.FieldCreatedAt, serviceconfig.FieldUpdatedAt:
values[i] = new(sql.NullTime)
@@ -331,6 +333,12 @@ func (_m *ServiceConfig) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.SessionAffinity = value.Bool
}
+ case serviceconfig.FieldRollout:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field rollout", values[i])
+ } else if value.Valid {
+ _m.Rollout = schema.Rollout(value.String)
+ }
case serviceconfig.FieldClientIPHeader:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field client_ip_header", values[i])
@@ -594,6 +602,9 @@ func (_m *ServiceConfig) String() string {
builder.WriteString("session_affinity=")
builder.WriteString(fmt.Sprintf("%v", _m.SessionAffinity))
builder.WriteString(", ")
+ builder.WriteString("rollout=")
+ builder.WriteString(fmt.Sprintf("%v", _m.Rollout))
+ builder.WriteString(", ")
if v := _m.ClientIPHeader; v != nil {
builder.WriteString("client_ip_header=")
builder.WriteString(*v)
diff --git a/apps/api/ent/serviceconfig/serviceconfig.go b/apps/api/ent/serviceconfig/serviceconfig.go
index 2e25cc3cb..c3c0b4764 100644
--- a/apps/api/ent/serviceconfig/serviceconfig.go
+++ b/apps/api/ent/serviceconfig/serviceconfig.go
@@ -62,6 +62,8 @@ const (
FieldMaxRequestBodySizeMB = "max_request_body_size_mb"
// FieldSessionAffinity holds the string denoting the session_affinity field in the database.
FieldSessionAffinity = "session_affinity"
+ // FieldRollout holds the string denoting the rollout field in the database.
+ FieldRollout = "rollout"
// FieldClientIPHeader holds the string denoting the client_ip_header field in the database.
FieldClientIPHeader = "client_ip_header"
// FieldStopped holds the string denoting the stopped field in the database.
@@ -145,6 +147,7 @@ var Columns = []string{
FieldIsPublic,
FieldMaxRequestBodySizeMB,
FieldSessionAffinity,
+ FieldRollout,
FieldClientIPHeader,
FieldStopped,
FieldCronSchedule,
@@ -230,6 +233,18 @@ func RailpackFrameworkValidator(rf enum.Framework) error {
}
}
+const DefaultRollout schema.Rollout = "rolling"
+
+// RolloutValidator is a validator for the "rollout" field enum values. It is called by the builders before save.
+func RolloutValidator(r schema.Rollout) error {
+ switch r {
+ case "rolling", "stop-first":
+ return nil
+ default:
+ return fmt.Errorf("serviceconfig: invalid enum value for rollout field: %q", r)
+ }
+}
+
// OrderOption defines the ordering options for the ServiceConfig queries.
type OrderOption func(*sql.Selector)
@@ -333,6 +348,11 @@ func BySessionAffinity(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldSessionAffinity, opts...).ToFunc()
}
+// ByRollout orders the results by the rollout field.
+func ByRollout(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldRollout, opts...).ToFunc()
+}
+
// ByClientIPHeader orders the results by the client_ip_header field.
func ByClientIPHeader(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldClientIPHeader, opts...).ToFunc()
diff --git a/apps/api/ent/serviceconfig/where.go b/apps/api/ent/serviceconfig/where.go
index 625f635e5..bf51d6504 100644
--- a/apps/api/ent/serviceconfig/where.go
+++ b/apps/api/ent/serviceconfig/where.go
@@ -1133,6 +1133,36 @@ func SessionAffinityNEQ(v bool) predicate.ServiceConfig {
return predicate.ServiceConfig(sql.FieldNEQ(FieldSessionAffinity, v))
}
+// RolloutEQ applies the EQ predicate on the "rollout" field.
+func RolloutEQ(v schema.Rollout) predicate.ServiceConfig {
+ vc := v
+ return predicate.ServiceConfig(sql.FieldEQ(FieldRollout, vc))
+}
+
+// RolloutNEQ applies the NEQ predicate on the "rollout" field.
+func RolloutNEQ(v schema.Rollout) predicate.ServiceConfig {
+ vc := v
+ return predicate.ServiceConfig(sql.FieldNEQ(FieldRollout, vc))
+}
+
+// RolloutIn applies the In predicate on the "rollout" field.
+func RolloutIn(vs ...schema.Rollout) predicate.ServiceConfig {
+ v := make([]any, len(vs))
+ for i := range v {
+ v[i] = vs[i]
+ }
+ return predicate.ServiceConfig(sql.FieldIn(FieldRollout, v...))
+}
+
+// RolloutNotIn applies the NotIn predicate on the "rollout" field.
+func RolloutNotIn(vs ...schema.Rollout) predicate.ServiceConfig {
+ v := make([]any, len(vs))
+ for i := range v {
+ v[i] = vs[i]
+ }
+ return predicate.ServiceConfig(sql.FieldNotIn(FieldRollout, v...))
+}
+
// ClientIPHeaderEQ applies the EQ predicate on the "client_ip_header" field.
func ClientIPHeaderEQ(v string) predicate.ServiceConfig {
return predicate.ServiceConfig(sql.FieldEQ(FieldClientIPHeader, v))
diff --git a/apps/api/ent/serviceconfig_create.go b/apps/api/ent/serviceconfig_create.go
index f839c552a..ddc0867cf 100644
--- a/apps/api/ent/serviceconfig_create.go
+++ b/apps/api/ent/serviceconfig_create.go
@@ -288,6 +288,20 @@ func (_c *ServiceConfigCreate) SetNillableSessionAffinity(v *bool) *ServiceConfi
return _c
}
+// SetRollout sets the "rollout" field.
+func (_c *ServiceConfigCreate) SetRollout(v schema.Rollout) *ServiceConfigCreate {
+ _c.mutation.SetRollout(v)
+ return _c
+}
+
+// SetNillableRollout sets the "rollout" field if the given value is not nil.
+func (_c *ServiceConfigCreate) SetNillableRollout(v *schema.Rollout) *ServiceConfigCreate {
+ if v != nil {
+ _c.SetRollout(*v)
+ }
+ return _c
+}
+
// SetClientIPHeader sets the "client_ip_header" field.
func (_c *ServiceConfigCreate) SetClientIPHeader(v string) *ServiceConfigCreate {
_c.mutation.SetClientIPHeader(v)
@@ -551,6 +565,10 @@ func (_c *ServiceConfigCreate) defaults() {
v := serviceconfig.DefaultSessionAffinity
_c.mutation.SetSessionAffinity(v)
}
+ if _, ok := _c.mutation.Rollout(); !ok {
+ v := serviceconfig.DefaultRollout
+ _c.mutation.SetRollout(v)
+ }
if _, ok := _c.mutation.Stopped(); !ok {
v := serviceconfig.DefaultStopped
_c.mutation.SetStopped(v)
@@ -613,6 +631,14 @@ func (_c *ServiceConfigCreate) check() error {
if _, ok := _c.mutation.SessionAffinity(); !ok {
return &ValidationError{Name: "session_affinity", err: errors.New(`ent: missing required field "ServiceConfig.session_affinity"`)}
}
+ if _, ok := _c.mutation.Rollout(); !ok {
+ return &ValidationError{Name: "rollout", err: errors.New(`ent: missing required field "ServiceConfig.rollout"`)}
+ }
+ if v, ok := _c.mutation.Rollout(); ok {
+ if err := serviceconfig.RolloutValidator(v); err != nil {
+ return &ValidationError{Name: "rollout", err: fmt.Errorf(`ent: validator failed for field "ServiceConfig.rollout": %w`, err)}
+ }
+ }
if _, ok := _c.mutation.Stopped(); !ok {
return &ValidationError{Name: "stopped", err: errors.New(`ent: missing required field "ServiceConfig.stopped"`)}
}
@@ -755,6 +781,10 @@ func (_c *ServiceConfigCreate) createSpec() (*ServiceConfig, *sqlgraph.CreateSpe
_spec.SetField(serviceconfig.FieldSessionAffinity, field.TypeBool, value)
_node.SessionAffinity = value
}
+ if value, ok := _c.mutation.Rollout(); ok {
+ _spec.SetField(serviceconfig.FieldRollout, field.TypeEnum, value)
+ _node.Rollout = value
+ }
if value, ok := _c.mutation.ClientIPHeader(); ok {
_spec.SetField(serviceconfig.FieldClientIPHeader, field.TypeString, value)
_node.ClientIPHeader = &value
@@ -1251,6 +1281,18 @@ func (u *ServiceConfigUpsert) UpdateSessionAffinity() *ServiceConfigUpsert {
return u
}
+// SetRollout sets the "rollout" field.
+func (u *ServiceConfigUpsert) SetRollout(v schema.Rollout) *ServiceConfigUpsert {
+ u.Set(serviceconfig.FieldRollout, v)
+ return u
+}
+
+// UpdateRollout sets the "rollout" field to the value that was provided on create.
+func (u *ServiceConfigUpsert) UpdateRollout() *ServiceConfigUpsert {
+ u.SetExcluded(serviceconfig.FieldRollout)
+ return u
+}
+
// SetClientIPHeader sets the "client_ip_header" field.
func (u *ServiceConfigUpsert) SetClientIPHeader(v string) *ServiceConfigUpsert {
u.Set(serviceconfig.FieldClientIPHeader, v)
@@ -2013,6 +2055,20 @@ func (u *ServiceConfigUpsertOne) UpdateSessionAffinity() *ServiceConfigUpsertOne
})
}
+// SetRollout sets the "rollout" field.
+func (u *ServiceConfigUpsertOne) SetRollout(v schema.Rollout) *ServiceConfigUpsertOne {
+ return u.Update(func(s *ServiceConfigUpsert) {
+ s.SetRollout(v)
+ })
+}
+
+// UpdateRollout sets the "rollout" field to the value that was provided on create.
+func (u *ServiceConfigUpsertOne) UpdateRollout() *ServiceConfigUpsertOne {
+ return u.Update(func(s *ServiceConfigUpsert) {
+ s.UpdateRollout()
+ })
+}
+
// SetClientIPHeader sets the "client_ip_header" field.
func (u *ServiceConfigUpsertOne) SetClientIPHeader(v string) *ServiceConfigUpsertOne {
return u.Update(func(s *ServiceConfigUpsert) {
@@ -2994,6 +3050,20 @@ func (u *ServiceConfigUpsertBulk) UpdateSessionAffinity() *ServiceConfigUpsertBu
})
}
+// SetRollout sets the "rollout" field.
+func (u *ServiceConfigUpsertBulk) SetRollout(v schema.Rollout) *ServiceConfigUpsertBulk {
+ return u.Update(func(s *ServiceConfigUpsert) {
+ s.SetRollout(v)
+ })
+}
+
+// UpdateRollout sets the "rollout" field to the value that was provided on create.
+func (u *ServiceConfigUpsertBulk) UpdateRollout() *ServiceConfigUpsertBulk {
+ return u.Update(func(s *ServiceConfigUpsert) {
+ s.UpdateRollout()
+ })
+}
+
// SetClientIPHeader sets the "client_ip_header" field.
func (u *ServiceConfigUpsertBulk) SetClientIPHeader(v string) *ServiceConfigUpsertBulk {
return u.Update(func(s *ServiceConfigUpsert) {
diff --git a/apps/api/ent/serviceconfig_update.go b/apps/api/ent/serviceconfig_update.go
index a10bafec8..f04a03986 100644
--- a/apps/api/ent/serviceconfig_update.go
+++ b/apps/api/ent/serviceconfig_update.go
@@ -407,6 +407,20 @@ func (_u *ServiceConfigUpdate) SetNillableSessionAffinity(v *bool) *ServiceConfi
return _u
}
+// SetRollout sets the "rollout" field.
+func (_u *ServiceConfigUpdate) SetRollout(v schema.Rollout) *ServiceConfigUpdate {
+ _u.mutation.SetRollout(v)
+ return _u
+}
+
+// SetNillableRollout sets the "rollout" field if the given value is not nil.
+func (_u *ServiceConfigUpdate) SetNillableRollout(v *schema.Rollout) *ServiceConfigUpdate {
+ if v != nil {
+ _u.SetRollout(*v)
+ }
+ return _u
+}
+
// SetClientIPHeader sets the "client_ip_header" field.
func (_u *ServiceConfigUpdate) SetClientIPHeader(v string) *ServiceConfigUpdate {
_u.mutation.SetClientIPHeader(v)
@@ -788,6 +802,11 @@ func (_u *ServiceConfigUpdate) check() error {
return &ValidationError{Name: "railpack_framework", err: fmt.Errorf(`ent: validator failed for field "ServiceConfig.railpack_framework": %w`, err)}
}
}
+ if v, ok := _u.mutation.Rollout(); ok {
+ if err := serviceconfig.RolloutValidator(v); err != nil {
+ return &ValidationError{Name: "rollout", err: fmt.Errorf(`ent: validator failed for field "ServiceConfig.rollout": %w`, err)}
+ }
+ }
if v, ok := _u.mutation.HealthCheck(); ok {
if err := v.Validate(); err != nil {
return &ValidationError{Name: "health_check", err: fmt.Errorf(`ent: validator failed for field "ServiceConfig.health_check": %w`, err)}
@@ -942,6 +961,9 @@ func (_u *ServiceConfigUpdate) sqlSave(ctx context.Context) (_node int, err erro
if value, ok := _u.mutation.SessionAffinity(); ok {
_spec.SetField(serviceconfig.FieldSessionAffinity, field.TypeBool, value)
}
+ if value, ok := _u.mutation.Rollout(); ok {
+ _spec.SetField(serviceconfig.FieldRollout, field.TypeEnum, value)
+ }
if value, ok := _u.mutation.ClientIPHeader(); ok {
_spec.SetField(serviceconfig.FieldClientIPHeader, field.TypeString, value)
}
@@ -1510,6 +1532,20 @@ func (_u *ServiceConfigUpdateOne) SetNillableSessionAffinity(v *bool) *ServiceCo
return _u
}
+// SetRollout sets the "rollout" field.
+func (_u *ServiceConfigUpdateOne) SetRollout(v schema.Rollout) *ServiceConfigUpdateOne {
+ _u.mutation.SetRollout(v)
+ return _u
+}
+
+// SetNillableRollout sets the "rollout" field if the given value is not nil.
+func (_u *ServiceConfigUpdateOne) SetNillableRollout(v *schema.Rollout) *ServiceConfigUpdateOne {
+ if v != nil {
+ _u.SetRollout(*v)
+ }
+ return _u
+}
+
// SetClientIPHeader sets the "client_ip_header" field.
func (_u *ServiceConfigUpdateOne) SetClientIPHeader(v string) *ServiceConfigUpdateOne {
_u.mutation.SetClientIPHeader(v)
@@ -1904,6 +1940,11 @@ func (_u *ServiceConfigUpdateOne) check() error {
return &ValidationError{Name: "railpack_framework", err: fmt.Errorf(`ent: validator failed for field "ServiceConfig.railpack_framework": %w`, err)}
}
}
+ if v, ok := _u.mutation.Rollout(); ok {
+ if err := serviceconfig.RolloutValidator(v); err != nil {
+ return &ValidationError{Name: "rollout", err: fmt.Errorf(`ent: validator failed for field "ServiceConfig.rollout": %w`, err)}
+ }
+ }
if v, ok := _u.mutation.HealthCheck(); ok {
if err := v.Validate(); err != nil {
return &ValidationError{Name: "health_check", err: fmt.Errorf(`ent: validator failed for field "ServiceConfig.health_check": %w`, err)}
@@ -2075,6 +2116,9 @@ func (_u *ServiceConfigUpdateOne) sqlSave(ctx context.Context) (_node *ServiceCo
if value, ok := _u.mutation.SessionAffinity(); ok {
_spec.SetField(serviceconfig.FieldSessionAffinity, field.TypeBool, value)
}
+ if value, ok := _u.mutation.Rollout(); ok {
+ _spec.SetField(serviceconfig.FieldRollout, field.TypeEnum, value)
+ }
if value, ok := _u.mutation.ClientIPHeader(); ok {
_spec.SetField(serviceconfig.FieldClientIPHeader, field.TypeString, value)
}
diff --git a/apps/api/internal/mcpserver/mcpserver.go b/apps/api/internal/mcpserver/mcpserver.go
index 500da920e..ee42864d8 100644
--- a/apps/api/internal/mcpserver/mcpserver.go
+++ b/apps/api/internal/mcpserver/mcpserver.go
@@ -40,7 +40,7 @@ A service can run on a cron schedule instead of continuously: set cron_schedule
Services of one team can share a domain on different paths: give each the same host with its own path, like / on the website and /api on the server, and the longest matching path wins. upsert_hosts and remove_hosts match a domain by host and path; send prev_host and prev_path to change either.
-A service holds one volume at most. A service with a volume runs a single replica, because replicas can't share a volume: set replicas to 1 before mounting one. Its rollouts stop the running replica before the new one starts, so expect a short gap. The new image is pulled first, and an image that can't be pulled leaves the running replica in place.`
+A service holds one volume at most. A service with a volume runs a single replica, because replicas can't share a volume: set replicas to 1 before mounting one. Its rollouts stop the running replica before the new one starts, so expect a short gap. The new image is pulled first, and an image that can't be pulled leaves the running replica in place. rollout stop-first gives a service without a volume the same rollout, for apps that must never run two versions at once like bots or wallets; rolling, the default, starts the new version next to the old one.`
)
type Options struct {
diff --git a/apps/api/internal/models/service_config_response.go b/apps/api/internal/models/service_config_response.go
index 73910423f..871275d26 100644
--- a/apps/api/internal/models/service_config_response.go
+++ b/apps/api/internal/models/service_config_response.go
@@ -23,6 +23,7 @@ type ServiceConfigResponse struct {
IsPublic bool `json:"is_public"`
MaxRequestBodySizeMB *int32 `json:"max_request_body_size_mb,omitempty" doc:"Largest request body the service's domains accept, in MB. Unset uses the default of 100"`
SessionAffinity bool `json:"session_affinity" doc:"Whether the service's domains pin each visitor to one replica with a cookie"`
+ Rollout schema.Rollout `json:"rollout" doc:"How a deployment replaces the running version. rolling starts the new one next to the old one, stop-first stops the old one before the new one starts"`
ClientIPHeader *string `json:"client_ip_header,omitempty" doc:"Header the service's domains take the visitor's IP address from. Unset means automatic: CF-Connecting-IP while a domain is proxied through Cloudflare"`
Stopped bool `json:"stopped" doc:"Whether the service is stopped and runs no replicas"`
CronSchedule *string `json:"cron_schedule,omitempty" doc:"Cron expression the service runs on instead of running continuously"`
@@ -113,6 +114,7 @@ func TransformServiceConfigEntity(entity *ent.ServiceConfig) *ServiceConfigRespo
IsPublic: entity.IsPublic,
MaxRequestBodySizeMB: entity.MaxRequestBodySizeMB,
SessionAffinity: entity.SessionAffinity,
+ Rollout: entity.Rollout,
ClientIPHeader: entity.ClientIPHeader,
Stopped: entity.Stopped,
CronSchedule: entity.CronSchedule,
diff --git a/apps/api/internal/models/service_input.go b/apps/api/internal/models/service_input.go
index 80b96a141..fc3a0f581 100644
--- a/apps/api/internal/models/service_input.go
+++ b/apps/api/internal/models/service_input.go
@@ -33,6 +33,7 @@ type CreateServiceInput struct {
IsPublic *bool `json:"is_public,omitempty" doc:"Whether the service is reachable from the internet. Defaults to true for a service with a port and to false for a database"`
MaxRequestBodySizeMB *int32 `json:"max_request_body_size_mb,omitempty" required:"false" minimum:"1" maximum:"10240" doc:"Largest request body the service's domains accept, in MB. Defaults to 100. Not for databases, and has no effect on clusters that route through a gateway"`
SessionAffinity *bool `json:"session_affinity,omitempty" required:"false" doc:"Pin each visitor to one replica with a cookie, for apps that keep sessions or websockets in memory. Defaults to false. Caches like Cloudflare skip responses that set the cookie. Not for databases"`
+ Rollout *schema.Rollout `json:"rollout,omitempty" required:"false" doc:"How a deployment replaces the running version. rolling (default) starts the new version next to the old one, so there is no gap. stop-first stops the old version before the new one starts, so the two never run at once, for apps that must not overlap like bots or wallets, at the cost of a few seconds of downtime per deployment. A service with a volume always stops first. Not for databases"`
ClientIPHeader *string `json:"client_ip_header,omitempty" required:"false" doc:"Header a proxy in front of the service, like Cloudflare, carries the visitor's IP address in, e.g. CF-Connecting-IP. Unset picks CF-Connecting-IP while a domain is proxied through Cloudflare and nothing otherwise. Not for databases, only on installs that route through Envoy Gateway"`
Image *string `json:"image,omitempty"`
DockerBuilderDockerfilePath *string `json:"docker_builder_dockerfile_path,omitempty" required:"false" doc:"Optional path to Dockerfile, if using docker builder"`
@@ -96,6 +97,7 @@ type UpdateServiceInput struct {
IsPublic *bool `json:"is_public,omitempty" required:"false" doc:"Whether the service is reachable from the internet. A database gets a public address when true and loses it when false"`
MaxRequestBodySizeMB *int32 `json:"max_request_body_size_mb,omitempty" required:"false" minimum:"0" maximum:"10240" doc:"Largest request body the service's domains accept, in MB. 0 resets it to the default of 100. Not for databases, and has no effect on clusters that route through a gateway"`
SessionAffinity *bool `json:"session_affinity,omitempty" required:"false" doc:"Pin each visitor to one replica with a cookie, for apps that keep sessions or websockets in memory. Caches like Cloudflare skip responses that set the cookie. Not for databases"`
+ Rollout *schema.Rollout `json:"rollout,omitempty" required:"false" doc:"How a deployment replaces the running version. rolling starts the new version next to the old one, so there is no gap. stop-first stops the old version before the new one starts, so the two never run at once, for apps that must not overlap like bots or wallets, at the cost of a few seconds of downtime per deployment. A service with a volume always stops first. Not for databases"`
ClientIPHeader *string `json:"client_ip_header,omitempty" required:"false" doc:"Header a proxy in front of the service, like Cloudflare, carries the visitor's IP address in, e.g. CF-Connecting-IP. An empty string goes back to automatic: CF-Connecting-IP while a domain is proxied through Cloudflare and nothing otherwise. Not for databases, only on installs that route through Envoy Gateway"`
Stopped *bool `json:"stopped,omitempty" required:"false" doc:"Stop or start the service. A stopped service runs no replicas while its volumes, domains, variables and settings stay in place. Set false to start it again. Not for databases"`
Image *string `json:"image,omitempty" required:"false" doc:"Image to run, with an optional tag. Only for an image service"`
diff --git a/apps/api/internal/repositories/service/mutations.go b/apps/api/internal/repositories/service/mutations.go
index 4c718797d..cf9bbd437 100644
--- a/apps/api/internal/repositories/service/mutations.go
+++ b/apps/api/internal/repositories/service/mutations.go
@@ -89,6 +89,7 @@ type MutateConfigInput struct {
MaxRequestBodySizeMB *int32
ClientIPHeader *string
SessionAffinity *bool
+ Rollout *schema.Rollout
Stopped *bool
CronSchedule *string
CronTimezone *string
@@ -180,6 +181,7 @@ func (self *ServiceRepository) CreateConfig(
SetNillableMaxRequestBodySizeMB(input.MaxRequestBodySizeMB).
SetNillableClientIPHeader(input.ClientIPHeader).
SetNillableSessionAffinity(input.SessionAffinity).
+ SetNillableRollout(input.Rollout).
SetNillableStopped(input.Stopped).
SetNillableCronSchedule(input.CronSchedule).
SetNillableCronTimezone(input.CronTimezone).
@@ -314,6 +316,7 @@ func (self *ServiceRepository) UpdateConfig(
SetNillableAutoDeploy(input.AutoDeploy).
SetNillableIsPublic(input.Public).
SetNillableSessionAffinity(input.SessionAffinity).
+ SetNillableRollout(input.Rollout).
SetNillableStopped(input.Stopped).
SetNillableImage(input.Image).
SetNillableDefinitionVersion(input.CustomDefinitionVersion).
diff --git a/apps/api/internal/repositories/service/queries.go b/apps/api/internal/repositories/service/queries.go
index fcb8d71f7..0da9f2c74 100644
--- a/apps/api/internal/repositories/service/queries.go
+++ b/apps/api/internal/repositories/service/queries.go
@@ -413,6 +413,7 @@ func (self *ServiceRepository) NeedsDeployment(ctx context.Context, service *ent
Public: current.ResourceDefinition.Spec.Config.Public,
MaxRequestBodySizeMB: current.ResourceDefinition.Spec.Config.MaxRequestBodySizeMB,
SessionAffinity: current.ResourceDefinition.Spec.Config.SessionAffinity,
+ Rollout: RolloutOrDefault(current.ResourceDefinition.Spec.Config.Rollout),
ClientIPHeader: current.ResourceDefinition.Spec.Config.ClientIPHeader,
Stopped: current.ResourceDefinition.Spec.Config.Stopped,
CronSchedule: current.ResourceDefinition.Spec.Config.CronSchedule,
@@ -457,6 +458,7 @@ func (self *ServiceRepository) NeedsDeployment(ctx context.Context, service *ent
Public: service.Edges.ServiceConfig.IsPublic,
MaxRequestBodySizeMB: service.Edges.ServiceConfig.MaxRequestBodySizeMB,
SessionAffinity: service.Edges.ServiceConfig.SessionAffinity,
+ Rollout: string(service.Edges.ServiceConfig.Rollout),
ClientIPHeader: ClientIPHeader(service.Edges.ServiceConfig),
Stopped: service.Edges.ServiceConfig.Stopped,
CronSchedule: valueOrEmpty(service.Edges.ServiceConfig.CronSchedule),
diff --git a/apps/api/internal/repositories/service/runtime_config.go b/apps/api/internal/repositories/service/runtime_config.go
index b29abc26f..d4418d4a7 100644
--- a/apps/api/internal/repositories/service/runtime_config.go
+++ b/apps/api/internal/repositories/service/runtime_config.go
@@ -14,6 +14,14 @@ var cloudflareChecker = utils.NewDNSChecker()
// ClientIPHeader is the header the service's domains take the visitor's address from:
// the one chosen for the service, or Cloudflare's while a domain is proxied through it.
// Resolved on every rollout, so a domain moving on or off Cloudflare is picked up then.
+// A resource rendered before the setting existed carries no rollout, which is rolling
+func RolloutOrDefault(rollout string) string {
+ if rollout == "" {
+ return string(schema.RolloutRolling)
+ }
+ return rollout
+}
+
func ClientIPHeader(config *ent.ServiceConfig) string {
return clientIPHeader(config, func(host string) bool {
proxied, _ := cloudflareChecker.IsUsingCloudflareProxy(host)
@@ -47,6 +55,7 @@ func ApplyRuntimeConfig(spec *v1.ServiceConfigSpec, config *ent.ServiceConfig) {
spec.Public = config.IsPublic
spec.MaxRequestBodySizeMB = config.MaxRequestBodySizeMB
spec.SessionAffinity = config.SessionAffinity
+ spec.Rollout = string(config.Rollout)
spec.Stopped = config.Stopped
spec.CronSchedule = valueOrEmpty(config.CronSchedule)
spec.CronTimeZone = valueOrEmpty(config.CronTimezone)
diff --git a/apps/api/internal/repositories/service/runtime_config_test.go b/apps/api/internal/repositories/service/runtime_config_test.go
index 0a292bda2..3987ae06e 100644
--- a/apps/api/internal/repositories/service/runtime_config_test.go
+++ b/apps/api/internal/repositories/service/runtime_config_test.go
@@ -30,6 +30,7 @@ func TestApplyRuntimeConfig(t *testing.T) {
Replicas: 3,
MaxRequestBodySizeMB: new(int32(500)),
SessionAffinity: true,
+ Rollout: schema.RolloutStopFirst,
Stopped: true,
CronSchedule: new("*/10 * * * *"),
CronTimezone: new("Europe/Berlin"),
@@ -47,6 +48,7 @@ func TestApplyRuntimeConfig(t *testing.T) {
assert.Equal(t, int32(3), *spec.Replicas)
assert.Equal(t, int32(500), *spec.MaxRequestBodySizeMB)
assert.True(t, spec.SessionAffinity)
+ assert.Equal(t, "stop-first", spec.Rollout)
assert.True(t, spec.Stopped)
assert.Equal(t, "*/10 * * * *", spec.CronSchedule)
assert.Equal(t, "Europe/Berlin", spec.CronTimeZone)
@@ -58,11 +60,18 @@ func TestApplyRuntimeConfig(t *testing.T) {
assert.Empty(t, spec.Ports)
assert.Nil(t, spec.MaxRequestBodySizeMB)
assert.False(t, spec.SessionAffinity)
+ assert.Empty(t, spec.Rollout)
assert.False(t, spec.Stopped)
assert.Empty(t, spec.CronSchedule, "a cleared schedule runs the service continuously again")
assert.Empty(t, spec.CronTimeZone)
}
+func TestRolloutOrDefault(t *testing.T) {
+ assert.Equal(t, "rolling", RolloutOrDefault(""), "a resource from before the setting rolls")
+ assert.Equal(t, "rolling", RolloutOrDefault("rolling"))
+ assert.Equal(t, "stop-first", RolloutOrDefault("stop-first"))
+}
+
func TestApplyRuntimeConfigHealthCheckAndVariableMounts(t *testing.T) {
spec := v1.ServiceConfigSpec{
HealthCheck: &v1.HealthCheckSpec{Type: "http", Path: "/sping"},
diff --git a/apps/api/internal/services/service/create.go b/apps/api/internal/services/service/create.go
index a5e467703..9377ba0e8 100644
--- a/apps/api/internal/services/service/create.go
+++ b/apps/api/internal/services/service/create.go
@@ -60,6 +60,9 @@ func (self *ServiceService) CreateService(ctx context.Context, requesterUserID u
if input.SessionAffinity != nil && input.Type == schema.ServiceTypeDatabase {
return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidInput, "Databases have no sticky sessions")
}
+ if input.Rollout != nil && input.Type == schema.ServiceTypeDatabase {
+ return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidInput, databaseRolloutMessage)
+ }
if err := validateCronInput(input.Type, input.CronSchedule, input.CronTimezone); err != nil {
return nil, err
}
@@ -459,6 +462,7 @@ func (self *ServiceService) CreateService(ctx context.Context, requesterUserID u
MaxRequestBodySizeMB: input.MaxRequestBodySizeMB,
ClientIPHeader: input.ClientIPHeader,
SessionAffinity: input.SessionAffinity,
+ Rollout: input.Rollout,
Image: input.Image,
DockerBuilderDockerfilePath: input.DockerBuilderDockerfilePath,
DockerBuilderBuildContext: input.DockerBuilderBuildContext,
diff --git a/apps/api/internal/services/service/rollout.go b/apps/api/internal/services/service/rollout.go
index 052a8066a..83861a629 100644
--- a/apps/api/internal/services/service/rollout.go
+++ b/apps/api/internal/services/service/rollout.go
@@ -155,6 +155,7 @@ func estimateConfigChange(config *ent.ServiceConfig, input *models.UpdateService
if input.Replicas != nil && *input.Replicas != config.Replicas ||
input.IsPublic != nil && *input.IsPublic != config.IsPublic ||
input.SessionAffinity != nil && *input.SessionAffinity != config.SessionAffinity ||
+ input.Rollout != nil && *input.Rollout != config.Rollout ||
input.Stopped != nil && *input.Stopped != config.Stopped ||
clearableChanged(input.CronSchedule, config.CronSchedule) ||
clearableChanged(input.CronTimezone, config.CronTimezone) ||
diff --git a/apps/api/internal/services/service/rollout_test.go b/apps/api/internal/services/service/rollout_test.go
index c4fc2ba4c..694c04137 100644
--- a/apps/api/internal/services/service/rollout_test.go
+++ b/apps/api/internal/services/service/rollout_test.go
@@ -44,6 +44,7 @@ func TestEstimateConfigChange(t *testing.T) {
Image: "nginx:1.25",
RunCommand: utils.ToPtr("npm start"),
MaxRequestBodySizeMB: utils.ToPtr[int32](500),
+ Rollout: schema.RolloutRolling,
}
tests := []struct {
@@ -67,6 +68,8 @@ func TestEstimateConfigChange(t *testing.T) {
{"request size reset", models.UpdateServiceInput{MaxRequestBodySizeMB: utils.ToPtr[int32](0)}, service_repo.NeedsDeployment},
{"same session affinity", models.UpdateServiceInput{SessionAffinity: utils.ToPtr(false)}, service_repo.NoDeploymentNeeded},
{"session affinity", models.UpdateServiceInput{SessionAffinity: utils.ToPtr(true)}, service_repo.NeedsDeployment},
+ {"same rollout", models.UpdateServiceInput{Rollout: utils.ToPtr(schema.RolloutRolling)}, service_repo.NoDeploymentNeeded},
+ {"rollout", models.UpdateServiceInput{Rollout: utils.ToPtr(schema.RolloutStopFirst)}, service_repo.NeedsDeployment},
{"still running", models.UpdateServiceInput{Stopped: utils.ToPtr(false)}, service_repo.NoDeploymentNeeded},
{"stopped", models.UpdateServiceInput{Stopped: utils.ToPtr(true)}, service_repo.NeedsDeployment},
{"cron schedule", models.UpdateServiceInput{CronSchedule: utils.ToPtr("0 * * * *")}, service_repo.NeedsDeployment},
diff --git a/apps/api/internal/services/service/update.go b/apps/api/internal/services/service/update.go
index 49102c669..29439c9d9 100644
--- a/apps/api/internal/services/service/update.go
+++ b/apps/api/internal/services/service/update.go
@@ -164,6 +164,9 @@ func (self *ServiceService) prepareServiceUpdate(ctx context.Context, requesterU
if input.SessionAffinity != nil && service.Type == schema.ServiceTypeDatabase {
return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidInput, "Databases have no sticky sessions")
}
+ if input.Rollout != nil && service.Type == schema.ServiceTypeDatabase {
+ return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidInput, databaseRolloutMessage)
+ }
if input.Stopped != nil && service.Type == schema.ServiceTypeDatabase {
return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidInput, "Databases can't be stopped")
}
@@ -506,6 +509,7 @@ func (self *ServiceService) applyServiceUpdate(ctx context.Context, update *serv
MaxRequestBodySizeMB: input.MaxRequestBodySizeMB,
ClientIPHeader: input.ClientIPHeader,
SessionAffinity: input.SessionAffinity,
+ Rollout: input.Rollout,
Stopped: input.Stopped,
CronSchedule: input.CronSchedule,
CronTimezone: input.CronTimezone,
@@ -666,6 +670,13 @@ func (self *ServiceService) notifyServiceUpdated(requesterUserID uuid.UUID, inpu
})
}
+ if input.Rollout != nil {
+ data.Fields = append(data.Fields, webhooks_service.WebhookDataField{
+ Name: "Rollout",
+ Value: string(*input.Rollout),
+ })
+ }
+
if input.Stopped != nil {
data.Fields = append(data.Fields, webhooks_service.WebhookDataField{
Name: "Stopped",
@@ -835,6 +846,8 @@ func validateVolumeReplicas(config *ent.ServiceConfig, input *models.UpdateServi
return nil
}
+const databaseRolloutMessage = "Databases have no rollout setting"
+
func ReplicatedMountError(replicas int32) error {
return errdefs.NewCustomError(errdefs.ErrTypeInvalidInput, fmt.Sprintf("This service runs %d replicas and replicas can't share a volume. Set its replicas to 1 before mounting a volume", replicas))
}
diff --git a/apps/docs/content/(platform)/services/deployments.mdx b/apps/docs/content/(platform)/services/deployments.mdx
index 752baf735..b594035c7 100644
--- a/apps/docs/content/(platform)/services/deployments.mdx
+++ b/apps/docs/content/(platform)/services/deployments.mdx
@@ -61,6 +61,8 @@ When a deployment replaces a version, the old replicas get a stop signal (`SIGTE
A service with a [volume](/services/volumes#deployments) gets about 2 seconds instead, because the new version can't start until the old one is gone.
+A service with [Rollout](/services/replicas-and-health#rollout) set to **Stop first** also waits for the old version to stop, but keeps the 5 seconds so it can finish its work.
+
## Actions on a deployment
Open the menu on a deployment.
diff --git a/apps/docs/content/(platform)/services/domains.mdx b/apps/docs/content/(platform)/services/domains.mdx
index d213ce718..78c5f7575 100644
--- a/apps/docs/content/(platform)/services/domains.mdx
+++ b/apps/docs/content/(platform)/services/domains.mdx
@@ -72,7 +72,7 @@ A few rules apply:
## Split a domain by path
-A domain can send each path to a different service, like `example.com` to the website and `example.com/api` to the API. Add the domain to every service that takes a part of it, and type a **Path** on each one except the service that answers the rest. The longest matching path wins.
+A domain can send each path to a different service, like `example.com` to the website and `example.com/api` to the API. Add the domain to every service that takes a part of it, and type a **Path** under **Advanced Settings** on each one except the service that answers the rest. The longest matching path wins.
- A path covers itself and everything under it: `/api` takes `/api` and `/api/users`, not `/apis`.
- The service gets the full path: the one under `/api` sees `/api/users`, not `/users`.
@@ -84,7 +84,7 @@ A domain can send each path to a different service, like `example.com` to the we
## Redirect a domain
-A domain can send visitors to another URL instead of the service. Press the pencil on the domain, type the URL in **Redirect**, like `https://example.com`, pick the **Redirect Status** and deploy.
+A domain can send visitors to another URL instead of the service. Press the pencil on the domain, open **Advanced Settings**, type the URL in **Redirect**, like `https://example.com`, pick the **Redirect Status** and deploy.
- A URL with no path keeps the path and query of the request: `www.example.com/pricing` goes to `example.com/pricing`.
- A URL with a path drops it: every page of `plugins.example.com` goes to `example.com/plugins`.
@@ -97,7 +97,7 @@ This is how to send `www.example.com` to `example.com`: add `www.example.com` to
## Proxy a domain
-A domain can serve another URL without a redirect, so visitors stay on your domain. Press the pencil on the domain, type the URL in **Proxy**, like `https://raw.githubusercontent.com/org/repo/main/install.sh`, and deploy. Tools that don't follow redirects, like `curl` without `-L`, get the content itself.
+A domain can serve another URL without a redirect, so visitors stay on your domain. Press the pencil on the domain, open **Advanced Settings**, type the URL in **Proxy**, like `https://raw.githubusercontent.com/org/repo/main/install.sh`, and deploy. Tools that don't follow redirects, like `curl` without `-L`, get the content itself.
- A URL with no path keeps the path of the request: `docs.example.com/start` serves `example.net/start`.
- A URL with a path serves that path for every request, like the install script above.
@@ -111,13 +111,13 @@ Proxies need Envoy Gateway, the default. Installs on ingress-nginx or Traefik do
## Password protect a domain
-**Basic Auth** asks visitors for a username and password before the service answers. Press the pencil on the domain, type a username and a password and deploy. Browsers show a login prompt, and tools send the pair with `curl -u`.
+**Basic Auth** asks visitors for a username and password before the service answers. Press the pencil on the domain, open **Advanced Settings**, type a username and a password and deploy. Browsers show a login prompt, and tools send the pair with `curl -u`.
Unbind keeps a hash of the password, never the password itself, and never shows it again. To change it, type a new one. Leaving the password empty on a later edit keeps the current one. A domain with a redirect has no basic auth, since the service never answers on it.
## Response headers
-**Response Headers** adds headers to every response of a domain, typed as `Name: value`. A header the service already sends is replaced.
+**Response Headers**, under **Advanced Settings** of a domain, adds headers to every response of it, typed as `Name: value`. A header the service already sends is replaced.
Common ones:
diff --git a/apps/docs/content/(platform)/services/replicas-and-health.mdx b/apps/docs/content/(platform)/services/replicas-and-health.mdx
index 736cd5779..ba93dfb54 100644
--- a/apps/docs/content/(platform)/services/replicas-and-health.mdx
+++ b/apps/docs/content/(platform)/services/replicas-and-health.mdx
@@ -15,6 +15,17 @@ A service with a [volume](/services/volumes#replicas) always runs 1 replica and
Databases have no replica setting.
+## Rollout
+
+**Rollout** under **Deploy** in the service settings is how a deployment replaces the running version:
+
+- **Rolling**, the default: the new version starts next to the old one, and the old one stops once the new one is ready. No gap.
+- **Stop first**: the old version stops before the new one starts, so the two never run at once. Every deployment has a gap of a few seconds. The new image is pulled first, and an image that can't be pulled leaves the running version in place.
+
+Pick **Stop first** for an app that must never run twice at the same time, not even for a few seconds: a bot that holds one token, a worker that owns a wallet, a job that must not overlap. With more than one replica, all old replicas stop before the new ones start.
+
+A service with a [volume](/services/volumes#deployments) always stops first, and the setting is locked.
+
## Stop a service
To turn a service off without deleting it, open the menu on its current deployment and choose **Stop**. The service runs no replicas until you choose **Start** from the same menu. Its volumes, domains, variables and settings stay as they are, and it comes back with the replica count it had.
diff --git a/apps/docs/content/(platform)/services/volumes.mdx b/apps/docs/content/(platform)/services/volumes.mdx
index 2d21ce77f..4e7508f7d 100644
--- a/apps/docs/content/(platform)/services/volumes.mdx
+++ b/apps/docs/content/(platform)/services/volumes.mdx
@@ -45,6 +45,8 @@ Unbind keeps that gap short in two ways. It pulls the new image while the old ve
A new version that crashes on start still takes the service down, because the old version has already stopped. [Health checks](/services/replicas-and-health#health-checks) do not prevent this for a service with a volume.
+A service without a volume gets the same rollout by setting [Rollout](/services/replicas-and-health#rollout) to **Stop first**.
+
## Permissions
A new volume is owned by root. Unbind mounts it with a group that your service runs with, so an image that runs as a non-root user can still create files in it. Files your app creates stay owned by the user it runs as.
diff --git a/apps/docs/generated/openapi.gen.yaml b/apps/docs/generated/openapi.gen.yaml
index 2900e262a..0eaa836ba 100644
--- a/apps/docs/generated/openapi.gen.yaml
+++ b/apps/docs/generated/openapi.gen.yaml
@@ -583,6 +583,9 @@ components:
resources:
$ref: "#/components/schemas/Resources"
description: Resource limits and requests for the service containers
+ rollout:
+ $ref: "#/components/schemas/Rollout"
+ description: How a deployment replaces the running version. rolling (default) starts the new version next to the old one, so there is no gap. stop-first stops the old version before the new one starts, so the two never run at once, for apps that must not overlap like bots or wallets, at the cost of a few seconds of downtime per deployment. A service with a volume always stops first. Not for databases
run_command:
type: string
s3_backup_bucket_id:
@@ -2557,6 +2560,12 @@ components:
required:
- restarted
type: object
+ Rollout:
+ enum:
+ - rolling
+ - stop-first
+ title: Rollout
+ type: string
S3AccessTestInput:
additionalProperties: false
properties:
@@ -2973,6 +2982,9 @@ components:
type: integer
resources:
$ref: "#/components/schemas/Resources"
+ rollout:
+ $ref: "#/components/schemas/Rollout"
+ description: How a deployment replaces the running version. rolling starts the new one next to the old one, stop-first stops the old one before the new one starts
run_command:
type: string
s3_backup_bucket_id:
@@ -3008,6 +3020,7 @@ components:
- auto_deploy
- is_public
- session_affinity
+ - rollout
- stopped
- backup_schedule
- backup_retention_count
@@ -4276,6 +4289,9 @@ components:
resources:
$ref: "#/components/schemas/Resources"
description: Resource limits and requests for the service containers
+ rollout:
+ $ref: "#/components/schemas/Rollout"
+ description: How a deployment replaces the running version. rolling starts the new version next to the old one, so there is no gap. stop-first stops the old version before the new one starts, so the two never run at once, for apps that must not overlap like bots or wallets, at the cost of a few seconds of downtime per deployment. A service with a volume always stops first. Not for databases
run_command:
type: string
s3_backup_bucket_id:
diff --git a/apps/operator/api/v1/service_types.go b/apps/operator/api/v1/service_types.go
index 31c39c804..64094487b 100644
--- a/apps/operator/api/v1/service_types.go
+++ b/apps/operator/api/v1/service_types.go
@@ -86,6 +86,13 @@ type ServiceSpec struct {
}
// ServiceConfigSpec defines configuration for a service
+const (
+ // RolloutRolling starts the new version next to the old one, the default
+ RolloutRolling = "rolling"
+ // RolloutStopFirst stops the old version before the new one starts
+ RolloutStopFirst = "stop-first"
+)
+
type ServiceConfigSpec struct {
// GitBranch to build from
GitBranch string `json:"gitBranch,omitempty"`
@@ -112,6 +119,9 @@ type ServiceConfigSpec struct {
// SessionAffinity pins each visitor to one replica with a cookie
SessionAffinity bool `json:"sessionAffinity,omitempty"`
+ // Rollout is how a deployment replaces the running version
+ // +kubebuilder:validation:Enum=rolling;stop-first
+ Rollout string `json:"rollout,omitempty"`
// ClientIPHeader names the header a proxy in front of the service carries the visitor's address in
ClientIPHeader string `json:"clientIPHeader,omitempty" required:"false"`
diff --git a/apps/operator/config/crd/bases/unbind.unbind.app_services.yaml b/apps/operator/config/crd/bases/unbind.unbind.app_services.yaml
index 950aabb0f..7716897e8 100644
--- a/apps/operator/config/crd/bases/unbind.unbind.app_services.yaml
+++ b/apps/operator/config/crd/bases/unbind.unbind.app_services.yaml
@@ -367,6 +367,13 @@ spec:
- memoryLimitsMegabytes
- memoryRequestsMegabytes
type: object
+ rollout:
+ description: Rollout is how a deployment replaces the running
+ version
+ enum:
+ - rolling
+ - stop-first
+ type: string
runCommand:
description: RunCommand is a custom run command
type: string
diff --git a/apps/operator/dist/chart/templates/crd/unbind.unbind.app_services.yaml b/apps/operator/dist/chart/templates/crd/unbind.unbind.app_services.yaml
index 662ef35db..9f2d7bba3 100755
--- a/apps/operator/dist/chart/templates/crd/unbind.unbind.app_services.yaml
+++ b/apps/operator/dist/chart/templates/crd/unbind.unbind.app_services.yaml
@@ -67,6 +67,10 @@ spec:
config:
description: Configuration for the service
properties:
+ clientIPHeader:
+ description: ClientIPHeader names the header a proxy in front
+ of the service carries the visitor's address in
+ type: string
cronSchedule:
description: CronSchedule runs the service as a CronJob on this
cron expression instead of a Deployment
@@ -80,6 +84,10 @@ spec:
properties:
config:
properties:
+ appendOnly:
+ description: Redis only, nil leaves databases created
+ before the setting untouched
+ type: boolean
defaultDatabaseName:
type: string
initdb:
@@ -208,6 +216,20 @@ spec:
service
items:
properties:
+ basicAuth:
+ description: BasicAuth asks visitors for a username and
+ password before the host answers
+ properties:
+ passwordHash:
+ description: PasswordHash is the password in htpasswd
+ SHA form
+ type: string
+ username:
+ type: string
+ required:
+ - passwordHash
+ - username
+ type: object
host:
description: Host is the external domain for the service
type: string
@@ -223,6 +245,52 @@ spec:
- http
- grpc
type: string
+ proxy:
+ description: Proxy serves another URL on the host instead
+ of the service
+ properties:
+ to:
+ description: To is the URL requests are sent to. With
+ a path every request gets that path, without one the
+ request path is kept
+ type: string
+ required:
+ - to
+ type: object
+ redirect:
+ description: Redirect answers every request for the host
+ with a redirect instead of routing it
+ properties:
+ statusCode:
+ enum:
+ - 301
+ - 302
+ - 307
+ - 308
+ format: int32
+ type: integer
+ to:
+ description: To is the URL visitors are sent to. The
+ request path is kept when it has no path
+ type: string
+ required:
+ - statusCode
+ - to
+ type: object
+ responseHeaders:
+ description: ResponseHeaders are set on every response of
+ the host
+ items:
+ properties:
+ name:
+ type: string
+ value:
+ type: string
+ required:
+ - name
+ - value
+ type: object
+ type: array
required:
- host
- path
@@ -305,6 +373,13 @@ spec:
- memoryLimitsMegabytes
- memoryRequestsMegabytes
type: object
+ rollout:
+ description: Rollout is how a deployment replaces the running
+ version
+ enum:
+ - rolling
+ - stop-first
+ type: string
runCommand:
description: RunCommand is a custom run command
type: string
diff --git a/apps/operator/internal/resourcebuilder/deployment.go b/apps/operator/internal/resourcebuilder/deployment.go
index a1cb30025..56025eeb8 100644
--- a/apps/operator/internal/resourcebuilder/deployment.go
+++ b/apps/operator/internal/resourcebuilder/deployment.go
@@ -5,6 +5,7 @@ import (
"fmt"
"strings"
+ v1 "github.com/unbindapp/unbind-operator/api/v1"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
@@ -96,8 +97,9 @@ func (rb *ResourceBuilder) buildPodSecurityContext() *corev1.PodSecurityContext
}
}
+// A volume takes one writer at a time, and a stop-first rollout must never overlap
func (rb *ResourceBuilder) buildDeploymentStrategy() appsv1.DeploymentStrategy {
- if len(rb.service.Spec.Config.Volumes) > 0 {
+ if len(rb.service.Spec.Config.Volumes) > 0 || rb.service.Spec.Config.Rollout == v1.RolloutStopFirst {
return appsv1.DeploymentStrategy{Type: appsv1.RecreateDeploymentStrategyType}
}
return appsv1.DeploymentStrategy{Type: appsv1.RollingUpdateDeploymentStrategyType}
diff --git a/apps/operator/internal/resourcebuilder/deployment_test.go b/apps/operator/internal/resourcebuilder/deployment_test.go
index ecaac05b8..5c0dab870 100644
--- a/apps/operator/internal/resourcebuilder/deployment_test.go
+++ b/apps/operator/internal/resourcebuilder/deployment_test.go
@@ -5,6 +5,7 @@ import (
"github.com/stretchr/testify/assert"
v1 "github.com/unbindapp/unbind-operator/api/v1"
+ appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/util/validation"
)
@@ -47,6 +48,27 @@ func TestReplicasWithVolume(t *testing.T) {
assert.Equal(t, int32(0), NewResourceBuilder(service, nil, nil).replicas(), "a stopped service runs nothing")
}
+func TestDeploymentStrategy(t *testing.T) {
+ service := &v1.Service{}
+ service.Spec.Config.Image = "app:1"
+
+ deployment, err := NewResourceBuilder(service, nil, nil).BuildDeployment()
+ assert.NoError(t, err)
+ assert.Equal(t, appsv1.RollingUpdateDeploymentStrategyType, deployment.Spec.Strategy.Type)
+
+ service.Spec.Config.Rollout = v1.RolloutStopFirst
+ deployment, err = NewResourceBuilder(service, nil, nil).BuildDeployment()
+ assert.NoError(t, err)
+ assert.Equal(t, appsv1.RecreateDeploymentStrategyType, deployment.Spec.Strategy.Type, "a stop-first rollout never overlaps")
+ assert.Equal(t, int64(5), *deployment.Spec.Template.Spec.TerminationGracePeriodSeconds, "it keeps the full grace period to finish its work")
+
+ service.Spec.Config.Rollout = v1.RolloutRolling
+ service.Spec.Config.Volumes = []v1.VolumeSpec{{Name: "data", MountPath: "/data"}}
+ deployment, err = NewResourceBuilder(service, nil, nil).BuildDeployment()
+ assert.NoError(t, err)
+ assert.Equal(t, appsv1.RecreateDeploymentStrategyType, deployment.Spec.Strategy.Type, "a volume takes one writer at a time")
+}
+
func TestTerminationGracePeriod(t *testing.T) {
service := &v1.Service{}
service.Spec.Config.Image = "app:1"
diff --git a/apps/web/src/components/block.tsx b/apps/web/src/components/block.tsx
index 44c99a75f..5e45f9fad 100644
--- a/apps/web/src/components/block.tsx
+++ b/apps/web/src/components/block.tsx
@@ -17,6 +17,7 @@ import {
const BLOCK_ROLE = {
header: "block.header",
+ description: "block.description",
content: "block.content",
} as const;
@@ -91,6 +92,7 @@ export function BlockItemDescription({
);
}
+withChildRole(BlockItemDescription, BLOCK_ROLE.description);
export function BlockItem({
className,
@@ -99,6 +101,8 @@ export function BlockItem({
}: { className?: string; children: ReactNode } & HTMLAttributes) {
const childrenArray = Children.toArray(children);
const Header = childrenArray.find((child) => hasChildRole(child, BLOCK_ROLE.header));
+ // A description next to the header, under a row header that holds a toggle
+ const Description = childrenArray.find((child) => hasChildRole(child, BLOCK_ROLE.description));
const Content = childrenArray.find((child) => hasChildRole(child, BLOCK_ROLE.content));
return (
{isScheduled && (
-
- Replicas are not available for cron schedules.
-
+ Replicas are not available for cron schedules.
)}
{hasVolume && !isScheduled && (
-
-
-
-
-
- Services with a volume can only have a single replica.
-
-
+
+ Services with a volume can only have a single replica.
+
)}
@@ -426,6 +327,77 @@ function Section({ service }: { service: TServiceShallow }) {
/>
)}
+ {showRollout && (
+
+ {
+ const value = hasVolume ? stopFirstRollout : field.state.value;
+ return (
+
+
+ Rollout
+
+ Rolling starts the new version next to the old one. Stop first stops the old
+ version before the new one starts, so the two never run at once.
+
+
+
+
);
},
});
diff --git a/apps/web/src/components/service/panel/content/deployed/settings/sections/networking/networking-section.tsx b/apps/web/src/components/service/panel/content/deployed/settings/sections/networking/networking-section.tsx
index 7cb8f1200..7b687df5b 100644
--- a/apps/web/src/components/service/panel/content/deployed/settings/sections/networking/networking-section.tsx
+++ b/apps/web/src/components/service/panel/content/deployed/settings/sections/networking/networking-section.tsx
@@ -1,4 +1,4 @@
-import CronScheduleNotice from "@/components/service/cron/cron-schedule-notice";
+import SettingNotice from "@/components/settings/setting-notice";
import { isScheduledService } from "@/components/service/cron/cron-schedule";
import {
Block,
@@ -62,9 +62,9 @@ export default function NetworkingSection({ service }: TProps) {
Icon={NetworkIcon}
entityId={getNetworkingEntityId(service.id)}
>
-
+
Domains and ports are not available for cron schedules.
-
+
);
}
@@ -415,8 +415,7 @@ function AllServiceTypesSection({ service }: { service: TServiceShallow }) {
Client IP Header
- Header a proxy in front of the service, like Cloudflare, puts the visitor's
- address in. Automatic uses CF-Connecting-IP while a domain is behind Cloudflare.
+ Header a proxy like Cloudflare puts the visitor's address in.
diff --git a/apps/web/src/components/service/panel/content/deployed/settings/use-service-changes.ts b/apps/web/src/components/service/panel/content/deployed/settings/use-service-changes.ts
index c4a4e91c2..9bd246de6 100644
--- a/apps/web/src/components/service/panel/content/deployed/settings/use-service-changes.ts
+++ b/apps/web/src/components/service/panel/content/deployed/settings/use-service-changes.ts
@@ -25,6 +25,7 @@ export type TStagedFields = Partial>;
diff --git a/apps/web/src/components/service/panel/content/undeployed/service-types/undeployed-docker-image.tsx b/apps/web/src/components/service/panel/content/undeployed/service-types/undeployed-docker-image.tsx
index dd7044eaa..c22fb203d 100644
--- a/apps/web/src/components/service/panel/content/undeployed/service-types/undeployed-docker-image.tsx
+++ b/apps/web/src/components/service/panel/content/undeployed/service-types/undeployed-docker-image.tsx
@@ -12,7 +12,7 @@ import BrandIcon from "@/components/icons/brand";
import DomainPortBlock from "@/components/service/panel/content/undeployed/blocks/domain-port-block";
import VariablesBlock from "@/components/service/panel/content/undeployed/blocks/variables-block";
import DeployButtonSection from "@/components/service/panel/content/undeployed/deploy-button-section";
-import AdvancedSettingsButton from "@/components/service/panel/content/undeployed/service-types/advanced-settings-button";
+import AdvancedSettingsButton from "@/components/service/advanced-settings-button";
import useCreateFirstDeployment from "@/components/service/panel/content/undeployed/use-create-first-deployment";
import { softValidateVariables } from "@/components/service/panel/content/undeployed/validators";
import { WrapperForm, WrapperInner } from "@/components/service/panel/content/undeployed/wrapper";
diff --git a/apps/web/src/components/service/panel/content/undeployed/service-types/undeployed-git.tsx b/apps/web/src/components/service/panel/content/undeployed/service-types/undeployed-git.tsx
index f18eacb20..56139fd3b 100644
--- a/apps/web/src/components/service/panel/content/undeployed/service-types/undeployed-git.tsx
+++ b/apps/web/src/components/service/panel/content/undeployed/service-types/undeployed-git.tsx
@@ -13,7 +13,7 @@ import HeaderToggle from "@/components/service/header-toggle";
import DomainPortBlock from "@/components/service/panel/content/undeployed/blocks/domain-port-block";
import VariablesBlock from "@/components/service/panel/content/undeployed/blocks/variables-block";
import DeployButtonSection from "@/components/service/panel/content/undeployed/deploy-button-section";
-import AdvancedSettingsButton from "@/components/service/panel/content/undeployed/service-types/advanced-settings-button";
+import AdvancedSettingsButton from "@/components/service/advanced-settings-button";
import useCreateFirstDeployment from "@/components/service/panel/content/undeployed/use-create-first-deployment";
import { softValidateVariables } from "@/components/service/panel/content/undeployed/validators";
import { WrapperForm, WrapperInner } from "@/components/service/panel/content/undeployed/wrapper";
diff --git a/apps/web/src/components/service/cron/cron-schedule-notice.tsx b/apps/web/src/components/settings/setting-notice.tsx
similarity index 81%
rename from apps/web/src/components/service/cron/cron-schedule-notice.tsx
rename to apps/web/src/components/settings/setting-notice.tsx
index 835d110a1..f2864cc6f 100644
--- a/apps/web/src/components/service/cron/cron-schedule-notice.tsx
+++ b/apps/web/src/components/settings/setting-notice.tsx
@@ -1,8 +1,8 @@
import { cn } from "@/components/ui/utils";
import { InfoIcon } from "lucide-react";
-// Tells why a setting is off for a service that runs on a cron schedule
-export default function CronScheduleNotice({
+// Tells why a setting is locked or unavailable
+export default function SettingNotice({
children,
className,
}: {
diff --git a/apps/web/src/components/settings/settings-ids.ts b/apps/web/src/components/settings/settings-ids.ts
index 741ef934f..3778243e8 100644
--- a/apps/web/src/components/settings/settings-ids.ts
+++ b/apps/web/src/components/settings/settings-ids.ts
@@ -34,6 +34,7 @@ export const settingsIds = {
cronSchedule: "deploy_cron-schedule",
cronTimezone: "deploy_cron-timezone",
replicas: "deploy_replicas",
+ rollout: "deploy_rollout",
resourceLimits: "deploy_resource-limits",
},
health: {
diff --git a/apps/web/src/components/settings/settings-search.ts b/apps/web/src/components/settings/settings-search.ts
index 39679973d..b179d04f6 100644
--- a/apps/web/src/components/settings/settings-search.ts
+++ b/apps/web/src/components/settings/settings-search.ts
@@ -214,6 +214,20 @@ export const settingsSearchIndex: TSettingsSearchSection[] = [
description: "The number of replicas to run for this service.",
keywords: ["instances", "scale", "count", "horizontal", "pods"],
},
+ {
+ id: settingsIds.deploy.rollout,
+ title: "Rollout",
+ description: "How a deployment replaces the running version.",
+ keywords: [
+ "rolling",
+ "stop first",
+ "recreate",
+ "overlap",
+ "strategy",
+ "single instance",
+ "downtime",
+ ],
+ },
{
id: settingsIds.deploy.resourceLimits,
title: "Resource Limits",
diff --git a/apps/web/src/components/staged-changes/payload.node-test.ts b/apps/web/src/components/staged-changes/payload.node-test.ts
index a5396a81e..e99b4b469 100644
--- a/apps/web/src/components/staged-changes/payload.node-test.ts
+++ b/apps/web/src/components/staged-changes/payload.node-test.ts
@@ -233,6 +233,14 @@ test("sends a staged auto deploy toggle as auto_deploy", () => {
assert.equal(untouched.services[0].auto_deploy, undefined);
});
+test("sends a staged rollout as rollout", () => {
+ const payload = buildApplyStagedChangesPayload(state([], [service("rollout", "stop-first")]));
+ assert.equal(payload.services[0].rollout, "stop-first");
+
+ const untouched = buildApplyStagedChangesPayload(state([], [service("gitBranch", "main")]));
+ assert.equal(untouched.services[0].rollout, undefined);
+});
+
test("sends a staged sticky sessions toggle as session_affinity", () => {
const payload = buildApplyStagedChangesPayload(state([], [service("sessionAffinity", true)]));
assert.equal(payload.services[0].session_affinity, true);
diff --git a/apps/web/src/components/staged-changes/types.ts b/apps/web/src/components/staged-changes/types.ts
index 375585f1e..546c740d6 100644
--- a/apps/web/src/components/staged-changes/types.ts
+++ b/apps/web/src/components/staged-changes/types.ts
@@ -32,6 +32,7 @@ export const ServiceChangeFieldSchema = z.enum([
"isPublic",
"maxRequestBodySizeMb",
"sessionAffinity",
+ "rollout",
"clientIpHeader",
"cronSchedule",
"cronTimezone",
diff --git a/apps/web/src/components/team/create-team-dialog.tsx b/apps/web/src/components/team/create-team-dialog.tsx
index 11df71930..7f147999f 100644
--- a/apps/web/src/components/team/create-team-dialog.tsx
+++ b/apps/web/src/components/team/create-team-dialog.tsx
@@ -77,7 +77,7 @@ export function CreateTeamDialog({ children, dialogOnOpenChange }: TProps) {
Create Team
- A team gets its own namespace. You become its first admin.
+ Teams keep their projects, members and settings apart. You become its first admin.
- People with a role on this team, and anyone who reaches it through another group.
+ Everyone who can open this team, with their role.
{members.error && !members.data && }
diff --git a/apps/web/src/lib/queries/update-service-input.ts b/apps/web/src/lib/queries/update-service-input.ts
index eb73ae22f..55fd5aab1 100644
--- a/apps/web/src/lib/queries/update-service-input.ts
+++ b/apps/web/src/lib/queries/update-service-input.ts
@@ -5,6 +5,7 @@ import {
HealthCheckTypeSchema,
HostSpecSchema,
PortSpecSchema,
+ RolloutSchema,
ServiceBuilderSchema,
ServiceVolumeSchema,
WalLevelSchema,
@@ -28,6 +29,8 @@ export const ServiceDescriptionSchema = z
`Description should be at most ${serviceDescriptionMaxLength} characters.`,
);
+export type TRollout = z.infer;
+
export const UpdateServiceInputSchema = z
.object({
teamId: z.string().uuid(),
@@ -45,6 +48,7 @@ export const UpdateServiceInputSchema = z
image: z.string().optional(),
isPublic: z.boolean().optional(),
sessionAffinity: z.boolean().optional(),
+ rollout: RolloutSchema.optional(),
// "" goes back to automatic
clientIpHeader: z.string().optional(),
stopped: z.boolean().optional(),
@@ -109,6 +113,7 @@ export function toUpdateServiceInput(input: TUpdateServiceInput): UpdateServiceI
image,
isPublic,
sessionAffinity,
+ rollout,
clientIpHeader,
stopped,
cronSchedule,
@@ -226,6 +231,7 @@ export function toUpdateServiceInput(input: TUpdateServiceInput): UpdateServiceI
image,
is_public: isPublic,
session_affinity: sessionAffinity,
+ rollout,
client_ip_header: clientIpHeader,
stopped,
cron_schedule: cronSchedule,
diff --git a/apps/web/src/lib/server/client.gen.ts b/apps/web/src/lib/server/client.gen.ts
index 078e259ca..0c29120e1 100644
--- a/apps/web/src/lib/server/client.gen.ts
+++ b/apps/web/src/lib/server/client.gen.ts
@@ -233,6 +233,8 @@ export const ResourcesSchema = z
})
.strip();
+export const RolloutSchema = z.enum(['rolling', 'stop-first']);
+
export const UpdateServiceInputSchema = z
.object({
add_ports: z.array(PortSpecSchema).nullable().optional(), // Additional ports to add, will not remove existing ports
@@ -274,6 +276,7 @@ export const UpdateServiceInputSchema = z
repository_name: z.string().optional(), // Must be a repository the GitHub installation has access to
repository_owner: z.string().optional(), // Must be the account the GitHub installation belongs to
resources: ResourcesSchema.optional(), // Resource limits and requests for the service containers
+ rollout: RolloutSchema.optional(), // How a deployment replaces the running version. rolling starts the new version next to the old one, so there is no gap. stop-first stops the old version before the new one starts, so the two never run at once, for apps that must not overlap like bots or wallets, at the cost of a few seconds of downtime per deployment. A service with a volume always stops first. Not for databases
run_command: z.string().optional(),
s3_backup_bucket_id: z.string().optional(), // S3 bucket to store database backups in. Only for database services
service_id: z.string(),
@@ -900,6 +903,7 @@ export const CreateServiceInputSchema = z
repository_name: z.string().optional(), // Must be a repository the GitHub installation has access to
repository_owner: z.string().optional(), // Must be the account the GitHub installation belongs to
resources: ResourcesSchema.optional(), // Resource limits and requests for the service containers
+ rollout: RolloutSchema.optional(), // How a deployment replaces the running version. rolling (default) starts the new version next to the old one, so there is no gap. stop-first stops the old version before the new one starts, so the two never run at once, for apps that must not overlap like bots or wallets, at the cost of a few seconds of downtime per deployment. A service with a volume always stops first. Not for databases
run_command: z.string().optional(),
s3_backup_bucket_id: z.string().optional(), // S3 bucket to store database backups in. Only for database services
session_affinity: z.boolean().optional(), // Pin each visitor to one replica with a cookie, for apps that keep sessions or websockets in memory. Defaults to false. Caches like Cloudflare skip responses that set the cookie. Not for databases
@@ -959,6 +963,7 @@ export const ServiceConfigResponseSchema = z
railpack_builder_install_command: z.string().optional(),
replicas: z.number(),
resources: ResourcesSchema.optional(),
+ rollout: RolloutSchema, // How a deployment replaces the running version. rolling starts the new one next to the old one, stop-first stops the old one before the new one starts
run_command: z.string().optional(),
s3_backup_bucket_id: z.string().optional(),
security_context: SecurityContextSchema.optional(),
@@ -3288,6 +3293,7 @@ export type HostRedirect = z.infer;
export type HostHeader = z.infer;
export type HostSpec = z.infer;
export type Resources = z.infer;
+export type Rollout = z.infer;
export type UpdateServiceInput = z.infer;
export type VariableReferenceSourceType = z.infer;
export type VariableUpsertInput = z.infer;