diff --git a/.agents/setup b/.agents/setup
index 81a7777d..59ad2500 100755
--- a/.agents/setup
+++ b/.agents/setup
@@ -3,6 +3,7 @@ set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
mise_bin="$HOME/.local/bin/mise"
+export PATH="$HOME/.local/bin:$PATH"
echo "Installing mise..."
if [[ ! -x "$mise_bin" ]]; then
diff --git a/AGENT.md b/AGENT.md
index 001a0b94..e70f8f06 100644
--- a/AGENT.md
+++ b/AGENT.md
@@ -37,6 +37,11 @@ An open container deployment platform. See README.md for architecture.
high-value critical behavior, serious regression risk, or contracts that
would be costly to break. Keep tests focused; avoid low-signal harnesses.
+## Communication
+
+- Keep responses concise and to the point. Avoid verbose responses unless
+ explicitly asked.
+
## ⚠️ Critical restrictions
- **NEVER run the Node application** (`next dev`, `next start`, `pnpm dev`), Go Agent or Go CLI
diff --git a/agent/cmd/agent/main.go b/agent/cmd/agent/main.go
index 51e58a6f..4605ad7c 100644
--- a/agent/cmd/agent/main.go
+++ b/agent/cmd/agent/main.go
@@ -282,7 +282,7 @@ func main() {
}
}
- reconciler := reconcile.NewReconciler(config.EncryptionKey, dataDir)
+ reconciler := reconcile.NewReconciler(config.EncryptionKey, dataDir, config.RegistryInsecure)
client := agenthttp.NewClient(controlPlaneURL, config.ServerID, signingKeyPair, dataDir)
var logCollector *logs.Collector
diff --git a/agent/internal/container/runtime.go b/agent/internal/container/runtime.go
index f5d320f9..412fd36d 100644
--- a/agent/internal/container/runtime.go
+++ b/agent/internal/container/runtime.go
@@ -75,7 +75,7 @@ func Deploy(config *DeployConfig) (*DeployResult, error) {
logFunc("stdout", fmt.Sprintf("Pulling image: %s", image))
- pullCmd := exec.Command("podman", "pull", "--tls-verify=false", image)
+ pullCmd := exec.Command("podman", buildPodmanPullArgs(config)...)
pullOutput, err := pullCmd.CombinedOutput()
if err != nil {
logFunc("stderr", fmt.Sprintf("Pull failed: %s", string(pullOutput)))
@@ -132,6 +132,14 @@ func Deploy(config *DeployConfig) (*DeployResult, error) {
}, nil
}
+func buildPodmanPullArgs(config *DeployConfig) []string {
+ args := []string{"pull"}
+ if config.RegistryInsecure {
+ args = append(args, "--tls-verify=false")
+ }
+ return append(args, config.Image)
+}
+
func buildPodmanRunArgs(config *DeployConfig, image string) []string {
networkMAC := StableMACAddress(config.IPAddress)
diff --git a/agent/internal/container/runtime_test.go b/agent/internal/container/runtime_test.go
index 1fcf4b18..629febf4 100644
--- a/agent/internal/container/runtime_test.go
+++ b/agent/internal/container/runtime_test.go
@@ -5,6 +5,36 @@ import (
"testing"
)
+func TestBuildPodmanPullArgs(t *testing.T) {
+ tests := []struct {
+ name string
+ insecure bool
+ want []string
+ }{
+ {
+ name: "does not disable TLS by default",
+ want: []string{"pull", "registry.example.com/app:latest"},
+ },
+ {
+ name: "disables TLS verification when configured",
+ insecure: true,
+ want: []string{"pull", "--tls-verify=false", "registry.example.com/app:latest"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := buildPodmanPullArgs(&DeployConfig{
+ Image: "registry.example.com/app:latest",
+ RegistryInsecure: tt.insecure,
+ })
+ if !slices.Equal(got, tt.want) {
+ t.Fatalf("buildPodmanPullArgs() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
func TestBuildPodmanRunArgsPublishesLoopbackPortsWithStaticIP(t *testing.T) {
args := buildPodmanRunArgs(&DeployConfig{
Name: "svc-dep",
diff --git a/agent/internal/container/types.go b/agent/internal/container/types.go
index f0571589..ea1730f7 100644
--- a/agent/internal/container/types.go
+++ b/agent/internal/container/types.go
@@ -28,6 +28,7 @@ type BuildLogFunc func(stream string, message string)
type DeployConfig struct {
Name string
Image string
+ RegistryInsecure bool
ServiceID string
ServiceName string
DeploymentID string
diff --git a/agent/internal/reconcile/reconcile.go b/agent/internal/reconcile/reconcile.go
index 65635afc..1817ff5f 100644
--- a/agent/internal/reconcile/reconcile.go
+++ b/agent/internal/reconcile/reconcile.go
@@ -11,14 +11,16 @@ import (
)
type Reconciler struct {
- encryptionKey string
- dataDir string
+ encryptionKey string
+ dataDir string
+ registryInsecure bool
}
-func NewReconciler(encryptionKey, dataDir string) *Reconciler {
+func NewReconciler(encryptionKey, dataDir string, registryInsecure bool) *Reconciler {
return &Reconciler{
- encryptionKey: encryptionKey,
- dataDir: dataDir,
+ encryptionKey: encryptionKey,
+ dataDir: dataDir,
+ registryInsecure: registryInsecure,
}
}
@@ -66,6 +68,7 @@ func (r *Reconciler) Deploy(exp agenthttp.ExpectedContainer) error {
_, err := container.Deploy(&container.DeployConfig{
Name: exp.Name,
Image: exp.Image,
+ RegistryInsecure: r.registryInsecure,
ServiceID: exp.ServiceID,
ServiceName: exp.ServiceName,
DeploymentID: exp.DeploymentID,
diff --git a/cli/internal/manifest/manifest.go b/cli/internal/manifest/manifest.go
index eb680d2a..876a6238 100644
--- a/cli/internal/manifest/manifest.go
+++ b/cli/internal/manifest/manifest.go
@@ -220,8 +220,8 @@ func Validate(m Manifest) error {
if m.Service.StartCommand != nil && *m.Service.StartCommand == "" {
return errors.New("service.startCommand cannot be blank")
}
- if m.Service.Replicas < 1 || m.Service.Replicas > 10 {
- return errors.New("service.replicas must be between 1 and 10")
+ if m.Service.Replicas < 1 || m.Service.Replicas > 32 {
+ return errors.New("service.replicas must be between 1 and 32")
}
if m.Service.Placement == nil {
return errors.New("service.placement is required")
@@ -249,8 +249,8 @@ func Validate(m Manifest) error {
}
total += server.Count
}
- if total < 1 || total > 10 {
- return errors.New("service.placement manual total must be between 1 and 10")
+ if total < 1 || total > 32 {
+ return errors.New("service.placement manual total must be between 1 and 32")
}
if total != m.Service.Replicas {
return errors.New("service.placement manual total must equal service.replicas")
diff --git a/cli/internal/manifest/manifest_test.go b/cli/internal/manifest/manifest_test.go
index 57cdcbb7..b7c5103a 100644
--- a/cli/internal/manifest/manifest_test.go
+++ b/cli/internal/manifest/manifest_test.go
@@ -66,7 +66,7 @@ func TestPlacementRoundTripAndValidation(t *testing.T) {
{"blank server", &Placement{Mode: "manual", Servers: []PlacementServer{{ServerID: " ", Count: 1}}}, 1},
{"duplicate server", &Placement{Mode: "manual", Servers: []PlacementServer{{ServerID: "a", Count: 1}, {ServerID: "a", Count: 1}}}, 2},
{"nonpositive count", &Placement{Mode: "manual", Servers: []PlacementServer{{ServerID: "a", Count: 0}}}, 1},
- {"total exceeds limit", &Placement{Mode: "manual", Servers: []PlacementServer{{ServerID: "a", Count: 11}}}, 10},
+ {"total exceeds limit", &Placement{Mode: "manual", Servers: []PlacementServer{{ServerID: "a", Count: 33}}}, 32},
{"total differs from replicas", &Placement{Mode: "manual", Servers: []PlacementServer{{ServerID: "a", Count: 1}}}, 2},
}
for _, tc := range tests {
@@ -82,6 +82,26 @@ func TestPlacementRoundTripAndValidation(t *testing.T) {
}
}
+func TestReplicaLimit(t *testing.T) {
+ for _, placement := range []*Placement{
+ {Mode: "automatic"},
+ {Mode: "manual", Servers: []PlacementServer{{ServerID: "a", Count: 32}}},
+ } {
+ m := base()
+ m.Service.Replicas = 32
+ m.Service.Placement = placement
+ if err := Validate(m); err != nil {
+ t.Fatalf("32 replicas rejected: %v", err)
+ }
+ }
+
+ m := base()
+ m.Service.Replicas = 33
+ if err := Validate(m); err == nil {
+ t.Fatal("33 replicas accepted")
+ }
+}
+
func TestPlacementIsRequired(t *testing.T) {
_, err := Parse([]byte(`apiVersion: v1
service:
diff --git a/docs/services/scaling.mdx b/docs/services/scaling.mdx
index 13ab4163..1ed8c62b 100644
--- a/docs/services/scaling.mdx
+++ b/docs/services/scaling.mdx
@@ -7,7 +7,7 @@ description: "Replicas, placement, and server pinning."
Each service can run multiple replicas across your cluster. Configure how many replicas run on each server from the service settings.
-Replica count ranges from 1 to 10 per service.
+Replica count ranges from 1 to 32 per service.
## Serverless scaling
@@ -59,7 +59,7 @@ You can also manually lock any service to a specific server by setting the locke
- Stateful services are limited to 1 replica.
- Stateful services are always pinned to their locked server.
- Stateful services do not automatically fail over to another server.
-- Maximum 10 replicas per service.
+- Maximum 32 replicas per service.
- Serverless scaling requires a public HTTP service domain.
- Sleep and wake are proxy-local; serverless replicas must be placed on proxy nodes.
- Serverless traffic must be routed only to proxy nodes that own a local proxy replica for that service.
diff --git a/web/actions/projects.ts b/web/actions/projects.ts
index 27d3f370..204f73df 100644
--- a/web/actions/projects.ts
+++ b/web/actions/projects.ts
@@ -1067,7 +1067,7 @@ export type ServiceConfigUpdate = {
const placementInputSchema = z.discriminatedUnion("mode", [
z.strictObject({
mode: z.literal("automatic"),
- replicas: z.number().int().min(1).max(10),
+ replicas: z.number().int().min(1).max(32),
}),
z
.strictObject({
@@ -1076,7 +1076,7 @@ const placementInputSchema = z.discriminatedUnion("mode", [
.array(
z.strictObject({
serverId: z.string().min(1),
- count: z.number().int().min(1).max(10),
+ count: z.number().int().min(1).max(32),
}),
)
.min(1),
@@ -1092,10 +1092,10 @@ const placementInputSchema = z.discriminatedUnion("mode", [
path: ["placements"],
});
const total = value.placements.reduce((sum, item) => sum + item.count, 0);
- if (total > 10)
+ if (total > 32)
context.addIssue({
code: "custom",
- message: "Total replicas must be between 1 and 10",
+ message: "Total replicas must be between 1 and 32",
path: ["placements"],
});
}),
diff --git a/web/app/api/projects/[id]/services/route.ts b/web/app/api/projects/[id]/services/route.ts
index 1137888d..e695a620 100644
--- a/web/app/api/projects/[id]/services/route.ts
+++ b/web/app/api/projects/[id]/services/route.ts
@@ -63,9 +63,11 @@ export async function GET(
replicas,
serviceSecrets,
serviceRollouts,
+ activeRollout,
volumes,
lockedServer,
latestBuild,
+ activeBuild,
githubRepo,
] = await Promise.all([
db
@@ -99,6 +101,18 @@ export async function GET(
.where(eq(rollouts.serviceId, service.id))
.orderBy(desc(rollouts.createdAt))
.limit(1),
+ db
+ .select()
+ .from(rollouts)
+ .where(
+ and(
+ eq(rollouts.serviceId, service.id),
+ inArray(rollouts.status, ["queued", "in_progress"]),
+ ),
+ )
+ .orderBy(desc(rollouts.createdAt))
+ .limit(1)
+ .then((r) => r[0] || null),
db
.select()
.from(serviceVolumes)
@@ -119,6 +133,24 @@ export async function GET(
.limit(1)
.then((r) => r[0] || null)
: Promise.resolve(null),
+ db
+ .select({ id: builds.id, status: builds.status })
+ .from(builds)
+ .where(
+ and(
+ eq(builds.serviceId, service.id),
+ inArray(builds.status, [
+ "pending",
+ "claimed",
+ "cloning",
+ "building",
+ "pushing",
+ ]),
+ ),
+ )
+ .orderBy(desc(builds.createdAt))
+ .limit(1)
+ .then((r) => r[0] || null),
service.sourceType === "github"
? db
.select()
@@ -262,9 +294,11 @@ export async function GET(
deployments: deploymentsWithDetails,
secrets: serviceSecrets,
rollouts: serviceRollouts,
+ activeRollout,
volumes,
lockedServer,
latestBuild,
+ activeBuild,
hasGithubAppRepo: githubRepo !== null,
activeConfig,
currentSource,
diff --git a/web/app/api/v1/agent/register/route.ts b/web/app/api/v1/agent/register/route.ts
index 7801be88..a06bc793 100644
--- a/web/app/api/v1/agent/register/route.ts
+++ b/web/app/api/v1/agent/register/route.ts
@@ -6,7 +6,11 @@ import { HOUR_IN_MILLISECONDS, subtractMilliseconds } from "@/lib/date";
import { EncryptionKeyUnavailableError, resolveEncryptionKey } from "@/lib/kms";
import { agentRegisterSchema } from "@/lib/schemas";
import { formatZodErrors } from "@/lib/utils";
-import { assignSubnet } from "@/lib/wireguard";
+import {
+ assignSubnet,
+ SUBNET_ALLOCATION_CONSTRAINTS,
+ withAllocationRetry,
+} from "@/lib/wireguard";
const TOKEN_EXPIRY_HOURS = 24;
@@ -60,38 +64,46 @@ export async function POST(request: NextRequest) {
const encryptionKeyBuffer = await resolveEncryptionKey();
const encryptionKey = encryptionKeyBuffer.toString("hex");
- const { subnetId, wireguardIp } = await assignSubnet();
+ const allocation = await withAllocationRetry(
+ () =>
+ db.transaction(async (tx) => {
+ const { subnetId, wireguardIp } = await assignSubnet(tx);
+ const claimedServers = await tx
+ .update(servers)
+ .set({
+ wireguardPublicKey,
+ signingPublicKey,
+ subnetId,
+ wireguardIp,
+ publicIp: publicIp || null,
+ privateIp: privateIp || null,
+ isProxy: isProxy === true,
+ tokenUsedAt: now,
+ status: "online",
+ lastHeartbeat: now,
+ })
+ .where(
+ and(
+ eq(servers.id, server.id),
+ eq(servers.agentToken, token),
+ isNull(servers.tokenUsedAt),
+ gt(servers.tokenCreatedAt, expiryThreshold),
+ ),
+ )
+ .returning({ id: servers.id });
- const claimedServers = await db
- .update(servers)
- .set({
- wireguardPublicKey,
- signingPublicKey,
- subnetId,
- wireguardIp,
- publicIp: publicIp || null,
- privateIp: privateIp || null,
- isProxy: isProxy === true,
- tokenUsedAt: now,
- status: "online",
- lastHeartbeat: now,
- })
- .where(
- and(
- eq(servers.id, server.id),
- eq(servers.agentToken, token),
- isNull(servers.tokenUsedAt),
- gt(servers.tokenCreatedAt, expiryThreshold),
- ),
- )
- .returning({ id: servers.id });
+ return claimedServers.length > 0 ? { subnetId, wireguardIp } : null;
+ }),
+ SUBNET_ALLOCATION_CONSTRAINTS,
+ );
- if (claimedServers.length === 0) {
+ if (!allocation) {
return NextResponse.json(
{ error: "Invalid, expired, or already used token" },
{ status: 401 },
);
}
+ const { subnetId, wireguardIp } = allocation;
return NextResponse.json({
serverId: server.id,
@@ -103,7 +115,7 @@ export async function POST(request: NextRequest) {
registryUrl: process.env.REGISTRY_URL ?? null,
registryUsername: process.env.REGISTRY_USERNAME ?? null,
registryPassword: process.env.REGISTRY_PASSWORD ?? null,
- registryInsecure: process.env.REGISTRY_INSECURE !== "false",
+ registryInsecure: process.env.REGISTRY_INSECURE === "true",
});
} catch (error) {
console.error("Agent registration error:", error);
diff --git a/web/components/core/summary-card.tsx b/web/components/core/summary-card.tsx
index 6d0990b1..104063cd 100644
--- a/web/components/core/summary-card.tsx
+++ b/web/components/core/summary-card.tsx
@@ -55,7 +55,7 @@ export function SummaryCardStat({
{label}
-
+
{children}
);
diff --git a/web/components/service/details/deployment-progress.tsx b/web/components/service/details/deployment-progress.tsx
index 239e5787..faebfb71 100644
--- a/web/components/service/details/deployment-progress.tsx
+++ b/web/components/service/details/deployment-progress.tsx
@@ -94,23 +94,28 @@ export function getBarState(
};
}
- if (
+ const activeBuild =
+ service.activeBuild === undefined &&
service.latestBuild &&
ACTIVE_BUILD_STATUSES.includes(service.latestBuild.status)
- ) {
+ ? service.latestBuild
+ : service.activeBuild;
+
+ if (activeBuild) {
return {
mode: "building",
- buildId: service.latestBuild.id,
- buildStatus: service.latestBuild.status,
+ buildId: activeBuild.id,
+ buildStatus: activeBuild.status,
};
}
const latestRollout = service.rollouts?.[0];
const activeRollout =
- latestRollout?.status === "queued" ||
- latestRollout?.status === "in_progress"
+ service.activeRollout === undefined &&
+ (latestRollout?.status === "queued" ||
+ latestRollout?.status === "in_progress")
? latestRollout
- : undefined;
+ : service.activeRollout;
if (activeRollout) {
const currentStage =
diff --git a/web/components/service/details/replicas-section.tsx b/web/components/service/details/replicas-section.tsx
index 80cec112..be1632c8 100644
--- a/web/components/service/details/replicas-section.tsx
+++ b/web/components/service/details/replicas-section.tsx
@@ -13,6 +13,7 @@ import {
EmptyTitle,
} from "@/components/ui/empty";
import { Input } from "@/components/ui/input";
+import { Slider } from "@/components/ui/slider";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
@@ -198,7 +199,7 @@ export const ReplicasSection = memo(function ReplicasSection({
setIsEditing(true);
setLocalReplicas((prev) => ({
...prev,
- [serverId]: Math.max(0, Math.min(10, Math.floor(value))),
+ [serverId]: Math.max(0, Math.min(32, Math.floor(value))),
}));
}, []);
@@ -211,7 +212,7 @@ export const ReplicasSection = memo(function ReplicasSection({
(sum, count) => sum + count,
0,
);
- setDesiredReplicas(Math.max(1, Math.min(10, manualTotal || 1)));
+ setDesiredReplicas(Math.max(1, Math.min(32, manualTotal || 1)));
}
setPlacementMode(nextMode);
};
@@ -268,7 +269,7 @@ export const ReplicasSection = memo(function ReplicasSection({
: null;
};
- const manualTotalIsValid = totalReplicas >= 1 && totalReplicas <= 10;
+ const manualTotalIsValid = totalReplicas >= 1 && totalReplicas <= 32;
if (service.stateful) {
return (
@@ -401,41 +402,36 @@ export const ReplicasSection = memo(function ReplicasSection({
{placementMode === "automatic" ? (
-
-
-
- The control plane distributes replicas evenly across healthy
- {service.serverlessEnabled ? " proxy nodes" : " nodes"} and
- moves them after failures.
-
+
+
+
Desired replicas
+
+ The control plane distributes replicas evenly across healthy
+ {service.serverlessEnabled ? " proxy nodes" : " nodes"} and
+ moves them after failures.
+