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 00000000..383915b3 --- /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 bad36296..8bb3969e 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 047da94b..6504adc5 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 bbc45d12..b109c718 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 423276e3..f34732a5 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 1a78921c..6d30878e 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 9c2133b1..4bd87f5b 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 9aaf49a2..419ed1ac 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 2e25cc3c..c3c0b476 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 625f635e..bf51d650 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 f839c552..ddc0867c 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 a10bafec..f04a0398 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 500da920..ee42864d 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 73910423..871275d2 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 80b96a14..fc3a0f58 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 4c718797..cf9bbd43 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 fcb8d71f..0da9f2c7 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 b29abc26..d4418d4a 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 0a292bda..3987ae06 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 a5e46770..9377ba0e 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 052a8066..83861a62 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 c4fc2ba4..694c0413 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 49102c66..29439c9d 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 752baf73..b594035c 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 d213ce71..78c5f757 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 736cd577..ba93dfb5 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 2d21ce77..4e7508f7 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 2900e262..0eaa836b 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 31c39c80..64094487 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 950aabb0..7716897e 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 662ef35d..9f2d7bba 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 a1cb3002..56025eeb 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 ecaac05b..5c0dab87 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 44c99a75..5e45f9fa 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{statusText}
+{text}
+ {!run ? "Scheduled" : run.manual ? "Started manually" : "Scheduled"} +
+{run ? timeAgo : "1 hr. ago"}
+ {(isPlaceholder || durationStr) && ( + <> + | +
+
+
+ {run.failure_reason} +
+ )}{text}
-{meta}
> ); - const className = - "group/line flex w-full min-w-0 items-start gap-2 rounded-lg border px-3 py-2.5 leading-tight"; + const className = cn(deploymentCardClassName, "sm:pr-4"); - if (!onOpen) { - return ( -- Services with a volume can only have a single replica. -
-- People with a role on this team, and anyone who reaches it through another group. + Everyone who can open this team, with their role.