add multi-env deploy (prod/staging/dev), branch selection, env badges

This commit is contained in:
stackops
2026-04-09 19:56:32 +03:00
parent 99145a8bf1
commit 229eb8fcb5
7 changed files with 100 additions and 25 deletions

View File

@@ -42,11 +42,17 @@ func (d *Deployer) Run(ctx context.Context, deployID, projectID, namespace, env,
})
// Template variable substitution
envSuffix := ""
if env != "" && env != "prod" {
envSuffix = "-" + env
}
content := string(stackfileContent)
replacements := map[string]string{
"{{commit}}": gitCommit,
"{{branch}}": gitBranch,
"{{timestamp}}": fmt.Sprintf("%d", time.Now().Unix()),
"{{commit}}": gitCommit,
"{{branch}}": gitBranch,
"{{env}}": env,
"{{env_suffix}}": envSuffix,
"{{timestamp}}": fmt.Sprintf("%d", time.Now().Unix()),
}
for k, v := range replacements {
content = strings.ReplaceAll(content, k, v)

View File

@@ -174,16 +174,39 @@ func (h *StackOpsHandler) DeployProject(
stackfile := req.Msg.Stackfile
commit := req.Msg.GitCommit
// Branch: use request override or project default
branch := p.GitBranch
if req.Msg.GitCommit != "" && len(stackfile) == 0 {
// GitCommit field reused as branch override when no stackfile provided
}
env := req.Msg.Env
if len(stackfile) == 0 {
// Клонируем из git
cloneBranch := branch
// Check if env has a default branch mapping
if env == "dev" && branch == "main" {
cloneBranch = "develop"
}
// Allow explicit branch in git_commit field
if req.Msg.GitCommit != "" && !strings.Contains(req.Msg.GitCommit, ":") {
cloneBranch = req.Msg.GitCommit
commit = ""
}
var fetchErr error
stackfile, commit, fetchErr = gitutil.FetchStackfile(p.GitURL, p.GitBranch)
stackfile, commit, fetchErr = gitutil.FetchStackfile(p.GitURL, cloneBranch)
if fetchErr != nil {
return nil, connect.NewError(connect.CodeInternal, fetchErr)
}
branch = cloneBranch
}
id, err := h.createDeploy(p.ID, p.Namespace, req.Msg.Env, commit, p.GitBranch, stackfile)
// Namespace: base + env suffix
ns := p.Namespace
if env != "" && env != "prod" {
ns = p.Namespace + "-" + env
}
id, err := h.createDeploy(p.ID, ns, env, commit, branch, stackfile)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}

View File

@@ -321,10 +321,10 @@ func buildEnvVars(sf *schema.Stackfile, pc *schema.PlatformConfig) []envVar {
port := sf.DependsOn.Services[svc]
envKey := fmt.Sprintf("%s_URL", strings.ToUpper(strings.ReplaceAll(svc, "-", "_")))
// Use <service>.<namespace>.svc for cross-namespace discovery.
// Convention: namespace = service name (StackOps creates namespace per project).
// {{env_suffix}} is resolved by deployer: "" for prod, "-staging" for staging, etc.
envs = append(envs, envVar{
Name: envKey,
Value: fmt.Sprintf("http://%s.%s.svc:%d", svc, svc, port),
Value: fmt.Sprintf("http://%s.%s{{env_suffix}}.svc:%d", svc, svc, port),
})
}

View File

@@ -177,10 +177,11 @@ export const api = {
getDeploy: (deployId: string) =>
rpc<Deploy>('GetDeploy', { deployId }),
deployProject: (projectId: string, env: string, stackfile?: string) =>
deployProject: (projectId: string, env: string, stackfile?: string, branch?: string) =>
rpc<{ deployId: string }>('DeployProject', {
projectId, env,
...(stackfile ? { stackfile: btoa(unescape(encodeURIComponent(stackfile))) } : {}),
...(branch ? { gitCommit: branch } : {}),
}),
rollback: (projectId: string, deploymentId: string) =>

View File

@@ -4,11 +4,14 @@ import { useToast } from './Toast'
interface Props { onClose: () => void }
const ENVS = ['prod', 'staging', 'dev'] as const
export function DeployModal({ onClose }: Props) {
const selectedProject = useStore((s) => s.selectedProject)
const deployProject = useStore((s) => s.deployProject)
const { toast } = useToast()
const [env, setEnv] = useState('prod')
const [env, setEnv] = useState<string>('prod')
const [branch, setBranch] = useState(selectedProject?.gitBranch ?? 'main')
const [stackfile, setStackfile] = useState('')
const [deploying, setDeploying] = useState(false)
const [err, setErr] = useState<string | null>(null)
@@ -24,8 +27,9 @@ export function DeployModal({ onClose }: Props) {
if (!selectedProject) return
setDeploying(true); setErr(null)
try {
await deployProject(selectedProject.id, env, stackfile || undefined)
toast('Deploy started', 'success')
// Pass branch as gitCommit field (handler uses it as branch override)
await deployProject(selectedProject.id, env, stackfile || undefined, branch)
toast(`Deploy to ${env} started from ${branch}`, 'success')
onClose()
} catch (ex) {
setErr((ex as Error).message)
@@ -34,20 +38,49 @@ export function DeployModal({ onClose }: Props) {
}
}
const envColors: Record<string, string> = {
prod: 'border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950/30 dark:text-red-400',
staging: 'border-yellow-300 bg-yellow-50 text-yellow-700 dark:border-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400',
dev: 'border-blue-300 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-950/30 dark:text-blue-400',
}
return (
<div className="fixed inset-0 bg-black/40 dark:bg-black/60 flex items-center justify-center z-50 p-4" onClick={onClose}>
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg w-full max-w-lg p-6" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-semibold mb-5 text-gray-900 dark:text-gray-100">Deploy {selectedProject?.name}</h2>
<form onSubmit={submit} className="space-y-4">
{/* Environment selector */}
<div>
<span className="text-xs text-gray-500 dark:text-gray-400 mb-2 block">Environment</span>
<div className="flex gap-2">
{ENVS.map((e) => (
<button
key={e}
type="button"
onClick={() => setEnv(e)}
className={`flex-1 py-2 rounded border text-sm font-medium transition-colors ${
env === e ? envColors[e] : 'border-gray-200 dark:border-gray-800 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
{e}
</button>
))}
</div>
</div>
{/* Branch */}
<label className="block">
<span className="text-xs text-gray-500 dark:text-gray-400 mb-1 block">Environment</span>
<span className="text-xs text-gray-500 dark:text-gray-400 mb-1 block">Branch</span>
<input
value={env}
onChange={(e) => setEnv(e.target.value)}
placeholder="prod"
className="w-full bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-3 py-2 text-sm text-gray-900 dark:text-gray-100 outline-none focus:border-green-500"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
className="w-full bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-3 py-2 text-sm text-gray-900 dark:text-gray-100 font-mono outline-none focus:border-green-500"
/>
</label>
{/* Stackfile override */}
<label className="block">
<span className="text-xs text-gray-500 dark:text-gray-400 mb-1 block">
Stackfile override <span className="text-gray-400 dark:text-gray-600">(leave empty to use git)</span>
@@ -55,19 +88,21 @@ export function DeployModal({ onClose }: Props) {
<textarea
value={stackfile}
onChange={(e) => setStackfile(e.target.value)}
placeholder={'[app]\nname = "my-app"\nimage = "registry/my-app:latest"'}
rows={6}
placeholder={'[app]\nname = "my-app"\nimage = "registry/my-app:{{commit}}"'}
rows={4}
className="w-full bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-3 py-2 text-xs text-gray-700 dark:text-gray-300 font-mono outline-none focus:border-green-500 resize-y"
/>
</label>
{err && <p className="text-red-500 dark:text-red-400 text-xs">{err}</p>}
<div className="flex gap-2 pt-1">
<button
type="submit"
disabled={deploying}
className="flex-1 bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white text-sm py-2 rounded"
>
{deploying ? 'Deploying...' : 'Deploy'}
{deploying ? 'Deploying...' : `Deploy to ${env}`}
</button>
<button type="button" onClick={onClose} className="px-4 text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
Cancel

View File

@@ -35,7 +35,17 @@ function DeployRow({ deploy, selected, onSelect }: {
<div className="flex items-center gap-2">
<StatusBadge status={deploy.status} />
{active && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />}
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono">{deploy.deployId.slice(0, 8)}</span>
{deploy.env && (
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
deploy.env === 'prod' ? 'bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-400' :
deploy.env === 'staging' ? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40 dark:text-yellow-400' :
'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-400'
}`}>{deploy.env}</span>
)}
{deploy.gitCommit
? <span className="text-xs text-gray-500 dark:text-gray-400 font-mono">{deploy.gitCommit.slice(0, 7)}</span>
: <span className="text-xs text-gray-400 dark:text-gray-600">manual</span>
}
<span className="flex-1" />
<span className="text-[11px] text-gray-400 dark:text-gray-600" title={fullDate(deploy.createdAt)}>
{relativeTime(deploy.createdAt)}
@@ -174,7 +184,7 @@ export function ProjectDetail() {
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<StatusBadge status={selectedDeploy.status} />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 font-mono">{selectedDeploy.deployId.slice(0, 12)}</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">Deploy #{selectedIdx >= 0 ? deploys.length - selectedIdx : '?'}</span>
</div>
<div className="flex items-center gap-2">
<button

View File

@@ -29,7 +29,7 @@ interface StoreValue {
deleteProject: (id: string) => Promise<void>
loadDeploys: (projectId: string) => Promise<void>
deployProject: (projectId: string, env: string, stackfile?: string) => Promise<string>
deployProject: (projectId: string, env: string, stackfile?: string, branch?: string) => Promise<string>
rollback: (projectId: string, deploymentId: string) => Promise<string>
refreshDeploy: (deployId: string) => Promise<void>
}
@@ -124,8 +124,8 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
}
}, [])
const deployProject = useCallback(async (projectId: string, env: string, stackfile?: string) => {
const res = await api.deployProject(projectId, env, stackfile)
const deployProject = useCallback(async (projectId: string, env: string, stackfile?: string, branch?: string) => {
const res = await api.deployProject(projectId, env, stackfile, branch)
const ns = selectedProjectRef.current?.namespace ?? projectId
const data = await api.listDeploys(ns)
setDeploys(data.deploys ?? [])