diff --git a/app/(protected)/deeploys/project-draft/[projectHash]/page.tsx b/app/(protected)/deeploys/project-draft/[projectHash]/page.tsx index bc3678d2..b461f211 100644 --- a/app/(protected)/deeploys/project-draft/[projectHash]/page.tsx +++ b/app/(protected)/deeploys/project-draft/[projectHash]/page.tsx @@ -2,6 +2,7 @@ import JobFormWrapper from '@components/create-job/JobFormWrapper'; import DraftOverview from '@components/draft/DraftOverview'; +import StackManager from '@components/stack/StackManager'; import { DeploymentContextType, useDeploymentContext } from '@lib/contexts/deployment'; import { routePath } from '@lib/routes/route-paths'; import db from '@lib/storage/db'; @@ -14,7 +15,7 @@ import { useParams, useRouter } from 'next/navigation'; import { useEffect, useRef } from 'react'; export default function ProjectDraft() { - const { jobType, setJobType, setStep, projectPage, setProjectPage, pendingRecoveredJobPrefill } = + const { jobType, setJobType, isCreatingStack, setCreatingStack, setStep, projectPage, setProjectPage, pendingRecoveredJobPrefill } = useDeploymentContext() as DeploymentContextType; const router = useRouter(); @@ -42,15 +43,17 @@ export default function ProjectDraft() { if (hasRecoveredPrefill && !hasAutoOpenedRecoveredPrefillRef.current) { hasAutoOpenedRecoveredPrefillRef.current = true; + setCreatingStack(false); setJobType(pendingRecoveredJobPrefill.jobType); setStep(pendingRecoveredJobPrefill.jobType === JobType.Service ? 1 : 0); return; } if (!hasRecoveredPrefill && !hasAutoOpenedRecoveredPrefillRef.current) { + setCreatingStack(false); setJobType(undefined); } - }, [pendingRecoveredJobPrefill, projectHash, setJobType, setProjectPage, setStep]); + }, [pendingRecoveredJobPrefill, projectHash, setCreatingStack, setJobType, setProjectPage, setStep]); useEffect(() => { suppressMissingProjectRedirectRef.current = false; @@ -81,7 +84,7 @@ export default function ProjectDraft() { const getProjectIdentity = () => ; - return !jobType ? ( + return !jobType && !isCreatingStack ? ( <> {projectPage === ProjectPage.Payment ? ( { suppressMissingProjectRedirectRef.current = true; }} @@ -109,6 +113,13 @@ export default function ProjectDraft() { /> )} + ) : isCreatingStack ? ( + setCreatingStack(false)} + /> ) : ( ); diff --git a/app/(protected)/deeploys/project/[projectHash]/page.tsx b/app/(protected)/deeploys/project/[projectHash]/page.tsx index cd0fb963..5d3d58bf 100644 --- a/app/(protected)/deeploys/project/[projectHash]/page.tsx +++ b/app/(protected)/deeploys/project/[projectHash]/page.tsx @@ -3,6 +3,7 @@ import JobFormWrapper from '@components/create-job/JobFormWrapper'; import ProjectPageLoading from '@components/loading/ProjectPageLoading'; import ProjectOverview from '@components/project/ProjectOverview'; +import StackManager from '@components/stack/StackManager'; import { DeploymentContextType, useDeploymentContext } from '@lib/contexts/deployment'; import { routePath } from '@lib/routes/route-paths'; import db from '@lib/storage/db'; @@ -21,8 +22,17 @@ import { RiAlertLine } from 'react-icons/ri'; export default function Project() { const router = useRouter(); - const { jobType, setJobType, projectPage, setProjectPage, getProjectName, getRunningJobsWithDetails, hasEscrowPermission } = - useDeploymentContext() as DeploymentContextType; + const { + jobType, + setJobType, + isCreatingStack, + setCreatingStack, + projectPage, + setProjectPage, + getProjectName, + getRunningJobsWithDetails, + hasEscrowPermission, + } = useDeploymentContext() as DeploymentContextType; const [isLoading, setLoading] = useState(true); const [projectName, setProjectName] = useState(); @@ -43,6 +53,7 @@ export default function Project() { useEffect(() => { setProjectPage(ProjectPage.Overview); setJobType(undefined); + setCreatingStack(false); }, []); useEffect(() => { @@ -110,7 +121,7 @@ export default function Project() { const getProjectIdentity = () => ; - return !jobType ? ( + return !jobType && !isCreatingStack ? ( <> {projectPage === ProjectPage.Payment ? ( )} + ) : isCreatingStack ? ( + setCreatingStack(false)} + /> ) : ( ); diff --git a/docs/adr/adr-0001-stack-v1.md b/docs/adr/adr-0001-stack-v1.md new file mode 100644 index 00000000..5aa5d2ba --- /dev/null +++ b/docs/adr/adr-0001-stack-v1.md @@ -0,0 +1,78 @@ +# ADR-0001: Deeploy/Ratio1 Stack v1 (Multi-Service Apps) + +- Status: Accepted +- Date: 2026-03-24 +- Scope: `deeploy-dapp`, `edge_node`, `naeural_core`, `ratio1-sc` + +## Context + +Deeploy is currently job-centric: a project contains independent jobs, each with independent pricing/payment/lifecycle. + +We need first-class support for multi-service applications (for example web/cms/db) while preserving: +- per-component billing and lifecycle as separate jobs, +- current contract and payment primitives where possible, +- existing single-job UX and API compatibility. + +## Decision + +### 1. Stack model + +A **Stack** is a logical grouping of component jobs under one project. + +- Stack = grouping + orchestration metadata. +- Stack Component = one deployable service definition. +- Each Stack Component compiles/deploys as one Ratio1 Job. + +This keeps current payment/accounting aligned with on-chain job granularity. + +### 2. v1 placement + +v1 Stack placement mode is explicitly **`co-located`**. + +- All components in a stack deployment target the same node. +- Distributed private stack networking is out of scope for v1. + +### 3. Internal connectivity + +v1 internal service discovery uses the existing semaphore/shared-memory mechanism. + +- Provider components publish service connection data under deterministic stack-scoped keys. +- Consumer components wait for required providers and resolve env references from semaphore payloads. + +No new overlay network, service mesh, or cross-node private DNS is introduced in v1. + +### 4. Public ingress + +Public exposure remains optional and per-component. + +- Internal-only component: no public tunnel required. +- Public component: existing tunnel/public ingress path remains unchanged. + +### 5. Contracts and billing + +`ratio1-sc` remains unchanged for v1. + +- Existing `createJobs` batch path already supports creating multiple independently billed jobs. +- Shared `projectHash` + off-chain metadata are sufficient for stack grouping/reconciliation in v1. + +## Consequences + +### Positive + +- Backward-compatible with current job model and existing dapp flows. +- Minimal protocol risk for v1. +- Reuses runtime primitives already deployed in production. +- Clear path to richer v2 networking. + +### Trade-offs + +- v1 stacks are constrained to same-node placements. +- No first-class private cross-node routing. +- Internal discovery is key/value semaphore-based, not DNS-like service naming. + +## v2 Deferred Items + +- Cross-node private networking/overlay. +- Internal DNS/service aliases across nodes. +- Multi-node HA topologies for stack components. +- Protocol-level stack-native billing primitives. diff --git a/docs/stacks-v1-user-guide.md b/docs/stacks-v1-user-guide.md new file mode 100644 index 00000000..61feddf4 --- /dev/null +++ b/docs/stacks-v1-user-guide.md @@ -0,0 +1,63 @@ +# Deeploy Stacks v1 User Guide + +## What Is a Stack? + +A **Stack** is a group of related application components under the same Deeploy project. + +- Each component is deployed as a separate Ratio1 job. +- Billing remains per component/job. +- Stack metadata is used for grouping, dependency wiring, and co-located placement. + +Example stack: +- `web` (public) +- `cms` (internal-only) +- `db` (internal-only) + +## Exposure Modes + +Each stack component chooses one mode: + +- `internal-only`: no public tunnel is required. +- `public`: existing public ingress/tunnel behavior is enabled for that component. + +In the example above, only `web` is public. + +## Internal References + +Component environment variables can reference upstream components: + +- `ref(service.host)` +- `ref(service.port)` +- `ref(service.url)` +- `ref(service.container_ip)` + +Examples: +- `DATABASE_HOST=ref(db.host)` +- `DATABASE_PORT=ref(db.port)` +- `DATABASE_URL=postgres://user:pass@ref(db.host):ref(db.port)/postgres` + +For v1, these refs compile to runtime shared-memory/semaphore discovery values, not public URLs. + +## v1 Placement Limitation + +Stacks in v1 must use: + +- placement mode: `co-located` +- target nodes: exactly one shared target node for all components + +Distributed private networking across multiple nodes is deferred to v2. + +## Deployment Flow + +1. Open a project and create/edit a stack draft. +2. Configure component dependencies and refs. +3. Click `Prepare Deploy` to compile the stack into component draft jobs. +4. Continue through the existing `Payment` flow. +5. Deeploy creates one on-chain job per component in one batch. + +## Compatibility + +Existing single-job flows are unchanged: + +- users can still create standalone job drafts as before, +- existing projects without stacks continue to work unchanged. diff --git a/package.json b/package.json index a61cb416..77eaabdc 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "next build", "start": "next start", "lint": "eslint .", + "test": "tsx --test src/lib/stacks/__tests__/*.test.ts", "add-service": "tsx scripts/add-service.ts", "validate-services": "tsx scripts/validate-services.ts" }, diff --git a/src/components/draft/DraftOverview.tsx b/src/components/draft/DraftOverview.tsx index 924ee8f7..e475ec16 100644 --- a/src/components/draft/DraftOverview.tsx +++ b/src/components/draft/DraftOverview.tsx @@ -3,6 +3,7 @@ import { InteractionContextType, useInteractionContext } from '@lib/contexts/interaction'; import { routePath } from '@lib/routes/route-paths'; import db from '@lib/storage/db'; +import StackManager from '@components/stack/StackManager'; import ActionButton from '@shared/ActionButton'; import AddJobCard from '@shared/projects/AddJobCard'; import CancelButton from '@shared/projects/buttons/CancelButton'; @@ -21,12 +22,14 @@ export default function DraftOverview({ project, draftJobs, projectIdentity, + projectHash, onBeforeDeleteProject, onDeleteProjectFailed, }: { project: DraftProject; draftJobs: DraftJob[] | undefined; projectIdentity: React.ReactNode; + projectHash: string; onBeforeDeleteProject?: () => void; onDeleteProjectFailed?: () => void; }) { @@ -79,6 +82,8 @@ export default function DraftOverview({ {/* Add Job */} + + {/* Jobs */} {!!draftJobs && !!draftJobs.length && ( <> diff --git a/src/components/project/ProjectOverview.tsx b/src/components/project/ProjectOverview.tsx index c0b87d2d..052a23c2 100644 --- a/src/components/project/ProjectOverview.tsx +++ b/src/components/project/ProjectOverview.tsx @@ -1,6 +1,7 @@ import GenericDraftJobsList from '@components/draft/job-lists/GenericDraftJobsList'; import NativeDraftJobsList from '@components/draft/job-lists/NativeDraftJobsList'; import ServiceDraftJobsList from '@components/draft/job-lists/ServiceDraftJobsList'; +import StackManager from '@components/stack/StackManager'; import { getRunningJobResources, RunningJobResources } from '@data/containerResources'; import { DeploymentContextType, ProjectOverviewTab, useDeploymentContext } from '@lib/contexts/deployment'; import CustomTabs from '@shared/CustomTabs'; @@ -25,6 +26,7 @@ export default function ProjectOverview({ runningJobs, draftJobs, projectIdentity, + projectHash, fetchRunningJobs, successfulJobs, setSuccessfulJobs, @@ -32,6 +34,7 @@ export default function ProjectOverview({ runningJobs: RunningJobWithDetails[] | undefined; draftJobs: DraftJob[] | undefined; projectIdentity: React.ReactNode; + projectHash: string; fetchRunningJobs: (appsOverride?: Apps) => Promise; successfulJobs: { text: string; serverAlias: string; tunnelURL?: string }[]; setSuccessfulJobs: (successfulJobs: { text: string; serverAlias: string; tunnelURL?: string }[]) => void; @@ -91,6 +94,8 @@ export default function ProjectOverview({ {/* Add Job */} + + {/* Jobs */}
keccak256(toBytes(`stack-${crypto.randomUUID()}`)); +const nowIso = () => new Date().toISOString(); +const makeComponentId = (prefix: string) => `${prefix}-${crypto.randomUUID().slice(0, 8)}`; + +const defaultWorkerCommands = ['npm install', 'npm run build', 'npm run start']; + +type ComponentOption = { + runtimeKind: StackRuntimeKind; + title: string; + icon: React.ReactNode; + textColorClass: string; + color: 'pink' | 'yellow'; +}; + +const COMPONENT_OPTIONS: ComponentOption[] = [ + { + runtimeKind: 'container', + title: 'Container App Runner', + icon: , + textColorClass: 'text-pink-400', + color: 'pink', + }, + { + runtimeKind: 'worker', + title: 'Worker App Runner', + icon: , + textColorClass: 'text-yellow-500', + color: 'yellow', + }, +]; + +const withRuntimeDefaults = (component: StackComponent, runtimeKind: StackRuntimeKind): StackComponent => { + if (runtimeKind === 'worker') { + return { + ...component, + runtimeKind, + workerImage: component.workerImage ?? 'node:22', + workerRepositoryUrl: component.workerRepositoryUrl ?? '', + workerRepositoryVisibility: component.workerRepositoryVisibility ?? 'public', + workerUsername: component.workerUsername ?? '', + workerAccessToken: component.workerAccessToken ?? '', + workerCommands: + component.workerCommands && component.workerCommands.length > 0 + ? component.workerCommands + : [...defaultWorkerCommands], + }; + } + + return { + ...component, + runtimeKind, + image: component.image ?? '', + }; +}; + +const buildDefaultComponent = (stackId: string, index: number, runtimeKind: StackRuntimeKind = 'container'): StackComponent => { + const base: StackComponent = { + id: makeComponentId(`component-${index + 1}`), + stackId, + name: `component-${index + 1}`, + serviceName: `svc-${index + 1}`, + role: 'custom', + jobType: genericContainerTypes[2].jobType, + containerTypeName: genericContainerTypes[2].name, + runtimeKind: 'container', + image: '', + env: [], + internalPort: 8080 + index, + paymentMonthsCount: 1, + networkMode: 'internal-only', + dependencies: [], + }; + + return withRuntimeDefaults(base, runtimeKind); +}; + +const buildDraftStack = (projectHash: string, targetNode?: R1Address): StackDraft => { + const id = makeStackId(); + const timestamp = nowIso(); + + return { + id, + projectHash, + name: '', + description: '', + deploymentMode: 'co-located', + targetNodes: targetNode ? [targetNode] : [], + targetNodesCount: targetNode ? 1 : 0, + components: [buildDefaultComponent(id, 0, 'container')], + createdAt: timestamp, + updatedAt: timestamp, + lastRuntimeStatus: 'draft', + componentState: [], + }; +}; + +const statusClass = (status: string) => { + switch (status) { + case 'running': + return 'bg-green-100 text-green-700'; + case 'partially running': + return 'bg-yellow-100 text-yellow-700'; + case 'deploying': + return 'bg-sky-100 text-sky-700'; + case 'failed': + return 'bg-red-100 text-red-700'; + default: + return 'bg-slate-100 text-slate-700'; + } +}; + +function StackTextField({ + label, + value, + onChange, + placeholder, + type = 'text', + isOptional = false, + isDisabled = false, +}: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder: string; + type?: 'text' | 'number'; + isOptional?: boolean; + isDisabled?: boolean; +}) { + return ( +
+
+ ); +} + +function StackSelectField({ + label, + value, + options, + onChange, + isDisabled = false, +}: { + label: string; + value: string; + options: Array<{ value: string; label: string }>; + onChange: (value: string) => void; + isDisabled?: boolean; +}) { + return ( +
+
+ ); +} + +function StackComponentInputsSection({ + component, + allComponents, + onUpdate, +}: { + component: StackComponent; + allComponents: StackComponent[]; + onUpdate: (component: StackComponent) => void; +}) { + const runtimeKind = component.runtimeKind ?? 'container'; + const dependencyValue = component.dependencies.join(', '); + + return ( +
+ +
+
+ onUpdate({ ...component, name: value })} + /> + onUpdate({ ...component, serviceName: value })} + /> +
+ + item.id !== component.id).map((item) => item.id).join(', ')} + isOptional + onChange={(value) => { + const dependencies = value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + onUpdate({ ...component, dependencies }); + }} + /> +
+ + +
+ ({ + value: containerType.name, + label: `${containerType.name} ($${containerType.monthlyBudgetPerWorker}/mo)`, + }))} + onChange={(value) => { + const selected = genericContainerTypes.find((item) => item.name === value); + if (!selected) { + return; + } + + onUpdate({ + ...component, + containerTypeName: selected.name, + jobType: selected.jobType, + }); + }} + /> + + onUpdate({ ...component, internalPort: Number(value) })} + /> + + onUpdate({ ...component, paymentMonthsCount: Number(value) })} + /> +
+ + + {runtimeKind === 'container' ? ( +
+ onUpdate({ ...component, image: value })} + /> +
+ ) : ( +
+
+ onUpdate({ ...component, workerRepositoryUrl: value })} + /> + onUpdate({ ...component, workerImage: value })} + /> +
+ +
+ + onUpdate({ + ...component, + workerRepositoryVisibility: value as 'public' | 'private', + }) + } + /> + onUpdate({ ...component, workerUsername: value })} + /> + onUpdate({ ...component, workerAccessToken: value })} + /> +
+ +
+