Skip to content

Commit 7df74b8

Browse files
authored
Merge pull request #250 from techulus/fix/serverless-http-visibility
Constrain serverless settings to HTTP services
2 parents a6aab1d + ca81784 commit 7df74b8

6 files changed

Lines changed: 129 additions & 26 deletions

File tree

‎docs/api/public-api.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ Configuration and revision responses never include secret names, values, or ciph
143143

144144
`PUT /configuration` is atomic and replaces the complete managed configuration. Send the `currentVersion` returned by the plan as one quoted strong ETag (for example, `If-Match: "sha256:…"`). Missing, unquoted, multiple, weak, or otherwise invalid headers are rejected; a service change after planning returns `409 CONFIGURATION_PLAN_STALE`. A successful response returns the authoritative target and the structured change set that was applied. The request must contain exactly `name`, `source`, `hostname`, `ports`, `placement`, `healthCheck`, `startCommand`, and `resources`. Omitted or unknown fields are rejected. `hostname` must be concrete and non-null. Use `null` to clear nullable fields, including `resources`.
145145

146+
Replacing the final public HTTP port with a domain automatically disables serverless in the mutable service configuration. The plan reports this side effect as a `serverless.enabled` change before apply. You do not send serverless settings in the replacement request.
147+
146148
```json
147149
{
148150
"name": "web",
@@ -215,7 +217,7 @@ Use manual placement to choose exact servers:
215217
}
216218
```
217219

218-
Manual placement requires online servers with WireGuard configured. Serverless services require proxy servers, including when automatic placement is used. Automatic placement is not available for stateful or volume-backed services. Submit replica changes through `placement`; the API rejects a top-level `replicas` field.
220+
Manual placement requires online servers with WireGuard configured. Serverless services require proxy servers, including when automatic placement is used. A replacement that removes the final public HTTP domain can move the service to worker placement because the same update disables serverless. Automatic placement is not available for stateful or volume-backed services. Submit replica changes through `placement`; the API rejects a top-level `replicas` field.
219221

220222
The API only manages stateless services with HTTP ports. Existing volumes, stateful mode, TCP or UDP ports, TLS passthrough, or invalid resource limits return a conflict with an actionable code.
221223

‎docs/services/scaling.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ Serverless settings are configured per service:
3030
| Sleep after | `300s` | Idle period before running containers are stopped |
3131
| Wake timeout | `300s` | Maximum time the wake gateway waits for ready upstreams |
3232

33+
The serverless settings appear only when the service has a public HTTP port with
34+
a domain. Removing the final qualifying endpoint disables serverless in the
35+
pending service configuration.
36+
3337
A cold wake starts the sleeping local proxy replicas for that host. Held
3438
requests resume when one upstream is ready.
3539

‎web/actions/projects.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1225,6 +1225,17 @@ export async function updateServiceConfig(
12251225
})),
12261226
);
12271227
if (issue) throw new Error(issue.message);
1228+
if (
1229+
!finalPorts.some(
1230+
(port) =>
1231+
port.isPublic && port.protocol === "http" && port.domain !== null,
1232+
)
1233+
) {
1234+
await tx
1235+
.update(services)
1236+
.set({ serverlessEnabled: false })
1237+
.where(eq(services.id, serviceId));
1238+
}
12281239

12291240
for (const port of additions) {
12301241
if (!port.domain) continue;

‎web/components/service/details/serverless-section.tsx‎

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ export const ServerlessSection = memo(function ServerlessSection(
1919
props: ServerlessSectionProps,
2020
) {
2121
const { service } = props;
22+
const hasPublicHttpEndpoint = service.ports.some(
23+
(port) => port.isPublic && port.protocol === "http" && !!port.domain,
24+
);
25+
if (!hasPublicHttpEndpoint) return null;
26+
2227
// Persisted changes are authoritative and intentionally discard any stale local draft.
2328
const settingsKey = `${service.id}:${service.serverlessEnabled}:${service.serverlessSleepAfterSeconds}:${service.serverlessWakeTimeoutSeconds}`;
2429

@@ -47,21 +52,12 @@ function ServerlessSectionEditor({
4752
const [wakeTimeoutSeconds, setWakeTimeoutSeconds] = useState(
4853
String(service.serverlessWakeTimeoutSeconds ?? 300),
4954
);
50-
const hasPublicHttpEndpoint = useMemo(
51-
() =>
52-
service.ports.some(
53-
(port) => port.isPublic && port.protocol === "http" && !!port.domain,
54-
),
55-
[service.ports],
56-
);
5755
const hasWorkerReplica = service.configuredReplicas.some(
5856
(replica) => replica.count > 0 && !replica.serverIsProxy,
5957
);
60-
const unavailableReason = !hasPublicHttpEndpoint
61-
? "Add a public HTTP port with a domain to enable serverless"
62-
: hasWorkerReplica
63-
? "Serverless services can only be deployed to proxy nodes"
64-
: null;
58+
const unavailableReason = hasWorkerReplica
59+
? "Serverless services can only be deployed to proxy nodes"
60+
: null;
6561
const optionsDisabled = !!unavailableReason || isSaving;
6662

6763
const parsed = useMemo(

‎web/lib/public-api.ts‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,7 @@ function canonicalReplacementState(
694694
healthCheck: healthCheckFromService(service),
695695
startCommand: service.startCommand?.trim() || null,
696696
resources,
697+
serverless: { enabled: service.serverlessEnabled },
697698
};
698699
}
699700

@@ -773,7 +774,15 @@ export function planCanonicalConfiguration(
773774
...current,
774775
source: canonicalPlanSource(current.source),
775776
};
776-
const desired = canonicalDesired(desiredInput);
777+
const desiredConfiguration = canonicalDesired(desiredInput);
778+
const desired = {
779+
...desiredConfiguration,
780+
serverless: {
781+
enabled:
782+
canonicalCurrent.serverless.enabled &&
783+
desiredConfiguration.ports.some((port) => port.public && port.domain),
784+
},
785+
};
777786
const changes = configurationChanges(canonicalCurrent, desired);
778787
return {
779788
action: changes.length ? ("updated" as const) : ("noop" as const),
@@ -869,6 +878,9 @@ async function replaceConfigurationInternal(
869878
);
870879
}
871880
const plan = planCanonicalConfiguration(currentState, input);
881+
const effectiveServerlessEnabled =
882+
persisted.serverlessEnabled &&
883+
input.ports.some((port) => port.public && port.domain);
872884
if (expectedVersion !== null && plan.currentVersion !== expectedVersion) {
873885
domainError(
874886
"Service configuration changed after the plan was created",
@@ -946,7 +958,7 @@ async function replaceConfigurationInternal(
946958
400,
947959
);
948960
if (
949-
persisted.serverlessEnabled &&
961+
effectiveServerlessEnabled &&
950962
selected.some((server) => !server.isProxy)
951963
)
952964
domainError(
@@ -967,16 +979,6 @@ async function replaceConfigurationInternal(
967979
if (duplicateHostname) {
968980
domainError("Hostname is already in use", "HOSTNAME_CONFLICT");
969981
}
970-
if (
971-
persisted.serverlessEnabled &&
972-
!input.ports.some((port) => port.public && port.domain)
973-
) {
974-
domainError(
975-
"Serverless services require a public HTTP port with a domain",
976-
"SERVERLESS_PORT_REQUIRED",
977-
400,
978-
);
979-
}
980982
const portIssue = findServicePortValidationIssue(
981983
input.ports.map((port) => ({
982984
containerPort: port.containerPort,
@@ -1013,6 +1015,9 @@ async function replaceConfigurationInternal(
10131015

10141016
const changes: string[] = [];
10151017
const set: Partial<NestedService> = {};
1018+
if (persisted.serverlessEnabled !== effectiveServerlessEnabled) {
1019+
set.serverlessEnabled = effectiveServerlessEnabled;
1020+
}
10161021
const changed = (label: string, from: unknown, to: unknown) => {
10171022
if (JSON.stringify(from) === JSON.stringify(to)) return false;
10181023
changes.push(label);

‎web/tests/public-api-plan.test.ts‎

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,20 @@ describe("configuration plan protocol", () => {
4747
healthCheck: null,
4848
startCommand: null,
4949
resources: null,
50+
serverless: { enabled: false },
5051
};
5152
const result = planCanonicalConfiguration(current, {
52-
...current,
53+
name: current.name,
5354
source: {
5455
...current.source,
5556
repository: "https://github.com/techulus/cloud",
5657
},
58+
hostname: current.hostname,
59+
ports: current.ports,
60+
placement: current.placement,
61+
healthCheck: current.healthCheck,
62+
startCommand: current.startCommand,
63+
resources: current.resources,
5764
});
5865

5966
expect(result.action).toBe("noop");
@@ -113,6 +120,7 @@ describe("configuration plan protocol", () => {
113120
},
114121
startCommand: "npm start",
115122
resources: { cpuCores: 2, memoryMb: 512 },
123+
serverless: { enabled: false },
116124
};
117125
const result = planCanonicalConfiguration(current, {
118126
name: "web",
@@ -174,6 +182,7 @@ describe("configuration plan protocol", () => {
174182
healthCheck: null,
175183
startCommand: null,
176184
resources: null,
185+
serverless: { enabled: false },
177186
};
178187
const desired = {
179188
name: "web",
@@ -208,4 +217,80 @@ describe("configuration plan protocol", () => {
208217
expect(first.changes).toEqual([]);
209218
expect(second.desiredVersion).toBe(first.desiredVersion);
210219
});
220+
221+
it("plans disabling serverless when the final public HTTP domain is removed", () => {
222+
const current = {
223+
name: "web",
224+
source: { type: "image" as const, image: "nginx" },
225+
hostname: "web",
226+
ports: [{ containerPort: 8080, public: true, domain: "web.example.com" }],
227+
placement: { mode: "automatic" as const, replicas: 1 },
228+
healthCheck: null,
229+
startCommand: null,
230+
resources: null,
231+
serverless: { enabled: true },
232+
};
233+
const desired = {
234+
name: "web",
235+
source: { type: "image" as const, image: "nginx" },
236+
hostname: "web",
237+
ports: [{ containerPort: 8080, public: false }],
238+
placement: { mode: "automatic" as const, replicas: 1 },
239+
healthCheck: null,
240+
startCommand: null,
241+
resources: null,
242+
};
243+
244+
const result = planCanonicalConfiguration(current, desired);
245+
246+
expect(result.changes).toContainEqual({
247+
field: "serverless.enabled",
248+
from: true,
249+
to: false,
250+
});
251+
expect(
252+
planCanonicalConfiguration(
253+
{ ...current, serverless: { enabled: false } },
254+
desired,
255+
).changes,
256+
).not.toContainEqual(
257+
expect.objectContaining({ field: "serverless.enabled" }),
258+
);
259+
});
260+
261+
it("preserves serverless and fingerprints its state when a public domain remains", () => {
262+
const current = {
263+
name: "web",
264+
source: { type: "image" as const, image: "nginx" },
265+
hostname: "web",
266+
ports: [{ containerPort: 8080, public: true, domain: "web.example.com" }],
267+
placement: { mode: "automatic" as const, replicas: 1 },
268+
healthCheck: null,
269+
startCommand: null,
270+
resources: null,
271+
serverless: { enabled: true },
272+
};
273+
const desired = {
274+
name: current.name,
275+
source: current.source,
276+
hostname: current.hostname,
277+
ports: current.ports,
278+
placement: current.placement,
279+
healthCheck: current.healthCheck,
280+
startCommand: current.startCommand,
281+
resources: current.resources,
282+
};
283+
284+
const enabled = planCanonicalConfiguration(current, desired);
285+
const disabled = planCanonicalConfiguration(
286+
{ ...current, serverless: { enabled: false } },
287+
desired,
288+
);
289+
290+
expect(enabled.action).toBe("noop");
291+
expect(enabled.changes).toEqual([]);
292+
expect(disabled.action).toBe("noop");
293+
expect(disabled.currentVersion).not.toBe(enabled.currentVersion);
294+
expect(disabled.desiredVersion).not.toBe(enabled.desiredVersion);
295+
});
211296
});

0 commit comments

Comments
 (0)