diff --git a/api/openapi.yaml b/api/openapi.yaml index 29822f0d..013e45d2 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -144,7 +144,6 @@ components: - port - protocol - service_name - - service_port properties: name: type: string @@ -160,8 +159,6 @@ components: type: string service_name: type: string - service_port: - type: integer selector: type: object additionalProperties: diff --git a/apps/druid/core/services/runtime_ports.go b/apps/druid/core/services/runtime_ports.go new file mode 100644 index 00000000..a08cc7f1 --- /dev/null +++ b/apps/druid/core/services/runtime_ports.go @@ -0,0 +1,71 @@ +package services + +import ( + "fmt" + + "github.com/highcard-dev/daemon/internal/core/domain" +) + +func resolveRuntimePorts(ports []domain.Port, routing []domain.RuntimeRouteAssignment, requireAssignments bool) ([]domain.Port, error) { + resolved := append([]domain.Port(nil), ports...) + for index := range resolved { + port := &resolved[index] + if port.Port != 0 { + continue + } + + assignedPort := 0 + for _, assignment := range routing { + portName := assignment.PortName + if portName == "" { + portName = assignment.Name + } + if portName != port.Name { + continue + } + if assignment.PublicPort < 1 || assignment.PublicPort > 65535 { + return nil, fmt.Errorf("dynamic port %q has invalid public port %d", port.Name, assignment.PublicPort) + } + if assignedPort != 0 && assignedPort != assignment.PublicPort { + return nil, fmt.Errorf("dynamic port %q has conflicting public ports %d and %d", port.Name, assignedPort, assignment.PublicPort) + } + assignedPort = assignment.PublicPort + } + + if assignedPort == 0 { + if requireAssignments { + return nil, fmt.Errorf("dynamic port %q has no public routing assignment", port.Name) + } + continue + } + port.Port = assignedPort + } + return resolved, nil +} + +func validateDynamicPortsUnchanged( + ports []domain.Port, + currentRouting []domain.RuntimeRouteAssignment, + nextRouting []domain.RuntimeRouteAssignment, +) error { + currentPorts, err := resolveRuntimePorts(ports, currentRouting, false) + if err != nil { + return err + } + nextPorts, err := resolveRuntimePorts(ports, nextRouting, false) + if err != nil { + return err + } + for index, port := range ports { + if port.Port != 0 || currentPorts[index].Port == nextPorts[index].Port { + continue + } + return fmt.Errorf( + "cannot change dynamic port %q from %d to %d while runtime is running; stop the runtime before applying routing", + port.Name, + currentPorts[index].Port, + nextPorts[index].Port, + ) + } + return nil +} diff --git a/apps/druid/core/services/runtime_session_execution.go b/apps/druid/core/services/runtime_session_execution.go index 1ffbc8bf..b845c91c 100644 --- a/apps/druid/core/services/runtime_session_execution.go +++ b/apps/druid/core/services/runtime_session_execution.go @@ -33,7 +33,14 @@ func (s *RuntimeSession) runCommand(cmd string) error { root = s.scrollService.GetCwd() } file := s.scrollService.GetFile() - procedureEnv, err := coreservices.BuildRuntimeProcedureEnv(file, cmd, command, coreservices.RuntimeEnvContext{ + runtimePorts, err := resolveRuntimePorts(file.Ports, routing, true) + if err != nil { + s.setCommandProcedureStatus(cmd, command, domain.ScrollLockStatusError, nil) + return err + } + runtimeFile := *file + runtimeFile.Ports = runtimePorts + procedureEnv, err := coreservices.BuildRuntimeProcedureEnv(&runtimeFile, cmd, command, coreservices.RuntimeEnvContext{ ScrollID: scrollID, ScrollName: scrollName, Backend: s.runtimeBackend.Name(), @@ -49,7 +56,7 @@ func (s *RuntimeSession) runCommand(cmd string) error { ScrollID: scrollID, Command: command, Root: root, - GlobalPorts: file.Ports, + GlobalPorts: runtimePorts, Routing: routing, ProcedureEnv: procedureEnv, ProcedureStatusObserver: func(procedure string, status domain.ScrollLockStatus, exitCode *int) { diff --git a/apps/druid/core/services/runtime_session_execution_test.go b/apps/druid/core/services/runtime_session_execution_test.go index d9122ecb..8dc096fa 100644 --- a/apps/druid/core/services/runtime_session_execution_test.go +++ b/apps/druid/core/services/runtime_session_execution_test.go @@ -2,6 +2,7 @@ package services import ( "errors" + "strings" "testing" "github.com/highcard-dev/daemon/internal/core/domain" @@ -62,6 +63,9 @@ func TestRuntimeSessionRunCommandPassesRoutingAndScrollIdentity(t *testing.T) { if len(seen.Routing) != 1 || seen.Routing[0].PublicPort != 443 { t.Fatalf("Routing = %#v", seen.Routing) } + if len(seen.GlobalPorts) != 1 || seen.GlobalPorts[0].Port != 8080 { + t.Fatalf("GlobalPorts = %#v, fixed port must remain 8080", seen.GlobalPorts) + } env := seen.ProcedureEnv["web"] if env["DRUID_SCROLL_ID"] != "scroll-a" || env["DRUID_SCROLL_NAME"] != "scroll-name" { t.Fatalf("scroll env = %#v", env) @@ -80,6 +84,64 @@ func TestRuntimeSessionRunCommandPassesRoutingAndScrollIdentity(t *testing.T) { } } +func TestRuntimeSessionRunCommandResolvesDynamicPortsFromRouting(t *testing.T) { + var seen ports.RuntimeCommand + session := newRuntimeSessionExecutionTest(t, dynamicExecutionScrollYAML(), &fakeWorkerBackend{ + runCommand: func(command ports.RuntimeCommand) (*int, error) { + seen = command + return nil, nil + }, + }) + session.runtimeScroll.Routing = []domain.RuntimeRouteAssignment{{ + Name: "main", + PortName: "main", + ExternalIP: "127.0.0.1", + PublicPort: 11000, + Protocol: "udp", + }} + + if err := session.runCommand("serve"); err != nil { + t.Fatal(err) + } + + if len(seen.GlobalPorts) != 1 || seen.GlobalPorts[0].Port != 11000 { + t.Fatalf("GlobalPorts = %#v, want dynamic port 11000", seen.GlobalPorts) + } + env := seen.ProcedureEnv["server"] + if env["DRUID_PORT_MAIN"] != "11000" || env["DRUID_PORT_MAIN_1"] != "11000" { + t.Fatalf("runtime env = %#v, want internal port 11000", env) + } + if env["DRUID_PORT_MAIN_PUBLIC"] != "11000" { + t.Fatalf("runtime env = %#v, want public port 11000", env) + } +} + +func TestRuntimeSessionRunCommandRejectsUnassignedDynamicPort(t *testing.T) { + called := false + session := newRuntimeSessionExecutionTest(t, dynamicExecutionScrollYAML(), &fakeWorkerBackend{ + runCommand: func(command ports.RuntimeCommand) (*int, error) { + called = true + return nil, nil + }, + }) + + err := session.runCommand("serve") + if err == nil || !strings.Contains(err.Error(), `dynamic port "main" has no public routing assignment`) { + t.Fatalf("runCommand error = %v", err) + } + if called { + t.Fatal("runtime backend was called without a dynamic port assignment") + } + + updated, storeErr := session.store.GetScroll(session.runtimeScroll.ID) + if storeErr != nil { + t.Fatal(storeErr) + } + if updated.Procedures["serve"]["server"].Status != domain.ScrollLockStatusError { + t.Fatalf("procedure status = %#v, want error", updated.Procedures["serve"]["server"]) + } +} + func TestRuntimeSessionRunCommandPersistsProcedureStatusCallbacks(t *testing.T) { exitCode := 0 session := newRuntimeSessionExecutionTest(t, executionScrollYAML(), &fakeWorkerBackend{ @@ -205,3 +267,23 @@ commands: image: alpine:3.20 ` } + +func dynamicExecutionScrollYAML() string { + return `name: scroll-name +desc: Dynamic runtime session execution test +version: 0.1.0 +app_version: "1.0" +ports: + - name: main + protocol: udp +serve: serve +commands: + serve: + run: persistent + procedures: + - id: server + image: alpine:3.20 + expectedPorts: + - name: main +` +} diff --git a/apps/druid/core/services/runtime_session_runtime.go b/apps/druid/core/services/runtime_session_runtime.go index d0e18046..6a5aa898 100644 --- a/apps/druid/core/services/runtime_session_runtime.go +++ b/apps/druid/core/services/runtime_session_runtime.go @@ -12,8 +12,14 @@ import ( func (s *RuntimeSession) Ports() ([]domain.RuntimePortStatus, error) { s.mu.Lock() runtimeScroll := *s.runtimeScroll + routing := append([]domain.RuntimeRouteAssignment(nil), s.runtimeScroll.Routing...) s.mu.Unlock() - return s.runtimeBackend.ExpectedPorts(runtimeScroll.Root, s.scrollService.GetFile().Commands, s.scrollService.GetFile().Ports) + file := s.scrollService.GetFile() + runtimePorts, err := resolveRuntimePorts(file.Ports, routing, false) + if err != nil { + return nil, err + } + return s.runtimeBackend.ExpectedPorts(runtimeScroll.Root, file.Commands, runtimePorts) } func (s *RuntimeSession) RoutingTargets() ([]domain.RuntimeRoutingTarget, error) { @@ -31,6 +37,12 @@ func (s *RuntimeSession) Queue() domain.ProcedureStatusMap { func (s *RuntimeSession) ApplyRouting(assignments []domain.RuntimeRouteAssignment) (*domain.RuntimeScroll, error) { s.mu.Lock() + if s.runtimeScroll.Status == domain.RuntimeScrollStatusRunning || hasRunningProcedure(s.runtimeScroll.Procedures) { + if err := validateDynamicPortsUnchanged(s.scrollService.GetFile().Ports, s.runtimeScroll.Routing, assignments); err != nil { + s.mu.Unlock() + return nil, err + } + } s.runtimeScroll.Routing = assignments s.runtimeScroll.LastError = "" err := s.store.UpdateScroll(s.runtimeScroll) @@ -42,6 +54,17 @@ func (s *RuntimeSession) ApplyRouting(assignments []domain.RuntimeRouteAssignmen return s.store.GetScroll(id) } +func hasRunningProcedure(procedures domain.ProcedureStatusMap) bool { + for _, commandProcedures := range procedures { + for _, procedure := range commandProcedures { + if procedure.Status == domain.ScrollLockStatusRunning { + return true + } + } + } + return false +} + func (s *RuntimeSession) StopRuntime() error { s.mu.Lock() root := s.runtimeScroll.Root diff --git a/apps/druid/core/services/runtime_supervisor_test.go b/apps/druid/core/services/runtime_supervisor_test.go index a6a00e04..4f52135b 100644 --- a/apps/druid/core/services/runtime_supervisor_test.go +++ b/apps/druid/core/services/runtime_supervisor_test.go @@ -958,6 +958,48 @@ func TestRuntimeSessionApplyRoutingPersistsAssignments(t *testing.T) { } } +func TestRuntimeSessionApplyRoutingRejectsRunningDynamicPortRemap(t *testing.T) { + session := newRuntimeSessionForTest(t, map[string]domain.LockStatus{}, dynamicExecutionScrollYAML()) + session.runtimeScroll.Status = domain.RuntimeScrollStatusRunning + session.runtimeScroll.Procedures = domain.ProcedureStatusMap{ + "serve": {"server": {Status: domain.ScrollLockStatusRunning}}, + } + session.runtimeScroll.Routing = []domain.RuntimeRouteAssignment{{ + Name: "main", + PortName: "main", + PublicPort: 11000, + Protocol: "udp", + }} + + _, err := session.ApplyRouting([]domain.RuntimeRouteAssignment{{ + Name: "main", + PortName: "main", + PublicPort: 11001, + Protocol: "udp", + }}) + if err == nil || !strings.Contains(err.Error(), `cannot change dynamic port "main" from 11000 to 11001 while runtime is running`) { + t.Fatalf("ApplyRouting error = %v", err) + } + if session.runtimeScroll.Routing[0].PublicPort != 11000 { + t.Fatalf("routing changed after rejection: %#v", session.runtimeScroll.Routing) + } + + session.runtimeScroll.Status = domain.RuntimeScrollStatusStopped + session.runtimeScroll.Procedures = nil + updated, err := session.ApplyRouting([]domain.RuntimeRouteAssignment{{ + Name: "main", + PortName: "main", + PublicPort: 11001, + Protocol: "udp", + }}) + if err != nil { + t.Fatal(err) + } + if updated.Routing[0].PublicPort != 11001 { + t.Fatalf("routing = %#v, want stopped runtime remapped to 11001", updated.Routing) + } +} + func TestRuntimeSessionQueueReturnsProcedureStatuses(t *testing.T) { session := newRuntimeSessionForTest(t, map[string]domain.LockStatus{}, cachedScrollYAML("")) session.RememberDoneItem("start") diff --git a/internal/api/generated.go b/internal/api/generated.go index 1757a040..2945cd53 100644 --- a/internal/api/generated.go +++ b/internal/api/generated.go @@ -176,7 +176,6 @@ type RuntimeRoutingTarget struct { Protocol string `json:"protocol"` Selector *map[string]string `json:"selector,omitempty"` ServiceName string `json:"service_name"` - ServicePort int `json:"service_port"` } // RuntimeScroll defines model for RuntimeScroll. @@ -3376,34 +3375,34 @@ var swaggerSpec = []string{ "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/core/domain/runtime_scroll.go b/internal/core/domain/runtime_scroll.go index 5690349e..92be84ec 100644 --- a/internal/core/domain/runtime_scroll.go +++ b/internal/core/domain/runtime_scroll.go @@ -49,7 +49,6 @@ type RuntimeRoutingTarget struct { Protocol string `json:"protocol"` Namespace string `json:"namespace,omitempty"` ServiceName string `json:"service_name"` - ServicePort int `json:"service_port"` Selector map[string]string `json:"selector,omitempty"` } diff --git a/internal/core/domain/scroll.go b/internal/core/domain/scroll.go index 85f36b32..b031e111 100644 --- a/internal/core/domain/scroll.go +++ b/internal/core/domain/scroll.go @@ -271,6 +271,10 @@ func (sc *Scroll) Validate(strict bool) error { ids := make(map[string]bool) portsByName := make(map[string]bool, len(sc.Ports)) for _, port := range sc.Ports { + protocol := strings.ToLower(strings.TrimSpace(port.Protocol)) + if port.Port == 0 && (protocol == "http" || protocol == "https") { + return fmt.Errorf("port %s uses %s and requires a fixed port", port.Name, protocol) + } if port.Name != "" { portsByName[port.Name] = true } diff --git a/internal/core/domain/scroll_test.go b/internal/core/domain/scroll_test.go index a34b736e..af242b7d 100644 --- a/internal/core/domain/scroll_test.go +++ b/internal/core/domain/scroll_test.go @@ -100,6 +100,39 @@ func TestScrollValidateRejectsUnknownServeCommand(t *testing.T) { } } +func TestScrollValidateAllowsDynamicTransportPorts(t *testing.T) { + scroll := testScroll(t, &Procedure{ + Image: "alpine:3.20", + Command: []string{"true"}, + }) + scroll.Ports = []Port{ + {Name: "tcp", Protocol: "tcp"}, + {Name: "udp", Protocol: "udp"}, + {Name: "web", Protocol: "http", Port: 8080}, + } + + if err := scroll.Validate(false); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} + +func TestScrollValidateRejectsDynamicHTTPPorts(t *testing.T) { + for _, protocol := range []string{"http", "https"} { + t.Run(protocol, func(t *testing.T) { + scroll := testScroll(t, &Procedure{ + Image: "alpine:3.20", + Command: []string{"true"}, + }) + scroll.Ports = []Port{{Name: "web", Protocol: protocol}} + + err := scroll.Validate(false) + if err == nil || !strings.Contains(err.Error(), "requires a fixed port") { + t.Fatalf("Validate() error = %v", err) + } + }) + } +} + func testScroll(t *testing.T, procedure *Procedure) *Scroll { t.Helper() version, err := semver.NewVersion("0.1.0") diff --git a/internal/runtime/docker/ports.go b/internal/runtime/docker/ports.go index 91cee1c2..a67e7f8c 100644 --- a/internal/runtime/docker/ports.go +++ b/internal/runtime/docker/ports.go @@ -56,7 +56,6 @@ func (b *Backend) RoutingTargets(root string, commands map[string]*domain.Comman Port: 8084, Protocol: "https", ServiceName: ContainerName(root, "dev"), - ServicePort: 8084, }} seen := map[string]struct{}{"webdav": {}} commandNames := make([]string, 0, len(commands)) @@ -91,7 +90,6 @@ func (b *Backend) RoutingTargets(root string, commands map[string]*domain.Comman Port: port.Port, Protocol: normalizeProtocol(port.Protocol), ServiceName: serviceName, - ServicePort: port.Port, }) } } diff --git a/internal/runtime/kubernetes/ports.go b/internal/runtime/kubernetes/ports.go index bde2bf09..bea9a654 100644 --- a/internal/runtime/kubernetes/ports.go +++ b/internal/runtime/kubernetes/ports.go @@ -105,7 +105,6 @@ func (b *Backend) RoutingTargets(root string, commands map[string]*domain.Comman Protocol: "https", Namespace: namespace, ServiceName: serviceName(root, "dev", "webdav"), - ServicePort: 8084, Selector: map[string]string{ labelManagedBy: "druid", labelComponent: "runtime", @@ -156,7 +155,6 @@ func (b *Backend) RoutingTargets(root string, commands map[string]*domain.Comman Protocol: normalizeProtocol(port.Protocol), Namespace: namespace, ServiceName: svcName, - ServicePort: port.Port, Selector: selector, }) } diff --git a/internal/runtime/kubernetes/resources_test.go b/internal/runtime/kubernetes/resources_test.go index c8021731..96b07500 100644 --- a/internal/runtime/kubernetes/resources_test.go +++ b/internal/runtime/kubernetes/resources_test.go @@ -1059,7 +1059,7 @@ func TestRoutingTargetsReturnStableBackendServices(t *testing.T) { webdav = item } } - if target.Namespace != "druid" || target.ServiceName != serviceName(root, "web", "http") || target.ServicePort != 8080 { + if target.Namespace != "druid" || target.ServiceName != serviceName(root, "web", "http") || target.Port != 8080 { t.Fatalf("target = %#v", target) } if target.Protocol != "http" || target.PortName != "http" || target.Procedure != "web" { @@ -1073,6 +1073,25 @@ func TestRoutingTargetsReturnStableBackendServices(t *testing.T) { } } +func TestServiceSpecUsesRuntimePortForServiceAndTarget(t *testing.T) { + service, err := serviceSpec( + "druid", + ref("druid", "druid-static-game-data"), + "start", + map[string]string{"druid.gg/procedure": "start"}, + "main", + domain.Port{Name: "main", Port: 7777, Protocol: "udp"}, + ) + if err != nil { + t.Fatal(err) + } + + port := service.Spec.Ports[0] + if port.Port != 7777 || port.TargetPort.IntValue() != 7777 { + t.Fatalf("service port = %#v, want port and targetPort 7777", port) + } +} + func TestRoutingTargetsCollapseColdstarterAndRuntimePort(t *testing.T) { backend := NewWithClient(Config{Namespace: "druid"}, coreservices.NewConsoleManager(coreservices.NewLogManager()), fake.NewSimpleClientset()) root := ref("druid", "druid-minecraft-data") diff --git a/test/integration/internal/e2e/harness.go b/test/integration/internal/e2e/harness.go index 6265408b..7c4f673e 100644 --- a/test/integration/internal/e2e/harness.go +++ b/test/integration/internal/e2e/harness.go @@ -56,9 +56,9 @@ type RuntimePortStatus struct { type RuntimeRoutingTarget struct { Procedure string `json:"procedure"` PortName string `json:"port_name"` + Port int `json:"port"` Namespace string `json:"namespace"` ServiceName string `json:"service_name"` - ServicePort int `json:"service_port"` } type LockedBuffer struct { diff --git a/test/integration/kubernetes/kubernetes_cli_test.go b/test/integration/kubernetes/kubernetes_cli_test.go index 065e7541..c7d72af7 100644 --- a/test/integration/kubernetes/kubernetes_cli_test.go +++ b/test/integration/kubernetes/kubernetes_cli_test.go @@ -72,8 +72,8 @@ func TestKubernetesBackendCLIComplexLifecycle(t *testing.T) { pvc := strings.TrimPrefix(created.Root, rootPrefix) targets := e2e.RunClientJSON[[]e2e.RuntimeRoutingTarget](t, bins, socket, "routing", "targets", created.ID) target := findTarget(t, targets, fixture) - if target.Namespace != namespace || target.ServicePort != fixture.Port { - t.Fatalf("target = %#v, want namespace %s service port %d", target, namespace, fixture.Port) + if target.Namespace != namespace || target.Port != fixture.Port { + t.Fatalf("target = %#v, want namespace %s port %d", target, namespace, fixture.Port) } started := e2e.RunClientJSON[e2e.RuntimeScroll](t, bins, socket, "start", created.ID)