Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/api/internal/models/service_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,20 +82,20 @@ 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"`
RailpackBuilderBuildCommand *string `json:"railpack_builder_build_command,omitempty"`
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"`
Expand Down
26 changes: 26 additions & 0 deletions apps/api/internal/services/service/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions apps/api/internal/services/service/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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))
})
}
4 changes: 3 additions & 1 deletion apps/docs/generated/openapi.gen.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/lib/server/client.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down