diff --git a/apps/api/internal/models/service_input.go b/apps/api/internal/models/service_input.go index 05aaea45..a228b041 100644 --- a/apps/api/internal/models/service_input.go +++ b/apps/api/internal/models/service_input.go @@ -82,12 +82,12 @@ type UpdateServiceInput struct { GitTag *string `json:"git_tag,omitempty" required:"false" doc:"Tag to build from, supports glob patterns"` WatchPaths *[]string `json:"watch_paths,omitempty" required:"false" doc:"Gitignore-style patterns, a push deploys only when a changed file matches. Empty deploys on every push"` Builder *schema.ServiceBuilder `json:"builder,omitempty" required:"false"` - OverwriteHosts []schema.HostSpec `json:"overwrite_hosts,omitempty" required:"false"` + OverwriteHosts []schema.HostSpec `json:"overwrite_hosts,omitempty" required:"false" doc:"Replaces every domain. An empty list removes them all"` UpsertHosts []schema.HostSpec `json:"upsert_hosts,omitempty" required:"false" doc:"Additional hosts to add, will not remove existing hosts"` RemoveHosts []schema.HostSpec `json:"remove_hosts,omitempty" required:"false" doc:"Hosts to remove"` AddPorts []schema.PortSpec `json:"add_ports,omitempty" required:"false" doc:"Additional ports to add, will not remove existing ports"` RemovePorts []schema.PortSpec `json:"remove_ports,omitempty" required:"false" doc:"Ports to remove"` - OverwritePorts []schema.PortSpec `json:"overwrite_ports,omitempty" required:"false"` + OverwritePorts []schema.PortSpec `json:"overwrite_ports,omitempty" required:"false" doc:"Replaces every port. An empty list removes them all. Not for databases"` Replicas *int32 `json:"replicas,omitempty" required:"false" minimum:"1" maximum:"10" doc:"Must be 1 for a service with a volume"` AutoDeploy *bool `json:"auto_deploy,omitempty" required:"false" doc:"Deploy on every push to the branch. Only for a GitHub service"` RailpackBuilderInstallCommand *string `json:"railpack_builder_install_command,omitempty"` @@ -95,7 +95,7 @@ type UpdateServiceInput struct { RunCommand *string `json:"run_command,omitempty" required:"false"` CronSchedule *string `json:"cron_schedule,omitempty" required:"false" doc:"Cron expression (minute hour day month weekday). When set the service runs its command on this schedule and exits instead of running continuously, with no replicas, ports or domains. Set empty string to run continuously again. Not for databases"` CronTimezone *string `json:"cron_timezone,omitempty" required:"false" doc:"IANA time zone for the cron schedule, e.g. 'Europe/Berlin'. Set empty string for UTC"` - 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"` + 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. Any other service loses every domain 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"` diff --git a/apps/api/internal/services/service/update.go b/apps/api/internal/services/service/update.go index 7ffd1216..7dc218a0 100644 --- a/apps/api/internal/services/service/update.go +++ b/apps/api/internal/services/service/update.go @@ -44,6 +44,26 @@ func removesLastHost(existing []schema.HostSpec, input *models.UpdateServiceInpu return len(service_repo.MergeHosts(existing, input.OverwriteHosts, input.UpsertHosts, input.RemoveHosts)) == 0 } +// expandClears turns an empty overwrite list, and going private, into removals of +// what the service has: the merge treats an empty overwrite as no change +func expandClears(config *ent.ServiceConfig, input *models.UpdateServiceInput) error { + if input.IsPublic != nil && !*input.IsPublic { + if len(input.OverwriteHosts) > 0 || len(input.UpsertHosts) > 0 { + return invalid("is_public false removes every domain, it can't be sent with overwrite_hosts or upsert_hosts") + } + input.OverwriteHosts = []schema.HostSpec{} + } + if input.OverwriteHosts != nil && len(input.OverwriteHosts) == 0 { + input.OverwriteHosts = nil + input.RemoveHosts = append(input.RemoveHosts, config.Hosts...) + } + if input.OverwritePorts != nil && len(input.OverwritePorts) == 0 { + input.OverwritePorts = nil + input.RemovePorts = append(input.RemovePorts, config.Ports...) + } + return nil +} + // serviceUpdate is a validated update, ready to apply type serviceUpdate struct { input *models.UpdateServiceInput @@ -180,6 +200,12 @@ func (self *ServiceService) prepareServiceUpdate(ctx context.Context, requesterU return nil, err } + if service.Type != schema.ServiceTypeDatabase { + if err := expandClears(service.Edges.ServiceConfig, input); err != nil { + return nil, err + } + } + // For database we don't want to set ports if service.Type == schema.ServiceTypeDatabase { input.OverwritePorts = nil diff --git a/apps/api/internal/services/service/update_test.go b/apps/api/internal/services/service/update_test.go index 4ad42787..9eb3f67e 100644 --- a/apps/api/internal/services/service/update_test.go +++ b/apps/api/internal/services/service/update_test.go @@ -3,10 +3,13 @@ package service_service import ( "testing" + "github.com/danielgtaylor/huma/v2" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/unbindapp/unbind-api/ent" "github.com/unbindapp/unbind-api/ent/schema" "github.com/unbindapp/unbind-api/internal/models" + service_repo "github.com/unbindapp/unbind-api/internal/repositories/service" ) func TestDatabasePortsWithNodePorts(t *testing.T) { @@ -165,3 +168,53 @@ func TestSmallestVolumeMiB(t *testing.T) { assert.Equal(t, int64(2048), smallestVolumeMiB(nil, "2Gi"), "no claim yet") assert.Equal(t, int64(1024), smallestVolumeMiB(nil, ""), "nothing recorded") } + +func TestExpandClears(t *testing.T) { + config := &ent.ServiceConfig{ + Hosts: []schema.HostSpec{{Host: "a.com"}, {Host: "b.com"}}, + Ports: []schema.PortSpec{{Port: 3000}}, + } + decode := func(body string) *models.UpdateServiceInput { + var input models.UpdateServiceInput + require.NoError(t, huma.DefaultJSONFormat.Unmarshal([]byte(body), &input)) + return &input + } + + t.Run("empty overwrites remove everything", func(t *testing.T) { + input := decode(`{"overwrite_hosts": [], "overwrite_ports": []}`) + require.NoError(t, expandClears(config, input)) + assert.Nil(t, input.OverwriteHosts) + assert.Nil(t, input.OverwritePorts) + assert.Equal(t, config.Hosts, input.RemoveHosts) + assert.Equal(t, config.Ports, input.RemovePorts) + assert.Empty(t, service_repo.MergeHosts(config.Hosts, input.OverwriteHosts, input.UpsertHosts, input.RemoveHosts)) + assert.Empty(t, service_repo.MergePorts(config.Ports, input.OverwritePorts, input.AddPorts, input.RemovePorts)) + assert.True(t, removesLastHost(config.Hosts, input)) + }) + + t.Run("missing overwrites change nothing", func(t *testing.T) { + input := decode(`{"replicas": 2}`) + require.NoError(t, expandClears(config, input)) + assert.Nil(t, input.RemoveHosts) + assert.Nil(t, input.RemovePorts) + }) + + t.Run("going private removes every domain", func(t *testing.T) { + input := decode(`{"is_public": false}`) + require.NoError(t, expandClears(config, input)) + assert.Equal(t, config.Hosts, input.RemoveHosts) + assert.Nil(t, input.RemovePorts) + assert.True(t, removesLastHost(config.Hosts, input)) + }) + + t.Run("going private with domains is refused", func(t *testing.T) { + assert.Error(t, expandClears(config, decode(`{"is_public": false, "upsert_hosts": [{"host": "c.com"}]}`))) + assert.Error(t, expandClears(config, decode(`{"is_public": false, "overwrite_hosts": [{"host": "c.com"}]}`))) + }) + + t.Run("empty overwrite with upserts keeps only the upserts", func(t *testing.T) { + input := decode(`{"overwrite_hosts": [], "upsert_hosts": [{"host": "c.com"}]}`) + require.NoError(t, expandClears(config, input)) + assert.Equal(t, []schema.HostSpec{{Host: "c.com"}}, service_repo.MergeHosts(config.Hosts, input.OverwriteHosts, input.UpsertHosts, input.RemoveHosts)) + }) +} diff --git a/apps/docs/generated/openapi.gen.yaml b/apps/docs/generated/openapi.gen.yaml index 2462b03b..ac28a1a7 100644 --- a/apps/docs/generated/openapi.gen.yaml +++ b/apps/docs/generated/openapi.gen.yaml @@ -4616,7 +4616,7 @@ components: - array - "null" is_public: - description: Whether the service is reachable from the internet. A database gets a public address when true and loses it when false + description: Whether the service is reachable from the internet. A database gets a public address when true and loses it when false. Any other service loses every domain when false type: boolean max_request_body_size_mb: description: 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 @@ -4632,12 +4632,14 @@ components: - string - "null" overwrite_hosts: + description: Replaces every domain. An empty list removes them all items: $ref: "#/components/schemas/HostSpec" type: - array - "null" overwrite_ports: + description: Replaces every port. An empty list removes them all. Not for databases items: $ref: "#/components/schemas/PortSpec" type: diff --git a/apps/web/src/lib/server/client.gen.ts b/apps/web/src/lib/server/client.gen.ts index 32f5cf14..949616bd 100644 --- a/apps/web/src/lib/server/client.gen.ts +++ b/apps/web/src/lib/server/client.gen.ts @@ -258,11 +258,11 @@ export const UpdateServiceInputSchema = z health_check: HealthCheckSchema.optional(), image: z.string().optional(), // Image to run, with an optional tag. Only for an image service init_containers: z.array(InitContainerSchema).nullable().optional(), // List of init containers - is_public: z.boolean().optional(), // Whether the service is reachable from the internet. A database gets a public address when true and loses it when false + is_public: z.boolean().optional(), // Whether the service is reachable from the internet. A database gets a public address when true and loses it when false. Any other service loses every domain when false max_request_body_size_mb: z.number().optional(), // 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 name: z.string().nullable().optional(), // Has to be unique in the environment - overwrite_hosts: z.array(HostSpecSchema).nullable().optional(), - overwrite_ports: z.array(PortSpecSchema).nullable().optional(), + overwrite_hosts: z.array(HostSpecSchema).nullable().optional(), // Replaces every domain. An empty list removes them all + overwrite_ports: z.array(PortSpecSchema).nullable().optional(), // Replaces every port. An empty list removes them all. Not for databases overwrite_variable_mounts: z.array(VariableMountSchema).nullable().optional(), // Mount variables as volumes overwrite_volumes: z.array(ServiceVolumeSchema).nullable().optional(), // Volume to mount on the service, replacing the current one. One at most project_id: z.string(),