From b9b65f3fb64ebf11b86dc5185702e1762d06c4b1 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sun, 3 May 2026 12:45:30 +0000 Subject: [PATCH 1/3] feat: Refactor app source management and added Dockerfile detection Co-authored-by: Copilot --- CONTEXT.md | 27 +- github-assets/qs-logo-header.svg | 2 +- .../app/[appId]/app-action-buttons.tsx | 10 +- src/app/project/app/[appId]/app-tabs.tsx | 8 + .../project/app/[appId]/general/actions.ts | 95 ++- .../app-source-wizard-dialog.tsx | 423 ++++++++++++ .../app-source-wizard/build-method-step.tsx | 37 ++ .../container-image-step.tsx | 42 ++ .../dockerfile-path-step.tsx | 27 + .../app-source-wizard/git-branch-step.tsx | 33 + .../app-source-wizard/git-https-url-step.tsx | 46 ++ .../app-source-wizard/git-ssh-url-step.tsx | 61 ++ .../public-deploy-key-dialog.tsx | 34 + .../app-source-wizard/readonly-info.tsx | 22 + .../app-source-wizard/source-summary-step.tsx | 43 ++ .../app-source-wizard/source-type-step.tsx | 44 ++ .../source-wizard-fields.tsx | 119 ++++ .../general/app-source-wizard/types.ts | 18 + .../app-source-wizard/wizard-progress.tsx | 25 + .../app/[appId]/general/app-source.tsx | 601 +++--------------- .../general/git-branch-picker-dialog.tsx | 102 --- src/app/project/app/[appId]/page.tsx | 3 +- src/components/custom/quickstack-logo.tsx | 18 +- src/frontend/utils/app-source.utils.ts | 14 + .../services/app-git-ssh-key.service.ts | 8 + .../app-git-ssh-key.service.unit.spec.ts | 8 + src/server/services/git.service.ts | 43 ++ src/server/services/git.service.unit.spec.ts | 49 ++ src/shared/model/app-source-info.model.ts | 16 + .../model/app-source-info.model.unit.spec.ts | 14 +- tailwind.config.ts | 2 +- 31 files changed, 1357 insertions(+), 637 deletions(-) create mode 100644 src/app/project/app/[appId]/general/app-source-wizard/app-source-wizard-dialog.tsx create mode 100644 src/app/project/app/[appId]/general/app-source-wizard/build-method-step.tsx create mode 100644 src/app/project/app/[appId]/general/app-source-wizard/container-image-step.tsx create mode 100644 src/app/project/app/[appId]/general/app-source-wizard/dockerfile-path-step.tsx create mode 100644 src/app/project/app/[appId]/general/app-source-wizard/git-branch-step.tsx create mode 100644 src/app/project/app/[appId]/general/app-source-wizard/git-https-url-step.tsx create mode 100644 src/app/project/app/[appId]/general/app-source-wizard/git-ssh-url-step.tsx create mode 100644 src/app/project/app/[appId]/general/app-source-wizard/public-deploy-key-dialog.tsx create mode 100644 src/app/project/app/[appId]/general/app-source-wizard/readonly-info.tsx create mode 100644 src/app/project/app/[appId]/general/app-source-wizard/source-summary-step.tsx create mode 100644 src/app/project/app/[appId]/general/app-source-wizard/source-type-step.tsx create mode 100644 src/app/project/app/[appId]/general/app-source-wizard/source-wizard-fields.tsx create mode 100644 src/app/project/app/[appId]/general/app-source-wizard/types.ts create mode 100644 src/app/project/app/[appId]/general/app-source-wizard/wizard-progress.tsx delete mode 100644 src/app/project/app/[appId]/general/git-branch-picker-dialog.tsx create mode 100644 src/frontend/utils/app-source.utils.ts diff --git a/CONTEXT.md b/CONTEXT.md index d9af41b4..f4ef79a5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -10,6 +10,10 @@ A deployable workload managed by QuickStack. **Source**: The origin QuickStack uses to build or run an **App**. +**Configured Source**: +A **Source** with all required connection details selected and ready to be saved or deployed. +_Avoid_: connected source, selected source when referring to readiness + **Git HTTPS Source**: A Git repository accessed through an HTTPS clone URL, optionally with username and token credentials. _Avoid_: git https, HTTPS repo @@ -18,6 +22,10 @@ _Avoid_: git https, HTTPS repo A Git repository accessed through an SSH clone URL using an app-specific deploy key. _Avoid_: SSH repo +**Container Image Source**: +A container image reference QuickStack uses to run an **App** without building from Git. +_Avoid_: Docker Container when referring to the domain concept + **Deploy Key**: A public SSH key registered with a Git provider to grant repository access to a **Git SSH Source**. _Avoid_: SSH key when specifically referring to the provider-side access grant @@ -31,10 +39,13 @@ A direct node-level exposure that maps a cluster node port to one container port ## Relationships -- An **App** has exactly one **Source**. +- An **App** can have zero or one **Configured Source**. - An **App** can have zero or more **App Node Ports**. +- A **Configured Source** belongs to exactly one **App**. +- Only application workloads can use a **Git HTTPS Source** or **Git SSH Source**. - A **Git HTTPS Source** belongs to exactly one **App**. - A **Git SSH Source** belongs to exactly one **App**. +- A **Container Image Source** belongs to exactly one **App**. - A **Git Source** has exactly one selected **Git Branch** before it can be saved. - A **Git SSH Source** requires a **Deploy Key** before QuickStack offers **Git Branch** selection. - An **App Node Port** belongs to exactly one **App** and exposes exactly one container port/protocol. @@ -47,11 +58,25 @@ A direct node-level exposure that maps a cluster node port to one container port > **Dev:** "Can QuickStack list branches for a **Git SSH Source** before the provider knows its **Deploy Key**?" > **Domain expert:** "No, the user must generate the key and register it as a **Deploy Key** with the Git provider before branch selection is shown." +> **Dev:** "Can a user type a **Git Branch** manually when QuickStack cannot load branches?" +> **Domain expert:** "No, the **Git Branch** must be selected from the branches QuickStack loads from the configured Git source." + +> **Dev:** "Should QuickStack generate a new **Deploy Key** every time a user edits the Git SSH URL?" +> **Domain expert:** "No, QuickStack reuses the app's existing key unless the user explicitly regenerates it." + +> **Dev:** "Should the Source card show Git HTTPS as selected just because it is the database default?" +> **Domain expert:** "No, the card shows an empty connect state until the **App** has a **Configured Source**." + +> **Dev:** "What should happen when the user saves a **Configured Source** and deploys immediately?" +> **Domain expert:** "QuickStack saves the **Configured Source**, starts deployment, closes the source dialog, and shows deployment progress in the Overview tab." + > **Dev:** "If an **App** uses an **App Node Port**, should a restrictive ingress policy still block that node-level traffic?" > **Domain expert:** "No — creating the **App Node Port** is the explicit decision to expose that one container port/protocol through the cluster node." ## Flagged Ambiguities - "connect to a git https" means configuring a **Git HTTPS Source** for an **App**. +- "no app source chosen" means the **App** has no **Configured Source**, even if storage contains a default source type. +- "Docker Container Image" is the UI label for a **Container Image Source**. - "branch" means the selected **Git Branch**, not a build branch or deployment branch. - "node portforwarding" means an **App Node Port**, not an ad hoc developer port-forward session. diff --git a/github-assets/qs-logo-header.svg b/github-assets/qs-logo-header.svg index 33105f61..67bc2a6b 100644 --- a/github-assets/qs-logo-header.svg +++ b/github-assets/qs-logo-header.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/app/project/app/[appId]/app-action-buttons.tsx b/src/app/project/app/[appId]/app-action-buttons.tsx index c147a8a4..6e58490f 100644 --- a/src/app/project/app/[appId]/app-action-buttons.tsx +++ b/src/app/project/app/[appId]/app-action-buttons.tsx @@ -14,6 +14,7 @@ import PodStatusIndicator from "@/components/custom/pod-status-indicator"; import { usePodsStatus } from "@/frontend/states/zustand.states"; import { useEffect, useState } from "react"; import { DeploymentStatus } from "@/shared/model/deployment-info.model"; +import { AppSourceUtils } from "@/frontend/utils/app-source.utils"; export default function AppActionButtons({ app, @@ -24,6 +25,7 @@ export default function AppActionButtons({ }) { const [deploymentStatus, setDeploaymentStatus] = useState('UNKNOWN'); const hasWriteAccess = UserGroupUtils.sessionHasWriteAccessForApp(session, app.id); + const appSourceIsConfigured = AppSourceUtils.isConfiguredSource(app); const { subscribeToStatusChanges, getPodsForApp } = usePodsStatus(); useEffect(() => { @@ -44,10 +46,10 @@ export default function AppActionButtons({
- {hasWriteAccess && <> - {app.appType === 'APP' && (app.sourceType === 'GIT' || app.sourceType === 'GIT_SSH') && } - - + {hasWriteAccess && <> + {app.appType === 'APP' && (app.sourceType === 'GIT' || app.sourceType === 'GIT_SSH') && } + + } {app.appDomains.length > 0 && + )} +
+
+ {step === 'summary' ? ( + <> + + + + ) : ( + + )} +
+ + + ); +} + +function getStepTitle(step: StepId, sourceType: AppSourceInfoInputModel['sourceType']) { + if (step === 'source') return 'Choose source'; + if (step === 'git-url') return 'Connect Git HTTPS'; + if (step === 'ssh-url') return 'Connect Git SSH'; + if (step === 'branch') return 'Choose Git Branch'; + if (step === 'build-method') return 'Choose Build Method'; + if (step === 'dockerfile') return 'Confirm Dockerfile Path'; + if (step === 'container-image') return 'Connect Docker Container Image'; + if (step === 'summary') return `${sourceTypeLabels[sourceType as SourceType]} Summary`; + return 'Connect App Source'; +} + +function getNextDisabled(step: StepId, formData: AppSourceInfoInputModel, publicKey: string | undefined, isLoadingBranches: boolean, isEnsuringKey: boolean, isDetectingDockerfile: boolean) { + if (isLoadingBranches || isEnsuringKey || isDetectingDockerfile) return true; + if (step === 'git-url') return !formData.gitUrl?.trim(); + if (step === 'ssh-url') return !formData.gitUrl?.trim() || !publicKey; + if (step === 'branch') return !formData.gitBranch; + if (step === 'dockerfile') return !formData.dockerfilePath?.trim(); + if (step === 'container-image') return !formData.containerImageSource?.trim(); + return false; +} + +function resetForSourceType(current: AppSourceInfoInputModel, sourceType: SourceType): AppSourceInfoInputModel { + if (sourceType === current.sourceType) { + return current; + } + if (sourceType === 'GIT') { + return { + sourceType, + buildMethod: 'RAILPACK', + gitUrl: '', + gitBranch: '', + gitUsername: '', + gitToken: '', + dockerfilePath: defaultDockerfilePath, + }; + } + if (sourceType === 'GIT_SSH') { + return { + sourceType, + buildMethod: 'RAILPACK', + gitUrl: '', + gitBranch: '', + dockerfilePath: defaultDockerfilePath, + }; + } + return { + sourceType, + buildMethod: 'RAILPACK', + containerImageSource: '', + containerRegistryUsername: '', + containerRegistryPassword: '', + dockerfilePath: defaultDockerfilePath, + }; +} + +function toSourceInput(app: AppExtendedModel): AppSourceInfoInputModel { + const sourceType = app.appType === 'APP' + ? app.sourceType as SourceType + : 'CONTAINER'; + return { + sourceType, + buildMethod: (app.buildMethod as AppBuildMethod | undefined) ?? 'RAILPACK', + containerImageSource: app.containerImageSource ?? '', + containerRegistryUsername: app.containerRegistryUsername ?? '', + containerRegistryPassword: app.containerRegistryPassword ?? '', + gitUrl: app.gitUrl ?? '', + gitBranch: app.gitBranch ?? '', + gitUsername: app.gitUsername ?? '', + gitToken: app.gitToken ?? '', + dockerfilePath: app.dockerfilePath ?? defaultDockerfilePath, + }; +} diff --git a/src/app/project/app/[appId]/general/app-source-wizard/build-method-step.tsx b/src/app/project/app/[appId]/general/app-source-wizard/build-method-step.tsx new file mode 100644 index 00000000..df58fc7b --- /dev/null +++ b/src/app/project/app/[appId]/general/app-source-wizard/build-method-step.tsx @@ -0,0 +1,37 @@ +import { Command, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"; +import { AppBuildMethod } from "@/shared/model/app-source-info.model"; +import { Check, FileCode2, Package, type LucideIcon } from "lucide-react"; + +export function BuildMethodStep({ value, onChange }: { + value: AppBuildMethod; + onChange: (buildMethod: AppBuildMethod) => void; +}) { + const options: Array<{ value: AppBuildMethod; label: string; description: string; icon: LucideIcon }> = [ + { value: 'DOCKERFILE', label: 'Dockerfile', description: 'Build with a Dockerfile from the repository.', icon: FileCode2 }, + { value: 'RAILPACK', label: 'Railpack', description: 'Detect and build the app automatically.', icon: Package }, + ]; + + return (
+ + + + {options.map((option) => ( + onChange(option.value)} + > + +
+

{option.label}

+

{option.description}

+
+ +
+ ))} +
+
+
+
); +} diff --git a/src/app/project/app/[appId]/general/app-source-wizard/container-image-step.tsx b/src/app/project/app/[appId]/general/app-source-wizard/container-image-step.tsx new file mode 100644 index 00000000..eee54ef1 --- /dev/null +++ b/src/app/project/app/[appId]/general/app-source-wizard/container-image-step.tsx @@ -0,0 +1,42 @@ +import { AppSourceInfoInputModel } from "@/shared/model/app-source-info.model"; +import { LockKeyhole, Package, User } from "lucide-react"; +import { IconInput, SecretInput } from "./source-wizard-fields"; +import { SourceFormPatch } from "./types"; + +export function ContainerImageStep({ formData, showCredentials, showPassword, setShowPassword, onChange }: { + formData: AppSourceInfoInputModel; + showCredentials: boolean; + showPassword: boolean; + setShowPassword: (show: boolean) => void; + onChange: (patch: SourceFormPatch) => void; +}) { + return ( +
+ onChange({ containerImageSource: event.target.value })} + /> + {showCredentials && ( +
+ onChange({ containerRegistryUsername: event.target.value })} + /> + onChange({ containerRegistryPassword: value })} + /> +
+ )} +
+ ); +} diff --git a/src/app/project/app/[appId]/general/app-source-wizard/dockerfile-path-step.tsx b/src/app/project/app/[appId]/general/app-source-wizard/dockerfile-path-step.tsx new file mode 100644 index 00000000..b8438f69 --- /dev/null +++ b/src/app/project/app/[appId]/general/app-source-wizard/dockerfile-path-step.tsx @@ -0,0 +1,27 @@ +import { FileCode2, Loader2 } from "lucide-react"; +import { IconInput } from "./source-wizard-fields"; +import { defaultDockerfilePath } from "./types"; + +export function DockerfilePathStep({ value, isDetecting, onChange }: { + value: string; + isDetecting: boolean; + onChange: (value: string) => void; +}) { + return ( +
+ {isDetecting && ( +
+ + Detecting Dockerfile... +
+ )} + onChange(event.target.value)} + /> +
+ ); +} diff --git a/src/app/project/app/[appId]/general/app-source-wizard/git-branch-step.tsx b/src/app/project/app/[appId]/general/app-source-wizard/git-branch-step.tsx new file mode 100644 index 00000000..43144f64 --- /dev/null +++ b/src/app/project/app/[appId]/general/app-source-wizard/git-branch-step.tsx @@ -0,0 +1,33 @@ +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Check, GitBranch } from "lucide-react"; + +export function GitBranchStep({ branches, selectedBranch, onSelect }: { + branches: string[]; + selectedBranch: string; + onSelect: (branch: string) => void; +}) { + return ( +
+ + + + No branches found. + + {branches.map((branch) => ( + onSelect(branch)} + > + + {branch} + + + ))} + + + +
+ ); +} diff --git a/src/app/project/app/[appId]/general/app-source-wizard/git-https-url-step.tsx b/src/app/project/app/[appId]/general/app-source-wizard/git-https-url-step.tsx new file mode 100644 index 00000000..0d12df4c --- /dev/null +++ b/src/app/project/app/[appId]/general/app-source-wizard/git-https-url-step.tsx @@ -0,0 +1,46 @@ +import { AppSourceInfoInputModel } from "@/shared/model/app-source-info.model"; +import { Link as LinkIcon, LockKeyhole, User } from "lucide-react"; +import { BranchLoadingState, IconInput, SecretInput } from "./source-wizard-fields"; +import { SourceFormPatch } from "./types"; + +export function GitHttpsUrlStep({ formData, showCredentials, showToken, setShowToken, onChange, isLoadingBranches, branchError, onRetry }: { + formData: AppSourceInfoInputModel; + showCredentials: boolean; + showToken: boolean; + setShowToken: (show: boolean) => void; + onChange: (patch: SourceFormPatch) => void; + isLoadingBranches: boolean; + branchError: string | null; + onRetry: () => Promise; +}) { + return ( +
+ onChange({ gitUrl: event.target.value, gitBranch: '' })} + /> + {showCredentials && ( +
+ onChange({ gitUsername: event.target.value })} + /> + onChange({ gitToken: value })} + /> +
+ )} + +
+ ); +} diff --git a/src/app/project/app/[appId]/general/app-source-wizard/git-ssh-url-step.tsx b/src/app/project/app/[appId]/general/app-source-wizard/git-ssh-url-step.tsx new file mode 100644 index 00000000..b15cbcdd --- /dev/null +++ b/src/app/project/app/[appId]/general/app-source-wizard/git-ssh-url-step.tsx @@ -0,0 +1,61 @@ +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { AppSourceInfoInputModel } from "@/shared/model/app-source-info.model"; +import { ClipboardCopy, KeyRound, Link as LinkIcon, RefreshCw } from "lucide-react"; +import { BranchLoadingState, IconInput } from "./source-wizard-fields"; +import { SourceFormPatch } from "./types"; + +export function GitSshUrlStep({ formData, publicKey, isEnsuringKey, isLoadingBranches, branchError, onChange, onCopy, onRegenerate, onRetry }: { + formData: AppSourceInfoInputModel; + publicKey?: string; + isEnsuringKey: boolean; + isLoadingBranches: boolean; + branchError: string | null; + onChange: (patch: SourceFormPatch) => void; + onCopy: () => void; + onRegenerate: () => void; + onRetry: () => Promise; +}) { + return ( +
+ + + Add this deploy key to your Git provider + Some Git providers require a unique deploy key per repository. Regenerate the key if this key is already used elsewhere. + + onChange({ gitUrl: event.target.value, gitBranch: '' })} + /> + {!!formData.gitUrl?.trim() && ( +
+
+ +
+ + +
+
+