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 (
{Header} + {Description} {Content}
); diff --git a/apps/web/src/components/deployment/deployment-card.tsx b/apps/web/src/components/deployment/deployment-card.tsx index 4c7267943..f0061176b 100644 --- a/apps/web/src/components/deployment/deployment-card.tsx +++ b/apps/web/src/components/deployment/deployment-card.tsx @@ -85,6 +85,9 @@ type TProps = HTMLAttributes & } ) & { service: TServiceShallow }; +export const deploymentCardClassName = + "has-hover:group-hover/card:bg-border/8-10 has-hover:group-hover/card:group-data-[color=destructive]/card:bg-destructive/3-10 has-hover:group-hover/card:group-data-[color=process]/card:bg-process/3-10 has-hover:group-hover/card:group-data-[color=success]/card:bg-success/3-10 has-hover:group-hover/card:group-data-[color=wait]/card:bg-wait/3-10 has-hover:hover:bg-border/8-10 has-hover:hover:group-data-[color=destructive]/card:bg-destructive/3-10 has-hover:hover:group-data-[color=process]/card:bg-process/3-10 has-hover:hover:group-data-[color=success]/card:bg-success/3-10 has-hover:hover:group-data-[color=wait]/card:bg-wait/3-10 focus-visible:bg-border/8-10 focus-visible:group-data-[color=process]/card:bg-process/3-10 focus-visible:group-data-[color=destructive]/card:bg-destructive/3-10 focus-visible:group-data-[color=success]/card:bg-success/3-10 focus-visible:group-data-[color=wait]/card:bg-wait/3-10 group-data-[color=destructive]/card:bg-destructive/1-10 group-data-[color=process]/card:bg-process/1-10 group-data-[color=success]/card:bg-success/1-10 group-data-[color=wait]/card:bg-wait/1-10 active:bg-border/8-10 active:group-data-[color=destructive]/card:bg-destructive/3-10 active:group-data-[color=process]/card:bg-process/3-10 active:group-data-[color=success]/card:bg-success/3-10 active:group-data-[color=wait]/card:bg-wait/3-10 group-data-[color=destructive]/card:border-destructive/4-10 group-data-[color=process]/card:border-process/4-10 group-data-[color=success]/card:border-success/4-10 group-data-[color=wait]/card:border-wait/4-10 focus-visible:ring-offset-background focus-visible:ring-primary/8-10 flex min-w-0 flex-1 flex-col rounded-xl border px-3.5 py-3 text-left focus-visible:ring-1 focus-visible:ring-offset-2 focus-visible:outline-hidden sm:flex-row sm:items-center sm:py-3.5 sm:pr-13 sm:pl-4"; + export default function DeploymentCard({ deployment, currentDeployment, @@ -112,9 +115,6 @@ export default function DeploymentCard({ showReplicas && (deployment?.status === "build-pending" || deployment?.status === "build-queued"); - const cardClassName = - "has-hover:group-hover/card:bg-border/8-10 has-hover:group-hover/card:group-data-[color=destructive]/card:bg-destructive/3-10 has-hover:group-hover/card:group-data-[color=process]/card:bg-process/3-10 has-hover:group-hover/card:group-data-[color=success]/card:bg-success/3-10 has-hover:group-hover/card:group-data-[color=wait]/card:bg-wait/3-10 has-hover:hover:bg-border/8-10 has-hover:hover:group-data-[color=destructive]/card:bg-destructive/3-10 has-hover:hover:group-data-[color=process]/card:bg-process/3-10 has-hover:hover:group-data-[color=success]/card:bg-success/3-10 has-hover:hover:group-data-[color=wait]/card:bg-wait/3-10 focus-visible:bg-border/8-10 focus-visible:group-data-[color=process]/card:bg-process/3-10 focus-visible:group-data-[color=destructive]/card:bg-destructive/3-10 focus-visible:group-data-[color=success]/card:bg-success/3-10 focus-visible:group-data-[color=wait]/card:bg-wait/3-10 group-data-[color=destructive]/card:bg-destructive/1-10 group-data-[color=process]/card:bg-process/1-10 group-data-[color=success]/card:bg-success/1-10 group-data-[color=wait]/card:bg-wait/1-10 active:bg-border/8-10 active:group-data-[color=destructive]/card:bg-destructive/3-10 active:group-data-[color=process]/card:bg-process/3-10 active:group-data-[color=success]/card:bg-success/3-10 active:group-data-[color=wait]/card:bg-wait/3-10 group-data-[color=destructive]/card:border-destructive/4-10 group-data-[color=process]/card:border-process/4-10 group-data-[color=success]/card:border-success/4-10 group-data-[color=wait]/card:border-wait/4-10 focus-visible:ring-offset-background focus-visible:ring-primary/8-10 flex min-w-0 flex-1 flex-col rounded-xl border px-3.5 py-3 text-left focus-visible:ring-1 focus-visible:ring-offset-2 focus-visible:outline-hidden sm:flex-row sm:items-center sm:py-3.5 sm:pr-13 sm:pl-4"; - const cardContent = ( <>
@@ -172,12 +172,12 @@ export default function DeploymentCard({ })} replace={true} resetScroll={false} - className={cardClassName} + className={deploymentCardClassName} > {cardContent} ) : ( - + )}
{isPlaceholder ? ( diff --git a/apps/web/src/components/deployment/deployment-status-chip.tsx b/apps/web/src/components/deployment/deployment-status-chip.tsx index a10351588..780833c16 100644 --- a/apps/web/src/components/deployment/deployment-status-chip.tsx +++ b/apps/web/src/components/deployment/deployment-status-chip.tsx @@ -43,17 +43,44 @@ export default function DeploymentStatusChip({ return ["REMOVED", BroomIcon]; }, [deployment, isPlaceholder, stopped]); + return ( + + ); +} + +export function StatusChip({ + text, + Icon, + color, + isPlaceholder, + iconClassName, + className, +}: { + text: string; + Icon: FC<{ className?: string }>; + color: TStatusColor; + isPlaceholder?: boolean; + iconClassName?: string; + className?: string; +}) { return (
-

{statusText}

+

{text}

); } @@ -62,11 +89,11 @@ function AnimatedHourglassIcon({ className }: { className?: string }) { return ; } -function AnimatedLoaderIcon({ className }: { className?: string }) { +export function AnimatedLoaderIcon({ className }: { className?: string }) { return ; } -type TDeploymentStatusColor = "default" | "wait" | "process" | "destructive" | "success"; +export type TStatusColor = "default" | "wait" | "process" | "destructive" | "success"; export function getDeploymentStatusChipColor({ deployment, @@ -76,7 +103,7 @@ export function getDeploymentStatusChipColor({ deployment?: TDeploymentShallow; isPlaceholder?: boolean; stopped?: boolean; -}): TDeploymentStatusColor { +}): TStatusColor { if (isPlaceholder || !deployment) return "default"; if (stopped && deployment.status === "active") return "default"; if (deployment.status === "build-queued") return "wait"; diff --git a/apps/web/src/components/service/panel/content/undeployed/service-types/advanced-settings-button.tsx b/apps/web/src/components/service/advanced-settings-button.tsx similarity index 100% rename from apps/web/src/components/service/panel/content/undeployed/service-types/advanced-settings-button.tsx rename to apps/web/src/components/service/advanced-settings-button.tsx diff --git a/apps/web/src/components/service/cron/cron-runs.tsx b/apps/web/src/components/service/cron/cron-runs.tsx index b7ea40fff..f6f0b4c37 100644 --- a/apps/web/src/components/service/cron/cron-runs.tsx +++ b/apps/web/src/components/service/cron/cron-runs.tsx @@ -1,6 +1,16 @@ +import { deploymentCardClassName } from "@/components/deployment/deployment-card"; +import { + AnimatedLoaderIcon, + StatusChip, + type TStatusColor, +} from "@/components/deployment/deployment-status-chip"; +import { deploymentPanelTabKey } from "@/components/deployment/panel/constants"; import { useDeploymentPanel } from "@/components/deployment/panel/deployment-panel-provider"; +import ErrorCard from "@/components/error-card"; import ErrorLine from "@/components/error-line"; +import AnimatedTimerIcon from "@/components/icons/animated-timer"; import NoItemsCard from "@/components/no-items-card"; +import { useNow } from "@/components/providers/now-provider"; import { useService } from "@/components/service/service-provider"; import { Button } from "@/components/ui/button"; import { cn } from "@/components/ui/utils"; @@ -8,13 +18,11 @@ import { getDurationStr, useTimeDifference } from "@/lib/hooks/use-time-differen import { cronRunsListQuery, startCronRun, TCronRun } from "@/lib/queries/cron"; import { TServiceShallow } from "@/lib/queries/services"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { - CalendarClockIcon, - CircleCheckIcon, - LoaderIcon, - PlayIcon, - TriangleAlertIcon, -} from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { CalendarClockIcon, CircleCheckIcon, PlayIcon, TriangleAlertIcon } from "lucide-react"; +import { FC } from "react"; + +const placeholderArray = Array.from({ length: 3 }, (_, i) => i); export default function CronRuns({ service }: { service: TServiceShallow }) { const { teamId, projectId, environmentId, serviceId } = useService(); @@ -23,7 +31,6 @@ export default function CronRuns({ service }: { service: TServiceShallow }) { ...cronRunsListQuery(ids), refetchInterval: 5000, }); - const { openPanel } = useDeploymentPanel(); const isRunning = data?.runs.some((run) => run.status === "running") ?? false; const { @@ -36,26 +43,28 @@ export default function CronRuns({ service }: { service: TServiceShallow }) { }); return ( -
-
-

Runs

+ <> +
+

+ Runs +

{startError && } - {!data && error && } - {!data && !error && ( -
- )} {data && data.runs.length === 0 && ( No runs yet. The first one starts at the next tick of the schedule. @@ -65,91 +74,113 @@ export default function CronRuns({ service }: { service: TServiceShallow }) {
    {data.runs.map((run) => (
  1. - openPanel(run.deployment_id as string, "deploy-logs") - : undefined - } - /> +
  2. ))}
)} -
+ {!data && isPending && placeholderArray.map((i) => )} + {!data && !isPending && error && } + ); } -type TRunTone = "muted" | "success" | "destructive"; +type TRunProps = { run: TCronRun; isPlaceholder?: never } | { run?: never; isPlaceholder: true }; -function describeRun(run: TCronRun): { - Icon: typeof CircleCheckIcon; - tone: TRunTone; +function describeRun(run: TCronRun | undefined): { text: string; + Icon: FC<{ className?: string }>; + color: TStatusColor; } { - switch (run.status) { + switch (run?.status) { case "running": - return { Icon: LoaderIcon, tone: "muted", text: "Running" }; + return { text: "RUNNING", Icon: AnimatedLoaderIcon, color: "process" }; case "failed": - return { - Icon: TriangleAlertIcon, - tone: "destructive", - text: run.failure_reason ? `Failed: ${run.failure_reason}` : "Failed", - }; - case "succeeded": { - const started = run.started_at ?? run.created_at; - const duration = run.completed_at - ? getDurationStr({ start: started, end: run.completed_at }) - : ""; - return { - Icon: CircleCheckIcon, - tone: "success", - text: duration ? `Succeeded in ${duration}` : "Succeeded", - }; - } + return { text: "FAILED", Icon: TriangleAlertIcon, color: "destructive" }; + case "succeeded": + return { text: "SUCCEEDED", Icon: CircleCheckIcon, color: "success" }; + default: + return { text: "LOADING", Icon: AnimatedLoaderIcon, color: "default" }; } } -function RunLine({ run, onOpen }: { run: TCronRun; onOpen?: () => void }) { - const { str: timeAgo } = useTimeDifference({ timestamp: new Date(run.created_at).getTime() }); - const { Icon, tone, text } = describeRun(run); - const trigger = run.manual ? "Started manually" : "Scheduled"; - const meta = timeAgo ? `${trigger}, ${timeAgo}` : trigger; +function RunCard({ run, isPlaceholder }: TRunProps) { + const { getOpenSearch } = useDeploymentPanel(); + const now = useNow(); + const { str: timeAgo } = useTimeDifference({ + timestamp: run ? new Date(run.created_at).getTime() : Date.now(), + }); + const { text, Icon, color } = describeRun(run); + const isRunning = run?.status === "running"; + const started = run?.started_at ?? run?.created_at; + const durationStr = + !run || !started + ? undefined + : run.completed_at + ? getDurationStr({ start: started, end: run.completed_at }) + : isRunning + ? getDurationStr({ start: started, end: now }) + : undefined; + const skeleton = + "group-data-placeholder/card:bg-foreground group-data-placeholder/card:animate-skeleton group-data-placeholder/card:rounded-md group-data-placeholder/card:text-transparent"; const content = ( <> -
- +
+ +
+
+

+ {!run ? "Scheduled" : run.manual ? "Started manually" : "Scheduled"} +

+
+

{run ? timeAgo : "1 hr. ago"}

+ {(isPlaceholder || durationStr) && ( + <> + | +

+ + + + {durationStr ?? "0:05"} +

+ + )} +
+ {run?.failure_reason && ( +

+ {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 ( -
- {content} -
- ); - } return ( - + {run?.deployment_id ? ( + ({ + ...prev, + ...getOpenSearch(run.deployment_id as string), + [deploymentPanelTabKey]: "deploy-logs", + })} + replace={true} + resetScroll={false} + className={className} + > + {content} + + ) : ( +
{content}
+ )} +
); } diff --git a/apps/web/src/components/service/panel/content/deployed/settings/sections/deploy-section.tsx b/apps/web/src/components/service/panel/content/deployed/settings/sections/deploy-section.tsx index 8c0a3b31c..075dc7ba3 100644 --- a/apps/web/src/components/service/panel/content/deployed/settings/sections/deploy-section.tsx +++ b/apps/web/src/components/service/panel/content/deployed/settings/sections/deploy-section.tsx @@ -9,7 +9,7 @@ import { BlockItemTitle, } from "@/components/block"; import { validateCronExpression } from "@/components/service/backups/backup-config"; -import CronScheduleNotice from "@/components/service/cron/cron-schedule-notice"; +import SettingNotice from "@/components/settings/setting-notice"; import { continuousScheduleValue, cronSchedulePresets, @@ -39,7 +39,15 @@ import { cn } from "@/components/ui/utils"; import { TCommandItem, useAppForm } from "@/lib/hooks/use-app-form"; import { TServiceShallow } from "@/lib/queries/services"; import { useStore } from "@tanstack/react-form"; -import { CalendarClockIcon, GlobeIcon, InfoIcon, LockIcon, RocketIcon } from "lucide-react"; +import { TRollout } from "@/lib/queries/update-service-input"; +import { + CalendarClockIcon, + GlobeIcon, + LockIcon, + PowerIcon, + RefreshCwIcon, + RocketIcon, +} from "lucide-react"; import { useMemo } from "react"; type TProps = { @@ -110,6 +118,7 @@ const deployFields: TServiceChangeField[] = [ "cronSchedule", "cronTimezone", "replicaCount", + "rollout", "cpuLimitMillicores", "memoryLimitMb", ]; @@ -124,6 +133,22 @@ function scheduleLabel(preset: string) { return scheduleItems.find((item) => item.value === preset)?.label ?? "Custom"; } +const stopFirstRollout: TRollout = "stop-first"; + +const rolloutItems: TCommandItem[] = [ + { value: "rolling", label: "Rolling" }, + { value: stopFirstRollout, label: "Stop first" }, +]; + +function rolloutLabel(rollout: TRollout) { + return rolloutItems.find((item) => item.value === rollout)?.label ?? rollout; +} + +function RolloutIcon({ rollout, className }: { rollout: TRollout; className?: string }) { + if (rollout === stopFirstRollout) return ; + return ; +} + const timezoneItems: TCommandItem[] = [ { value: "", label: "UTC" }, ...Intl.supportedValuesOf("timeZone") @@ -139,12 +164,14 @@ function Section({ service }: { service: TServiceShallow }) { const serverSchedule = service.config.cron_schedule ?? continuousScheduleValue; const serverTimezone = service.config.cron_timezone ?? ""; const serverReplicaCount = service.config.replicas; + const serverRollout = service.config.rollout; const serverCpu = service.config.resources?.cpu_limits_millicores || unlimitedApiValue; const serverMemory = service.config.resources?.memory_limits_megabytes || unlimitedApiValue; const { staged, stage, unstage, revert } = useServiceChanges(service, { cronSchedule: serverSchedule, cronTimezone: serverTimezone, replicaCount: serverReplicaCount, + rollout: serverRollout, cpuLimitMillicores: serverCpu, memoryLimitMb: serverMemory, }); @@ -158,6 +185,7 @@ function Section({ service }: { service: TServiceShallow }) { cronScheduleCustom: stagedSchedule, cronTimezone: stagedString(staged.cronTimezone, serverTimezone), replicaCount: stagedNumber(staged.replicaCount, serverReplicaCount), + rollout: stagedString(staged.rollout, serverRollout) as TRollout, cpuLimitMillicores: toSlider( stagedNumber(staged.cpuLimitMillicores, serverCpu), cpuLimits.unlimited, @@ -201,8 +229,10 @@ function Section({ service }: { service: TServiceShallow }) { const showTimezone = showSchedule && isScheduled && isItemVisible(settingsIds.deploy.cronTimezone); const showReplicas = hasReplicas && isItemVisible(settingsIds.deploy.replicas); + // A schedule runs jobs, there is no running version to replace + const showRollout = hasReplicas && !isScheduled && isItemVisible(settingsIds.deploy.rollout); const showResourceLimits = isItemVisible(settingsIds.deploy.resourceLimits); - if (!showSchedule && !showReplicas && !showResourceLimits) return null; + if (!showSchedule && !showReplicas && !showRollout && !showResourceLimits) return null; return ( unstage(deployFields)} > - {showSchedule && ( - - - - Cron Schedule - Run the start command on a schedule. - - - ( - { - field.handleChange(v); - if (v === customCronScheduleValue) { - stageSchedule(form.getFieldValue("cronScheduleCustom")); - return; - } - form.setFieldValue("cronScheduleCustom", v); - stageSchedule(v); - }} - items={scheduleItems} - isPending={false} - error={undefined} - > - {({ isOpen }) => ( - } - variant="outline" - open={isOpen} - onBlur={field.handleBlur} - hasChanges={staged.cronSchedule !== undefined} - /> - )} - - )} - /> - {schedulePreset === customCronScheduleValue && ( - <> -
- validateCronExpression(value), - }} - children={(field) => ( - { - field.handleChange(e.target.value); - if (field.state.meta.errors.length > 0) return; - stageSchedule(e.target.value); - }} - placeholder="*/10 * * * *" - autoCapitalize="off" - autoCorrect="off" - autoComplete="off" - spellCheck="false" - revertTo={serverSchedule} - onRevert={() => revert(field, "cronSchedule", serverSchedule)} - hasChanges={staged.cronSchedule !== undefined} - /> - )} - /> - - )} - - - {showTimezone && ( - ( - - - Time Zone - Time zone the schedule is read in. - - - { - field.handleChange(v); - stageTimezone(v); - }} - items={timezoneItems} - isPending={false} - error={undefined} - commandInputPlaceholder="Search time zones..." - CommandEmptyText="No time zones found" - CommandEmptyIcon={GlobeIcon} - > - {({ isOpen }) => ( - } - variant="outline" - open={isOpen} - onBlur={field.handleBlur} - hasChanges={staged.cronTimezone !== undefined} - /> - )} - - - - )} - /> - )} - - )} {showReplicas && (
{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. + + + +
+ { + const rollout = v as TRollout; + field.handleChange(rollout); + stage({ + field: "rollout", + label: "Rollout", + value: rollout, + previous: serverRollout, + format: (r) => rolloutLabel(r as TRollout), + }); + }} + items={rolloutItems} + ItemIcon={({ className, value }) => ( + + )} + isPending={false} + error={undefined} + > + {({ isOpen }) => ( + + hasVolume ? ( + + ) : ( + + ) + } + variant="outline" + open={isOpen} + disabled={hasVolume} + onBlur={field.handleBlur} + hasChanges={staged.rollout !== undefined} + /> + )} + + {hasVolume && ( + + Services with a volume always stop the old version first. + + )} +
+
+
+ ); + }} + /> +
+ )} {showResourceLimits && ( @@ -543,6 +515,131 @@ function Section({ service }: { service: TServiceShallow }) { )} + {showSchedule && ( + + + + Cron Schedule + Run the start command on a schedule. + + + ( + { + field.handleChange(v); + if (v === customCronScheduleValue) { + stageSchedule(form.getFieldValue("cronScheduleCustom")); + return; + } + form.setFieldValue("cronScheduleCustom", v); + stageSchedule(v); + }} + items={scheduleItems} + isPending={false} + error={undefined} + > + {({ isOpen }) => ( + } + variant="outline" + open={isOpen} + onBlur={field.handleBlur} + hasChanges={staged.cronSchedule !== undefined} + /> + )} + + )} + /> + {schedulePreset === customCronScheduleValue && ( + <> +
+ validateCronExpression(value), + }} + children={(field) => ( + { + field.handleChange(e.target.value); + if (field.state.meta.errors.length > 0) return; + stageSchedule(e.target.value); + }} + placeholder="*/10 * * * *" + autoCapitalize="off" + autoCorrect="off" + autoComplete="off" + spellCheck="false" + revertTo={serverSchedule} + onRevert={() => revert(field, "cronSchedule", serverSchedule)} + hasChanges={staged.cronSchedule !== undefined} + /> + )} + /> + + )} + + + + )} + {showTimezone && ( + + ( + + + Time Zone + Time zone the schedule is read in. + + + { + field.handleChange(v); + stageTimezone(v); + }} + items={timezoneItems} + isPending={false} + error={undefined} + commandInputPlaceholder="Search time zones..." + CommandEmptyText="No time zones found" + CommandEmptyIcon={GlobeIcon} + > + {({ isOpen }) => ( + } + variant="outline" + open={isOpen} + onBlur={field.handleBlur} + hasChanges={staged.cronTimezone !== undefined} + /> + )} + + + + )} + /> + + )} ); } diff --git a/apps/web/src/components/service/panel/content/deployed/settings/sections/health-section.tsx b/apps/web/src/components/service/panel/content/deployed/settings/sections/health-section.tsx index e6765f19b..5530ae0a2 100644 --- a/apps/web/src/components/service/panel/content/deployed/settings/sections/health-section.tsx +++ b/apps/web/src/components/service/panel/content/deployed/settings/sections/health-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, @@ -48,9 +48,9 @@ export default function HealthSection({ service }: TProps) { if (isScheduledService(service)) { return ( - + Health checks are not available for cron schedules. - + ); } diff --git a/apps/web/src/components/service/panel/content/deployed/settings/sections/networking/_components/domain-options-fields.tsx b/apps/web/src/components/service/panel/content/deployed/settings/sections/networking/_components/domain-options-fields.tsx index 10e81f010..a38ca9b08 100644 --- a/apps/web/src/components/service/panel/content/deployed/settings/sections/networking/_components/domain-options-fields.tsx +++ b/apps/web/src/components/service/panel/content/deployed/settings/sections/networking/_components/domain-options-fields.tsx @@ -8,6 +8,7 @@ import { BlockItemTitle, } from "@/components/block"; import ErrorLine from "@/components/error-line"; +import AdvancedSettingsButton from "@/components/service/advanced-settings-button"; import ResponseHeadersInput from "@/components/service/panel/content/deployed/settings/sections/networking/_components/response-headers-input"; import { defaultRedirectStatusCode, @@ -20,6 +21,7 @@ import type { THostTarget } from "@/components/staged-changes/types"; import { useSystem } from "@/components/system/system-provider"; import { withFieldGroup } from "@/lib/hooks/use-app-form"; import type { AnyFieldApi } from "@tanstack/react-form"; +import { useState } from "react"; import { ArrowRightLeftIcon, CornerDownRightIcon, @@ -79,6 +81,16 @@ export function domainOptionsToValues(options: THostOptions | undefined): TDomai }; } +export function hasDomainOptions(values: TDomainOptionsValues) { + return ( + routePath(values.path.trim()) !== "/" || + values.redirectTo.trim() !== "" || + values.proxyTo.trim() !== "" || + values.basicAuthUsername.trim() !== "" || + values.responseHeaders.length > 0 + ); +} + export function domainTarget(values: TDomainOptionsValues) { if (values.redirectTo.trim() !== "") return "redirect"; if (values.proxyTo.trim() !== "") return "proxy"; @@ -162,258 +174,260 @@ export const DomainOptionsFields = withFieldGroup({ }, render: function Render({ group, serverValues }) { const { data: systemData } = useSystem(); + // Open from the start when the domain already has options, so they are not missed + const [isOpen, setIsOpen] = useState(() => hasDomainOptions(group.state.values)); const hasSavedPassword = serverValues.basicAuthUsername !== ""; // A saved proxy stays editable so it can be removed after the provider changed const canProxy = systemData?.data.networking_provider === "gateway" || serverValues.proxyTo !== ""; return ( - <> - - validateDomainPath(value) }} - > - {(field) => ( - - - Path - - Answer only requests under this path, like /api. Leave it empty for the whole - domain. - - - - field.handleChange(e.target.value)} - placeholder="/" - autoCapitalize="off" - autoCorrect="off" - autoComplete="off" - spellCheck="false" - Icon={RouteIcon} - hasChanges={ - routePath(field.state.value.trim()) !== routePath(serverValues.path) - } - /> - - - )} - - - domainTarget(s.values)}> - {(target) => ( - <> - {target !== "proxy" && ( - - validateRedirectTo(value) }} - > - {(field) => ( - - - Redirect - - Send visitors to another URL instead of the service. The path is kept - when the URL has none. - - - - field.handleChange(e.target.value)} - placeholder="https://example.com" - autoCapitalize="off" - autoCorrect="off" - autoComplete="off" - spellCheck="false" - inputMode="url" - Icon={CornerDownRightIcon} - hasChanges={field.state.value.trim() !== serverValues.redirectTo} - /> - - - )} - - - )} - {target === "redirect" && ( - - - {(field) => ( - - - Redirect Status - - - field.handleChange(v)} - items={redirectStatusItems} - isPending={false} - error={undefined} - > - {({ isOpen }) => ( - i.value === field.state.value) - ?.label ?? field.state.value - } - variant="outline" - open={isOpen} +
+ setIsOpen(!isOpen)} /> + {isOpen && ( +
+ + validateDomainPath(value) }} + > + {(field) => ( + + + Path + Leave empty for the whole domain. + + + field.handleChange(e.target.value)} + placeholder="/api" + autoCapitalize="off" + autoCorrect="off" + autoComplete="off" + spellCheck="false" + Icon={RouteIcon} + hasChanges={ + routePath(field.state.value.trim()) !== routePath(serverValues.path) + } + /> + + + )} + + + domainTarget(s.values)}> + {(target) => ( + <> + {target !== "proxy" && ( + + validateRedirectTo(value) }} + > + {(field) => ( + + + Redirect + + Send visitors to another URL instead of the service. + + + + field.handleChange(e.target.value)} + placeholder="https://example.com" + autoCapitalize="off" + autoCorrect="off" + autoComplete="off" + spellCheck="false" + inputMode="url" + Icon={CornerDownRightIcon} + hasChanges={field.state.value.trim() !== serverValues.redirectTo} /> - )} - - - - )} - - - )} - {target !== "redirect" && canProxy && ( - - validateProxyTo(value) }} - > - {(field) => ( + + + )} + + + )} + {target === "redirect" && ( + + + {(field) => ( + + + Redirect Status + + + field.handleChange(v)} + items={redirectStatusItems} + isPending={false} + error={undefined} + > + {({ isOpen }) => ( + i.value === field.state.value) + ?.label ?? field.state.value + } + variant="outline" + open={isOpen} + onBlur={field.handleBlur} + hasChanges={field.state.value !== serverValues.redirectStatus} + /> + )} + + + + )} + + + )} + {target !== "redirect" && canProxy && ( + + validateProxyTo(value) }} + > + {(field) => ( + + + Proxy + + Serve another URL on this domain instead of the service. + + + + field.handleChange(e.target.value)} + placeholder="https://example.com/install.sh" + autoCapitalize="off" + autoCorrect="off" + autoComplete="off" + spellCheck="false" + inputMode="url" + Icon={ArrowRightLeftIcon} + hasChanges={field.state.value.trim() !== serverValues.proxyTo} + /> + + + )} + + + )} + {target !== "redirect" && ( + - Proxy - - Serve another URL on this domain instead of the service. - + Basic Auth - field.handleChange(e.target.value)} - placeholder="https://example.com/install.sh" - autoCapitalize="off" - autoCorrect="off" - autoComplete="off" - spellCheck="false" - inputMode="url" - Icon={ArrowRightLeftIcon} - hasChanges={field.state.value.trim() !== serverValues.proxyTo} - /> +
+ + {(field) => ( + field.handleChange(e.target.value)} + placeholder="Username" + autoCapitalize="off" + autoCorrect="off" + autoComplete="off" + spellCheck="false" + Icon={UserIcon} + classNameInput="rounded-b-none" + hasChanges={ + field.state.value.trim() !== serverValues.basicAuthUsername + } + /> + )} + +
+ { + const isProtected = + group.getFieldValue("redirectTo").trim() === "" && + group.getFieldValue("basicAuthUsername").trim() !== ""; + if (!isProtected || hasSavedPassword || value !== "") + return undefined; + return { message: "Enter a password." }; + }, + }} + > + {(field) => ( + field.handleChange(e.target.value)} + type="password" + placeholder={ + hasSavedPassword ? "Password (unchanged)" : "Password" + } + autoComplete="new-password" + Icon={KeyRoundIcon} + classNameInput="rounded-t-none border-t-0" + hasChanges={field.state.value !== ""} + /> + )} + +
- )} - - + + )} + )} - {target !== "redirect" && ( - + + + validateResponseHeaders(value) }} + > + {(field) => ( - Basic Auth - - Ask visitors for a username and password before the domain answers. - + Response Headers -
- - {(field) => ( - field.handleChange(e.target.value)} - placeholder="Username" - autoCapitalize="off" - autoCorrect="off" - autoComplete="off" - spellCheck="false" - Icon={UserIcon} - classNameInput="rounded-b-none" - hasChanges={ - field.state.value.trim() !== serverValues.basicAuthUsername - } - /> - )} - -
- { - const isProtected = - group.getFieldValue("redirectTo").trim() === "" && - group.getFieldValue("basicAuthUsername").trim() !== ""; - if (!isProtected || hasSavedPassword || value !== "") - return undefined; - return { message: "Enter a password." }; - }, - }} - > - {(field) => ( - field.handleChange(e.target.value)} - type="password" - placeholder={hasSavedPassword ? "Password (unchanged)" : "Password"} - autoComplete="new-password" - Icon={KeyRoundIcon} - classNameInput="rounded-t-none border-t-0" - hasChanges={field.state.value !== ""} - /> - )} - +
+ field.handleChange(v)} + onBlur={field.handleBlur} + /> + {field.state.meta.errors.length > 0 && field.state.meta.isTouched && ( + + )}
- - )} - - )} - - - validateResponseHeaders(value) }} - > - {(field) => ( - - - Response Headers - - Set on every response of the domain, as Name: value. - - - -
- field.handleChange(v)} - onBlur={field.handleBlur} - /> - {field.state.meta.errors.length > 0 && field.state.meta.isTouched && ( - - )} -
-
-
- )} -
-
- + )} + + +
+ )} +
); }, }); 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.
field.handleChange(e.target.value)} className="w-full" - placeholder="Platform" + placeholder="Name" maxLength={teamNameMaxLength} /> )} diff --git a/apps/web/src/components/team/settings/members/members-tab-content.tsx b/apps/web/src/components/team/settings/members/members-tab-content.tsx index 0b07077d4..21b864b43 100644 --- a/apps/web/src/components/team/settings/members/members-tab-content.tsx +++ b/apps/web/src/components/team/settings/members/members-tab-content.tsx @@ -41,7 +41,7 @@ function Content({ className }: { className?: string }) { )}

- 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;