Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cfdc1df
chore: install Basecamp CLI in orbs
ampagent Jul 29, 2026
5b176af
Merge pull request #229 from techulus/chore/install-basecamp-cli
arjunkomath Jul 29, 2026
4f0bb6c
Use slider for automatic replica selection
ampagent Jul 29, 2026
494d3a6
Raise service replica limit to 32
ampagent Jul 29, 2026
3b98af3
Make summary card dots less prominent
arjunkomath Jul 29, 2026
1ef9464
Merge pull request #232 from techulus/ui/subtle-summary-card-dots
arjunkomath Jul 29, 2026
a59d8ee
Constrain replica slider width
ampagent Jul 29, 2026
5a23697
Increase replica slider control size
ampagent Jul 29, 2026
ae15df8
Merge pull request #231 from techulus/fix/basecamp-10143069415
arjunkomath Jul 29, 2026
67cf39a
Remove Basecamp from orb setup
ampagent Jul 29, 2026
18828bd
Merge pull request #233 from techulus/chore/remove-basecamp-orb-setup
arjunkomath Jul 29, 2026
d6aa949
docs: require concise agent responses
ampagent Jul 29, 2026
18b48b1
docs: refine response guidance
ampagent Jul 29, 2026
5ea82ae
Merge pull request #234 from techulus/docs/concise-agent-responses
arjunkomath Jul 29, 2026
3f4001b
Default registry image pulls to verified TLS
ampagent Jul 30, 2026
fb0485f
Merge pull request #235 from techulus/fix/registry-tls-verification
arjunkomath Jul 30, 2026
a020f0d
Fix multi-platform build progress status
ampagent Jul 30, 2026
551fa7e
Merge pull request #238 from techulus/fix/multi-platform-build-status
arjunkomath Jul 30, 2026
150a282
Make network allocation transactional
ampagent Jul 30, 2026
1e12305
Merge pull request #239 from techulus/fix/transactional-network-alloc…
arjunkomath Jul 30, 2026
07ebf1e
Remove registry compatibility shim
techulus-agent Jul 30, 2026
1a34f7f
Merge pull request #240 from techulus/fix/registry-insecure-config-key
arjunkomath Jul 30, 2026
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
1 change: 1 addition & 0 deletions .agents/setup
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion agent/cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion agent/internal/container/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -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)

Expand Down
30 changes: 30 additions & 0 deletions agent/internal/container/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions agent/internal/container/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions agent/internal/reconcile/reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions cli/internal/manifest/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
22 changes: 21 additions & 1 deletion cli/internal/manifest/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions docs/services/scaling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions web/actions/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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),
Expand All @@ -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"],
});
}),
Expand Down
34 changes: 34 additions & 0 deletions web/app/api/projects/[id]/services/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@ export async function GET(
replicas,
serviceSecrets,
serviceRollouts,
activeRollout,
volumes,
lockedServer,
latestBuild,
activeBuild,
githubRepo,
] = await Promise.all([
db
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -262,9 +294,11 @@ export async function GET(
deployments: deploymentsWithDetails,
secrets: serviceSecrets,
rollouts: serviceRollouts,
activeRollout,
volumes,
lockedServer,
latestBuild,
activeBuild,
hasGithubAppRepo: githubRepo !== null,
activeConfig,
currentSource,
Expand Down
66 changes: 39 additions & 27 deletions web/app/api/v1/agent/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
Loading
Loading